Compare commits

..

82 Commits

Author SHA1 Message Date
Dan Brown
53f3cca85d Updated version and assets for release v21.12.2 2022-01-10 18:23:44 +00:00
Dan Brown
ed08bbcecc Merge branch 'master' into release 2022-01-10 18:23:19 +00:00
Dan Brown
2aace16704 Updated translator attribution before release v21.12.2 2022-01-10 18:22:43 +00:00
Dan Brown
ade66dcf2f Applied latest styleci changes 2022-01-10 18:18:15 +00:00
Dan Brown
d3eaaf6457 New Crowdin updates (#3148)
* New translations common.php (Czech)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations errors.php (Japanese)

* New translations entities.php (Japanese)

* New translations common.php (Japanese)

* New translations settings.php (Japanese)

* New translations entities.php (Japanese)

* New translations settings.php (Japanese)

* New translations auth.php (Japanese)

* New translations common.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)
2022-01-10 18:17:28 +00:00
Dan Brown
941217d9fb Improved loading for images with failed thumbnails
- A placeholder is now shown in the gallery.
- The page editors will use the original image url if the display
  thumbnail is missing.

For #3142
2022-01-10 18:13:48 +00:00
Dan Brown
4239d4c54d Fixed error on webhooks for recycle bin operations
Updated the getUrl method on deletions to not require any passed
params to align with usage in webhooks.
Probably better to have a proper interface but would require a wider
change.

Fixes #3154
2022-01-10 17:47:49 +00:00
Dan Brown
8d91f4369b Improved custom homepage check on item deletion
Custom homepage usage will now be checked before any actioning
of deletion rather than potentially causing an exception acting
during the deletion.

Previously a deletion could still be created, within the recycle bin,
for the parent which may lead to the page being deleted anyway.

For #3150
2022-01-10 17:04:01 +00:00
Dan Brown
722aa04577 Merge pull request #3153 from AitorMatxi/patch-1
Update auth.php
2022-01-10 16:10:39 +00:00
Aitor Matxinea
2d0abc4164 Update auth.php
Fix misspelled word "As" to "Has".
2022-01-10 11:45:48 +01:00
Dan Brown
c3f7b39a0f Addressed phpstan cases 2022-01-07 13:04:49 +00:00
Dan Brown
de97ebf9b7 Updated version and assets for release v21.12.1 2022-01-06 12:20:37 +00:00
Dan Brown
f492a660a8 Merge branch 'master' into release 2022-01-06 12:20:26 +00:00
Dan Brown
ef11100863 Updated translator attribution before release v21.12.1 2022-01-06 12:20:13 +00:00
Dan Brown
1a26b47782 Applied latest styleCI changes 2022-01-06 12:18:11 +00:00
Dan Brown
cb0d674a71 Merge branch 'sort_changes'
Related to #3134
2022-01-06 12:03:15 +00:00
Dan Brown
4d094331cf New Crowdin updates (#3117)
* New translations auth.php (Bulgarian)

* New translations auth.php (Catalan)

* New translations auth.php (Czech)

* New translations auth.php (Danish)

* New translations auth.php (Hebrew)

* New translations auth.php (Swedish)

* New translations auth.php (Hungarian)

* New translations auth.php (Italian)

* New translations auth.php (Japanese)

* New translations auth.php (Korean)

* New translations auth.php (Lithuanian)

* New translations auth.php (Dutch)

* New translations auth.php (Polish)

* New translations auth.php (Russian)

* New translations auth.php (Slovak)

* New translations auth.php (Slovenian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Bosnian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations settings.php (Croatian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Indonesian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Dutch)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (German Informal)

* New translations settings.php (Spanish)

* New translations activities.php (Spanish)

* New translations auth.php (Spanish)

* New translations common.php (Spanish)

* New translations settings.php (Spanish)

* New translations auth.php (German)

* New translations passwords.php (German)

* New translations settings.php (German)

* New translations activities.php (German)

* New translations auth.php (German)

* New translations auth.php (German Informal)

* New translations common.php (German)

* New translations entities.php (German)

* New translations errors.php (German)

* New translations errors.php (German Informal)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations entities.php (Japanese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Slovak)

* New translations entities.php (Slovenian)

* New translations entities.php (Swedish)

* New translations entities.php (Turkish)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Chinese Traditional)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Polish)

* New translations entities.php (Indonesian)

* New translations entities.php (Persian)

* New translations entities.php (Croatian)

* New translations entities.php (Estonian)

* New translations entities.php (Latvian)

* New translations entities.php (Bosnian)

* New translations entities.php (Russian)

* New translations entities.php (Dutch)

* New translations entities.php (Portuguese)

* New translations entities.php (Bulgarian)

* New translations entities.php (Ukrainian)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Norwegian Bokmal)

* New translations entities.php (French)

* New translations entities.php (Spanish)

* New translations entities.php (Arabic)

* New translations entities.php (Catalan)

* New translations entities.php (Lithuanian)

* New translations entities.php (Czech)

* New translations entities.php (Danish)

* New translations entities.php (German)

* New translations entities.php (Hebrew)

* New translations entities.php (Hungarian)

* New translations entities.php (Italian)

* New translations entities.php (Korean)

* New translations entities.php (German Informal)

* New translations entities.php (Spanish)

* New translations auth.php (Portuguese)

* New translations common.php (Portuguese)

* New translations errors.php (Portuguese)

* New translations settings.php (Portuguese)

* New translations activities.php (French)

* New translations activities.php (French)

* New translations auth.php (French)

* New translations common.php (French)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations activities.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)

* New translations common.php (Spanish, Argentina)

* New translations activities.php (German Informal)

* New translations common.php (German Informal)

* New translations settings.php (Spanish, Argentina)

* New translations activities.php (Chinese Simplified)

* New translations activities.php (Chinese Simplified)

* New translations auth.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations activities.php (Estonian)

* New translations auth.php (Estonian)

* New translations common.php (Estonian)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Estonian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Estonian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations entities.php (Italian)

* New translations settings.php (Italian)

* New translations activities.php (Russian)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations activities.php (Russian)

* New translations entities.php (Russian)

* New translations settings.php (Russian)

* New translations activities.php (Japanese)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Arabic)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations auth.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations auth.php (Czech)

* New translations auth.php (Czech)

* New translations activities.php (Latvian)

* New translations auth.php (Latvian)

* New translations common.php (Latvian)

* New translations entities.php (Latvian)

* New translations settings.php (Latvian)

* New translations activities.php (Latvian)

* New translations settings.php (Latvian)

* New translations activities.php (Italian)

* New translations entities.php (Italian)

* New translations activities.php (Italian)

* New translations settings.php (Italian)

* New translations common.php (Japanese)

* New translations settings.php (French)

* New translations common.php (Vietnamese)

* New translations common.php (Portuguese, Brazilian)

* New translations common.php (Indonesian)

* New translations common.php (Persian)

* New translations common.php (Croatian)

* New translations common.php (Estonian)

* New translations common.php (Latvian)

* New translations common.php (Bosnian)

* New translations common.php (German Informal)

* New translations settings.php (Spanish)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Arabic)

* New translations settings.php (Bulgarian)

* New translations settings.php (Catalan)

* New translations settings.php (Czech)

* New translations settings.php (Danish)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations settings.php (Hungarian)

* New translations settings.php (Italian)

* New translations settings.php (Japanese)

* New translations common.php (Chinese Traditional)

* New translations common.php (Turkish)

* New translations common.php (Portuguese)

* New translations common.php (Danish)

* New translations common.php (Ukrainian)

* New translations common.php (Spanish, Argentina)

* New translations common.php (Norwegian Bokmal)

* New translations settings.php (Ukrainian)

* New translations common.php (French)

* New translations common.php (Spanish)

* New translations common.php (Arabic)

* New translations common.php (Bulgarian)

* New translations common.php (Catalan)

* New translations common.php (Czech)

* New translations common.php (German)

* New translations common.php (Swedish)

* New translations common.php (Hebrew)

* New translations common.php (Hungarian)

* New translations common.php (Italian)

* New translations common.php (Korean)

* New translations common.php (Lithuanian)

* New translations common.php (Dutch)

* New translations common.php (Polish)

* New translations common.php (Russian)

* New translations common.php (Slovak)

* New translations common.php (Slovenian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Bosnian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations settings.php (Croatian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Indonesian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Dutch)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (German Informal)

* New translations common.php (Estonian)

* New translations entities.php (Estonian)

* New translations settings.php (Estonian)

* New translations common.php (Spanish)

* New translations settings.php (Spanish)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations common.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)
2022-01-06 12:02:49 +00:00
Dan Brown
2312d07bb5 Removed old book sort permission test
Permission handling now done via other means with more extensive
permissions testing in SortTest class.
2022-01-05 16:46:03 +00:00
Dan Brown
fbd388ba4c Aligned chapter move permissions with page move permissions 2022-01-05 16:18:19 +00:00
Dan Brown
d3ca23b195 Added additional permission checks and tests for book sorts
- Aligned permissions control with move operations to check
  delete/create permissions against old/new locations.
- Added tests to cover additional permissions scenarios.
2022-01-05 15:42:59 +00:00
Dan Brown
553954ad18 Altered sort permission checking and started tests
Previous implemenations were hard to read so changing to be more
logically simplistic. Still needs further coverage in tests and
review/alignment of permissions to use.
2022-01-05 14:39:21 +00:00
Dan Brown
d8c45f5746 Changed model loading and permission checking on book sort
Models are now loaded into their own map to then be used for sorting and
reporting back of changed books. Prevents akward logic ordering issues
of before where some bits of code assumed/hoped for loaded models on
abstract data structures.

New levels of permissions are now checked for items within the
sort operation. Needs testing to cover.
2022-01-04 21:09:34 +00:00
Dan Brown
edc7c12edf Refactored sort system a little
To standardise the handled data format a little better.
2022-01-04 17:31:57 +00:00
Dan Brown
a72bd75e3a Added page titles to many missing app areas
Many pages were missing their unique tab/page titles
so this change is just to distribute them back over
many common areas where they were missing.
2022-01-04 13:33:24 +00:00
Dan Brown
31f1dca8a8 Added detection and thumbnail bypass for apng images
Adds apng sniffing when generating thumbnails with retained ratios to
serve the original image files, as we do for GIF images, to prevent
the image being resized to a static version.

Is more tricky than GIF since apng file mimes and extensions
are the same as png, we have to detect part of the file header
to sniff the type. Means we have to sniff at a later stage
than GIF since we have to load the image file data.

Made some changes to the image thubmnail caching while doing
this work to fit in with this handling.

Added test to cover.
For #3136.
2022-01-04 13:10:35 +00:00
Dan Brown
819ec55b1b Fixed code block language parsing issue
Language parsing of code blocks could falter on pasted code blocks due
to the lanuage being parsed with a space which would throw an error when
used as a css class.
This adds more extensive language parsing to be safer.

Fixes #3133
2022-01-04 11:54:24 +00:00
Dan Brown
dba506a20e Added search autofocus on entity-selector-popup
Closes #3127
2022-01-04 11:30:44 +00:00
Dan Brown
d0de4fd8f9 Fixed failing webhook test cases 2022-01-03 19:51:13 +00:00
Dan Brown
00eedafbfd Added timeout and debugging statuses to webhooks
- Added a user-configurable timeout option to webhooks.
- Added webhook fields for last-call/error datetime, in addition to last
  error string, which are shown on  webhook edit view.

Related to #3122
2022-01-03 19:42:48 +00:00
Dan Brown
6e18620a0a Added webhook call http exception handling
Will now catch and log errors on events such as http timeouts.
For #3122
2022-01-03 18:37:56 +00:00
Dan Brown
fe54c7f27a Added webhook_call_before theme event hook 2022-01-03 18:22:03 +00:00
Dan Brown
65830b428c Fixed linked images being micro on pdf export
Was caused by max-width: 100% causing confusion when images were
inside an anchor. This change resets that property on PDF
exports allowing full width images to be shown as so
without affecting smaller sizes.

Fixes #3120
2022-01-01 18:18:37 +00:00
Dan Brown
b438e0187c Updated webhooks list to not squash events/status
Closes #3135
2022-01-01 17:43:33 +00:00
Dan Brown
8614775c14 Updated sponsors in readme 2021-12-30 16:43:28 +00:00
Dan Brown
09436836a5 Updated version and assets for release v21.12 2021-12-22 17:04:18 +00:00
Dan Brown
bb455d7788 Merge branch 'master' into release 2021-12-22 17:03:50 +00:00
Dan Brown
b0666e5d70 Updated translator contribution before v21.12 release 2021-12-22 16:30:48 +00:00
Dan Brown
fc109f7e1c Applied latest StyleCI changes 2021-12-20 17:40:27 +00:00
Dan Brown
21f2a7087c Merge pull request #3118 from BookStackApp/copy_stuff
Additional copy/clone abilities
2021-12-20 17:39:44 +00:00
Dan Brown
ff70509fca Added copy considerations
Show to the user when copying stuff to highlight important things such
as what's not copied or change in permissions.
2021-12-20 17:33:19 +00:00
Dan Brown
0288320700 Added ability to clone books 2021-12-19 19:20:31 +00:00
Dan Brown
20e093a7a1 Added ability to copy/clone chapters
Builds upon page clone work. Takes permissions into account to decide
if child pages should be copied.
2021-12-19 15:40:52 +00:00
Dan Brown
3f9527f166 Extracted page copy to new cloner class
Fundemental refactor for planned additional clone operations.
No behaviour change intended in this commit.
2021-12-19 12:56:27 +00:00
Dan Brown
da01913616 Added ability to copy a role
- Copies via loading in model on create view.
- Updated role views while editing to bring up to similar format as
  that used for more modern app areas.
- Added tests to cover.

Related to #1123
2021-12-19 12:27:14 +00:00
Dan Brown
67b6c07548 Updated failing tests, Applied StyleCI changes 2021-12-18 16:41:42 +00:00
Dan Brown
bb9cd9d610 Aligned password length requirements
Updated all password validation to use central password defaults
system while updating length requirements to now all match
at 8 characters minimum.

Some language text was technically correct (More than 7 characters)
but this has been updated for clarity and to prompt other translations
to be updated.

Closes #2237
2021-12-18 16:33:40 +00:00
Dan Brown
04f37e21e2 Applied latest StyleCI changes 2021-12-18 11:43:05 +00:00
Dan Brown
a3ead5062a Merge branch 'webhooks' 2021-12-18 11:40:08 +00:00
Dan Brown
24e29c523b Aligned notification capitalisation 2021-12-18 11:24:58 +00:00
Dan Brown
04d59763c3 Updated auditlog IP search test
To ensure the test covers filtering logic.
Related to #3081.
2021-12-18 11:05:41 +00:00
Dan Brown
5c04f25c86 Merge branch 'search-by-ip' of https://github.com/johnroyer/BookStack into johnroyer-search-by-ip 2021-12-18 10:58:07 +00:00
Dan Brown
767a82fb41 Reverted unrequired use of mb_ function 2021-12-18 10:43:43 +00:00
Dan Brown
5c5a3de7cb Merge branch 'fix/multibyte-safe-search' 2021-12-18 10:40:38 +00:00
Dan Brown
c6e3e85e82 Added test case for multibyte search highlighting
Related to #3113
2021-12-18 10:38:33 +00:00
Kristian Krastev
d0fd1b7f5c Make building of search results work for multi-byte encoded characters 2021-12-15 16:29:43 +02:00
Dan Brown
d00ac3101d Allowed database queue usage where desired 2021-12-13 18:34:18 +00:00
Dan Brown
f27d0d5aeb Added testing to cover webhook calling
Migrated call logic to Laravel's HTTP client for easier testing
capabilities.
2021-12-12 19:01:50 +00:00
Dan Brown
8d8b45860a Updated REST API docs with links to webhooks & theme-systems 2021-12-12 18:14:22 +00:00
Dan Brown
3bf34b6a0d Added webhook format example to webhook management views 2021-12-12 18:02:08 +00:00
Dan Brown
dbd4281ae8 Added active toggle to webhooks
To allow easy temporary de-activation without deletion or other
workarounds. Updated tests to cover.
2021-12-12 17:39:06 +00:00
Dan Brown
917598f7c8 Added webhook call functionality 2021-12-11 22:29:33 +00:00
Dan Brown
9079700170 Refactored the activity service
- Renamed to "ActivityLogger" to be more focused in usage.
- Extracted out query elements to seperate "ActivityQueries" class.
- Removed old 'addForEntity' activity method to limit activity record
  points.
2021-12-11 17:29:33 +00:00
Dan Brown
f2cb3b94f9 Added missing migration down table drop 2021-12-10 14:58:14 +00:00
Dan Brown
6381041252 Added testing for webhook management interface 2021-12-10 14:54:58 +00:00
Zero
7d13666039 Add unit test for ip addess searching 2021-12-10 15:11:30 +08:00
Zero
e6e92618b1 Fix PHP CS 2021-12-10 14:58:05 +08:00
Zero
2342f0c1c7 Fix UI error of IP searching input box 2021-12-10 14:50:04 +08:00
Zero
ee1106630e Update translation setting in blade 2021-12-10 14:50:04 +08:00
Zero
93e80e5d4e Delete duplicated translation 2021-12-10 14:50:04 +08:00
Zero
72d19968dd Search IP by partial-equal 2021-12-10 14:50:04 +08:00
Zero
2fd7b1f0d5 Update index name to 'activities_ip_index' 2021-12-10 14:50:04 +08:00
Zero
a93254430c Add index for user IP address 2021-12-10 14:50:04 +08:00
Zero
e686b2cf3c Show current search IP 2021-12-10 14:50:04 +08:00
Zero
4e63554cc6 Add an hidden submit
This hidden submit makes auto submit while user press Enter on IP
input field.
2021-12-10 14:50:04 +08:00
Zero
882f195927 Add margin right for IP input box 2021-12-10 14:50:04 +08:00
Zero
a12e346439 Add filter of user IP 2021-12-10 14:50:04 +08:00
Zero
8dee3d3a83 Add label translation 2021-12-10 14:50:04 +08:00
Zero
0e25298db9 Fix label and input box error 2021-12-10 14:50:04 +08:00
Zero
9cac6fad73 Add IP address search field mock 2021-12-10 14:50:04 +08:00
Dan Brown
8716b1922b Completed webhook management interface
Got webhook CRUD actions in place within the interface.
Quick manual test pass done, Needs automated tests.
2021-12-08 17:35:58 +00:00
Dan Brown
4621d8bcc5 Initial controller/views for webhooks management 2021-12-08 14:29:42 +00:00
Dan Brown
a3a3055695 Started webhook implementation 2021-12-07 14:55:11 +00:00
312 changed files with 5834 additions and 1487 deletions

View File

@@ -100,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
REDIS_SERVERS=127.0.0.1:6379:0
# Queue driver to use
# Queue not really currently used but may be configurable in the future.
# Would advise not to change this for now.
# Can be 'sync', 'database' or 'redis'
QUEUE_CONNECTION=sync
# Storage system to use

View File

@@ -126,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
tatsuya.info :: Japanese
fadiapp :: Arabic
Jakub Bouček (jakubboucek) :: Czech
Marco (cdrfun) :: German
Marco (cdrfun) :: German; German Informal
10935336 :: Chinese Simplified
孟繁阳 (FanyangMeng) :: Chinese Simplified
Andrej Močan (andrejm) :: Slovenian
@@ -206,3 +206,8 @@ Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
Nguyen Hung Phuong (hnwolf) :: Vietnamese
Umut ERGENE (umutergene67) :: Turkish
Tomáš Batelka (Vofy) :: Czech
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
Zarik (3apuk) :: Russian
Ali Shaatani (a.shaatani) :: Arabic
ChacMaster :: Portuguese, Brazilian

View File

@@ -0,0 +1,115 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
$activity = $this->newActivityForUser($type);
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
}
$activity->save();
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type): void
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* @param string|Loggable $detail
*/
protected function dispatchWebhooks(string $type, $detail): void
{
$webhooks = Webhook::query()
->whereHas('trackedEvents', function (Builder $query) use ($type) {
$query->where('event', '=', $type)
->orWhere('event', '=', 'all');
})
->where('active', '=', true)
->get();
foreach ($webhooks as $webhook) {
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book;
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
class ActivityQueries
{
protected $activity;
protected $permissionService;
public function __construct(Activity $activity, PermissionService $permissionService)
public function __construct(PermissionService $permissionService)
{
$this->activity = $activity;
$this->permissionService = $permissionService;
}
/**
* Add activity data to database for an entity.
*/
public function addForEntity(Entity $entity, string $type)
{
$activity = $this->newActivityForUser($type);
$entity->activity()->save($activity);
$this->setNotification($type);
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
if ($detail instanceof Loggable) {
$detail = $detail->logDescriptor();
}
$activity = $this->newActivityForUser($type);
$activity->detail = $detail;
$activity->save();
$this->setNotification($type);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return $this->activity->newInstance()->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
@@ -111,7 +52,7 @@ class ActivityService
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
}
$query = $this->activity->newQuery();
$query = Activity::query();
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
@@ -138,7 +79,7 @@ class ActivityService
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
@@ -152,8 +93,6 @@ class ActivityService
* Filters out similar activity.
*
* @param Activity[] $activities
*
* @return array
*/
protected function filterSimilar(iterable $activities): array
{
@@ -170,32 +109,4 @@ class ActivityService
return $newActivity;
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type)
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -53,4 +53,16 @@ class ActivityType
const MFA_SETUP_METHOD = 'mfa_setup_method';
const MFA_REMOVE_METHOD = 'mfa_remove_method';
const WEBHOOK_CREATE = 'webhook_create';
const WEBHOOK_UPDATE = 'webhook_update';
const WEBHOOK_DELETE = 'webhook_delete';
/**
* Get all the possible values.
*/
public static function all(): array
{
return (new \ReflectionClass(static::class))->getConstants();
}
}

View File

@@ -45,7 +45,7 @@ class CommentRepo
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
}

View File

@@ -0,0 +1,132 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Theming\ThemeEvents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* @var Webhook
*/
protected $webhook;
/**
* @var string
*/
protected $event;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var User
*/
protected $initiator;
/**
* @var int
*/
protected $initiatedTime;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Webhook $webhook, string $event, $detail)
{
$this->webhook = $webhook;
$this->event = $event;
$this->detail = $detail;
$this->initiator = user();
$this->initiatedTime = time();
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$lastError = null;
try {
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->post($this->webhook->endpoint, $webhookData);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
if (isset($response) && $response->failed()) {
$lastError = "Response status from endpoint was {$response->status()}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
}
$this->webhook->last_called_at = now();
if ($lastError) {
$this->webhook->last_errored_at = now();
$this->webhook->last_error = $lastError;
}
$this->webhook->save();
}
protected function buildWebhookData(): array
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
$data = [
'event' => $this->event,
'text' => implode(' ', $textParts),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->detail->attributesToArray();
}
return $data;
}
}

85
app/Actions/Webhook.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
namespace BookStack\Actions;
use BookStack\Interfaces\Loggable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $endpoint
* @property Collection $trackedEvents
* @property bool $active
* @property int $timeout
* @property string $last_error
* @property Carbon $last_called_at
* @property Carbon $last_errored_at
*/
class Webhook extends Model implements Loggable
{
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
protected $casts = [
'last_called_at' => 'datetime',
'last_errored_at' => 'datetime',
];
/**
* Define the tracked event relation a webhook.
*/
public function trackedEvents(): HasMany
{
return $this->hasMany(WebhookTrackedEvent::class);
}
/**
* Update the tracked events for a webhook from the given list of event types.
*/
public function updateTrackedEvents(array $events): void
{
$this->trackedEvents()->delete();
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
if (in_array('all', $events)) {
$eventsToStore = ['all'];
}
$trackedEvents = [];
foreach ($eventsToStore as $event) {
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
}
$this->trackedEvents()->saveMany($trackedEvents);
}
/**
* Check if this webhook tracks the given event.
*/
public function tracksEvent(string $event): bool
{
return $this->trackedEvents->pluck('event')->contains($event);
}
/**
* Get a URL for this webhook within the settings interface.
*/
public function getUrl(string $path = ''): string
{
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
}
/**
* Get the string descriptor for this item.
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\Actions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $webhook_id
* @property string $event
*/
class WebhookTrackedEvent extends Model
{
protected $fillable = ['event'];
use HasFactory;
}

View File

@@ -2,7 +2,6 @@
namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -218,14 +217,6 @@ class UserRepo
}
}
/**
* Get the latest activity for a user.
*/
public function getActivity(User $user, int $count = 20, int $page = 0): array
{
return Activity::userActivity($user, $count, $page);
}
/**
* Get the recently created content for this given user.
*/

View File

@@ -11,7 +11,7 @@
return [
// Default driver to use for the queue
// Options: null, sync, redis
// Options: sync, database, redis
'default' => env('QUEUE_CONNECTION', 'sync'),
// Queue connection configuration

View File

@@ -4,6 +4,9 @@ namespace BookStack\Console\Commands;
use BookStack\Auth\UserRepo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
class CreateAdmin extends Command
@@ -45,43 +48,33 @@ class CreateAdmin extends Command
*/
public function handle()
{
$email = trim($this->option('email'));
if (empty($email)) {
$email = $this->ask('Please specify an email address for the new admin user');
$details = $this->options();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('Invalid email address provided');
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required', Password::default()],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return SymfonyCommand::FAILURE;
}
if ($this->userRepo->getByEmail($email) !== null) {
$this->error('A user with the provided email already exists!');
return SymfonyCommand::FAILURE;
}
$name = trim($this->option('name'));
if (empty($name)) {
$name = $this->ask('Please specify an name for the new admin user');
}
if (mb_strlen($name) < 2) {
$this->error('Invalid name provided');
return SymfonyCommand::FAILURE;
}
$password = trim($this->option('password'));
if (empty($password)) {
$password = $this->secret('Please specify a password for the new admin user');
}
if (mb_strlen($password) < 5) {
$this->error('Invalid password provided, Must be at least 5 characters');
return SymfonyCommand::FAILURE;
}
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
$user = $this->userRepo->create($validator->validated());
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);
$user->email_confirmed = true;

View File

@@ -18,7 +18,7 @@ class Chapter extends BookChild
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/**

View File

@@ -59,7 +59,7 @@ class Deletion extends Model implements Loggable
/**
* Get a URL for this specific deletion.
*/
public function getUrl($path): string
public function getUrl(string $path = 'restore'): string
{
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
}

View File

@@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
@@ -35,6 +36,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon $deleted_at
* @property int $created_by
* @property int $updated_by
* @property bool $restricted
@@ -45,7 +47,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@@ -321,4 +323,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
->where('user_id', '=', user()->id)
->exists();
}
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -46,19 +46,10 @@ class PageRevision extends Model
/**
* Get the url for this revision.
*
* @param null|string $path
*
* @return string
*/
public function getUrl($path = null)
public function getUrl(string $path = ''): string
{
$url = $this->page->getUrl() . '/revisions/' . $this->id;
if ($path) {
return $url . '/' . trim($path, '/');
}
return $url;
return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
}
/**

View File

@@ -91,7 +91,7 @@ class BookRepo
{
$book = new Book();
$this->baseRepo->create($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
}
@@ -102,7 +102,7 @@ class BookRepo
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;
}
@@ -127,7 +127,7 @@ class BookRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
Activity::add(ActivityType::BOOK_DELETE, $book);
$trashCan->autoClearOld();
}

View File

@@ -90,7 +90,7 @@ class BookshelfRepo
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
return $shelf;
}
@@ -106,7 +106,7 @@ class BookshelfRepo
$this->updateBooks($shelf, $bookIds);
}
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
return $shelf;
}
@@ -177,7 +177,7 @@ class BookshelfRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
$trashCan->autoClearOld();
}
}

View File

@@ -5,10 +5,12 @@ namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
@@ -49,7 +51,7 @@ class ChapterRepo
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
return $chapter;
}
@@ -60,7 +62,7 @@ class ChapterRepo
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
return $chapter;
}
@@ -74,7 +76,7 @@ class ChapterRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
$trashCan->autoClearOld();
}
@@ -84,27 +86,43 @@ class ChapterRepo
* 'book:<id>' (book:5).
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
$stringExploded = explode(':', $parentIdentifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be moved into books');
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
throw new MoveOperationException('Book to move chapter into not found');
}
/** @var Book $parent */
$parent = Book::visible()->where('id', '=', $entityId)->first();
if ($parent === null) {
throw new MoveOperationException('Book to move chapter into not found');
if (!userCan('chapter-create', $parent)) {
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
}
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
return $parent;
}
/**
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
public function findParentByIdentifier(string $identifier): ?Book
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be in books');
}
return Book::visible()->where('id', '=', $entityId)->first();
}
}

View File

@@ -171,7 +171,7 @@ class PageRepo
$draft->indexForSearch();
$draft->refresh();
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
Activity::add(ActivityType::PAGE_CREATE, $draft);
return $draft;
}
@@ -205,7 +205,7 @@ class PageRepo
$this->savePageRevision($page, $summary);
}
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
Activity::add(ActivityType::PAGE_UPDATE, $page);
return $page;
}
@@ -281,7 +281,7 @@ class PageRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
Activity::add(ActivityType::PAGE_DELETE, $page);
$trashCan->autoClearOld();
}
@@ -312,7 +312,7 @@ class PageRepo
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->savePageRevision($page, $summary);
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
Activity::add(ActivityType::PAGE_RESTORE, $page);
return $page;
}
@@ -328,7 +328,7 @@ class PageRepo
public function move(Page $page, string $parentIdentifier): Entity
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
if (is_null($parent)) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
@@ -341,56 +341,19 @@ class PageRepo
$page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
Activity::add(ActivityType::PAGE_MOVE, $page);
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->getParent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$copyPage = $this->getNewDraftPage($parent);
$pageData = $page->getAttributes();
// Update name
if (!empty($newName)) {
$pageData['name'] = $newName;
}
// Copy tags from previous page if set
if ($page->tags) {
$pageData['tags'] = [];
foreach ($page->tags as $tag) {
$pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
}
}
return $this->publishDraft($copyPage, $pageData);
}
/**
* Find a page parent entity via a identifier string in the format:
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
protected function findParentByIdentifier(string $identifier): ?Entity
public function findParentByIdentifier(string $identifier): ?Entity
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];

View File

@@ -7,7 +7,6 @@ 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
@@ -107,111 +106,209 @@ class BookContents
}
/**
* Sort the books content using the given map.
* The map is a single-dimension collection of objects in the following format:
* {
* +"id": "294" (ID of item)
* +"sort": 1 (Sort order index)
* +"parentChapter": false (ID of parent chapter, as string, or false)
* +"type": "page" (Entity type of item)
* +"book": "1" (Id of book to place item in)
* }.
*
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @throws SortOperationException
* @returns Book[]
*/
public function sortUsingMap(Collection $sortMap): Collection
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$this->loadModelsIntoSortMap($sortMap);
$booksInvolved = $this->getBooksInvolvedInSort($sortMap);
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
$sortMap->each(function ($mapItem) {
$this->applySortUpdates($mapItem);
});
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
// Update permissions and activity.
$booksInvolved->each(function (Book $book) {
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return strpos($key, 'book:') === 0;
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
});
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required.
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(\stdClass $sortMapItem)
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $sortMapItem->model;
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
$chapterChanged = ($model instanceof Page) && intval($model->chapter_id) !== $sortMapItem->parentChapter;
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($sortMapItem->book);
$model->changeBook($newBook->id);
}
if ($chapterChanged) {
$model->chapter_id = intval($sortMapItem->parentChapter);
$model->save();
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = intval($sortMapItem->sort);
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model->save();
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*/
protected function loadModelsIntoSortMap(Collection $sortMap): void
{
$keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
return $sortMapItem->type . ':' . $sortMapItem->id;
});
$pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
$chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
$pages = Page::visible()->whereIn('id', $pageIds)->get();
$chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
foreach ($pages as $page) {
$sortItem = $keyMap->get('page:' . $page->id);
$sortItem->model = $page;
}
foreach ($chapters as $chapter) {
$sortItem = $keyMap->get('chapter:' . $chapter->id);
$sortItem->model = $chapter;
}
}
/**
* Get the books involved in a sort.
* The given sort map should have its models loaded first.
*
* @throws SortOperationException
* @return array<string, Entity>
*/
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$bookIdsInvolved = collect([$this->book->id]);
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
$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');
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
return $books;
$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace BookStack\Entities\Tools;
class BookSortMap
{
/**
* @var BookSortMapItem[]
*/
protected $mapData = [];
public function addItem(BookSortMapItem $mapItem): void
{
$this->mapData[] = $mapItem;
}
/**
* @return BookSortMapItem[]
*/
public function all(): array
{
return $this->mapData;
}
public static function fromJson(string $json): self
{
$map = new BookSortMap();
$mapData = json_decode($json);
foreach ($mapData as $mapDataItem) {
$item = new BookSortMapItem(
intval($mapDataItem->id),
intval($mapDataItem->sort),
$mapDataItem->parentChapter ? intval($mapDataItem->parentChapter) : null,
$mapDataItem->type,
intval($mapDataItem->book)
);
$map->addItem($item);
}
return $map;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace BookStack\Entities\Tools;
class BookSortMapItem
{
/**
* @var int
*/
public $id;
/**
* @var int
*/
public $sort;
/**
* @var ?int
*/
public $parentChapterId;
/**
* @var string
*/
public $type;
/**
* @var int
*/
public $parentBookId;
public function __construct(int $id, int $sort, ?int $parentChapterId, string $type, int $parentBookId)
{
$this->id = $id;
$this->sort = $sort;
$this->parentChapterId = $parentChapterId;
$this->type = $type;
$this->parentBookId = $parentBookId;
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
class Cloner
{
/**
* @var PageRepo
*/
protected $pageRepo;
/**
* @var ChapterRepo
*/
protected $chapterRepo;
/**
* @var BookRepo
*/
protected $bookRepo;
/**
* @var ImageService
*/
protected $imageService;
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
{
$this->pageRepo = $pageRepo;
$this->chapterRepo = $chapterRepo;
$this->bookRepo = $bookRepo;
$this->imageService = $imageService;
}
/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $original->getAttributes();
// Update name & tags
$pageData['name'] = $newName;
$pageData['tags'] = $this->entityTagsToInputArray($original);
return $this->pageRepo->publishDraft($copyPage, $pageData);
}
/**
* Clone the given page into the given parent using the provided name.
* Clones all child pages.
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $original->getAttributes();
$chapterDetails['name'] = $newName;
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
if (userCan('page-create', $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name);
}
}
return $copyChapter;
}
/**
* Clone the given book.
* Clones all child chapters & pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
$bookDetails = $original->getAttributes();
$bookDetails['name'] = $newName;
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
$copyBook = $this->bookRepo->create($bookDetails);
$directChildren = $original->getDirectChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);
}
if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
$this->clonePage($child, $copyBook, $child->name);
}
}
if ($original->cover) {
try {
$tmpImgFile = tmpfile();
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
} catch (\Exception $exception) {
}
}
return $copyBook;
}
/**
* Convert an image instance to an UploadedFile instance to mimic
* a file being uploaded.
*/
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
{
$imgData = $this->imageService->getImageData($image);
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
file_put_contents($tmpImgFilePath, $imgData);
return new UploadedFile($tmpImgFilePath, basename($image->path));
}
/**
* Convert the tags on the given entity to the raw format
* that's used for incoming request data.
*/
protected function entityTagsToInputArray(Entity $entity): array
{
$tags = [];
/** @var Tag $tag */
foreach ($entity->tags as $tag) {
$tags[] = ['name' => $tag->name, 'value' => $tag->value];
}
return $tags;
}
}

View File

@@ -35,7 +35,7 @@ class PermissionsUpdater
$entity->save();
$entity->rebuildPermissions();
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
}
/**

View File

@@ -57,17 +57,17 @@ class SearchResultsFormatter
protected function highlightTagsContainingTerms(array $tags, array $terms): void
{
foreach ($tags as $tag) {
$tagName = strtolower($tag->name);
$tagValue = strtolower($tag->value);
$tagName = mb_strtolower($tag->name);
$tagValue = mb_strtolower($tag->value);
foreach ($terms as $term) {
$termLower = strtolower($term);
$termLower = mb_strtolower($term);
if (strpos($tagName, $termLower) !== false) {
if (mb_strpos($tagName, $termLower) !== false) {
$tag->setAttribute('highlight_name', true);
}
if (strpos($tagValue, $termLower) !== false) {
if (mb_strpos($tagValue, $termLower) !== false) {
$tag->setAttribute('highlight_value', true);
}
}
@@ -84,17 +84,17 @@ class SearchResultsFormatter
protected function getMatchPositions(string $text, array $terms): array
{
$matchRefs = [];
$text = strtolower($text);
$text = mb_strtolower($text);
foreach ($terms as $term) {
$offset = 0;
$term = strtolower($term);
$pos = strpos($text, $term, $offset);
$term = mb_strtolower($term);
$pos = mb_strpos($text, $term, $offset);
while ($pos !== false) {
$end = $pos + strlen($term);
$end = $pos + mb_strlen($term);
$matchRefs[$pos] = $end;
$offset = $end;
$pos = strpos($text, $term, $offset);
$pos = mb_strpos($text, $term, $offset);
}
}
@@ -141,7 +141,7 @@ class SearchResultsFormatter
*/
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
{
$maxEnd = strlen($originalText);
$maxEnd = mb_strlen($originalText);
$fetchAll = ($targetLength === 0);
$contextLength = ($fetchAll ? 0 : 32);
@@ -165,7 +165,7 @@ class SearchResultsFormatter
$contextStart = $start;
// Trims off '$startDiff' number of characters to bring it back to the start
// if this current match zone.
$content = substr($content, 0, strlen($content) + $startDiff);
$content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
$contentTextLength += $startDiff;
}
@@ -176,16 +176,16 @@ class SearchResultsFormatter
} elseif ($fetchAll) {
// Or fill in gap since the previous match
$fillLength = $contextStart - $lastEnd;
$content .= e(substr($originalText, $lastEnd, $fillLength));
$content .= e(mb_substr($originalText, $lastEnd, $fillLength));
$contentTextLength += $fillLength;
}
// Add our content including the bolded matching text
$content .= e(substr($originalText, $contextStart, $start - $contextStart));
$content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
$contentTextLength += $start - $contextStart;
$content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
$content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
$contentTextLength += $end - $start;
$content .= e(substr($originalText, $end, $contextEnd - $end));
$content .= e(mb_substr($originalText, $end, $contextEnd - $end));
$contentTextLength += $contextEnd - $end;
// Update our last end position
@@ -204,7 +204,7 @@ class SearchResultsFormatter
// Just copy out the content if we haven't moved along anywhere.
if ($lastEnd === 0) {
$content = e(substr($originalText, 0, $targetLength));
$content = e(mb_substr($originalText, 0, $targetLength));
$contentTextLength = $targetLength;
$lastEnd = $targetLength;
}
@@ -213,7 +213,7 @@ class SearchResultsFormatter
$remainder = $targetLength - $contentTextLength;
if ($remainder > 10) {
$padEndLength = min($maxEnd - $lastEnd, $remainder);
$content .= e(substr($originalText, $lastEnd, $padEndLength));
$content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
$lastEnd += $padEndLength;
$contentTextLength += $padEndLength;
}
@@ -223,7 +223,7 @@ class SearchResultsFormatter
$firstStart = $firstStart ?: 0;
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
$padStart = max(0, $firstStart - $remainder);
$content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
$content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
}
// Add ellipsis if we're not at the end

View File

@@ -22,9 +22,12 @@ class TrashCan
{
/**
* Send a shelf to the recycle bin.
*
* @throws NotifyException
*/
public function softDestroyShelf(Bookshelf $shelf)
{
$this->ensureDeletable($shelf);
Deletion::createForEntity($shelf);
$shelf->delete();
}
@@ -36,6 +39,7 @@ class TrashCan
*/
public function softDestroyBook(Book $book)
{
$this->ensureDeletable($book);
Deletion::createForEntity($book);
foreach ($book->pages as $page) {
@@ -57,6 +61,7 @@ class TrashCan
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
{
if ($recordDelete) {
$this->ensureDeletable($chapter);
Deletion::createForEntity($chapter);
}
@@ -77,19 +82,47 @@ class TrashCan
public function softDestroyPage(Page $page, bool $recordDelete = true)
{
if ($recordDelete) {
$this->ensureDeletable($page);
Deletion::createForEntity($page);
}
// Check if set as custom homepage & remove setting if not used or throw error if active
$customHome = setting('app-homepage', '0:');
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
$page->delete();
}
/**
* Ensure the given entity is deletable.
* Is not for permissions, but logical conditions within the application.
* Will throw if not deletable.
*
* @throws NotifyException
*/
protected function ensureDeletable(Entity $entity): void
{
$customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
$customHomeActive = setting('app-homepage-type') === 'page';
$removeCustomHome = false;
// Check custom homepage usage for pages
if ($entity instanceof Page && $entity->id === $customHomeId) {
if ($customHomeActive) {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
}
setting()->remove('app-homepage');
$removeCustomHome = true;
}
$page->delete();
// Check custom homepage usage within chapters or books
if ($entity instanceof Chapter || $entity instanceof Book) {
if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
if ($customHomeActive) {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
}
$removeCustomHome = true;
}
}
if ($removeCustomHome) {
setting()->remove('app-homepage');
}
}
/**

View File

@@ -1,9 +0,0 @@
<?php
namespace BookStack\Exceptions;
use Exception;
class SortOperationException extends Exception
{
}

View File

@@ -4,6 +4,9 @@ namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @see \BookStack\Actions\ActivityLogger
*/
class Activity extends Facade
{
/**

View File

@@ -20,6 +20,7 @@ class AuditLogController extends Controller
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
'ip' => $request->get('ip', ''),
];
$query = Activity::query()
@@ -44,6 +45,9 @@ class AuditLogController extends Controller
if ($listDetails['date_to']) {
$query->where('created_at', '<=', $listDetails['date_to']);
}
if ($listDetails['ip']) {
$query->where('ip', 'like', $listDetails['ip'] . '%');
}
$activities = $query->paginate(100);
$activities->appends($listDetails);

View File

@@ -2,11 +2,11 @@
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\Facades\Activity;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;

View File

@@ -29,6 +29,8 @@ class MfaBackupCodesController extends Controller
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
$this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
return view('mfa.backup-codes-generate', [
'codes' => $codes,
'downloadUrl' => $downloadUrl,

View File

@@ -21,6 +21,8 @@ class MfaController extends Controller
->get(['id', 'method'])
->groupBy('method');
$this->setPageTitle(trans('auth.mfa_setup'));
return view('mfa.setup', [
'userMethods' => $userMethods,
]);

View File

@@ -34,6 +34,8 @@ class MfaTotpController extends Controller
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
return view('mfa.totp-generate', [
'url' => $qrCodeUrl,
'svg' => $svg,

View File

@@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
@@ -70,7 +71,7 @@ class RegisterController extends Controller
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'min:8'],
'password' => ['required', Password::default()],
]);
}

View File

@@ -11,6 +11,7 @@ use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\Rules\Password;
class UserInviteController extends Controller
{
@@ -55,7 +56,7 @@ class UserInviteController extends Controller
public function setPassword(Request $request, string $token)
{
$this->validate($request, [
'password' => ['required', 'min:8'],
'password' => ['required', Password::default()],
]);
try {

View File

@@ -2,15 +2,18 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
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\Cloner;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -101,7 +104,7 @@ class BookController extends Controller
if ($bookshelf) {
$bookshelf->appendBook($book);
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
}
return redirect($book->getUrl());
@@ -110,7 +113,7 @@ class BookController extends Controller
/**
* Display the specified book.
*/
public function show(Request $request, string $slug)
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$bookChildren = (new BookContents($book))->getTree(true);
@@ -128,7 +131,7 @@ class BookController extends Controller
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'activity' => Activity::entityActivity($book, 20, 1),
'activity' => $activities->entityActivity($book, 20, 1),
]);
}
@@ -224,4 +227,39 @@ class BookController extends Controller
return redirect($book->getUrl());
}
/**
* Show the view to copy a book.
*
* @throws NotFoundException
*/
public function showCopy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-view', $book);
session()->flashInput(['name' => $book->name]);
return view('books.copy', [
'book' => $book,
]);
}
/**
* Create a copy of a book within the requested target destination.
*
* @throws NotFoundException
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
$this->showSuccessNotification(trans('entities.books_copy_success'));
return redirect($bookCopy->getUrl());
}
}

View File

@@ -3,10 +3,9 @@
namespace BookStack\Http\Controllers;
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\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
@@ -59,20 +58,14 @@ class BookSortController extends Controller
return redirect($book->getUrl());
}
$sortMap = collect(json_decode($request->get('sort-tree')));
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = collect();
try {
$booksInvolved = $bookContents->sortUsingMap($sortMap);
} catch (SortOperationException $exception) {
$this->showPermissionError();
}
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
Activity::addForEntity($book, ActivityType::BOOK_SORT);
});
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
}
return redirect($book->getUrl());
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookshelfRepo;
@@ -101,7 +101,7 @@ class BookshelfController extends Controller
*
* @throws NotFoundException
*/
public function show(string $slug)
public function show(ActivityQueries $activities, string $slug)
{
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $shelf);
@@ -124,7 +124,7 @@ class BookshelfController extends Controller
'shelf' => $shelf,
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
'view' => $view,
'activity' => Activity::entityActivity($shelf, 20, 1),
'activity' => $activities->entityActivity($shelf, 20, 1),
'order' => $order,
'sort' => $sort,
]);

View File

@@ -6,10 +6,12 @@ use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -179,6 +181,8 @@ class ChapterController extends Controller
try {
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (MoveOperationException $exception) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
@@ -190,6 +194,53 @@ class ChapterController extends Controller
return redirect($chapter->getUrl());
}
/**
* Show the view to copy a chapter.
*
* @throws NotFoundException
*/
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
return view('chapters.copy', [
'book' => $chapter->book,
'chapter' => $chapter,
]);
}
/**
* Create a copy of a chapter within the requested target destination.
*
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
if (is_null($newParentBook)) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('chapter-create', $newParentBook);
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
return redirect($chapterCopy->getUrl());
}
/**
* Show the Restrictions view.
*

View File

@@ -48,6 +48,8 @@ abstract class Controller extends BaseController
/**
* On a permission error redirect to home and display.
* the error as a notification.
*
* @return never
*/
protected function showPermissionError()
{

View File

@@ -21,6 +21,8 @@ class FavouriteController extends Controller
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
$this->setPageTitle(trans('entities.my_favourites'));
return view('common.detailed-listing-with-more', [
'title' => trans('entities.my_favourites'),
'entities' => $favourites->slice(0, $viewCount),

View File

@@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\RecentlyViewed;
@@ -16,9 +16,9 @@ class HomeController extends Controller
/**
* Display the homepage.
*/
public function index()
public function index(ActivityQueries $activities)
{
$activity = Activity::latest(10);
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {

View File

@@ -67,7 +67,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try {
user()->notify(new TestEmail());
user()->notifyNow(new TestEmail());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();

View File

@@ -6,6 +6,7 @@ use BookStack\Actions\View;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
@@ -367,6 +368,8 @@ class PageController extends Controller
->paginate(20)
->setPath(url('/pages/recently-updated'));
$this->setPageTitle(trans('entities.recently_updated_pages'));
return view('common.detailed-listing-paginated', [
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
@@ -409,11 +412,9 @@ class PageController extends Controller
try {
$parent = $this->pageRepo->move($page, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
@@ -447,26 +448,24 @@ class PageController extends Controller
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, string $bookSlug, string $pageSlug)
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$entitySelection = $request->get('entity_selection', null) ?? null;
$newName = $request->get('name', null);
try {
$pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
if (is_null($newParent)) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('page-create', $newParent);
$newName = $request->get('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
$this->showSuccessNotification(trans('entities.pages_copy_success'));
return redirect($pageCopy->getUrl());

View File

@@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Http\Request;
@@ -23,22 +24,36 @@ class RoleController extends Controller
/**
* Show a listing of the roles in the system.
*/
public function list()
public function index()
{
$this->checkPermission('user-roles-manage');
$roles = $this->permissionsRepo->getAllRoles();
$this->setPageTitle(trans('settings.roles'));
return view('settings.roles.index', ['roles' => $roles]);
}
/**
* Show the form to create a new role.
*/
public function create()
public function create(Request $request)
{
$this->checkPermission('user-roles-manage');
return view('settings.roles.create');
/** @var ?Role $role */
$role = null;
if ($request->has('copy_from')) {
$role = Role::query()->find($request->get('copy_from'));
}
if ($role) {
$role->display_name .= ' (' . trans('common.copy') . ')';
}
$this->setPageTitle(trans('settings.role_create'));
return view('settings.roles.create', ['role' => $role]);
}
/**
@@ -49,7 +64,7 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180',
'description' => ['max:180'],
]);
$this->permissionsRepo->saveNewRole($request->all());
@@ -71,6 +86,8 @@ class RoleController extends Controller
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
}
$this->setPageTitle(trans('settings.role_edit'));
return view('settings.roles.edit', ['role' => $role]);
}
@@ -84,7 +101,7 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180',
'description' => ['max:180'],
]);
$this->permissionsRepo->updateRole($id, $request->all());
@@ -105,6 +122,8 @@ class RoleController extends Controller
$blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
$roles->prepend($blankRole);
$this->setPageTitle(trans('settings.role_delete'));
return view('settings.roles.delete', ['role' => $role, 'roles' => $roles]);
}

View File

@@ -32,6 +32,8 @@ class TagController extends Controller
'name' => $nameFilter,
]));
$this->setPageTitle(trans('entities.tags'));
return view('tags.index', [
'tags' => $tags,
'search' => $search,

View File

@@ -13,6 +13,7 @@ use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
@@ -82,7 +83,7 @@ class UserController extends Controller
$sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = ['required', 'min:6'];
$validationRules['password'] = ['required', Password::default()];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = ['required'];
@@ -155,11 +156,11 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->validate($request, [
'name' => 'min:2',
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['min:6', 'required_with:password_confirm'],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'setting' => 'array',
'setting' => ['array'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);

View File

@@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@@ -9,14 +10,16 @@ class UserProfileController extends Controller
/**
* Show the user profile page.
*/
public function show(UserRepo $repo, string $slug)
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
{
$user = $repo->getBySlug($slug);
$userActivity = $repo->getActivity($user);
$userActivity = $activities->userActivity($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
$this->setPageTitle($user->name);
return view('users.profile', [
'user' => $user,
'activity' => $userActivity,

View File

@@ -0,0 +1,134 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __construct()
{
$this->middleware([
'can:settings-manage',
]);
}
/**
* Show all webhooks configured in the system.
*/
public function index()
{
$webhooks = Webhook::query()
->orderBy('name', 'desc')
->with('trackedEvents')
->get();
$this->setPageTitle(trans('settings.webhooks'));
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
}
/**
* Show the view for creating a new webhook in the system.
*/
public function create()
{
$this->setPageTitle(trans('settings.webhooks_create'));
return view('settings.webhooks.create');
}
/**
* Store a new webhook in the system.
*/
public function store(Request $request)
{
$validated = $this->validate($request, [
'name' => ['required', 'max:150'],
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
]);
$webhook = new Webhook($validated);
$webhook->active = $validated['active'] === 'true';
$webhook->save();
$webhook->updateTrackedEvents(array_values($validated['events']));
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
return redirect('/settings/webhooks');
}
/**
* Show the view to edit an existing webhook.
*/
public function edit(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()
->with('trackedEvents')
->findOrFail($id);
$this->setPageTitle(trans('settings.webhooks_edit'));
return view('settings.webhooks.edit', ['webhook' => $webhook]);
}
/**
* Update an existing webhook with the provided request data.
*/
public function update(Request $request, string $id)
{
$validated = $this->validate($request, [
'name' => ['required', 'max:150'],
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
]);
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$webhook->active = $validated['active'] === 'true';
$webhook->fill($validated)->save();
$webhook->updateTrackedEvents($validated['events']);
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
return redirect('/settings/webhooks');
}
/**
* Show the view to delete a webhook.
*/
public function delete(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$this->setPageTitle(trans('settings.webhooks_delete'));
return view('settings.webhooks.delete', ['webhook' => $webhook]);
}
/**
* Destroy a webhook from the system.
*/
public function destroy(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$webhook->trackedEvents()->delete();
$webhook->delete();
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
return redirect('/settings/webhooks');
}
}

View File

@@ -11,6 +11,7 @@ use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AuthServiceProvider extends ServiceProvider
{
@@ -21,6 +22,12 @@ class AuthServiceProvider extends ServiceProvider
*/
public function boot()
{
// Password Configuration
Password::defaults(function () {
return Password::min(8);
});
// Custom guards
Auth::extend('api-token', function ($app, $name, array $config) {
return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
});

View File

@@ -2,7 +2,7 @@
namespace BookStack\Providers;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ActivityLogger;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
@@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
public function register()
{
$this->app->singleton('activity', function () {
return $this->app->make(ActivityService::class);
return $this->app->make(ActivityLogger::class);
});
$this->app->singleton('images', function () {

View File

@@ -79,4 +79,20 @@ class ThemeEvents
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
*/
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
/**
* Webhook call before event.
* Runs before a webhook endpoint is called. Allows for customization
* of the data format & content within the webhook POST request.
* Provides the original event name as a string (see \BookStack\Actions\ActivityType)
* along with the webhook instance along with the event detail which may be a
* "Loggable" model type or a string.
* If the listener returns a non-null value, that will be used as the POST data instead
* of the system default.
*
* @param string $event
* @param \BookStack\Actions\Webhook $webhook
* @param string|\BookStack\Interfaces\Loggable $detail
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -228,6 +228,21 @@ class ImageService
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return strpos($initialHeader, 'acTL') !== false;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
@@ -238,6 +253,7 @@ class ImageService
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
@@ -246,19 +262,35 @@ class ImageService
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
return $this->getPublicUrl($thumbFilePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
if ($cachedThumbPath) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
$thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
$imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
$this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
return $this->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}

View File

@@ -17,6 +17,7 @@ class WebSafeMimeSniffer
'application/json',
'application/octet-stream',
'application/pdf',
'image/apng',
'image/bmp',
'image/jpeg',
'image/png',

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories\Actions;
use BookStack\Actions\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookFactory extends Factory
{
protected $model = Webhook::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url,
'active' => true,
'timeout' => 3,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Actions;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookTrackedEventFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'webhook_id' => Webhook::factory(),
'event' => ActivityType::all()[array_rand(ActivityType::all())],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace Database\Factories\Auth;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -12,7 +13,7 @@ class UserFactory extends Factory
*
* @var string
*/
protected $model = \BookStack\Auth\User::class;
protected $model = User::class;
/**
* Define the model's default state.
@@ -26,7 +27,7 @@ class UserFactory extends Factory
return [
'name' => $name,
'email' => $this->faker->email,
'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
'slug' => Str::slug($name . '-' . Str::random(5)),
'password' => Str::random(10),
'remember_token' => Str::random(10),
'email_confirmed' => 1,

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddIndexForUserIp extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('activities', function (Blueprint $table) {
$table->index('ip', 'activities_ip_index');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('activities', function (Blueprint $table) {
$table->dropIndex('activities_ip_index');
});
}
}

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebhooksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('webhooks', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 150);
$table->boolean('active');
$table->string('endpoint', 500);
$table->timestamps();
$table->index('name');
$table->index('active');
});
Schema::create('webhook_tracked_events', function (Blueprint $table) {
$table->increments('id');
$table->integer('webhook_id');
$table->string('event', 50);
$table->timestamps();
$table->index('event');
$table->index('webhook_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('webhooks');
Schema::dropIfExists('webhook_tracked_events');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('jobs');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateFailedJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('failed_jobs');
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddWebhooksTimeoutErrorColumns extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('webhooks', function (Blueprint $table) {
$table->unsignedInteger('timeout')->default(3);
$table->text('last_error')->default('');
$table->timestamp('last_called_at')->nullable();
$table->timestamp('last_errored_at')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('webhooks', function (Blueprint $table) {
$table->dropColumn('timeout');
$table->dropColumn('last_error');
$table->dropColumn('last_called_at');
$table->dropColumn('last_errored_at');
});
}
}

48
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

BIN
public/loading_error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -34,13 +34,17 @@ Big thanks to these companies for supporting the project.
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
[View all sponsors](https://github.com/sponsors/ssddanbrown).
#### Bronze Sponsors
#### Silver Sponsor
<table><tbody><tr>
<td><a href="https://www.diagrams.net/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
</a></td>
</tr></tbody></table>
#### Bronze Sponsor
<table><tbody><tr>
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>

After

Width:  |  Height:  |  Size: 903 B

View File

@@ -7,6 +7,8 @@ class EntitySelectorPopup {
setup() {
this.elem = this.$el;
this.selectButton = this.$refs.select;
this.searchInput = this.$refs.searchInput;
window.EntitySelectorPopup = this;
this.callback = null;
@@ -20,6 +22,7 @@ class EntitySelectorPopup {
show(callback) {
this.callback = callback;
this.elem.components.popup.show();
this.searchInput.focus();
}
hide() {

View File

@@ -74,6 +74,10 @@ class ImageManager {
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
this.listContainer.addEventListener('error', event => {
event.target.src = baseUrl('loading_error.png');
}, true);
onSelect(this.selectButton, () => {
if (this.callback) {
this.callback(this.lastSelected);

View File

@@ -50,6 +50,7 @@ import templateManager from "./template-manager.js"
import toggleSwitch from "./toggle-switch.js"
import triLayout from "./tri-layout.js"
import userSelect from "./user-select.js"
import webhookEvents from "./webhook-events";
import wysiwygEditor from "./wysiwyg-editor.js"
const componentMapping = {
@@ -105,6 +106,7 @@ const componentMapping = {
"toggle-switch": toggleSwitch,
"tri-layout": triLayout,
"user-select": userSelect,
"webhook-events": webhookEvents,
"wysiwyg-editor": wysiwygEditor,
};

View File

@@ -395,8 +395,9 @@ class MarkdownEditor {
actionInsertImage() {
const cursorPos = this.cm.getCursor('from');
window.ImageManager.show(image => {
const imageUrl = image.thumbs.display || image.url;
let selectedText = this.cm.getSelection();
let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")";
let newText = "[![" + (selectedText || image.name) + "](" + imageUrl + ")](" + image.url + ")";
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);

View File

@@ -0,0 +1,32 @@
/**
* Webhook Events
* Manages dynamic selection control in the webhook form interface.
* @extends {Component}
*/
class WebhookEvents {
setup() {
this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]');
this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]');
this.$el.addEventListener('change', event => {
if (event.target.checked && event.target === this.allCheckbox) {
this.deselectIndividualEvents();
} else if (event.target.checked) {
this.allCheckbox.checked = false;
}
});
}
deselectIndividualEvents() {
for (const checkbox of this.checkboxes) {
if (checkbox !== this.allCheckbox) {
checkbox.checked = false;
}
}
}
}
export default WebhookEvents;

View File

@@ -563,8 +563,9 @@ class WysiwygEditor {
}
// Replace the actively selected content with the linked image
const imageUrl = image.thumbs.display || image.url;
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += `<img src="${imageUrl}" alt="${image.name}">`;
html += '</a>';
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
}, 'gallery');
@@ -723,8 +724,9 @@ class WysiwygEditor {
tooltip: 'Insert an image',
onclick: function () {
window.ImageManager.show(function (image) {
const imageUrl = image.thumbs.display || image.url;
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
html += `<img src="${imageUrl}" alt="${image.name}">`;
html += '</a>';
editor.execCommand('mceInsertContent', false, html);
}, 'gallery');

View File

@@ -211,9 +211,9 @@ function wysiwygView(elem) {
const doc = elem.ownerDocument;
const codeElem = elem.querySelector('code');
let lang = (elem.className || '').replace('language-', '');
if (lang === '' && codeElem) {
lang = (codeElem.className || '').replace('language-', '')
let lang = getLanguageFromCssClasses(elem.className || '');
if (!lang && codeElem) {
lang = getLanguageFromCssClasses(codeElem.className || '');
}
elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
@@ -228,7 +228,7 @@ function wysiwygView(elem) {
elem.parentNode.replaceChild(newWrap, elem);
newWrap.appendChild(newTextArea);
newWrap.contentEditable = false;
newWrap.contentEditable = 'false';
newTextArea.textContent = content;
let cm = CodeMirror(function(elt) {
@@ -245,6 +245,16 @@ function wysiwygView(elem) {
return {wrap: newWrap, editor: cm};
}
/**
* Get the code language from the given css classes.
* @param {String} classes
* @return {String}
*/
function getLanguageFromCssClasses(classes) {
const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
return (langClasses[0] || '').replace('language-', '');
}
/**
* Create a CodeMirror instance to show in the WYSIWYG pop-up editor
* @param {HTMLElement} elem

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'تم إنشاء صفحة',
'page_create_notification' => 'تم إنشاء الصفحة بنجاح',
'page_create_notification' => 'Page successfully created',
'page_update' => 'تم تحديث الصفحة',
'page_update_notification' => 'تم تحديث الصفحة بنجاح',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'تم حذف الصفحة',
'page_delete_notification' => 'تم حذف الصفحة بنجاح',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'تمت استعادة الصفحة',
'page_restore_notification' => 'تمت استعادة الصفحة بنجاح',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'تم نقل الصفحة',
// Chapters
'chapter_create' => 'تم إنشاء فصل',
'chapter_create_notification' => 'تم إنشاء فصل بنجاح',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'تم تحديث الفصل',
'chapter_update_notification' => 'تم تحديث الفصل بنجاح',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'تم حذف الفصل',
'chapter_delete_notification' => 'تم حذف الفصل بنجاح',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'تم نقل الفصل',
// Books
'book_create' => 'تم إنشاء كتاب',
'book_create_notification' => 'تم إنشاء كتاب بنجاح',
'book_create_notification' => 'Book successfully created',
'book_update' => 'تم تحديث الكتاب',
'book_update_notification' => 'تم تحديث الكتاب بنجاح',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'تم حذف الكتاب',
'book_delete_notification' => 'تم حذف الكتاب بنجاح',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'تم سرد الكتاب',
'book_sort_notification' => 'أُعِيدَ سرد الكتاب بنجاح',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'تم إنشاء رف الكتب',
'bookshelf_create_notification' => 'تم إنشاء الرف بنجاح',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'تم تحديث الرف',
'bookshelf_update_notification' => 'تم تحديث الرف بنجاح',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'تم تحديث الرف',
'bookshelf_delete_notification' => 'تم حذف الرف بنجاح',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'تم التعليق',
'permissions_update' => 'تحديث الأذونات',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'password_confirm' => 'تأكيد كلمة المرور',
'password_hint' => 'يجب أن تكون أكثر من 7 حروف',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'نسيت كلمة المرور؟',
'remember_me' => 'تذكرني',
'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
@@ -79,7 +79,7 @@ return [
'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_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.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'عرض منسدل',
'default' => 'افتراضي',
'breadcrumb' => 'شريط التنقل',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'عرض القائمة',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'الفصول الأخيرة',
'books_sort_show_other' => 'عرض كتب أخرى',
'books_sort_save' => 'حفظ الترتيب الجديد',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'فصل',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'نقل الفصل',
'chapters_move_named' => 'نقل فصل :chapterName',
'chapter_move_success' => 'تم نقل الفصل إلى :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'أذونات الفصل',
'chapters_empty' => 'لا توجد أي صفحات في هذا الفصل حالياً',
'chapters_permissions_active' => 'أذونات الفصل مفعلة',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'هل أنت متأكد من أنك تريد استعادة هذه المراجعة؟ سيتم استبدال محتوى الصفحة الحالية.',
'revision_delete_success' => 'تم حذف المراجعة',
'revision_cannot_delete_latest' => 'لايمكن حذف آخر مراجعة.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'أدوار المستخدمين',
'users_role_desc' => 'حدد الأدوار التي سيتم تعيين هذا المستخدم لها. إذا تم تعيين مستخدم لأدوار متعددة ، فسيتم تكديس الأذونات من هذه الأدوار وسيتلقى كل قدرات الأدوار المعينة.',
'users_password' => 'كلمة مرور المستخدم',
'users_password_desc' => 'قم بتعيين كلمة مرور مستخدمة لتسجيل الدخول إلى التطبيق. يجب ألا يقل طول هذه الكلمة عن 6 أحرف.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'يمكنك اختيار إرسال دعوة بالبريد الإلكتروني إلى هذا المستخدم مما يسمح له بتعيين كلمة المرور الخاصة به أو يمكنك تعيين كلمة المرور الخاصة به بنفسك.',
'users_send_invite_option' => 'أرسل بريدًا إلكترونيًا لدعوة المستخدم',
'users_external_auth_id' => 'ربط الحساب بمواقع التواصل',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف رمز API؟',
'user_api_token_delete_success' => 'تم حذف رمز الـ API بنجاح',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'създадена страница',
'page_create_notification' => 'Страницата беше успешно създадена',
'page_create_notification' => 'Page successfully created',
'page_update' => 'обновена страница',
'page_update_notification' => 'Страницата успешно обновена',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'изтрита страница',
'page_delete_notification' => 'Страницата беше успешно изтрита',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'възстановена страница',
'page_restore_notification' => 'Страницата беше успешно възстановена',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'преместена страница',
// Chapters
'chapter_create' => 'създадена страница',
'chapter_create_notification' => 'Главата беше успешно създадена',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'обновена глава',
'chapter_update_notification' => 'Главата беше успешно обновена',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'изтрита глава',
'chapter_delete_notification' => 'Главата беше успешно изтрита',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'преместена глава',
// Books
'book_create' => 'създадена книга',
'book_create_notification' => 'Книгата беше успешно създадена',
'book_create_notification' => 'Book successfully created',
'book_update' => 'обновена книга',
'book_update_notification' => 'Книгата беше успешно обновена',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'изтрита книга',
'book_delete_notification' => 'Книгата беше успешно изтрита',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'сортирана книга',
'book_sort_notification' => 'Книгата беше успешно преподредена',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'създаден рафт',
'bookshelf_create_notification' => 'Рафтът беше успешно създаден',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'обновен рафт',
'bookshelf_update_notification' => 'Рафтът беше успешно обновен',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'изтрит рафт',
'bookshelf_delete_notification' => 'Рафтът беше успешно изтрит',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'коментирано на',
'permissions_update' => 'updated permissions',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'Имейл',
'password' => 'Парола',
'password_confirm' => 'Потвърди паролата',
'password_hint' => 'Трябва да бъде поне 7 символа',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Забравена парола?',
'remember_me' => 'Запомни ме',
'ldap_email_hint' => 'Моля въведете емейл, който да използвате за дадения акаунт.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Изглед списък',
'default' => 'Основен',
'breadcrumb' => 'Трасиране',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Последна глава',
'books_sort_show_other' => 'Покажи други книги',
'books_sort_save' => 'Запази новата подредба',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Глава',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Премести глава',
'chapters_move_named' => 'Премести глава :chapterName',
'chapter_move_success' => 'Главата беше преместена в :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Настойки за достъп на главата',
'chapters_empty' => 'Няма създадени страници в тази глава.',
'chapters_permissions_active' => 'Настройките за достъп до глава са активни',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Сигурни ли сте, че искате да изтриете тази версия? Настоящата страница ще бъде заместена.',
'revision_delete_success' => 'Версията беше изтрита',
'revision_cannot_delete_latest' => 'Не може да изтриете последната версия.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'User Roles',
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
'users_password' => 'User Password',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
'users_send_invite_option' => 'Send user invite email',
'users_external_auth_id' => 'External Authentication ID',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
'user_api_token_delete_success' => 'API token successfully deleted',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'je kreirao/la stranicu',
'page_create_notification' => 'Stranica Uspješno Kreirana',
'page_create_notification' => 'Page successfully created',
'page_update' => 'je ažurirao/la stranicu',
'page_update_notification' => 'Stranica Uspješno Ažurirana',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'je izbrisao/la stranicu',
'page_delete_notification' => 'Stranica Uspješno Izbrisana',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'je vratio/la stranicu',
'page_restore_notification' => 'Stranica Uspješno Vraćena',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'je premjestio/la stranicu',
// Chapters
'chapter_create' => 'je kreirao/la poglavlje',
'chapter_create_notification' => 'Poglavlje Uspješno Kreirano',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'je ažurirao/la poglavlje',
'chapter_update_notification' => 'Poglavlje Uspješno Ažurirano',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'je izbrisao/la poglavlje',
'chapter_delete_notification' => 'Poglavlje Uspješno Izbrisano',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'je premjestio/la poglavlje',
// Books
'book_create' => 'je kreirao/la knjigu',
'book_create_notification' => 'Knjiga Uspješno Kreirana',
'book_create_notification' => 'Book successfully created',
'book_update' => 'je ažurirao/la knjigu',
'book_update_notification' => 'Knjiga Uspješno Ažurirana',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'je izbrisao/la knjigu',
'book_delete_notification' => 'Knjiga Uspješno Izbrisana',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'je sortirao/la knjigu',
'book_sort_notification' => 'Knjiga Uspješno Ponovno Sortirana',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'je kreirao/la Policu za knjige',
'bookshelf_create_notification' => 'Polica za knjige Uspješno Kreirana',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'je ažurirao/la policu za knjige',
'bookshelf_update_notification' => 'Polica za knjige Uspješno Ažurirana',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'je izbrisao/la policu za knjige',
'bookshelf_delete_notification' => 'Polica za knjige Uspješno Izbrisana',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" je dodan u tvoje favorite',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'je komentarisao/la na',
'permissions_update' => 'je ažurirao/la dozvole',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'E-mail',
'password' => 'Lozinka',
'password_confirm' => 'Potvrdi lozinku',
'password_hint' => 'Mora imati više od 7 karaktera',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Zaboravljena lozinka?',
'remember_me' => 'Zapamti me',
'ldap_email_hint' => 'Unesite e-mail koji će se koristiti za ovaj račun.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Prikaz liste',
'default' => 'Početne postavke',
'breadcrumb' => 'Navigacijske stavke',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Otvori meni u zaglavlju',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Poglavlja zadnja',
'books_sort_show_other' => 'Prikaži druge knjige',
'books_sort_save' => 'Spremi trenutni poredak',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Poglavlje',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Premjesti poglavlje',
'chapters_move_named' => 'Premjesti poglavlje :chapterName',
'chapter_move_success' => 'Poglavlje premješteno u :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Dozvole poglavlja',
'chapters_empty' => 'U ovom poglavlju trenutno nema stranica.',
'chapters_permissions_active' => 'Dozvole za poglavlje su aktivne',
@@ -332,4 +336,12 @@ 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.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'User Roles',
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
'users_password' => 'User Password',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
'users_send_invite_option' => 'Send user invite email',
'users_external_auth_id' => 'External Authentication ID',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
'user_api_token_delete_success' => 'API token successfully deleted',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'ha creat la pàgina',
'page_create_notification' => 'Pàgina creada correctament',
'page_create_notification' => 'Page successfully created',
'page_update' => 'ha actualitzat la pàgina',
'page_update_notification' => 'Pàgina actualitzada correctament',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'ha suprimit una pàgina',
'page_delete_notification' => 'Pàgina suprimida correctament',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'ha restaurat la pàgina',
'page_restore_notification' => 'Pàgina restaurada correctament',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'ha mogut la pàgina',
// Chapters
'chapter_create' => 'ha creat el capítol',
'chapter_create_notification' => 'Capítol creat correctament',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'ha actualitzat el capítol',
'chapter_update_notification' => 'Capítol actualitzat correctament',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'ha suprimit un capítol',
'chapter_delete_notification' => 'Capítol suprimit correctament',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'ha mogut el capítol',
// Books
'book_create' => 'ha creat el llibre',
'book_create_notification' => 'Llibre creat correctament',
'book_create_notification' => 'Book successfully created',
'book_update' => 'ha actualitzat el llibre',
'book_update_notification' => 'Llibre actualitzat correctament',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'ha suprimit un llibre',
'book_delete_notification' => 'Llibre suprimit correctament',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'ha ordenat el llibre',
'book_sort_notification' => 'Llibre reordenat correctament',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'ha creat el prestatge',
'bookshelf_create_notification' => 'Prestatge creat correctament',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'ha actualitzat el prestatge',
'bookshelf_update_notification' => 'Prestatge actualitzat correctament',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'ha suprimit un prestatge',
'bookshelf_delete_notification' => 'Prestatge suprimit correctament',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'ha comentat a',
'permissions_update' => 'ha actualitzat els permisos',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'Adreça electrònica',
'password' => 'Contrasenya',
'password_confirm' => 'Confirmeu la contrasenya',
'password_hint' => 'Cal que tingui més de 7 caràcters',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Heu oblidat la contrasenya?',
'remember_me' => 'Recorda\'m',
'ldap_email_hint' => 'Introduïu una adreça electrònica per a aquest compte.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Visualització en llista',
'default' => 'Per defecte',
'breadcrumb' => 'Ruta de navegació',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Els capítols al final',
'books_sort_show_other' => 'Mostra altres llibres',
'books_sort_save' => 'Desa l\'ordre nou',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Capítol',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Mou el capítol',
'chapters_move_named' => 'Mou el capítol :chapterName',
'chapter_move_success' => 'S\'ha mogut el capítol a :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Permisos del capítol',
'chapters_empty' => 'De moment, aquest capítol no conté cap pàgina.',
'chapters_permissions_active' => 'S\'han activat els permisos del capítol',
@@ -332,4 +336,12 @@ return [
'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ó.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'Rols de l\'usuari',
'users_role_desc' => 'Seleccioneu a quins rols s\'assignarà l\'usuari. Si un usuari s\'assigna a múltiples rols, els permisos dels rols s\'acumularan i l\'usuari rebrà tots els permisos dels rols assignats.',
'users_password' => 'Contrasenya de l\'usuari',
'users_password_desc' => 'Definiu una contrasenya per a iniciar la sessió a l\'aplicació. Cal que tingui un mínim de 6 caràcters.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'Podeu elegir enviar un correu d\'invitació a aquest usuari, la qual cosa li permetrà definir la seva contrasenya, o podeu definir-li una contrasenya vós.',
'users_send_invite_option' => 'Envia un correu d\'invitació a l\'usuari',
'users_external_auth_id' => 'Identificador d\'autenticació extern',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Segur que voleu suprimir aquest testimoni d\'API?',
'user_api_token_delete_success' => 'Testimoni d\'API suprimit correctament',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -11,7 +11,7 @@ return [
'page_update' => 'aktualizoval/a stránku',
'page_update_notification' => 'Stránka byla úspěšně aktualizována',
'page_delete' => 'odstranil/a stránku',
'page_delete_notification' => 'Stránka byla odstraněna',
'page_delete_notification' => 'Stránka byla úspěšně smazána',
'page_restore' => 'obnovil/a stránku',
'page_restore_notification' => 'Stránka byla úspěšně obnovena',
'page_move' => 'přesunul/a stránku',
@@ -22,18 +22,18 @@ return [
'chapter_update' => 'aktualizoval/a kapitolu',
'chapter_update_notification' => 'Kapitola byla úspěšně aktualizována',
'chapter_delete' => 'odstranila/a kapitolu',
'chapter_delete_notification' => 'Kapitola byla odstraněna',
'chapter_delete_notification' => 'Kapitola byla úspěšně odstraněna',
'chapter_move' => 'přesunul/a kapitolu',
// Books
'book_create' => 'vytvořil/a knihu',
'book_create_notification' => 'Kniha byla vytvořena',
'book_create_notification' => 'Kniha byla úspěšně vytvořena',
'book_update' => 'aktualizoval/a knihu',
'book_update_notification' => 'Kniha byla aktualizována',
'book_update_notification' => 'Kniha byla úspěšně aktualizována',
'book_delete' => 'odstranil/a knihu',
'book_delete_notification' => 'Kniha byla odstraněna',
'book_delete_notification' => 'Kniha byla úspěšně odstraněna',
'book_sort' => 'seřadil/a knihu',
'book_sort_notification' => 'Kniha byla seřazena',
'book_sort_notification' => 'Kniha byla úspěšně seřazena',
// Bookshelves
'bookshelf_create' => 'vytvořil/a knihovnu',
@@ -41,7 +41,7 @@ return [
'bookshelf_update' => 'aktualizoval/a knihovnu',
'bookshelf_update_notification' => 'Knihovna byla úspěšně aktualizována',
'bookshelf_delete' => 'odstranil/a knihovnu',
'bookshelf_delete_notification' => 'Knihovna byla odstraněna',
'bookshelf_delete_notification' => 'Knihovna byla úspěšně smazána',
// Favourites
'favourite_add_notification' => '":name" byla přidána do Vašich oblíbených',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Vícefaktorová metoda byla úspěšně nakonfigurována',
'mfa_remove_method_notification' => 'Vícefaktorová metoda byla úspěšně odstraněna',
// Webhooks
'webhook_create' => 'vytvořil/a webhook',
'webhook_create_notification' => 'Webhook byl úspěšně vytvořen',
'webhook_update' => 'aktualizoval/a webhook',
'webhook_update_notification' => 'Webhook byl úspěšně aktualizován',
'webhook_delete' => 'odstranil/a webhook',
'webhook_delete_notification' => 'Webhook byl úspěšně odstraněn',
// Other
'commented_on' => 'okomentoval/a',
'permissions_update' => 'oprávnění upravena',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'E-mail',
'password' => 'Heslo',
'password_confirm' => 'Potvrzení hesla',
'password_hint' => 'Musí mít víc než 7 znaků',
'password_hint' => 'Musí mít alespoň 8 znaků',
'forgot_password' => 'Zapomenuté heslo?',
'remember_me' => 'Zapamatovat si mě',
'ldap_email_hint' => 'Zadejte email, který chcete přiřadit k tomuto účtu.',
@@ -54,7 +54,7 @@ return [
'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' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => 'Váš email byl ověřen! Nyní byste měli být schopni se touto emailovou adresou přihlásit.',
'email_confirm_resent' => 'E-mail s potvrzením byl znovu odeslán. Zkontrolujte svou příchozí poštu.',
'email_not_confirmed' => 'E-mailová adresa nebyla potvrzena',
@@ -71,40 +71,40 @@ return [
'user_invite_page_welcome' => 'Vítejte v :appName!',
'user_invite_page_text' => 'Pro dokončení vašeho účtu a získání přístupu musíte nastavit heslo, které bude použito k přihlášení do :appName při dalších návštěvách.',
'user_invite_page_confirm_button' => 'Potvrdit heslo',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => 'Heslo bylo nasteaveno, nyní byste měli být schopni přihlásit se nastaveným heslem do aplikace :appName!',
// Multi-factor Authentication
'mfa_setup' => '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_setup' => 'Nastavit vícefaktorové ověření',
'mfa_setup_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
'mfa_setup_configured' => 'Již nastaveno',
'mfa_setup_reconfigure' => 'Přenastavit',
'mfa_setup_remove_confirmation' => 'Opravdu chcete odstranit tuto metodu vícefaktorového ověřování?',
'mfa_setup_action' => 'Nastavit',
'mfa_backup_codes_usage_limit_warning' => 'Zbývá vám méně než 5 záložních kódů. Před vypršením kódu si prosím vygenerujte a uložte novou sadu, abyste se vyhnuli zablokování vašeho účtu.',
'mfa_option_totp_title' => 'Mobilní aplikace',
'mfa_option_totp_desc' => '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_option_totp_desc' => 'Pro použití vícefaktorového ověření budete potřebovat mobil aplikaci, která podporuje TOTP jako např. Google Authenticator, Authy nebo Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Záložní kódy',
'mfa_option_backup_codes_desc' => 'Bezpečně si uložte sadu jednorázových záložních kódů, které můžete použít pro ověření vaší identity.',
'mfa_gen_confirm_and_enable' => 'Potvrdit a povolit',
'mfa_gen_backup_codes_title' => '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_backup_codes_title' => 'Nastavení záložních kódů',
'mfa_gen_backup_codes_desc' => 'Uložte níže uvedený seznam kódů na bezpečné místo. Při přístupu k systému budete moci použít jeden z kódů jako druhou metodu ověření.',
'mfa_gen_backup_codes_download' => 'Stáhnout kódy',
'mfa_gen_backup_codes_usage_warning' => 'Každý kód může být použit pouze jednou',
'mfa_gen_totp_title' => 'Nastavení mobilní aplikace',
'mfa_gen_totp_desc' => '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_desc' => 'Pro použití vícefaktorového ověření budete potřebovat mobil aplikaci, která podporuje TOTP jako např. Google Authenticator, Authy nebo Microsoft Authenticator.',
'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
'mfa_gen_totp_verify_setup' => 'Verify Setup',
'mfa_gen_totp_verify_setup' => 'Ověřit nastavení',
'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
'mfa_verify_access' => 'Verify Access',
'mfa_gen_totp_provide_code_here' => 'Zde zadejte kód vygenerovaný vaší aplikací',
'mfa_verify_access' => 'Ověřit přístup',
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
'mfa_verify_no_methods' => 'No Methods Configured',
'mfa_verify_no_methods' => 'Nejsou nastaveny žádné metody',
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
'mfa_verify_use_totp' => '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_verify_use_totp' => 'Ověřit pomocí mobil aplikace',
'mfa_verify_use_backup_codes' => 'Ověřit pomocí záložního kódu',
'mfa_verify_backup_code' => 'Záložní kód',
'mfa_verify_backup_code_desc' => 'Níže zadejte jeden z vašich zbývajících záložních kódů:',
'mfa_verify_backup_code_enter_here' => 'Zde zadejte záložní kód',
'mfa_verify_totp_desc' => 'Níže zadejte kód, který jste si vygenerovali pomocí mobil aplikace:',
'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
];

View File

@@ -45,8 +45,8 @@ return [
'unfavourite' => 'Odebrat z oblíbených',
'next' => 'Další',
'previous' => 'Předchozí',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => 'Aktivní filtr:',
'filter_clear' => 'Zrušit filtr',
// Sort Options
'sort_options' => 'Možnosti řazení',
@@ -71,6 +71,10 @@ return [
'list_view' => 'Zobrazení seznamu',
'default' => 'Výchozí',
'breadcrumb' => 'Drobečková navigace',
'status' => 'Stav',
'status_active' => 'Aktivní',
'status_inactive' => 'Neaktivní',
'never' => 'Nikdy',
// Header
'header_menu_expand' => 'Rozbalit menu v záhlaví',
@@ -78,7 +82,7 @@ return [
'view_profile' => 'Zobrazit profil',
'edit_profile' => 'Upravit profil',
'dark_mode' => 'Tmavý režim',
'light_mode' => 'Světelný režim',
'light_mode' => 'Světlý režim',
// Layout tabs
'tab_info' => 'Informace',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Kapitoly jako poslední',
'books_sort_show_other' => 'Zobrazit ostatní knihy',
'books_sort_save' => 'Uložit nové pořadí',
'books_copy' => 'Kopírovat knihu',
'books_copy_success' => 'Kniha byla úspěšně zkopírována',
// Chapters
'chapter' => 'Kapitola',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Přesunout kapitolu',
'chapters_move_named' => 'Přesunout kapitolu :chapterName',
'chapter_move_success' => 'Kapitola přesunuta do knihy :bookName',
'chapters_copy' => 'Kopírovat kapitolu',
'chapters_copy_success' => 'Kapitola byla úspěšně zkopírována',
'chapters_permissions' => 'Oprávnění kapitoly',
'chapters_empty' => 'Tato kapitola neobsahuje žádné stránky',
'chapters_permissions_active' => 'Oprávnění kapitoly byla aktivována',
@@ -258,16 +262,16 @@ return [
'tags_explain' => "Přidejte si štítky pro lepší kategorizaci knih. \n Štítky mohou nést i hodnotu pro detailnější klasifikaci.",
'tags_add' => 'Přidat další štítek',
'tags_remove' => 'Odstranit tento štítek',
'tags_usages' => 'Total tag usages',
'tags_assigned_pages' => 'Assigned to Pages',
'tags_assigned_chapters' => 'Assigned to Chapters',
'tags_assigned_books' => 'Assigned to Books',
'tags_assigned_shelves' => 'Assigned to Shelves',
'tags_x_unique_values' => ':count unique values',
'tags_all_values' => 'All values',
'tags_view_tags' => 'View Tags',
'tags_view_existing_tags' => 'View existing tags',
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
'tags_usages' => 'Počet použití štítku',
'tags_assigned_pages' => 'Přiřazeno ke stránkám',
'tags_assigned_chapters' => 'Přiřazeno ke kapitolám',
'tags_assigned_books' => 'Přiřazeno ke knihám',
'tags_assigned_shelves' => 'Přiřazeno ke knihovnám',
'tags_x_unique_values' => ':count jedinečných hodnot',
'tags_all_values' => 'Všechny hodnoty',
'tags_view_tags' => 'Zobrazit štítky',
'tags_view_existing_tags' => 'Zobrazit existující štítky',
'tags_list_empty_hint' => 'Štítky mohou být přiřazeny pomocí postranního panelu editoru stránky nebo při úpravách podrobností knihy, kapitoly nebo knihovny.',
'attachments' => 'Přílohy',
'attachments_explain' => 'Nahrajte soubory nebo připojte odkazy, které se zobrazí na stránce. Budou k nalezení v postranní liště.',
'attachments_explain_instant_save' => 'Změny zde provedené se okamžitě ukládají.',
@@ -332,4 +336,12 @@ return [
'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.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Vlastní nastavení oprávnění nebudou zkopírovány.',
'copy_consider_owner' => 'Stanete se vlastníkem veškerého kopírovaného obsahu.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Přílohy stránky nebudou zkopírovány.',
'copy_consider_access' => 'Po změně umístění, vlastníka nebo oprávnění může dojít k tomu, že obsah může být přístupný těm, kteří přístup dříve něměli.',
];

View File

@@ -174,14 +174,14 @@ return [
'users_role' => 'Uživatelské role',
'users_role_desc' => 'Zvolte role, do kterých chcete uživatele zařadit. Pokud bude uživatel zařazen do více rolí, oprávnění z těchto rolí se sloučí a uživateli bude dovoleno vše, k čemu mají jednotlivé role oprávnění.',
'users_password' => 'Heslo uživatele',
'users_password_desc' => 'Zadejte heslo pro přihlášení do aplikace. Heslo musí být nejméně 6 znaků dlouhé.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'Uživateli můžete poslat pozvánku e-mailem, která umožní uživateli, aby si zvolil sám svoje heslo do aplikace a nebo můžete zadat heslo sami.',
'users_send_invite_option' => 'Poslat uživateli pozvánku e-mailem',
'users_external_auth_id' => 'Přihlašovací identifikátor třetích stran',
'users_external_auth_id_desc' => 'ID použité pro rozpoznání tohoto uživatele když komunikuje s externím přihlašovacím systémem.',
'users_password_warning' => 'Vyplňujte pouze v případě, že chcete heslo změnit.',
'users_system_public' => 'Symbolizuje každého nepřihlášeného návštěvníka, který navštívil aplikaci. Nelze ho použít k přihlášení ale je přiřazen automaticky nepřihlášeným.',
'users_delete' => 'Smazat uživatele',
'users_delete' => 'Odstranit uživatele',
'users_delete_named' => 'Odstranit uživatele :userName',
'users_delete_warning' => 'Uživatel \':userName\' bude zcela odstraněn ze systému.',
'users_delete_confirm' => 'Opravdu chcete tohoto uživatele smazat?',
@@ -206,10 +206,10 @@ return [
'users_api_tokens_none' => 'Tento uživatel nemá vytvořené žádné API Tokeny',
'users_api_tokens_create' => 'Vytvořit Token',
'users_api_tokens_expires' => 'Vyprší',
'users_api_tokens_docs' => 'API Dokumentace',
'users_api_tokens_docs' => 'Dokumentace API',
'users_mfa' => 'Vícefázové ověření',
'users_mfa_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
'users_mfa_x_methods' => ':count method configured|:count methods configured',
'users_mfa_x_methods' => ':count nastavená metoda|:count nastavených metod',
'users_mfa_configure' => 'Konfigurovat metody',
// API Tokens
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Opravdu chcete odstranit tento API Token?',
'user_api_token_delete_success' => 'API Token byl odstraněn',
// Webhooks
'webhooks' => 'Webhooky',
'webhooks_create' => 'Vytvořit nový webhook',
'webhooks_none_created' => 'Žádné webhooky nebyly doposud vytvořeny.',
'webhooks_edit' => 'Upravit webhook',
'webhooks_save' => 'Uložit webhook',
'webhooks_details' => 'Podrobnosti webhooku',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Události webhooku',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'Všechny události systému',
'webhooks_name' => 'Název webhooku',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook aktivní',
'webhook_events_table_header' => 'Události',
'webhooks_delete' => 'Odstranit webhook',
'webhooks_delete_warning' => 'Webhook s názvem \':webhookName\' bude úplně odstraněn ze systému.',
'webhooks_delete_confirm' => 'Opravdu chcete odstranit tento webhook?',
'webhooks_format_example' => 'Příklad formátu webhooku',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'oprettede side',
'page_create_notification' => 'Siden blev oprettet',
'page_create_notification' => 'Page successfully created',
'page_update' => 'opdaterede side',
'page_update_notification' => 'Siden blev opdateret',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'slettede side',
'page_delete_notification' => 'Siden blev slettet',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'gendannede side',
'page_restore_notification' => 'Siden blev gendannet',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'flyttede side',
// Chapters
'chapter_create' => 'oprettede kapitel',
'chapter_create_notification' => 'Kapitel blev oprettet',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'opdaterede kapitel',
'chapter_update_notification' => 'Kapitlet blev opdateret',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'slettede kapitel',
'chapter_delete_notification' => 'Kapitel blev slettet',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'flyttede kapitel',
// Books
'book_create' => 'oprettede bog',
'book_create_notification' => 'Bogen blev oprettet',
'book_create_notification' => 'Book successfully created',
'book_update' => 'opdaterede bog',
'book_update_notification' => 'Bogen blev opdateret',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'slettede bog',
'book_delete_notification' => 'Bogen blev slettet',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'sorterede bogen',
'book_sort_notification' => 'Bogen blev re-sorteret',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'oprettede bogreol',
'bookshelf_create_notification' => 'Bogreolen blev oprettet',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'opdaterede bogreolen',
'bookshelf_update_notification' => 'Bogreolen blev opdateret',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'slettede bogreol',
'bookshelf_delete_notification' => 'Bogreolen blev opdateret',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" er blevet tilføjet til dine favoritter',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret',
'mfa_remove_method_notification' => 'Multi-faktor metode fjernet',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'kommenterede til',
'permissions_update' => 'Tilladelser opdateret',

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