Skip to content

Refactor EXIF date handling and add unit tests#8884

Open
pbearne wants to merge 24 commits into
WordPress:trunkfrom
pbearne:56887-and-48204-fix-wp_exif_date2ts
Open

Refactor EXIF date handling and add unit tests#8884
pbearne wants to merge 24 commits into
WordPress:trunkfrom
pbearne:56887-and-48204-fix-wp_exif_date2ts

Conversation

@pbearne

@pbearne pbearne commented Jun 3, 2025

Copy link
Copy Markdown

Replaced wp_exif_date2ts logic with the new wp_exif_datetime function for cleaner and reusable code. Enhanced metadata to store RFC3339 formatted dates while maintaining backward compatibility. Added comprehensive PHPUnit tests for EXIF date validation and edge cases.

Fixes 48204 as well

Trac ticket: https://core.trac.wordpress.org/ticket/56887

Replaced `wp_exif_date2ts` logic with the new `wp_exif_datetime` function for cleaner and reusable code. Enhanced metadata to store RFC3339 formatted dates while maintaining backward compatibility. Added comprehensive PHPUnit tests for EXIF date validation and edge cases.
@github-actions

github-actions Bot commented Jun 3, 2025

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @pbearne@git.wordpress.org.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

Core Committers: Use this line as a base for the props when committing in SVN:

Props wildworks, audrasjb, jorbin.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jun 3, 2025

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • The Plugin and Theme Directories cannot be accessed within Playground.
  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@audrasjb audrasjb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some small improvements

Comment thread src/wp-admin/includes/image.php Outdated
Comment thread src/wp-admin/includes/image.php Outdated
Comment thread src/wp-admin/includes/image.php Outdated
Comment thread src/wp-admin/includes/image.php Outdated
t-hamano added 2 commits July 25, 2026 11:51
Remove the stray @ticket tag that does not belong in a function docblock,
correct the @SInCE version to 7.2.0 for the upcoming release, and fix the
summary wording and return description punctuation.
Copilot AI review requested due to automatic review settings July 25, 2026 02:53
@t-hamano
t-hamano dismissed audrasjb’s stale review July 25, 2026 02:54

All feedback has been addressed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors EXIF/IPTC date handling by introducing wp_exif_datetime() and updating image metadata parsing to store a RFC3339 created datetime string while keeping the legacy created_timestamp for back-compat. It also updates/introduces PHPUnit tests to cover date parsing scenarios.

Changes:

  • Added wp_exif_datetime() and refactored wp_exif_date2ts() to use it.
  • Updated wp_read_image_metadata() to store RFC3339 created in addition to created_timestamp.
  • Added/updated PHPUnit expectations and new unit tests for EXIF datetime parsing.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
src/wp-admin/includes/image.php Adds wp_exif_datetime(), refactors timestamp conversion, and stores RFC3339 created metadata.
tests/phpunit/tests/image/meta.php Updates expected metadata arrays to include the new created field for applicable fixtures.
tests/phpunit/tests/admin/includes/wpExifDate2TS.php Adds a new test class intended for EXIF date conversion behavior (currently misaligned with what it tests).
tests/phpunit/tests/admin/includes/wpExicDatetime.php Adds tests for wp_exif_datetime() across valid/invalid/edge inputs (contains some inconsistent vectors).
Comments suppressed due to low confidence (2)

src/wp-admin/includes/image.php:1068

  • wp_exif_datetime() can return false for invalid EXIF values (or invalid timezone strings), but the result is used unconditionally. Calling ->format() / ->getTimestamp() on false will fatal. Guard the assignments so they only run when a DateTimeImmutable is returned.
			$datetime = wp_exif_datetime( $exif['DateTimeDigitized'], $timezone );

			// Store as a RFC3339 formatted timestring as this includes both date, time, and timezone.
			$meta['created'] = $datetime->format( DATE_RFC3339 );
			// Retain the original created timestamp for backcompat.

tests/phpunit/tests/admin/includes/wpExicDatetime.php:129

  • This test repeats the exact same input and assertion twice, so it doesn’t add coverage and makes failures noisier to diagnose. Remove the duplicate assertion or change the second one to exercise a different separator format.
		$datetime = wp_exif_datetime( '2024/03/15 14:30:00' );
		$this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) );

		$datetime = wp_exif_datetime( '2024/03/15 14:30:00' );
		$this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) );
	}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wp-admin/includes/image.php Outdated
Comment thread src/wp-admin/includes/image.php Outdated
Comment thread src/wp-admin/includes/image.php Outdated
Comment thread tests/phpunit/tests/admin/includes/wpExifDatetime.php Outdated
Comment thread tests/phpunit/tests/admin/includes/wpExifDate2TS.php
t-hamano and others added 5 commits July 25, 2026 12:14
The docblock claimed the wp_exif_datetime() based implementation landed in
6.9.0, but wp_exif_datetime() is introduced as of 7.2.0. Align both tags.

Co-Authored-By: Claude <noreply@anthropic.com>
DateTimeZone throws for an unrecognized timezone or offset string, and the
constructor sat outside the try block, so an unexpected EXIF OffsetTime value
resulted in a fatal error. Construct it inside the try so invalid input
returns false like any other parse failure.

Co-Authored-By: Claude <noreply@anthropic.com>
The IPTC branch instantiated DateTimeImmutable directly, so malformed IPTC
data threw and aborted the whole metadata read. Route it through
wp_exif_datetime(), and only assign created/created_timestamp when a
DateTimeImmutable comes back — in both the IPTC and the EXIF DateTimeDigitized
branches.

Co-Authored-By: Claude <noreply@anthropic.com>
The class was named for wp_exif_date2ts() but exercised wp_exif_datetime(),
duplicating Tests_Admin_wpExifDatetime and leaving the refactored
wp_exif_date2ts() itself untested. Assert unix timestamps instead, with the
site timezone pinned to UTC so the expectations are deterministic.

Co-Authored-By: Claude <noreply@anthropic.com>
The file was added as wpExicDatetime.php while the class it contains is
Tests_Admin_wpExifDatetime.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 03:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (3)

tests/phpunit/tests/admin/includes/wpExifDatetime.php:56

  • The "unix timestamp" test case is internally inconsistent: the input/comment say "March 15, 2024 14:30:00" but the expected value is 0000:06:03 17:10:50. If the intent is to support Unix timestamps, the input should be the matching timestamp and the expected formatted output should reflect that date/time.
			'unix timestamp'  => array(
				'1710500000', // March 15, 2024 14:30:00
				'0000:06:03 17:10:50',
			),

tests/phpunit/tests/admin/includes/wpExifDatetime.php:31

  • test_valid_dates() calls $datetime->format() without asserting that wp_exif_datetime() actually returned a DateTime instance. If the function returns false for any provider row, this will trigger a fatal error and obscure the real failure.
	public function test_valid_dates( $input_date, $expected ) {
		$datetime = wp_exif_datetime( $input_date );
		$this->assertEquals( $expected, $datetime->format( 'Y:m:d H:i:s' ) );
	}

tests/phpunit/tests/admin/includes/wpExifDatetime.php:128

  • test_different_separators() repeats the exact same input twice, so it doesn’t actually validate multiple separators.
		$datetime = wp_exif_datetime( '2024/03/15 14:30:00' );
		$this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) );

		$datetime = wp_exif_datetime( '2024/03/15 14:30:00' );
		$this->assertEquals( '2024:03:15 14:30:00', $datetime->format( 'Y:m:d H:i:s' ) );

Comment thread src/wp-admin/includes/image.php
Comment thread tests/phpunit/tests/admin/includes/wpExifDatetime.php
Comment thread tests/phpunit/tests/admin/includes/wpExifDate2TS.php
DateTimeImmutable does not read '1710500000' as a unix timestamp: it parses
171050 as a time and 0000 as a year, filling the month and day from the
current date. The expected value therefore only held on the day it was
written. A bare unix timestamp is outside the Exif input the function accepts,
so the case is removed rather than repaired.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 03:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

src/wp-admin/includes/image.php:812

  • wp_exif_datetime() currently relies on new DateTimeImmutable( $str, $timezone ) and only returns false when an exception is thrown. The DateTime parser can normalize out-of-range components (e.g. month/day/time overflow) instead of throwing, so inputs like 2024-13-15 may be treated as valid and produce incorrect timestamps. Also, defaulting to wp_timezone() changes the interpretation vs the historical strtotime() behavior (WordPress sets the PHP default timezone to UTC in wp-settings.php:73), which risks breaking back-compat for created_timestamp. Consider strict parsing against the accepted formats and defaulting to UTC unless an explicit timezone/offset is provided.
	try {
		$timezone = ( $timezone ) ? new DateTimeZone( $timezone ) : wp_timezone();
		$datetime = new DateTimeImmutable( $str, $timezone );
	} catch ( Exception $e ) {

src/wp-admin/includes/image.php:797

  • The new @since tag is set to 7.2.0, but the current development version in this branch is 7.1 (see src/wp-includes/version.php). The @since value should reflect the first core version that will ship this function.
 * @since 7.2.0

src/wp-admin/includes/image.php:823

  • The @since tag here is set to 7.2.0, but this change is being introduced while core is on 7.1 (see src/wp-includes/version.php). The @since tag should match the release that will first include this behavior change.
 * @since 7.2.0 Uses wp_exif_datetime() to generate the timestamp.

tests/phpunit/tests/admin/includes/wpExifDatetime.php:15

  • These tests call wp_exif_datetime() without providing a timezone, but the function defaults to the site timezone (wp_timezone()). To avoid order-dependent/flaky tests, pin timezone_string to a known value and restore it in tear_down() (similar to other date/datetime tests).
class Tests_Admin_wpExifDatetime extends WP_UnitTestCase {

tests/phpunit/tests/admin/includes/wpExifDate2TS.php:21

  • set_up() changes the timezone_string option but never restores it, which can leak state into later tests. Store the original value and restore it in tear_down() (as done in other datetime-related tests).
	public function set_up() {
		parent::set_up();

		// Pin the site timezone so the expected timestamps are deterministic.
		update_option( 'timezone_string', 'UTC' );

Comment thread tests/phpunit/tests/admin/includes/wpExifDatetime.php Outdated
Two ways wp_exif_datetime() broke its "false on an invalid date" contract:

- A non-string $timezone made DateTimeZone throw a TypeError, which does not
  extend Exception and so escaped the catch. Exif tag values are untrusted, so
  UndefinedTag:0x9012 holding an array was a fatal error. Anything that is not
  a non-empty string now falls back to the site timezone.
- Out of range components are rolled over rather than rejected, so the
  '0000:00:00 00:00:00' cameras write for an unset field parsed as a year -1
  date and was stored as created/created_timestamp. Corrections are reported
  through getLastErrors(), so any warning or error now returns false.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 03:40
test_different_separators() ran the same slash separated input and assertion
twice, so the second half added no coverage. The dash and colon separators are
already exercised by the provider, so move the slash case there, add a case
with no separators at all, and drop the method.

Co-Authored-By: Claude <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

tests/phpunit/tests/admin/includes/wpExifDatetime.php:126

  • test_different_separators() repeats the exact same input/assertion twice, which adds noise without increasing coverage. Remove the duplicate block (or change it to a genuinely different separator if that was the intent).
	 * @ticket 56887
	 *
	 * Test that an offset from the Exif OffsetTime tags is applied.
	 *
	 * @return void

function wp_exif_date2ts( $str ) {
list( $date, $time ) = explode( ' ', trim( $str ) );
list( $y, $m, $d ) = explode( ':', $date );
$datetime = wp_exif_datetime( $str );
*
* @dataProvider provideInvalidDates
*
* @param string $input_date The date string to be tested for validation.
Comment on lines +72 to +74
/**
* Data provider for invalid dates that should trigger exceptions
*/
'array input' => array( array() ),
'object input' => array( new stdClass() ),
'out of bounds timestamp' => array( 253402300800 ), // Year 9999
'negative timestamp' => array( - 62167219200 ), // Year 0
Copilot AI review requested due to automatic review settings July 25, 2026 03:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (3)

src/wp-admin/includes/image.php:846

  • wp_exif_date2ts() previously used strtotime() while WordPress runs with date_default_timezone_set('UTC') (see wp-settings.php:73), so its output was effectively UTC regardless of the site timezone setting. The refactor now defaults to wp_timezone() via wp_exif_datetime(), which changes timestamps on sites that are not set to UTC. If backward compatibility is a goal, pass an explicit 'UTC' timezone here.
function wp_exif_date2ts( $str ) {
	$datetime = wp_exif_datetime( $str );

	if ( $datetime instanceof DateTimeImmutable ) {
		return $datetime->getTimestamp();
	}
	return false;

tests/phpunit/tests/admin/includes/wpExifDatetime.php:83

  • The provideInvalidDates() doc comment says these inputs “should trigger exceptions”, but wp_exif_datetime() is explicitly designed to return false rather than throw. Updating the comment avoids confusion about the intended contract.
	/**
	 * Data provider for invalid dates that should trigger exceptions
	 */
	public function provideInvalidDates() {

tests/phpunit/tests/admin/includes/wpExifDatetime.php:42

  • test_invalid_dates() documents $input_date as a string, but the provider passes null, booleans, arrays, and objects. Updating the @param type makes the test intent clearer.
	 * Test handling of invalid date inputs.
	 *
	 * @dataProvider provideInvalidDates
	 *
	 * @param string $input_date The date string to be tested for validation.
	 *
	 * @return void

Comment on lines +795 to +799
* Converts the exif date format to DateTime object
*
* @since 7.2.0
*
* @param string $str
Comment on lines 956 to +964
if ( ! empty( $iptc['2#055'][0] ) && ! empty( $iptc['2#060'][0] ) ) { // Created date and time.
$meta['created_timestamp'] = strtotime( $iptc['2#055'][0] . ' ' . $iptc['2#060'][0] );
$datetime = wp_exif_datetime( $iptc['2#055'][0] . ' ' . $iptc['2#060'][0] );

if ( $datetime instanceof DateTimeImmutable ) {
// Store as a RFC3339 formatted timestring as this includes both date, time, and timezone.
$meta['created'] = $datetime->format( DATE_RFC3339 );
// Retain the original created timestamp for backcompat.
$meta['created_timestamp'] = $datetime->getTimestamp();
}
Comment on lines 1073 to +1086
if ( empty( $meta['created_timestamp'] ) && ! empty( $exif['DateTimeDigitized'] ) ) {
$meta['created_timestamp'] = wp_exif_date2ts( $exif['DateTimeDigitized'] );
$timezone = null;
if ( ! empty( $exif['UndefinedTag:0x9012'] ) ) {
$timezone = $exif['UndefinedTag:0x9012'];
}

$datetime = wp_exif_datetime( $exif['DateTimeDigitized'], $timezone );

if ( $datetime instanceof DateTimeImmutable ) {
// Store as a RFC3339 formatted timestring as this includes both date, time, and timezone.
$meta['created'] = $datetime->format( DATE_RFC3339 );
// Retain the original created timestamp for backcompat.
$meta['created_timestamp'] = $datetime->getTimestamp();
}
Comment on lines +28 to +31
public function test_valid_dates( $input_date, $expected ) {
$datetime = wp_exif_datetime( $input_date );
$this->assertEquals( $expected, $datetime->format( 'Y:m:d H:i:s' ) );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants