diff --git a/.env.example.complete b/.env.example.complete index 25687aaac..18e7bd00d 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -36,10 +36,14 @@ APP_LANG=en # APP_LANG will be used if such a header is not provided. APP_AUTO_LANG_PUBLIC=true -# Application timezone -# Used where dates are displayed such as on exported content. +# Application timezones +# The first option is used to determine what timezone is used for date storage. +# Leaving that as "UTC" is advised. +# The second option is used to set the timezone which will be used for date +# formatting and display. This defaults to the "APP_TIMEZONE" value. # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php APP_TIMEZONE=UTC +APP_DISPLAY_TIMEZONE=UTC # Application theme # Used to specific a themes/ folder where BookStack UI diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 7115dcb51..6771e513f 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Entities\BreadcrumbsViewComposer; +use BookStack\Util\DateFormatter; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider; class ViewTweaksServiceProvider extends ServiceProvider { + public function register() + { + $this->app->singleton(DateFormatter::class, function ($app) { + return new DateFormatter( + $app['config']->get('app.display_timezone'), + ); + }); + } + /** * Bootstrap services. */ @@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider // View Composers View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); + // View Globals + View::share('dates', $this->app->make(DateFormatter::class)); + // Custom blade view directives Blade::directive('icon', function ($expression) { return "toHtml(); ?>"; diff --git a/app/Config/app.php b/app/Config/app.php index b96d0bdb7..40e542d3e 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -70,8 +70,8 @@ return [ // A list of the sources/hostnames that can be reached by application SSR calls. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // Host-specific functionality (usually controlled via other options) like auth - // or user avatars for example, won't use this list. - // Space seperated if multiple. Can use '*' as a wildcard. + // or user avatars, for example, won't use this list. + // Space separated if multiple. Can use '*' as a wildcard. // Values will be compared prefix-matched, case-insensitive, against called SSR urls. // Defaults to allow all hosts. 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), @@ -80,8 +80,10 @@ return [ // Integer value between 0 (IP hidden) to 4 (Full IP usage) 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), - // Application timezone for back-end date functions. + // Application timezone for stored date/time values. 'timezone' => env('APP_TIMEZONE', 'UTC'), + // Application timezone for displayed date/time values in the UI. + 'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')), // Default locale to use // A default variant is also stored since Laravel can overwrite diff --git a/app/Entities/Tools/PageEditActivity.php b/app/Entities/Tools/PageEditActivity.php index 646b200f1..22f89bf62 100644 --- a/app/Entities/Tools/PageEditActivity.php +++ b/app/Entities/Tools/PageEditActivity.php @@ -4,19 +4,15 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Page; use BookStack\Entities\Models\PageRevision; +use BookStack\Util\DateFormatter; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; class PageEditActivity { - protected Page $page; - - /** - * PageEditActivity constructor. - */ - public function __construct(Page $page) - { - $this->page = $page; + public function __construct( + protected Page $page + ) { } /** @@ -50,11 +46,9 @@ class PageEditActivity /** * Get any editor clash warning messages to show for the given draft revision. * - * @param PageRevision|Page $draft - * * @return string[] */ - public function getWarningMessagesForDraft($draft): array + public function getWarningMessagesForDraft(Page|PageRevision $draft): array { $warnings = []; @@ -82,7 +76,8 @@ class PageEditActivity */ public function getEditingActiveDraftMessage(PageRevision $draft): string { - $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); + $formatter = resolve(DateFormatter::class); + $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $formatter->relative($draft->updated_at)]); if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) { return $message; } diff --git a/app/Util/DateFormatter.php b/app/Util/DateFormatter.php new file mode 100644 index 000000000..c6e60bd53 --- /dev/null +++ b/app/Util/DateFormatter.php @@ -0,0 +1,26 @@ +clone()->setTimezone($this->displayTimezone); + + return $withDisplayTimezone->format('Y-m-d H:i:s T'); + } + + public function relative(Carbon $date, bool $includeSuffix = true): string + { + return $date->diffForHumans(null, $includeSuffix ? null : CarbonInterface::DIFF_ABSOLUTE); + } +} diff --git a/phpunit.xml b/phpunit.xml index a8e725d41..8a7ab9cb7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,6 +16,8 @@ + + diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index d70a8c1d9..67aac7203 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -14,7 +14,8 @@
@if ($comment->createdBy)
- {{ $comment->createdBy->name }} + {{ $comment->createdBy->name }}
@endif
@@ -23,50 +24,55 @@ @else {{ trans('common.deleted_user') }} @endif -  {{ trans('entities.comment_created', ['createDiff' => $comment->created_at->diffForHumans() ]) }} +  {{ trans('entities.comment_created', ['createDiff' => $dates->relative($comment->created_at) ]) }} @if($comment->isUpdated()) - + {{ trans('entities.comment_updated_indicator') }} @endif
@if(!$readOnly && (userCan('comment-create-all') || userCan('comment-update', $comment) || userCan('comment-delete', $comment))) -
- @if(userCan('comment-create-all')) - - @endif - @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment))) - - @endif - @if(userCan('comment-update', $comment)) - - @endif - @if(userCan('comment-delete', $comment)) - - @endif - +
+ @if(userCan('comment-create-all')) + + @endif + @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment))) + + @endif + @if(userCan('comment-update', $comment)) + + @endif + @if(userCan('comment-delete', $comment)) + + @endif +  •  -
+
@endif
@@ -76,7 +82,8 @@
@if ($comment->parent_id)

- @icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }} + @icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}

@endif @if($comment->content_ref) @@ -86,7 +93,8 @@ option:page-comment-reference:view-comment-text="{{ trans('entities.comment_view') }}" option:page-comment-reference:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}" option:page-comment-reference:close-text="{{ trans('common.close') }}" - href="#">@icon('bookmark'){{ trans('entities.comment_reference') }} {{ trans('entities.comment_reference_outdated') }} + href="#">@icon('bookmark'){{ trans('entities.comment_reference') }} + {{ trans('entities.comment_reference_outdated') }}
@endif {!! $commentHtml !!} @@ -95,10 +103,12 @@ @if(!$readOnly && userCan('comment-update', $comment)) diff --git a/resources/views/common/activity-item.blade.php b/resources/views/common/activity-item.blade.php index 1c970084f..1d3c7bd75 100644 --- a/resources/views/common/activity-item.blade.php +++ b/resources/views/common/activity-item.blade.php @@ -26,5 +26,5 @@
- @icon('time'){{ $activity->created_at->diffForHumans() }} + @icon('time'){{ $dates->relative($activity->created_at) }} diff --git a/resources/views/entities/grid-item.blade.php b/resources/views/entities/grid-item.blade.php index ee31b53f2..17c54e263 100644 --- a/resources/views/entities/grid-item.blade.php +++ b/resources/views/entities/grid-item.blade.php @@ -10,7 +10,7 @@

{{ $entity->getExcerpt(130) }}

\ No newline at end of file diff --git a/resources/views/entities/list-item.blade.php b/resources/views/entities/list-item.blade.php index 2fadef191..5174de431 100644 --- a/resources/views/entities/list-item.blade.php +++ b/resources/views/entities/list-item.blade.php @@ -27,9 +27,9 @@ @endif @if(($showUpdatedBy ?? false) && $entity->relationLoaded('updatedBy') && $entity->updatedBy) - + {!! trans('entities.meta_updated_name', [ - 'timeLength' => $entity->updated_at->diffForHumans(), + 'timeLength' => $dates->relative($entity->updated_at), 'user' => e($entity->updatedBy->name) ]) !!} diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 9d3c4b956..060c197a4 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -31,7 +31,7 @@ @icon('star')
{!! trans('entities.meta_created_name', [ - 'timeLength' => ''.$entity->created_at->diffForHumans() . '', + 'timeLength' => ''. $dates->relative($entity->created_at) . '', 'user' => "".e($entity->createdBy->name). "" ]) !!}
@@ -39,7 +39,7 @@ @else
@icon('star') - {{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }} + {{ trans('entities.meta_created', ['timeLength' => $dates->relative($entity->created_at)]) }}
@endif @@ -48,7 +48,7 @@ @icon('edit')
{!! trans('entities.meta_updated_name', [ - 'timeLength' => '' . $entity->updated_at->diffForHumans() .'', + 'timeLength' => '' . $dates->relative($entity->updated_at) .'', 'user' => "".e($entity->updatedBy->name). "" ]) !!}
@@ -56,7 +56,7 @@ @elseif (!$entity->isA('revision'))
@icon('edit') - {{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }} + {{ trans('entities.meta_updated', ['timeLength' => $dates->relative($entity->updated_at)]) }}
@endif diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index a28b79bb3..1c46b7a0b 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -26,7 +26,7 @@
{{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}
-
{{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}
+
{{ trans('entities.import_uploaded_at', ['relativeTime' => $dates->relative($import->created_at)]) }}
@if($import->createdBy)
{{ trans('entities.import_uploaded_by') }} diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php index 2f7659c46..dc287370e 100644 --- a/resources/views/exports/parts/import.blade.php +++ b/resources/views/exports/parts/import.blade.php @@ -5,6 +5,6 @@
{{ $import->getSizeString() }}
-
@icon('time'){{ $import->created_at->diffForHumans() }}
+
@icon('time'){{ $dates->relative($import->created_at) }}
\ No newline at end of file diff --git a/resources/views/exports/parts/meta.blade.php b/resources/views/exports/parts/meta.blade.php index d4128898b..00117f4a1 100644 --- a/resources/views/exports/parts/meta.blade.php +++ b/resources/views/exports/parts/meta.blade.php @@ -4,13 +4,13 @@ @endif @icon('star'){!! trans('entities.meta_created' . ($entity->createdBy ? '_name' : ''), [ - 'timeLength' => $entity->created_at->isoFormat('D MMMM Y HH:mm:ss'), + 'timeLength' => $dates->absolute($entity->created_at), 'user' => e($entity->createdBy->name ?? ''), ]) !!}
@icon('edit'){!! trans('entities.meta_updated' . ($entity->updatedBy ? '_name' : ''), [ - 'timeLength' => $entity->updated_at->isoFormat('D MMMM Y HH:mm:ss'), + 'timeLength' => $dates->absolute($entity->updated_at), 'user' => e($entity->updatedBy->name ?? '') ]) !!} \ No newline at end of file diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php index bd84e247d..452a0aaa1 100644 --- a/resources/views/pages/parts/image-manager-form.blade.php +++ b/resources/views/pages/parts/image-manager-form.blade.php @@ -94,12 +94,12 @@

-
- @icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->diffForHumans()]) }} +
+ @icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $dates->relative($image->created_at)]) }}
@if($image->created_at->valueOf() !== $image->updated_at->valueOf()) -
- @icon('edit') {{ trans('components.image_updated', ['updateDate' => $image->updated_at->diffForHumans()]) }} +
+ @icon('edit') {{ trans('components.image_updated', ['updateDate' => $dates->relative($image->updated_at)]) }}
@endif @if($image->createdBy) diff --git a/resources/views/pages/parts/revisions-index-row.blade.php b/resources/views/pages/parts/revisions-index-row.blade.php index 48bea5b57..19b924763 100644 --- a/resources/views/pages/parts/revisions-index-row.blade.php +++ b/resources/views/pages/parts/revisions-index-row.blade.php @@ -17,8 +17,8 @@ @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif
- {{ $revision->created_at->isoFormat('D MMMM Y HH:mm:ss') }} - ({{ $revision->created_at->diffForHumans() }}) + {{ $dates->absolute($revision->created_at) }} + ({{ $dates->relative($revision->created_at) }})
diff --git a/resources/views/pages/parts/template-manager-list.blade.php b/resources/views/pages/parts/template-manager-list.blade.php index f2f70c142..990583a71 100644 --- a/resources/views/pages/parts/template-manager-list.blade.php +++ b/resources/views/pages/parts/template-manager-list.blade.php @@ -6,7 +6,7 @@ draggable="true" template-id="{{ $template->id }}">
{{ $template->name }}
-
{{ trans('entities.meta_updated', ['timeLength' => $template->updated_at->diffForHumans()]) }}
+
{{ trans('entities.meta_updated', ['timeLength' => $dates->relative($template->updated_at)]) }}
diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php index aa3e49ded..3a1ff49d3 100644 --- a/resources/views/users/api-tokens/edit.blade.php +++ b/resources/views/users/api-tokens/edit.blade.php @@ -42,12 +42,12 @@
- - {{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }} + + {{ trans('settings.user_api_token_created', ['timeAgo' => $dates->relative($token->created_at)]) }}
- - {{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }} + + {{ trans('settings.user_api_token_updated', ['timeAgo' => $dates->relative($token->created_at)]) }}
diff --git a/resources/views/users/parts/users-list-item.blade.php b/resources/views/users/parts/users-list-item.blade.php index dc7c9f272..9c7ecd147 100644 --- a/resources/views/users/parts/users-list-item.blade.php +++ b/resources/views/users/parts/users-list-item.blade.php @@ -20,7 +20,7 @@ @if($user->last_activity_at) {{ trans('settings.users_latest_activity') }}
- {{ $user->last_activity_at->diffForHumans() }} + {{ $dates->relative($user->last_activity_at) }} @endif
diff --git a/resources/views/users/profile.blade.php b/resources/views/users/profile.blade.php index a8be8a4c1..9879091a5 100644 --- a/resources/views/users/profile.blade.php +++ b/resources/views/users/profile.blade.php @@ -23,7 +23,7 @@

{{ $user->name }}

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

diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php index 069cf2801..e039fb2cc 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -99,9 +99,9 @@ class HtmlExportTest extends TestCase $page = $this->entities->page(); $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertSee($page->created_at->format('Y-m-d H:i:s T')); $resp->assertDontSee($page->created_at->diffForHumans()); - $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertSee($page->updated_at->format('Y-m-d H:i:s T')); $resp->assertDontSee($page->updated_at->diffForHumans()); } diff --git a/tests/Util/DateFormatterTest.php b/tests/Util/DateFormatterTest.php new file mode 100644 index 000000000..1c0a458e0 --- /dev/null +++ b/tests/Util/DateFormatterTest.php @@ -0,0 +1,37 @@ +absolute($dateTime); + $this->assertEquals('2020-06-01 13:00:00 BST', $result); + } + + public function test_iso_with_timezone_works_from_non_utc_dates() + { + $formatter = new DateFormatter('Asia/Shanghai'); + $dateTime = new Carbon('2025-06-10 15:25:00', 'America/New_York'); + + $result = $formatter->absolute($dateTime); + $this->assertEquals('2025-06-11 03:25:00 CST', $result); + } + + public function test_relative() + { + $formatter = new DateFormatter('Europe/London'); + $dateTime = (new Carbon('now', 'UTC'))->subMinutes(50); + + $result = $formatter->relative($dateTime); + $this->assertEquals('50 minutes ago', $result); + } +}