Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/Http/Resources/Models/Utils/TimelineData.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
use App\Http\Resources\Models\PhotoResource;
use App\Http\Resources\Models\ThumbAlbumResource;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
use Illuminate\Support\Collection;
use Safe\Exceptions\PcreException;
use function Safe\preg_match;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

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

/**
* Attempts to parse a date from a title string.
*
* @param string $title The title string to parse
*
* @return ?Carbon The parsed Carbon date object or null if parsing fails
*/
public static function parseDateFromTitle(string $title): ?Carbon
{
// A title is expected to be in one of the following formats:
// "YYYY something"
// "YYYY-MM something"
// "YYYY-MM-DD something"
// We match the first part that looks like a date.
// Then use Carbon to create a date object from the matched components.
$pattern = '/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/';
try {
if (preg_match($pattern, $title, $matches) === 1) {
$year = intval($matches[1]);
$month = intval($matches[2] ?? 1);
$day = intval($matches[3] ?? 1);

return Carbon::createFromDate($year, $month, $day);
}

return null;
} catch (PcreException|InvalidFormatException $e) {
// fail silently.
return null;
}
}

private static function fromAlbum(ThumbAlbumResource $album, ColumnSortingType $column_sorting, TimelineAlbumGranularity $granularity): ?self
{
$timeline_date_format_year = request()->configs()->getValueAsString('timeline_album_date_format_year');
Expand All @@ -64,6 +99,8 @@ private static function fromAlbum(ThumbAlbumResource $album, ColumnSortingType $
ColumnSortingType::CREATED_AT => $album->created_at_carbon(),
ColumnSortingType::MAX_TAKEN_AT => $album->max_taken_at_carbon(),
ColumnSortingType::MIN_TAKEN_AT => $album->min_taken_at_carbon(),
// Parse the title as date (e.g. "2020 something" or "2020-03 something" or "2020-03-25 something")
ColumnSortingType::TITLE => self::parseDateFromTitle($album->title),
default => null,
};

Expand Down
186 changes: 186 additions & 0 deletions tests/Unit/Http/Resources/TimelineDataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2025 LycheeOrg.
*/

/**
* We don't care for unhandled exceptions in tests.
* It is the nature of a test to throw an exception.
* Without this suppression we had 100+ Linter warning in this file which
* don't help anything.
*
* @noinspection PhpDocMissingThrowsInspection
* @noinspection PhpUnhandledExceptionInspection
*/

namespace Tests\Unit\Http\Resources;

use App\Http\Resources\Models\Utils\TimelineData;
use Tests\AbstractTestCase;

class TimelineDataTest extends AbstractTestCase
{
public function testParseDateFromTitleWithFullDate(): void
{
$result = TimelineData::parseDateFromTitle('2023-12-25 Christmas Day');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(12, $result->month);
self::assertEquals(25, $result->day);
}

public function testParseDateFromTitleWithYearAndMonth(): void
{
$result = TimelineData::parseDateFromTitle('2023-12 December');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(12, $result->month);
self::assertEquals(1, $result->day); // Should default to day 1
}

public function testParseDateFromTitleWithYearOnly(): void
{
$result = TimelineData::parseDateFromTitle('2023 A Great Year');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(1, $result->month); // Should default to month 1
self::assertEquals(1, $result->day); // Should default to day 1
}

public function testParseDateFromTitleWithNoText(): void
{
$result = TimelineData::parseDateFromTitle('2023-06-15');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(6, $result->month);
self::assertEquals(15, $result->day);
}

public function testParseDateFromTitleWithInvalidFormat(): void
{
$result = TimelineData::parseDateFromTitle('December 25, 2023');

self::assertNull($result);
}

public function testParseDateFromTitleWithNoDate(): void
{
$result = TimelineData::parseDateFromTitle('Just a regular title');

self::assertNull($result);
}

public function testParseDateFromTitleWithEmptyString(): void
{
$result = TimelineData::parseDateFromTitle('');

self::assertNull($result);
}

public function testParseDateFromTitleWithDateInMiddle(): void
{
// Should not match - date must be at the beginning
$result = TimelineData::parseDateFromTitle('Some text 2023-12-25 more text');

self::assertNull($result);
}

public function testParseDateFromTitleWithLeadingZeros(): void
{
$result = TimelineData::parseDateFromTitle('2023-01-05 New Year');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(1, $result->month);
self::assertEquals(5, $result->day);
}

public function testParseDateFromTitleWithInvalidMonth(): void
{
// Invalid month (13) - Carbon will overflow to next year
$result = TimelineData::parseDateFromTitle('2023-13-01 Invalid Month');

self::assertNotNull($result);
// Month 13 overflows to January of 2024
self::assertEquals(2024, $result->year);
self::assertEquals(1, $result->month);
self::assertEquals(1, $result->day);
}

public function testParseDateFromTitleWithInvalidDay(): void
{
// Invalid day (32) - Carbon will overflow to next month
$result = TimelineData::parseDateFromTitle('2023-12-32 Invalid Day');

self::assertNotNull($result);
// Day 32 of December overflows to January 1 of 2024
self::assertEquals(2024, $result->year);
self::assertEquals(1, $result->month);
self::assertEquals(1, $result->day);
}

public function testParseDateFromTitleWithShortYear(): void
{
// Year must be 4 digits
$result = TimelineData::parseDateFromTitle('23-12-25 Short Year');

self::assertNull($result);
}

public function testParseDateFromTitleWithExtraHyphens(): void
{
$result = TimelineData::parseDateFromTitle('2023-12-25-something');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(12, $result->month);
self::assertEquals(25, $result->day);
}

public function testParseDateFromTitleWithWhitespace(): void
{
$result = TimelineData::parseDateFromTitle('2023-12-25 Multiple Spaces');

self::assertNotNull($result);
self::assertEquals(2023, $result->year);
self::assertEquals(12, $result->month);
self::assertEquals(25, $result->day);
}

public function testParseDateFromTitleWithLeapYear(): void
{
$result = TimelineData::parseDateFromTitle('2024-02-29 Leap Day');

self::assertNotNull($result);
self::assertEquals(2024, $result->year);
self::assertEquals(2, $result->month);
self::assertEquals(29, $result->day);
}

public function testParseDateFromTitleWithHistoricalDate(): void
{
$result = TimelineData::parseDateFromTitle('1900-01-01 Turn of Century');

self::assertNotNull($result);
self::assertEquals(1900, $result->year);
self::assertEquals(1, $result->month);
self::assertEquals(1, $result->day);
}

public function testParseDateFromTitleWithFutureDate(): void
{
$result = TimelineData::parseDateFromTitle('2099-12-31 Future Date');

self::assertNotNull($result);
self::assertEquals(2099, $result->year);
self::assertEquals(12, $result->month);
self::assertEquals(31, $result->day);
}
}
Loading