diff --git a/class-wp-press-this-plugin.php b/class-wp-press-this-plugin.php index f446d35..866c67c 100644 --- a/class-wp-press-this-plugin.php +++ b/class-wp-press-this-plugin.php @@ -1550,6 +1550,8 @@ public function html() { 'postId' => $post_ID, 'title' => $post_title, 'content' => $post_content, + 'postStatus' => $post->post_status, + 'postDate' => mysql_to_rfc3339( $post->post_date ), 'nonce' => wp_create_nonce( 'update-post_' . $post_ID ), 'categoryNonce' => wp_create_nonce( 'add-category' ), @@ -1613,6 +1615,7 @@ public function html() { // Config. 'redirInParent' => $site_settings['redirInParent'], 'isRTL' => is_rtl(), + 'timezone' => wp_timezone_string(), // Allowed blocks. 'allowedBlocks' => $this->get_allowed_blocks(), diff --git a/includes/class-press-this-assets.php b/includes/class-press-this-assets.php index 643c900..7fe27b1 100644 --- a/includes/class-press-this-assets.php +++ b/includes/class-press-this-assets.php @@ -230,6 +230,7 @@ public function get_script_dependencies() { 'wp-format-library', 'wp-html-entities', 'wp-i18n', + 'wp-keyboard-shortcuts', 'wp-primitives', ); } diff --git a/press-this-plugin.php b/press-this-plugin.php index 66363ec..ac4fce8 100644 --- a/press-this-plugin.php +++ b/press-this-plugin.php @@ -184,7 +184,25 @@ function press_this_register_rest_routes() { 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'draft', - 'enum' => array( 'draft', 'publish' ), + 'enum' => array( 'draft', 'publish', 'future' ), + ), + 'date' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $value ) { + if ( empty( $value ) ) { + return true; + } + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/', $value ) ) { + return new WP_Error( + 'press_this_invalid_date_format', + __( 'Date must be in ISO 8601 format.', 'press-this' ), + array( 'status' => 400 ) + ); + } + return true; + }, + 'default' => '', ), 'format' => array( 'type' => 'string', @@ -342,6 +360,31 @@ function press_this_rest_save_post( $request ) { } } + // Handle future (scheduled) status. + if ( 'future' === $status ) { + if ( current_user_can( 'publish_posts' ) ) { + $date = $request->get_param( 'date' ); + if ( empty( $date ) || false === strtotime( $date ) ) { + return new WP_Error( + 'press_this_invalid_date', + __( 'A valid date is required to schedule a post.', 'press-this' ), + array( 'status' => 400 ) + ); + } + // The frontend sends a naive datetime in the site's local timezone (no TZ qualifier). + // WordPress sets PHP's timezone to UTC, so strtotime() interprets the string as UTC + // and gmdate() formats it back as UTC -- the round-trip preserves the original value. + // The result is the site-local time string we need for post_date. + $post_data['post_date'] = gmdate( 'Y-m-d H:i:s', strtotime( $date ) ); + $post_data['post_date_gmt'] = get_gmt_from_date( $post_data['post_date'] ); + $post_data['post_status'] = 'future'; + // Required: wp_update_post ignores post_date changes unless edit_date is true. + $post_data['edit_date'] = true; + } else { + $post_data['post_status'] = 'pending'; + } + } + // Side-load images from content. // Require admin includes needed by media_sideload_image() (not loaded in REST context). require_once ABSPATH . 'wp-admin/includes/file.php'; diff --git a/src/App.js b/src/App.js index 85f4a2d..3c5b34a 100644 --- a/src/App.js +++ b/src/App.js @@ -47,6 +47,10 @@ export default function App() { publishLabel: __( 'Publish', 'press-this' ), } ); + // Post status and date as React state so they update after scheduling. + const [ postStatus, setPostStatus ] = useState( () => data.postStatus || '' ); + const [ postDate, setPostDate ] = useState( () => data.postDate || '' ); + // Build initial post object for editor. const post = useMemo( () => ( { @@ -316,6 +320,21 @@ export default function App() { setSaveState( state ); }, [] ); + /** + * Handle post status changes from PressThisEditor after a successful save. + * Updates local state so the Header reflects the new status (e.g., "Reschedule"). + * + * @param {Object} change Status change details. + * @param {string} change.status New post status. + * @param {string} change.date New post date (ISO 8601). + */ + const handlePostStatusChange = useCallback( ( change ) => { + setPostStatus( change.status ); + if ( change.date ) { + setPostDate( change.date ); + } + }, [] ); + // State for undo/redo from editor. // Split into primitive values so React can skip re-renders when values // haven't changed (Object.is comparison). The handlers are stable refs @@ -360,6 +379,10 @@ export default function App() { onRedo={ redoHandler } hasUndo={ hasUndo } hasRedo={ hasRedo } + capabilities={ capabilities } + timezone={ data.timezone } + postStatus={ postStatus } + postDate={ postDate } />
@@ -377,6 +400,8 @@ export default function App() { onScrapeProcessed={ handleScrapeProcessed } onSaveReady={ handleSaveReady } onUndoReady={ handleUndoReady } + timezone={ data.timezone } + onPostStatusChange={ handlePostStatusChange } categoryNonce={ data.categoryNonce || '' } ajaxUrl={ data.ajaxUrl || '' } /> diff --git a/src/components/Header.js b/src/components/Header.js index 89cee34..5c488eb 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -20,6 +20,8 @@ import { DropdownMenu, MenuGroup, MenuItem, + Popover, + DateTimePicker, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { @@ -33,6 +35,8 @@ import { */ import { buildSuggestedContentFromMetadata } from '../utils'; +const ONE_MINUTE = 60 * 1000; + /** * Detect if running on macOS for keyboard shortcut hints. * @@ -45,6 +49,146 @@ function isMacOS() { ); } +/** + * Derive a short timezone abbreviation from a timezone string. + * + * @param {string} tz Timezone string (e.g., 'America/New_York' or 'UTC+5'). + * @param {string} date ISO date string to derive abbreviation for (for DST accuracy). + * @return {string} Timezone abbreviation (e.g., 'EST' or 'UTC+5'). + */ +function getTimezoneAbbreviation( tz, date ) { + if ( ! tz ) { + return ''; + } + + // Fixed offset timezones pass through directly. + if ( /^UTC[+-]?\d*$/.test( tz ) ) { + return tz; + } + + try { + const formatter = new Intl.DateTimeFormat( 'en-US', { + timeZone: tz, + timeZoneName: 'short', + } ); + // Parse date parts to build a UTC instant matching the wall-clock time, + // so DST lookup is correct for the selected date, not the browser's interpretation. + let refDate = new Date(); + if ( date ) { + const m = date.match( + /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/ + ); + if ( m ) { + refDate = new Date( + Date.UTC( + Number( m[ 1 ] ), + Number( m[ 2 ] ) - 1, + Number( m[ 3 ] ), + Number( m[ 4 ] ), + Number( m[ 5 ] ) + ) + ); + } + } + const parts = formatter.formatToParts( refDate ); + const tzPart = parts.find( ( p ) => p.type === 'timeZoneName' ); + return tzPart ? tzPart.value : tz; + } catch { + return tz; + } +} + +/** + * Get the current date/time as an ISO string in the site timezone. + * + * @param {string} tz Timezone string. + * @return {string} ISO 8601 date string. + */ +function getCurrentDateInTimezone( tz ) { + const pad = ( n ) => String( n ).padStart( 2, '0' ); + const formatNaive = ( d ) => + `${ d.getUTCFullYear() }-${ pad( d.getUTCMonth() + 1 ) }-${ pad( + d.getUTCDate() + ) }T${ pad( d.getUTCHours() ) }:${ pad( d.getUTCMinutes() ) }:${ pad( + d.getUTCSeconds() + ) }`; + + if ( ! tz ) { + return formatNaive( new Date() ); + } + + // Handle fixed-offset timezones (e.g. "UTC+2", "UTC-10") which + // Intl.DateTimeFormat does not accept. + const offsetMatch = tz.match( /^UTC([+-]\d+(?:\.\d+)?)$/ ); + if ( offsetMatch ) { + const offsetHours = parseFloat( offsetMatch[ 1 ] ); + const now = new Date(); + // Shift UTC time by the fixed offset to get wall-clock time. + return formatNaive( new Date( now.getTime() + offsetHours * 3600000 ) ); + } + + try { + const now = new Date(); + const formatter = new Intl.DateTimeFormat( 'en-CA', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + } ); + const parts = formatter.formatToParts( now ); + const get = ( type ) => + parts.find( ( p ) => p.type === type )?.value || ''; + return `${ get( 'year' ) }-${ get( 'month' ) }-${ get( 'day' ) }T${ get( + 'hour' + ) }:${ get( 'minute' ) }:${ get( 'second' ) }`; + } catch { + return formatNaive( new Date() ); + } +} + +/** + * Parse a naive datetime string to UTC milliseconds by extracting parts directly, + * avoiding browser-local timezone interpretation via new Date(). + * + * @param {string} dateString Naive ISO date string (e.g., "2026-03-07T15:00:00"). + * @return {number} Milliseconds (as if the wall-clock time were in UTC). + */ +function parseNaiveToMs( dateString ) { + const m = dateString.match( /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/ ); + if ( ! m ) { + return new Date( dateString ).getTime(); + } + return Date.UTC( + Number( m[ 1 ] ), + Number( m[ 2 ] ) - 1, + Number( m[ 3 ] ), + Number( m[ 4 ] ), + Number( m[ 5 ] ) + ); +} + +/** + * Check whether a date string represents a future date using a 1-minute buffer. + * Matches Gutenberg's isEditedPostBeingScheduled pattern. + * + * Both dateString and the current time are parsed as wall-clock parts via + * parseNaiveToMs to avoid browser-local timezone reinterpretation at DST boundaries. + * + * @param {string} dateString ISO date string to check (naive, in site timezone). + * @param {string} tz Site timezone string. + * @return {boolean} True if the date is more than 1 minute in the future. + */ +function isFutureDate( dateString, tz ) { + const selectedMs = parseNaiveToMs( dateString ); + const nowInSiteTz = getCurrentDateInTimezone( tz ); + const nowMs = parseNaiveToMs( nowInSiteTz ); + return selectedMs - nowMs > ONE_MINUTE; +} + /** * Header component. * @@ -66,8 +210,19 @@ function isMacOS() { * @param {Function} props.onRedo Redo callback. * @param {boolean} props.hasUndo Whether undo is available. * @param {boolean} props.hasRedo Whether redo is available. + * @param {Object} props.capabilities User capabilities. + * @param {string} props.timezone Site timezone string from wp_timezone_string(). + * @param {string} props.postStatus Current post status. + * @param {string} props.postDate Current post date (ISO 8601). * @return {JSX.Element} Header component. */ +export { + getTimezoneAbbreviation, + getCurrentDateInTimezone, + parseNaiveToMs, + isFutureDate, +}; + export default function Header( { siteName, siteUrl, @@ -86,6 +241,10 @@ export default function Header( { onRedo, hasUndo = false, hasRedo = false, + capabilities = {}, + timezone = '', + postStatus = '', + postDate = '', } ) { const [ scanUrl, setScanUrl ] = useState( sourceUrl || '' ); const [ isScanning, setIsScanning ] = useState( false ); @@ -93,13 +252,39 @@ export default function Header( { const [ showUpgradeNotice, setShowUpgradeNotice ] = useState( isLegacyBookmarklet ); + // Schedule popover state. + const [ isScheduleOpen, setIsScheduleOpen ] = useState( false ); + const [ scheduleDate, setScheduleDate ] = useState( () => { + if ( postStatus === 'future' && postDate ) { + return postDate; + } + return getCurrentDateInTimezone( timezone ); + } ); + // Track if initial auto-scan has been performed. const hasAutoScanned = useRef( false ); + // Ref for anchoring the schedule popover near the More actions button. + const moreMenuRef = useRef( null ); + // Keyboard shortcut hints based on platform. const undoShortcut = isMacOS() ? '\u2318Z' : 'Ctrl+Z'; const redoShortcut = isMacOS() ? '\u21E7\u2318Z' : 'Ctrl+Shift+Z'; + const timezoneAbbreviation = getTimezoneAbbreviation( + timezone, + scheduleDate + ); + const isScheduleFuture = isFutureDate( scheduleDate, timezone ); + const scheduleButtonLabel = isScheduleFuture + ? __( 'Schedule', 'press-this' ) + : __( 'Publish', 'press-this' ); + + const scheduleMenuLabel = + postStatus === 'future' + ? __( 'Reschedule', 'press-this' ) + : __( 'Schedule', 'press-this' ); + /** * Handle URL scan via proxy API. * @@ -277,6 +462,25 @@ export default function Header( { } }, [ onSave ] ); + /** + * Handle schedule confirmation. + * Calls onSave with 'future' status and date for future dates, + * or 'publish' for past dates. + */ + const handleScheduleConfirm = useCallback( () => { + if ( ! onSave ) { + return; + } + + if ( isFutureDate( scheduleDate, timezone ) ) { + onSave( 'future', { date: scheduleDate } ); + } else { + onSave( 'publish' ); + } + + setIsScheduleOpen( false ); + }, [ onSave, scheduleDate, timezone ] ); + // Only show scanner when proxy is enabled. // Without proxy, server-side scraping doesn't work - the scanner would be non-functional. // Bookmarklet flow works without proxy (client-side scraping) but doesn't need the scanner UI. @@ -386,27 +590,87 @@ export default function Header( { > { publishLabel } - - { ( { onClose } ) => ( - - { - handleContinueInEditor(); - onClose(); - } } - > - { __( - 'Continue in Standard Editor', - 'press-this' +
+ + { ( { onClose } ) => ( + <> + + { + handleContinueInEditor(); + onClose(); + } } + > + { __( + 'Continue in Standard Editor', + 'press-this' + ) } + + + { capabilities.canPublish && ( + + { + setIsScheduleOpen( + true + ); + setScheduleDate( + postStatus === + 'future' && + postDate + ? postDate + : getCurrentDateInTimezone( + timezone + ) + ); + onClose(); + } } + > + { scheduleMenuLabel } + + ) } - - - ) } - + + ) } + +
+ { isScheduleOpen && ( + setIsScheduleOpen( false ) } + placement="bottom-end" + className="press-this-header__schedule-popover" + aria-label={ __( + 'Schedule post', + 'press-this' + ) } + > +
+ + { timezoneAbbreviation && ( +

+ { timezoneAbbreviation } +

+ ) } + +
+
+ ) }
) } diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js index 143a3fc..8412484 100644 --- a/src/components/PressThisEditor.js +++ b/src/components/PressThisEditor.js @@ -41,7 +41,7 @@ import { PanelBody, FormTokenField, } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { registerCoreBlocks } from '@wordpress/block-library'; /** @@ -228,27 +228,83 @@ function getWpRestBaseUrl( pressThisRestUrl ) { return pressThisRestUrl.replace( /press-this\/v1\/$/, '' ); } +/** + * Format a date string for display in the schedule snackbar. + * + * Uses the browser's default locale for formatting so the date is displayed + * in the user's preferred language rather than hardcoded to English. + * + * @param {string} dateString ISO date string to format. + * @param {string} timezone Timezone identifier (e.g., "America/New_York" or "UTC+2"). + * @return {string} Human-readable formatted date. + */ +function formatScheduleDate( dateString, timezone ) { + try { + // Parse date parts directly to avoid browser-timezone reinterpretation. + // dateString is a naive site-timezone string like "2026-03-15T15:00:00". + const match = dateString.match( + /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/ + ); + if ( ! match ) { + return dateString; + } + const [ , year, month, day, hour, minute ] = match; + // Build a UTC Date representing the wall-clock time, then format in UTC. + const utcDate = new Date( + Date.UTC( + Number( year ), + Number( month ) - 1, + Number( day ), + Number( hour ), + Number( minute ) + ) + ); + const options = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', + }; + const formatted = new Intl.DateTimeFormat( undefined, options ).format( + utcDate + ); + // Append timezone identifier if available. + if ( timezone ) { + return `${ formatted } ${ timezone }`; + } + return formatted; + } catch { + return dateString; + } +} + /** * Press This Editor component. * - * @param {Object} props Component props. - * @param {Object} props.post Post object with ID, title, content. - * @param {Object} props.settings Editor settings. - * @param {Array} props.images Scraped images from source. - * @param {Array} props.embeds Scraped embeds from source. - * @param {Object} props.categories Available categories. - * @param {Array} props.postFormats Available post formats. - * @param {Object} props.capabilities User capabilities. - * @param {Object} props.restConfig REST API configuration. - * @param {string} props.sourceUrl Source URL being clipped. - * @param {Object} props.pendingScrape Pending scraped content to append. - * @param {Function} props.onScrapeProcessed Callback after scrape is processed. - * @param {Function} props.onSaveReady Callback when save handler is ready (receives { handleSave, isSaving, publishLabel }). - * @param {Function} props.onUndoReady Callback when undo/redo handlers are ready (receives { handleUndo, handleRedo, hasUndo, hasRedo }). + * @param {Object} props Component props. + * @param {Object} props.post Post object with ID, title, content. + * @param {Object} props.settings Editor settings. + * @param {Array} props.images Scraped images from source. + * @param {Array} props.embeds Scraped embeds from source. + * @param {Object} props.categories Available categories. + * @param {Array} props.postFormats Available post formats. + * @param {Object} props.capabilities User capabilities. + * @param {Object} props.restConfig REST API configuration. + * @param {string} props.sourceUrl Source URL being clipped. + * @param {Object} props.pendingScrape Pending scraped content to append. + * @param {Function} props.onScrapeProcessed Callback after scrape is processed. + * @param {Function} props.onSaveReady Callback when save handler is ready (receives { handleSave, isSaving, publishLabel }). + * @param {Function} props.onUndoReady Callback when undo/redo handlers are ready (receives { handleUndo, handleRedo, hasUndo, hasRedo }). + * @param {string} props.timezone Site timezone string from wp_timezone_string(). + * @param {Function} props.onPostStatusChange Callback when post status changes after save (receives { status, date }). * @param {string} props.categoryNonce * @param {string} props.ajaxUrl * @return {JSX.Element} Press This Editor component. */ +export { formatScheduleDate }; + export default function PressThisEditor( { post, settings, @@ -263,6 +319,8 @@ export default function PressThisEditor( { onScrapeProcessed = () => {}, onSaveReady = () => {}, onUndoReady = () => {}, + timezone = '', + onPostStatusChange = () => {}, categoryNonce = '', ajaxUrl = '', } ) { @@ -472,7 +530,7 @@ export default function PressThisEditor( { /** * Handle save operation. * - * @param {string} status Post status (draft, publish). + * @param {string} status Post status (draft, publish, future). * @param {Object} options Save options. */ const handleSave = useCallback( @@ -499,6 +557,7 @@ export default function PressThisEditor( { tags, featured_image: featuredImageId, force_redirect: options.forceRedirect || false, + date: options.date || '', } ), } ); @@ -517,6 +576,26 @@ export default function PressThisEditor( { } else { performSafeRedirect( result.redirect, false ); } + } else if ( status === 'future' && options.date ) { + // Scheduled post -- show formatted date in snackbar. + const formatted = formatScheduleDate( + options.date, + timezone + ); + setNotice( { + status: 'success', + message: sprintf( + /* translators: %s: formatted date and time */ + __( 'Post scheduled for %s.', 'press-this' ), + formatted + ), + } ); + + // Notify parent that post status has changed. + onPostStatusChange( { + status: 'future', + date: options.date, + } ); } else { // No redirect - show success notice. setNotice( { @@ -550,6 +629,8 @@ export default function PressThisEditor( { featuredImageId, post.id, restConfig, + timezone, + onPostStatusChange, ] ); diff --git a/src/styles/main.scss b/src/styles/main.scss index 5742d1e..da8b218 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -32,6 +32,9 @@ // WordPress component overrides. @use "partials/wordpress-overrides"; +// Header schedule popover. +@use "partials/header-schedule"; + // ========================================================================== // WordPress Block Editor Styles // ========================================================================== diff --git a/src/styles/partials/_header-schedule.scss b/src/styles/partials/_header-schedule.scss new file mode 100644 index 0000000..6ca7145 --- /dev/null +++ b/src/styles/partials/_header-schedule.scss @@ -0,0 +1,64 @@ +/** + * Header Schedule Popover Styles + * + * Styles for the schedule DateTimePicker popover in the header. + * + * @package press-this + */ + +@use "variables" as *; + +// ========================================================================== +// Popover Container +// ========================================================================== + +.press-this-header__schedule-popover { + z-index: $z-index-popover; + + .components-popover__content { + min-width: 270px; + max-width: 320px; + } +} + +// ========================================================================== +// Popover Content +// ========================================================================== + +.press-this-header__schedule-popover-content { + padding: $spacing-lg; +} + +// ========================================================================== +// Timezone Label +// ========================================================================== + +.press-this-header__schedule-timezone { + margin: $spacing-sm 0 0; + font-size: $font-size-xs; + color: $color-text-muted; + text-align: center; +} + +// ========================================================================== +// Confirmation Button +// ========================================================================== + +.press-this-header__schedule-confirm { + width: 100%; + justify-content: center; + margin-top: $spacing-lg; +} + +// ========================================================================== +// Mobile Adjustments +// ========================================================================== + +@media (max-width: $breakpoint-mobile) { + .press-this-header__schedule-popover { + .components-popover__content { + min-width: 240px; + max-width: calc(100vw - #{$spacing-xl * 2}); + } + } +} diff --git a/tests/components/header-schedule-ui.test.js b/tests/components/header-schedule-ui.test.js new file mode 100644 index 0000000..02881c5 --- /dev/null +++ b/tests/components/header-schedule-ui.test.js @@ -0,0 +1,150 @@ +/** + * Header Schedule UI Tests + * + * Behavioral tests for the scheduling utility functions in the Header component: + * timezone abbreviation derivation, current-date-in-timezone formatting, + * naive datetime parsing, and future-date detection. + * + * @package press-this + */ + +import { + getTimezoneAbbreviation, + getCurrentDateInTimezone, + parseNaiveToMs, + isFutureDate, +} from '../../src/components/Header'; + +// Mock @wordpress/element so the module can load without React. +jest.mock( '@wordpress/element', () => ( { + useState: ( init ) => [ typeof init === 'function' ? init() : init, jest.fn() ], + useCallback: ( fn ) => fn, + useEffect: jest.fn(), + useRef: ( val ) => ( { current: val } ), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + Button: 'Button', + TextControl: 'TextControl', + Notice: 'Notice', + Tooltip: 'Tooltip', + DropdownMenu: 'DropdownMenu', + MenuGroup: 'MenuGroup', + MenuItem: 'MenuItem', + Popover: 'Popover', + DateTimePicker: 'DateTimePicker', +} ) ); + +jest.mock( '@wordpress/i18n', () => ( { + __: ( text ) => text, +} ) ); + +jest.mock( '@wordpress/icons', () => ( { + undo: 'undo-icon', + redo: 'redo-icon', + moreVertical: 'more-vertical-icon', +} ) ); + +describe( 'getTimezoneAbbreviation', () => { + test( 'returns empty string for falsy timezone', () => { + expect( getTimezoneAbbreviation( '', null ) ).toBe( '' ); + expect( getTimezoneAbbreviation( null, null ) ).toBe( '' ); + expect( getTimezoneAbbreviation( undefined, null ) ).toBe( '' ); + } ); + + test( 'returns fixed-offset timezones unchanged', () => { + expect( getTimezoneAbbreviation( 'UTC', null ) ).toBe( 'UTC' ); + expect( getTimezoneAbbreviation( 'UTC+5', null ) ).toBe( 'UTC+5' ); + expect( getTimezoneAbbreviation( 'UTC-10', null ) ).toBe( 'UTC-10' ); + } ); + + test( 'returns a short abbreviation for IANA timezones', () => { + const abbr = getTimezoneAbbreviation( 'America/New_York', '2026-01-15T12:00:00' ); + // In January, New York is in Eastern Standard Time. + expect( abbr ).toMatch( /^(EST|GMT-5|UTC-5)/ ); + } ); + + test( 'returns DST-aware abbreviation when date is in summer', () => { + const abbr = getTimezoneAbbreviation( 'America/New_York', '2026-07-15T12:00:00' ); + // In July, New York is in Eastern Daylight Time. + expect( abbr ).toMatch( /^(EDT|GMT-4|UTC-4)/ ); + } ); + + test( 'returns the timezone string on invalid IANA identifier', () => { + expect( getTimezoneAbbreviation( 'Invalid/Zone', '2026-01-15T12:00:00' ) ).toBe( 'Invalid/Zone' ); + } ); +} ); + +describe( 'getCurrentDateInTimezone', () => { + test( 'returns an ISO-like string with T separator', () => { + const result = getCurrentDateInTimezone( 'America/New_York' ); + expect( result ).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ ); + } ); + + test( 'returns a string that differs from UTC for non-UTC timezones', () => { + // This is a loose check -- we just verify the function runs without error + // and returns a properly formatted string. + const result = getCurrentDateInTimezone( 'Asia/Tokyo' ); + expect( result ).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ ); + } ); + + test( 'falls back to ISO string for empty timezone', () => { + const result = getCurrentDateInTimezone( '' ); + // Should return a valid ISO string (from toISOString). + expect( result ).toMatch( /^\d{4}-\d{2}-\d{2}T/ ); + } ); +} ); + +describe( 'parseNaiveToMs', () => { + test( 'parses a naive ISO datetime string to UTC milliseconds', () => { + const ms = parseNaiveToMs( '2026-03-15T14:30:00' ); + expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) ); + } ); + + test( 'handles space separator between date and time', () => { + const ms = parseNaiveToMs( '2026-03-15 14:30:00' ); + expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) ); + } ); + + test( 'handles datetime without seconds', () => { + const ms = parseNaiveToMs( '2026-03-15T14:30' ); + expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) ); + } ); + + test( 'falls back to Date constructor for unparseable strings', () => { + const ms = parseNaiveToMs( 'not-a-date' ); + expect( ms ).toBeNaN(); + } ); +} ); + +describe( 'isFutureDate', () => { + test( 'returns true for a date far in the future', () => { + expect( isFutureDate( '2099-12-31T23:59:00', 'UTC' ) ).toBe( true ); + } ); + + test( 'returns false for a date in the past', () => { + expect( isFutureDate( '2000-01-01T00:00:00', 'UTC' ) ).toBe( false ); + } ); + + test( 'returns false for the current time (within 1-minute buffer)', () => { + // Get the current time in UTC and check it's not considered future. + const now = getCurrentDateInTimezone( 'UTC' ); + expect( isFutureDate( now, 'UTC' ) ).toBe( false ); + } ); + + test( 'uses 1-minute buffer -- date exactly 1 minute ahead is not future', () => { + // Build a date exactly 60 seconds from "now" in UTC. + const nowMs = Date.now(); + const justAhead = new Date( nowMs + 60000 ); + const formatted = `${ justAhead.getUTCFullYear() }-${ String( justAhead.getUTCMonth() + 1 ).padStart( 2, '0' ) }-${ String( justAhead.getUTCDate() ).padStart( 2, '0' ) }T${ String( justAhead.getUTCHours() ).padStart( 2, '0' ) }:${ String( justAhead.getUTCMinutes() ).padStart( 2, '0' ) }:${ String( justAhead.getUTCSeconds() ).padStart( 2, '0' ) }`; + // Exactly at the 1-minute boundary should NOT be future (strict >). + expect( isFutureDate( formatted, 'UTC' ) ).toBe( false ); + } ); + + test( 'date well beyond 1 minute in the future returns true', () => { + const nowMs = Date.now(); + const fiveMinAhead = new Date( nowMs + 5 * 60000 ); + const formatted = `${ fiveMinAhead.getUTCFullYear() }-${ String( fiveMinAhead.getUTCMonth() + 1 ).padStart( 2, '0' ) }-${ String( fiveMinAhead.getUTCDate() ).padStart( 2, '0' ) }T${ String( fiveMinAhead.getUTCHours() ).padStart( 2, '0' ) }:${ String( fiveMinAhead.getUTCMinutes() ).padStart( 2, '0' ) }:${ String( fiveMinAhead.getUTCSeconds() ).padStart( 2, '0' ) }`; + expect( isFutureDate( formatted, 'UTC' ) ).toBe( true ); + } ); +} ); diff --git a/tests/components/save-handler-scheduling.test.js b/tests/components/save-handler-scheduling.test.js new file mode 100644 index 0000000..2578d94 --- /dev/null +++ b/tests/components/save-handler-scheduling.test.js @@ -0,0 +1,120 @@ +/** + * Save Handler Scheduling Tests + * + * Behavioral tests for the formatScheduleDate utility function in PressThisEditor. + * Verifies date formatting, timezone label appending, and error handling. + * + * @package press-this + */ + +import { formatScheduleDate } from '../../src/components/PressThisEditor'; + +// Mock all @wordpress/* dependencies so the module loads without React/DOM. +jest.mock( '@wordpress/element', () => ( { + useMemo: ( fn ) => fn(), + useCallback: ( fn ) => fn, + useState: ( init ) => [ typeof init === 'function' ? init() : init, jest.fn() ], + useEffect: jest.fn(), + useRef: ( val ) => ( { current: val } ), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn( () => false ), + useDispatch: jest.fn( () => ( {} ) ), +} ) ); + +jest.mock( '@wordpress/blocks', () => ( { + parse: jest.fn( () => [] ), + registerCoreBlocks: jest.fn(), +} ) ); + +jest.mock( '@wordpress/block-library', () => ( { + registerCoreBlocks: jest.fn(), +} ) ); + +jest.mock( '@wordpress/block-editor', () => ( { + BlockEditorProvider: 'BlockEditorProvider', + BlockList: 'BlockList', + BlockTools: 'BlockTools', + WritingFlow: 'WritingFlow', + ObserveTyping: 'ObserveTyping', + BlockEditorKeyboardShortcuts: { Register: 'Register' }, + BlockToolbar: 'BlockToolbar', + BlockInspector: 'BlockInspector', + Inserter: 'Inserter', + store: { name: 'core/block-editor' }, +} ) ); + +jest.mock( '@wordpress/components', () => ( { + SlotFillProvider: 'SlotFillProvider', + Popover: Object.assign( () => null, { Slot: 'Slot' } ), + Button: 'Button', + Spinner: 'Spinner', + Snackbar: 'Snackbar', + Panel: 'Panel', + PanelBody: 'PanelBody', + FormTokenField: 'FormTokenField', +} ) ); + +jest.mock( '@wordpress/i18n', () => ( { + __: ( text ) => text, + sprintf: ( fmt, ...args ) => { + let result = fmt; + args.forEach( ( arg ) => { + result = result.replace( '%s', arg ); + } ); + return result; + }, +} ) ); + +// Mock internal component imports to prevent cascading dependency issues. +jest.mock( '../../src/components/BlockTransformShortcuts', () => 'BlockTransformShortcuts' ); +jest.mock( '../../src/components/ScrapedMediaPanel', () => 'ScrapedMediaPanel' ); +jest.mock( '../../src/components/FeaturedImagePanel', () => 'FeaturedImagePanel' ); +jest.mock( '../../src/components/CategoryPanel', () => 'CategoryPanel' ); + +describe( 'formatScheduleDate', () => { + test( 'formats a naive ISO datetime into a human-readable string', () => { + const result = formatScheduleDate( '2026-06-15T14:30:00', '' ); + // Should contain the date components (month, day, year, time). + expect( result ).toMatch( /June/ ); + expect( result ).toMatch( /15/ ); + expect( result ).toMatch( /2026/ ); + expect( result ).toMatch( /2:30/ ); + } ); + + test( 'appends timezone identifier when provided', () => { + const result = formatScheduleDate( '2026-06-15T14:30:00', 'America/New_York' ); + expect( result ).toContain( 'America/New_York' ); + } ); + + test( 'appends fixed-offset timezone string', () => { + const result = formatScheduleDate( '2026-06-15T14:30:00', 'UTC+2' ); + expect( result ).toContain( 'UTC+2' ); + } ); + + test( 'does not append timezone when empty', () => { + const result = formatScheduleDate( '2026-06-15T14:30:00', '' ); + // Should not have trailing whitespace from empty timezone. + expect( result ).toBe( result.trim() ); + } ); + + test( 'returns the raw string for unparseable input', () => { + const result = formatScheduleDate( 'not-a-date', 'UTC' ); + expect( result ).toBe( 'not-a-date' ); + } ); + + test( 'handles space separator in datetime', () => { + const result = formatScheduleDate( '2026-06-15 14:30:00', '' ); + expect( result ).toMatch( /June/ ); + expect( result ).toMatch( /15/ ); + } ); + + test( 'formats correctly for different months', () => { + const jan = formatScheduleDate( '2026-01-05T09:00:00', '' ); + expect( jan ).toMatch( /January/ ); + + const dec = formatScheduleDate( '2026-12-25T18:00:00', '' ); + expect( dec ).toMatch( /December/ ); + } ); +} ); diff --git a/tests/components/scheduling-integration.test.js b/tests/components/scheduling-integration.test.js new file mode 100644 index 0000000..2336b90 --- /dev/null +++ b/tests/components/scheduling-integration.test.js @@ -0,0 +1,156 @@ +/** + * Scheduling Integration Tests + * + * Cross-cutting integration tests for the post-scheduling feature. + * Tests the interaction between Header scheduling utilities and + * PressThisEditor formatting, verifying the full data flow. + * + * @package press-this + */ + +import { + getTimezoneAbbreviation, + getCurrentDateInTimezone, + parseNaiveToMs, + isFutureDate, +} from '../../src/components/Header'; + +import { formatScheduleDate } from '../../src/components/PressThisEditor'; + +// Mock @wordpress dependencies for Header. +jest.mock( '@wordpress/element', () => ( { + useState: ( init ) => [ typeof init === 'function' ? init() : init, jest.fn() ], + useCallback: ( fn ) => fn, + useEffect: jest.fn(), + useRef: ( val ) => ( { current: val } ), + useMemo: ( fn ) => fn(), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + Button: 'Button', + TextControl: 'TextControl', + Notice: 'Notice', + Tooltip: 'Tooltip', + DropdownMenu: 'DropdownMenu', + MenuGroup: 'MenuGroup', + MenuItem: 'MenuItem', + Popover: Object.assign( () => null, { Slot: 'Slot' } ), + DateTimePicker: 'DateTimePicker', + SlotFillProvider: 'SlotFillProvider', + Spinner: 'Spinner', + Snackbar: 'Snackbar', + Panel: 'Panel', + PanelBody: 'PanelBody', + FormTokenField: 'FormTokenField', +} ) ); + +jest.mock( '@wordpress/i18n', () => ( { + __: ( text ) => text, + sprintf: ( fmt, ...args ) => { + let result = fmt; + args.forEach( ( arg ) => { + result = result.replace( '%s', arg ); + } ); + return result; + }, +} ) ); + +jest.mock( '@wordpress/icons', () => ( { + undo: 'undo-icon', + redo: 'redo-icon', + moreVertical: 'more-vertical-icon', +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn( () => false ), + useDispatch: jest.fn( () => ( {} ) ), +} ) ); + +jest.mock( '@wordpress/blocks', () => ( { + parse: jest.fn( () => [] ), + registerCoreBlocks: jest.fn(), +} ) ); + +jest.mock( '@wordpress/block-library', () => ( { + registerCoreBlocks: jest.fn(), +} ) ); + +jest.mock( '@wordpress/block-editor', () => ( { + BlockEditorProvider: 'BlockEditorProvider', + BlockList: 'BlockList', + BlockTools: 'BlockTools', + WritingFlow: 'WritingFlow', + ObserveTyping: 'ObserveTyping', + BlockEditorKeyboardShortcuts: { Register: 'Register' }, + BlockToolbar: 'BlockToolbar', + BlockInspector: 'BlockInspector', + Inserter: 'Inserter', + store: { name: 'core/block-editor' }, +} ) ); + +jest.mock( '../../src/components/BlockTransformShortcuts', () => 'BlockTransformShortcuts' ); +jest.mock( '../../src/components/ScrapedMediaPanel', () => 'ScrapedMediaPanel' ); +jest.mock( '../../src/components/FeaturedImagePanel', () => 'FeaturedImagePanel' ); +jest.mock( '../../src/components/CategoryPanel', () => 'CategoryPanel' ); + +describe( 'Scheduling Integration: Header utilities + Editor formatting', () => { + test( 'a date produced by getCurrentDateInTimezone is parseable by parseNaiveToMs', () => { + const dateStr = getCurrentDateInTimezone( 'America/Chicago' ); + const ms = parseNaiveToMs( dateStr ); + expect( ms ).not.toBeNaN(); + expect( ms ).toBeGreaterThan( 0 ); + } ); + + test( 'a date from getCurrentDateInTimezone is formattable by formatScheduleDate', () => { + const dateStr = getCurrentDateInTimezone( 'Europe/London' ); + const formatted = formatScheduleDate( dateStr, 'Europe/London' ); + expect( formatted ).toContain( 'Europe/London' ); + // Should contain year. + expect( formatted ).toMatch( /\d{4}/ ); + } ); + + test( 'isFutureDate and formatScheduleDate agree on a far-future date', () => { + const futureDate = '2099-06-15T14:30:00'; + expect( isFutureDate( futureDate, 'UTC' ) ).toBe( true ); + const formatted = formatScheduleDate( futureDate, 'UTC' ); + expect( formatted ).toMatch( /June/ ); + expect( formatted ).toMatch( /2099/ ); + } ); + + test( 'timezone abbreviation matches the timezone appended to formatted date', () => { + const date = '2026-01-15T10:00:00'; + const tz = 'America/New_York'; + const abbr = getTimezoneAbbreviation( tz, date ); + const formatted = formatScheduleDate( date, tz ); + // formatScheduleDate appends the IANA string, not the abbreviation. + expect( formatted ).toContain( tz ); + // But the abbreviation is a real value (not empty). + expect( abbr.length ).toBeGreaterThan( 0 ); + } ); + + test( 'round-trip: parse -> check future -> format for a scheduled post', () => { + const scheduledDate = '2099-03-15T09:00:00'; + const tz = 'Asia/Tokyo'; + + // Step 1: Parse the date. + const ms = parseNaiveToMs( scheduledDate ); + expect( ms ).not.toBeNaN(); + + // Step 2: Check if it's in the future. + expect( isFutureDate( scheduledDate, tz ) ).toBe( true ); + + // Step 3: Format for the snackbar. + const formatted = formatScheduleDate( scheduledDate, tz ); + expect( formatted ).toMatch( /March/ ); + expect( formatted ).toMatch( /2099/ ); + expect( formatted ).toContain( 'Asia/Tokyo' ); + } ); + + test( 'past date is not future and formats correctly', () => { + const pastDate = '2020-06-01T08:00:00'; + expect( isFutureDate( pastDate, 'UTC' ) ).toBe( false ); + const formatted = formatScheduleDate( pastDate, 'UTC' ); + expect( formatted ).toMatch( /June/ ); + expect( formatted ).toMatch( /2020/ ); + } ); +} ); diff --git a/tests/php/test-scheduling.php b/tests/php/test-scheduling.php new file mode 100644 index 0000000..e1e02e2 --- /dev/null +++ b/tests/php/test-scheduling.php @@ -0,0 +1,267 @@ +editor_user_id = wp_insert_user( + array( + 'user_login' => 'sched_editor_' . wp_generate_password( 6, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'sched_editor_' . wp_generate_password( 6, false ) . '@example.com', + 'role' => 'editor', + ) + ); + + // Create a contributor user (lacks publish_posts). + $this->contributor_user_id = wp_insert_user( + array( + 'user_login' => 'sched_contrib_' . wp_generate_password( 6, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'sched_contrib_' . wp_generate_password( 6, false ) . '@example.com', + 'role' => 'contributor', + ) + ); + + // Create a draft post owned by the editor. + wp_set_current_user( $this->editor_user_id ); + $this->test_post_id = wp_insert_post( + array( + 'post_author' => $this->editor_user_id, + 'post_status' => 'draft', + 'post_title' => 'Scheduling Test Post', + 'post_content' => '', + ) + ); + } + + /** + * Tear down after each test. + */ + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'block_http' ), 1 ); + parent::tear_down(); + } + + /** + * Block all HTTP requests during tests. + * + * @return WP_Error + */ + public function block_http() { + return new WP_Error( 'http_blocked', 'Blocked in test' ); + } + + /** + * Build a WP_REST_Request for the save endpoint. + * + * @param array $params Parameters to set on the request. + * @return WP_REST_Request + */ + protected function build_save_request( $params = array() ) { + $request = new WP_REST_Request( 'POST', '/press-this/v1/save' ); + $request->set_param( 'post_id', $this->test_post_id ); + $request->set_param( 'title', 'Scheduled Post' ); + $request->set_param( 'content', '

Content

' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return $request; + } + + /** + * Test that 'future' is accepted in the status enum on the save endpoint. + */ + public function test_future_status_is_accepted() { + wp_set_current_user( $this->editor_user_id ); + + $future_date = gmdate( 'Y-m-d\TH:i:s', strtotime( '+1 week' ) ); + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => $future_date, + ) + ); + + $response = press_this_rest_save_post( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'future', get_post_status( $this->test_post_id ) ); + } + + /** + * Test that a date parameter is accepted and validated with strtotime(). + */ + public function test_date_parameter_is_validated() { + wp_set_current_user( $this->editor_user_id ); + + $future_date = '2027-06-15T14:30:00'; + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => $future_date, + ) + ); + + $response = press_this_rest_save_post( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + + $post = get_post( $this->test_post_id ); + $this->assertEquals( '2027-06-15 14:30:00', $post->post_date ); + } + + /** + * Test that post_date and post_date_gmt are set when status is 'future' with a valid date. + */ + public function test_post_date_and_gmt_set_for_future_status() { + wp_set_current_user( $this->editor_user_id ); + + $future_date = '2027-06-15T14:30:00'; + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => $future_date, + ) + ); + + $response = press_this_rest_save_post( $request ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + + $post = get_post( $this->test_post_id ); + $this->assertEquals( '2027-06-15 14:30:00', $post->post_date ); + $this->assertNotEmpty( $post->post_date_gmt ); + $this->assertNotEquals( '0000-00-00 00:00:00', $post->post_date_gmt ); + } + + /** + * Test that 'future' status without a valid date returns a WP_Error. + */ + public function test_future_status_without_date_returns_error() { + wp_set_current_user( $this->editor_user_id ); + + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => '', + ) + ); + + $response = press_this_rest_save_post( $request ); + + $this->assertInstanceOf( WP_Error::class, $response ); + $this->assertEquals( 'press_this_invalid_date', $response->get_error_code() ); + } + + /** + * Test that rescheduling an already-scheduled post updates the date. + */ + public function test_reschedule_updates_date() { + wp_set_current_user( $this->editor_user_id ); + + // First, schedule the post for an initial date. + $initial_date = '2027-06-15T14:30:00'; + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => $initial_date, + ) + ); + + $response = press_this_rest_save_post( $request ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 'future', get_post_status( $this->test_post_id ) ); + $this->assertEquals( '2027-06-15 14:30:00', get_post( $this->test_post_id )->post_date ); + + // Reschedule to a different date. + $new_date = '2027-09-20T10:00:00'; + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => $new_date, + ) + ); + + $response = press_this_rest_save_post( $request ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + + $post = get_post( $this->test_post_id ); + $this->assertEquals( 'future', $post->post_status ); + $this->assertEquals( '2027-09-20 10:00:00', $post->post_date ); + $this->assertNotEquals( '2027-06-15 14:30:00', $post->post_date ); + } + + /** + * Test that 'future' status is gated behind publish_posts capability. + */ + public function test_future_status_gated_behind_publish_posts() { + wp_set_current_user( $this->contributor_user_id ); + + // Grant the contributor edit access to the post for the test. + wp_update_post( + array( + 'ID' => $this->test_post_id, + 'post_author' => $this->contributor_user_id, + ) + ); + + $future_date = gmdate( 'Y-m-d\TH:i:s', strtotime( '+1 week' ) ); + $request = $this->build_save_request( + array( + 'status' => 'future', + 'date' => $future_date, + ) + ); + + $response = press_this_rest_save_post( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 'pending', get_post_status( $this->test_post_id ) ); + } +}