Skip to content

Commit 172b60c

Browse files
committed
add timeline backend end-points
1 parent 5c39470 commit 172b60c

File tree

20 files changed

+744
-6
lines changed

20 files changed

+744
-6
lines changed

app/Actions/Photo/Timeline.php

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Actions\Photo;
10+
11+
use App\Eloquent\FixedQueryBuilder;
12+
use App\Enum\ColumnSortingPhotoType;
13+
use App\Enum\OrderSortingType;
14+
use App\Enum\TimelinePhotoGranularity;
15+
use App\Exceptions\Internal\LycheeInvalidArgumentException;
16+
use App\Exceptions\Internal\TimelineGranularityException;
17+
use App\Models\Configs;
18+
use App\Models\Photo;
19+
use App\Policies\PhotoQueryPolicy;
20+
use Illuminate\Database\Eloquent\Builder;
21+
use Illuminate\Support\Carbon;
22+
use Illuminate\Support\Collection;
23+
use Illuminate\Support\Facades\DB;
24+
25+
class Timeline
26+
{
27+
protected PhotoQueryPolicy $photo_query_policy;
28+
private TimelinePhotoGranularity $photo_granularity;
29+
30+
public function __construct(PhotoQueryPolicy $photo_query_policy)
31+
{
32+
$this->photo_query_policy = $photo_query_policy;
33+
$this->photo_granularity = Configs::getValueAsEnum('timeline_photos_granularity', TimelinePhotoGranularity::class);
34+
}
35+
36+
/**
37+
* Create the query manually.
38+
*
39+
* @return FixedQueryBuilder<Photo>
40+
*/
41+
public function do(): Builder
42+
{
43+
$order = Configs::getValueAsEnum('timeline_photos_order', ColumnSortingPhotoType::class);
44+
45+
// Safe default (should not be needed).
46+
// @codeCoverageIgnoreStart
47+
if (!in_array($order, [ColumnSortingPhotoType::CREATED_AT, ColumnSortingPhotoType::TAKEN_AT], true)) {
48+
$order = ColumnSortingPhotoType::TAKEN_AT;
49+
}
50+
// @codeCoverageIgnoreEnd
51+
52+
return $this->photo_query_policy->applySearchabilityFilter(
53+
query: Photo::query()->with(['album', 'size_variants', 'size_variants.sym_links']),
54+
origin: null,
55+
include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_timeline')
56+
)->orderBy($order->value, OrderSortingType::DESC->value);
57+
}
58+
59+
/**
60+
* Return the number of pictures that are younger than this.
61+
* We use this to dertermine the current page given a date.
62+
*
63+
* @param Carbon $date
64+
*
65+
* @return int
66+
*/
67+
public function countYoungerFromDate(Carbon $date): int
68+
{
69+
$order = Configs::getValueAsEnum('timeline_photos_order', ColumnSortingPhotoType::class);
70+
71+
// Safe default (should not be needed).
72+
// @codeCoverageIgnoreStart
73+
if (!in_array($order, [ColumnSortingPhotoType::CREATED_AT, ColumnSortingPhotoType::TAKEN_AT], true)) {
74+
$order = ColumnSortingPhotoType::TAKEN_AT;
75+
}
76+
// @codeCoverageIgnoreEnd
77+
78+
return $this->photo_query_policy->applySearchabilityFilter(
79+
query: Photo::query()
80+
->where($order->value, '>', $date)
81+
->whereNotNull($order->value),
82+
origin: null,
83+
include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_timeline')
84+
)->count();
85+
}
86+
87+
/**
88+
* Return the number of pictures that are younger than this.
89+
* We use this to dertermine the current page given a photo.
90+
*
91+
* @param Photo $photo
92+
*
93+
* @return int
94+
*/
95+
public function countYoungerFromPhoto(Photo $photo): int
96+
{
97+
$order = Configs::getValueAsEnum('timeline_photos_order', ColumnSortingPhotoType::class);
98+
99+
// Safe default (should not be needed).
100+
// @codeCoverageIgnoreStart
101+
if (!in_array($order, [ColumnSortingPhotoType::CREATED_AT, ColumnSortingPhotoType::TAKEN_AT], true)) {
102+
$order = ColumnSortingPhotoType::TAKEN_AT;
103+
}
104+
// @codeCoverageIgnoreEnd
105+
106+
return $this->photo_query_policy->applySearchabilityFilter(
107+
query: Photo::query()
108+
->joinSub(
109+
query: Photo::query()->select($order->value)->where('id', $photo->id),
110+
as: 'sub',
111+
first: 'sub.' . $order->value,
112+
operator: '<',
113+
second: 'photos.' . $order->value
114+
)
115+
->whereNotNull('photos.' . $order->value),
116+
origin: null,
117+
include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_timeline')
118+
)->count();
119+
}
120+
121+
/**
122+
* Get all the dates of the timeline.
123+
*
124+
* @return Collection<int,string>
125+
*/
126+
public function dates(): Collection
127+
{
128+
$order = Configs::getValueAsEnum('timeline_photos_order', ColumnSortingPhotoType::class);
129+
130+
// Safe default (should not be needed).
131+
// @codeCoverageIgnoreStart
132+
if (!in_array($order, [ColumnSortingPhotoType::CREATED_AT, ColumnSortingPhotoType::TAKEN_AT], true)) {
133+
$order = ColumnSortingPhotoType::TAKEN_AT;
134+
}
135+
// @codeCoverageIgnoreEnd
136+
137+
// This is among the ugliest piece of code I had ever to write...
138+
$is_driver_pgsql = DB::getDriverName() === 'pgsql';
139+
140+
$formatter = match (DB::getDriverName()) {
141+
'sqlite' => 'strftime("%2$s", %1$s)',
142+
'mysql' => 'DATE_FORMAT(%s, "%s")',
143+
'mariadb' => 'DATE_FORMAT(%s, "%s")',
144+
'pgsql' => "to_char(%s, '%s')",
145+
default => throw new LycheeInvalidArgumentException('Unsupported database driver'),
146+
};
147+
148+
$date_format = match ($this->photo_granularity) {
149+
TimelinePhotoGranularity::YEAR => $is_driver_pgsql ? 'YYYY' : '%Y',
150+
TimelinePhotoGranularity::MONTH => $is_driver_pgsql ? 'YYYY-mm' : '%Y-%m',
151+
TimelinePhotoGranularity::DAY => $is_driver_pgsql ? 'YYYY-MM-DD' : '%Y-%m-%d',
152+
TimelinePhotoGranularity::HOUR => $is_driver_pgsql ? 'YYYY-MM-DD"T"HH24' : '%Y-%m-%dT%H', // hoepfully this is correct
153+
TimelinePhotoGranularity::DEFAULT, TimelinePhotoGranularity::DISABLED => throw new TimelineGranularityException(),
154+
};
155+
156+
return $this->photo_query_policy->applySearchabilityFilter(
157+
query: Photo::query()
158+
159+
->selectRaw(sprintf($formatter, $order->value, $date_format) . ' as date')
160+
->whereNotNull($order->value),
161+
origin: null,
162+
include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_timeline')
163+
)->groupBy('date')
164+
->orderBy('date', OrderSortingType::DESC->value)
165+
->pluck('date');
166+
}
167+
}

app/Contracts/Http/Requests/RequestAttribute.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class RequestAttribute
3232
public const HEADER_ID_ATTRIBUTE = 'header_id';
3333

3434
public const TITLE_ATTRIBUTE = 'title';
35+
public const DATE_ATTRIBUTE = 'date';
3536
public const UPLOAD_DATE_ATTRIBUTE = 'upload_date';
3637
public const TAKEN_DATE_ATTRIBUTE = 'taken_at';
3738
public const DESCRIPTION_ATTRIBUTE = 'description';

app/Enum/TimelineAlbumGranularity.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
namespace App\Enum;
1010

11+
use App\Exceptions\Internal\TimelineGranularityException;
12+
1113
/**
1214
* Defines the possible granularities for album timelines.
1315
*/
@@ -18,4 +20,19 @@ enum TimelineAlbumGranularity: string
1820
case YEAR = 'year';
1921
case MONTH = 'month';
2022
case DAY = 'day';
23+
24+
/**
25+
* Return the ISO date format for the associated granularity.
26+
*
27+
* @return string
28+
*/
29+
public function format(): string
30+
{
31+
return match ($this) {
32+
self::YEAR => 'Y',
33+
self::MONTH => 'Y-m',
34+
self::DAY => 'Y-m-d',
35+
self::DEFAULT, self::DISABLED => throw new TimelineGranularityException(),
36+
};
37+
}
2138
}

app/Enum/TimelinePhotoGranularity.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
namespace App\Enum;
1010

11+
use App\Exceptions\Internal\TimelineGranularityException;
12+
1113
/**
1214
* Defines the possible granularities for photo timelines.
1315
*/
@@ -19,4 +21,20 @@ enum TimelinePhotoGranularity: string
1921
case MONTH = 'month';
2022
case DAY = 'day';
2123
case HOUR = 'hour';
24+
25+
/**
26+
* Return whether the smart album is enabled.
27+
*
28+
* @return string
29+
*/
30+
public function format(): string
31+
{
32+
return match ($this) {
33+
self::YEAR => 'Y',
34+
self::MONTH => 'Y-m',
35+
self::DAY => 'Y-m-d',
36+
self::HOUR => 'Y-m-d H',
37+
self::DEFAULT, self::DISABLED => throw new TimelineGranularityException(),
38+
};
39+
}
2240
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Exceptions\Internal;
10+
11+
class TimelineGranularityException extends LycheeLogicException
12+
{
13+
public function __construct(?string $msg = null)
14+
{
15+
parent::__construct($msg ?? 'Invalid granularity for timeline');
16+
}
17+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Controllers\Gallery;
10+
11+
use App\Actions\Photo\Timeline;
12+
use App\Http\Requests\Timeline\GetTimelineRequest;
13+
use App\Http\Requests\Timeline\IdOrDatedTimelineRequest;
14+
use App\Http\Resources\Models\Utils\TimelineData;
15+
use App\Http\Resources\Timeline\InitResource;
16+
use App\Http\Resources\Timeline\TimelineResource;
17+
use App\Models\Configs;
18+
use App\Models\Photo;
19+
use Illuminate\Pagination\LengthAwarePaginator;
20+
use Illuminate\Pagination\Paginator;
21+
use Illuminate\Routing\Controller;
22+
use Spatie\LaravelData\Data;
23+
24+
/**
25+
* Controller responsible for the Timeline data.
26+
*/
27+
class TimelineController extends Controller
28+
{
29+
/**
30+
* Return the photos given some contraints.
31+
*
32+
* @param IdOrDatedTimelineRequest $request
33+
* @param Timeline $timeline
34+
*
35+
* @return Data
36+
*/
37+
public function __invoke(IdOrDatedTimelineRequest $request, Timeline $timeline): Data
38+
{
39+
$pagination_limit = Configs::getValueAsInt('timeline_photos_pagination_limit');
40+
41+
if ($request->photo() !== null) {
42+
$youngers = $timeline->countYoungerFromPhoto($request->photo());
43+
Paginator::currentPageResolver(fn () => ceil($youngers / $pagination_limit));
44+
} elseif ($request->date !== null) {
45+
$youngers = $timeline->countYoungerFromDate($request->date);
46+
Paginator::currentPageResolver(fn () => ceil($youngers / $pagination_limit));
47+
}
48+
49+
/** @var LengthAwarePaginator<Photo> $photo_results */
50+
/** @disregard P1013 Undefined method withQueryString() (stupid intelephense) */
51+
$photo_results = $timeline->do()->paginate($pagination_limit);
52+
53+
return TimelineResource::fromData($photo_results);
54+
}
55+
56+
/**
57+
* Return init Search.
58+
*
59+
* @return InitResource
60+
*/
61+
public function init(): Data
62+
{
63+
return new InitResource();
64+
}
65+
66+
/**
67+
* Return all the dates of the timeline.
68+
*
69+
* @param GetTimelineRequest $request
70+
* @param Timeline $timeline
71+
*
72+
* @return TimelineData[]
73+
*/
74+
public function dates(GetTimelineRequest $request, Timeline $timeline): array
75+
{
76+
return $timeline->dates()->map(fn (string $date) => TimelineData::fromDate($date))->toArray();
77+
}
78+
}

app/Http/Controllers/VueController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ public function view(?string $album_id = null, ?string $photo_id = null): View
7575
return view('vueapp');
7676
}
7777

78+
/**
79+
* Same as above but without arguments.
80+
*
81+
* @return View
82+
*/
83+
public function viewNoArgs(): View
84+
{
85+
return view('vueapp');
86+
}
87+
7888
/**
7989
* Check if user can access the album.
8090
*

app/Http/Middleware/ConfigIntegrity.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ class ConfigIntegrity
3333
'timeline_album_date_format_year',
3434
'timeline_album_date_format_month',
3535
'timeline_album_date_format_day',
36+
'timeline_quick_access_date_format_year',
37+
'timeline_quick_access_date_format_month',
38+
'timeline_quick_access_date_format_day',
39+
'timeline_quick_access_date_format_hour',
3640
'number_albums_per_row_mobile',
3741
'client_side_favourite_enabled',
3842
'cache_ttl',
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Timeline;
10+
11+
use App\Http\Requests\AbstractEmptyRequest;
12+
use App\Models\Configs;
13+
use Illuminate\Support\Facades\Auth;
14+
15+
class GetTimelineRequest extends AbstractEmptyRequest
16+
{
17+
/**
18+
* {@inheritDoc}
19+
*/
20+
public function authorize(): bool
21+
{
22+
if (!Auth::check() && !Configs::getValueAsBool('timeline_photos_public')) {
23+
return false;
24+
}
25+
26+
return Configs::getValueAsBool('timeline_page_enabled');
27+
}
28+
}

0 commit comments

Comments
 (0)