Skip to content

Commit 1f8c509

Browse files
authored
Support titles as dates for albums (#3896)
1 parent 162dab4 commit 1f8c509

File tree

2 files changed

+223
-0
lines changed

2 files changed

+223
-0
lines changed

app/Http/Resources/Models/Utils/TimelineData.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
use App\Http\Resources\Models\PhotoResource;
1717
use App\Http\Resources\Models\ThumbAlbumResource;
1818
use Carbon\Carbon;
19+
use Carbon\Exceptions\InvalidFormatException;
1920
use Illuminate\Support\Collection;
21+
use Safe\Exceptions\PcreException;
22+
use function Safe\preg_match;
2023
use Spatie\LaravelData\Data;
2124
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
2225

@@ -55,6 +58,38 @@ public static function fromPhoto(PhotoResource $photo, TimelinePhotoGranularity
5558
return new TimelineData(time_date: $time_date, format: $format);
5659
}
5760

61+
/**
62+
* Attempts to parse a date from a title string.
63+
*
64+
* @param string $title The title string to parse
65+
*
66+
* @return ?Carbon The parsed Carbon date object or null if parsing fails
67+
*/
68+
public static function parseDateFromTitle(string $title): ?Carbon
69+
{
70+
// A title is expected to be in one of the following formats:
71+
// "YYYY something"
72+
// "YYYY-MM something"
73+
// "YYYY-MM-DD something"
74+
// We match the first part that looks like a date.
75+
// Then use Carbon to create a date object from the matched components.
76+
$pattern = '/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/';
77+
try {
78+
if (preg_match($pattern, $title, $matches) === 1) {
79+
$year = intval($matches[1]);
80+
$month = intval($matches[2] ?? 1);
81+
$day = intval($matches[3] ?? 1);
82+
83+
return Carbon::createFromDate($year, $month, $day);
84+
}
85+
86+
return null;
87+
} catch (PcreException|InvalidFormatException $e) {
88+
// fail silently.
89+
return null;
90+
}
91+
}
92+
5893
private static function fromAlbum(ThumbAlbumResource $album, ColumnSortingType $column_sorting, TimelineAlbumGranularity $granularity): ?self
5994
{
6095
$timeline_date_format_year = request()->configs()->getValueAsString('timeline_album_date_format_year');
@@ -64,6 +99,8 @@ private static function fromAlbum(ThumbAlbumResource $album, ColumnSortingType $
6499
ColumnSortingType::CREATED_AT => $album->created_at_carbon(),
65100
ColumnSortingType::MAX_TAKEN_AT => $album->max_taken_at_carbon(),
66101
ColumnSortingType::MIN_TAKEN_AT => $album->min_taken_at_carbon(),
102+
// Parse the title as date (e.g. "2020 something" or "2020-03 something" or "2020-03-25 something")
103+
ColumnSortingType::TITLE => self::parseDateFromTitle($album->title),
67104
default => null,
68105
};
69106

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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+
/**
10+
* We don't care for unhandled exceptions in tests.
11+
* It is the nature of a test to throw an exception.
12+
* Without this suppression we had 100+ Linter warning in this file which
13+
* don't help anything.
14+
*
15+
* @noinspection PhpDocMissingThrowsInspection
16+
* @noinspection PhpUnhandledExceptionInspection
17+
*/
18+
19+
namespace Tests\Unit\Http\Resources;
20+
21+
use App\Http\Resources\Models\Utils\TimelineData;
22+
use Tests\AbstractTestCase;
23+
24+
class TimelineDataTest extends AbstractTestCase
25+
{
26+
public function testParseDateFromTitleWithFullDate(): void
27+
{
28+
$result = TimelineData::parseDateFromTitle('2023-12-25 Christmas Day');
29+
30+
self::assertNotNull($result);
31+
self::assertEquals(2023, $result->year);
32+
self::assertEquals(12, $result->month);
33+
self::assertEquals(25, $result->day);
34+
}
35+
36+
public function testParseDateFromTitleWithYearAndMonth(): void
37+
{
38+
$result = TimelineData::parseDateFromTitle('2023-12 December');
39+
40+
self::assertNotNull($result);
41+
self::assertEquals(2023, $result->year);
42+
self::assertEquals(12, $result->month);
43+
self::assertEquals(1, $result->day); // Should default to day 1
44+
}
45+
46+
public function testParseDateFromTitleWithYearOnly(): void
47+
{
48+
$result = TimelineData::parseDateFromTitle('2023 A Great Year');
49+
50+
self::assertNotNull($result);
51+
self::assertEquals(2023, $result->year);
52+
self::assertEquals(1, $result->month); // Should default to month 1
53+
self::assertEquals(1, $result->day); // Should default to day 1
54+
}
55+
56+
public function testParseDateFromTitleWithNoText(): void
57+
{
58+
$result = TimelineData::parseDateFromTitle('2023-06-15');
59+
60+
self::assertNotNull($result);
61+
self::assertEquals(2023, $result->year);
62+
self::assertEquals(6, $result->month);
63+
self::assertEquals(15, $result->day);
64+
}
65+
66+
public function testParseDateFromTitleWithInvalidFormat(): void
67+
{
68+
$result = TimelineData::parseDateFromTitle('December 25, 2023');
69+
70+
self::assertNull($result);
71+
}
72+
73+
public function testParseDateFromTitleWithNoDate(): void
74+
{
75+
$result = TimelineData::parseDateFromTitle('Just a regular title');
76+
77+
self::assertNull($result);
78+
}
79+
80+
public function testParseDateFromTitleWithEmptyString(): void
81+
{
82+
$result = TimelineData::parseDateFromTitle('');
83+
84+
self::assertNull($result);
85+
}
86+
87+
public function testParseDateFromTitleWithDateInMiddle(): void
88+
{
89+
// Should not match - date must be at the beginning
90+
$result = TimelineData::parseDateFromTitle('Some text 2023-12-25 more text');
91+
92+
self::assertNull($result);
93+
}
94+
95+
public function testParseDateFromTitleWithLeadingZeros(): void
96+
{
97+
$result = TimelineData::parseDateFromTitle('2023-01-05 New Year');
98+
99+
self::assertNotNull($result);
100+
self::assertEquals(2023, $result->year);
101+
self::assertEquals(1, $result->month);
102+
self::assertEquals(5, $result->day);
103+
}
104+
105+
public function testParseDateFromTitleWithInvalidMonth(): void
106+
{
107+
// Invalid month (13) - Carbon will overflow to next year
108+
$result = TimelineData::parseDateFromTitle('2023-13-01 Invalid Month');
109+
110+
self::assertNotNull($result);
111+
// Month 13 overflows to January of 2024
112+
self::assertEquals(2024, $result->year);
113+
self::assertEquals(1, $result->month);
114+
self::assertEquals(1, $result->day);
115+
}
116+
117+
public function testParseDateFromTitleWithInvalidDay(): void
118+
{
119+
// Invalid day (32) - Carbon will overflow to next month
120+
$result = TimelineData::parseDateFromTitle('2023-12-32 Invalid Day');
121+
122+
self::assertNotNull($result);
123+
// Day 32 of December overflows to January 1 of 2024
124+
self::assertEquals(2024, $result->year);
125+
self::assertEquals(1, $result->month);
126+
self::assertEquals(1, $result->day);
127+
}
128+
129+
public function testParseDateFromTitleWithShortYear(): void
130+
{
131+
// Year must be 4 digits
132+
$result = TimelineData::parseDateFromTitle('23-12-25 Short Year');
133+
134+
self::assertNull($result);
135+
}
136+
137+
public function testParseDateFromTitleWithExtraHyphens(): void
138+
{
139+
$result = TimelineData::parseDateFromTitle('2023-12-25-something');
140+
141+
self::assertNotNull($result);
142+
self::assertEquals(2023, $result->year);
143+
self::assertEquals(12, $result->month);
144+
self::assertEquals(25, $result->day);
145+
}
146+
147+
public function testParseDateFromTitleWithWhitespace(): void
148+
{
149+
$result = TimelineData::parseDateFromTitle('2023-12-25 Multiple Spaces');
150+
151+
self::assertNotNull($result);
152+
self::assertEquals(2023, $result->year);
153+
self::assertEquals(12, $result->month);
154+
self::assertEquals(25, $result->day);
155+
}
156+
157+
public function testParseDateFromTitleWithLeapYear(): void
158+
{
159+
$result = TimelineData::parseDateFromTitle('2024-02-29 Leap Day');
160+
161+
self::assertNotNull($result);
162+
self::assertEquals(2024, $result->year);
163+
self::assertEquals(2, $result->month);
164+
self::assertEquals(29, $result->day);
165+
}
166+
167+
public function testParseDateFromTitleWithHistoricalDate(): void
168+
{
169+
$result = TimelineData::parseDateFromTitle('1900-01-01 Turn of Century');
170+
171+
self::assertNotNull($result);
172+
self::assertEquals(1900, $result->year);
173+
self::assertEquals(1, $result->month);
174+
self::assertEquals(1, $result->day);
175+
}
176+
177+
public function testParseDateFromTitleWithFutureDate(): void
178+
{
179+
$result = TimelineData::parseDateFromTitle('2099-12-31 Future Date');
180+
181+
self::assertNotNull($result);
182+
self::assertEquals(2099, $result->year);
183+
self::assertEquals(12, $result->month);
184+
self::assertEquals(31, $result->day);
185+
}
186+
}

0 commit comments

Comments
 (0)