Skip to content
Open
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
203 changes: 196 additions & 7 deletions includes/events/event-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ public function get_content_tags()
// List of attendees.
'organizer',
// Creator info.
'schema-meta',
// Hidden schema.org meta properties for SEO.

/* ================ *
* Conditional Tags *
Expand Down Expand Up @@ -244,9 +246,18 @@ public function parse_event_template_tags($template_tags = '')
// Process tags.
$result = preg_replace_callback($this->get_regex(), [$this, 'process_event_content'], $template_tags);

// Removes extra consecutive <br> tags.
// TODO: Doesn't seem to work but going to remove it to allow multiple <br> tags in the editor
return do_shortcode(trim($result));
// Process shortcodes first
$result = do_shortcode(trim($result));

// Add schema properties if not already present in processed output
// Check the processed result instead of template_tags to handle conditional tags correctly
// Look for actual schema output marker (itemprop="endDate") rather than the tag itself
if (strpos($result, 'itemprop="endDate"') === false) {
// Add schema meta at the end with a newline to ensure it's properly separated
$result .= "\n" . $this->get_missing_schema_properties($this->event);
}

return $result;
}

/**
Expand Down Expand Up @@ -328,7 +339,7 @@ public function process_event_content($match)

return ' <span class="simcal-event-address simcal-event-' .
$location_class .
'-location" itemprop="location" itemscope itemtype="http://schema.org/Place">' .
'-location" itemprop="location" itemscope itemtype="https://schema.org/Place">' .
'<meta itemprop="name" content="' .
$meta_location_name_and_address .
'" />' .
Expand All @@ -353,7 +364,7 @@ public function process_event_content($match)
case 'url':
$content = 'link' == $tag ? $calendar->get_event_html($event, $partial) : '';

return $this->make_link($tag, $event->link, $content, $attr);
return $this->make_link($tag, $event->link, $content, $attr, true);

case 'add-to-gcal-link':
$content = 'add-to-gcal-link' == $tag ? $calendar->get_event_html($event, $partial) : '';
Expand Down Expand Up @@ -412,6 +423,9 @@ public function process_event_content($match)
}
break;

case 'schema-meta':
return $this->get_missing_schema_properties($event);

/* ================ *
* Conditional Tags *
* ================ */
Expand Down Expand Up @@ -929,10 +943,11 @@ private function get_dt($tag, Event $event, $attr)
* @param string $url
* @param string $content
* @param string $attr
* @param bool $is_event_url Whether this is the event's main URL (for schema.org)
*
* @return string
*/
private function make_link($tag, $url, $content, $attr)
private function make_link($tag, $url, $content, $attr, $is_event_url = false)
{
if (empty($url)) {
return '';
Expand All @@ -953,6 +968,9 @@ private function make_link($tag, $url, $content, $attr)
$anchor = $tag != 'url' ? 'yes' : $attr['autolink'];
$target = false !== $attr['newwindow'] ? 'target="_blank"' : '';

// Add itemprop="url" for event's main link to help Google recognize it
$itemprop = $is_event_url ? 'itemprop="url" ' : '';

/**
* Add additional event link attributes.
*
Expand All @@ -969,6 +987,7 @@ private function make_link($tag, $url, $content, $attr)
? ' <a href="' .
esc_url($url) .
'" ' .
$itemprop .
wp_kses_post($target) .
' ' .
wp_kses_post($additional_link_atts) .
Expand Down Expand Up @@ -1061,7 +1080,7 @@ private function get_attendees($attendees, $attr)
}

$html .=
'<li class="simcal-attendee" itemprop="attendee" itemscope itemtype="http://schema.org/Person">' .
'<li class="simcal-attendee" itemprop="attendee" itemscope itemtype="https://schema.org/Person">' .
$guest .
'</li>' .
"\n";
Expand Down Expand Up @@ -1168,6 +1187,176 @@ private function get_organizer($organizer, $attr)
'</div>';
}

/**
* Get missing schema.org Event properties as hidden meta tags.
*
* @since 3.0.0
* @access private
*
* @param Event $event
*
* @return string
*/
private function get_missing_schema_properties(Event $event)
{
$schema_meta = '';

// Ensure endDate is always present (required by Google)
// Always add endDate to schema meta to ensure Google recognizes it consistently
if ($event->end_dt instanceof Carbon) {
// Use the actual end date if available
$end_iso = $event->end_dt->toIso8601String();
$schema_meta .= '<meta itemprop="endDate" content="' . esc_attr($end_iso) . '" />';
} else {
// If no end date, use start date + 1 hour as fallback
$start = Carbon::createFromTimestamp($event->start, $event->timezone);
$end_date = $start->copy()->addHour();
$end_iso = $end_date->toIso8601String();
$schema_meta .= '<meta itemprop="endDate" content="' . esc_attr($end_iso) . '" />';
}

// Add eventAttendanceMode (default to OfflineEventAttendanceMode for physical events)
$attendance_mode = !empty($event->start_location['address'])
? 'OfflineEventAttendanceMode'
: 'OnlineEventAttendanceMode';
$schema_meta .=
'<meta itemprop="eventAttendanceMode" content="https://schema.org/' . esc_attr($attendance_mode) . '" />';

// Add eventStatus (default to EventScheduled)
$schema_meta .= '<meta itemprop="eventStatus" content="https://schema.org/EventScheduled" />';

// Add event URL (always include for Google - use link element for better compatibility)
// Note: If [link] tag is used in template, it will also have itemprop="url" on the <a> tag
$event_url = '';
if (!empty($event->link)) {
$event_url = $event->link;
} else {
// Fallback to calendar/page URL
if (!empty($event->calendar) && is_numeric($event->calendar)) {
$event_url = get_permalink($event->calendar);
}
// Fallback to current page URL
if (empty($event_url)) {
global $wp;
$current_url = home_url($wp->request);
if (empty($current_url) || $current_url == home_url()) {
$event_url = home_url('/');
} else {
$event_url = $current_url;
}
}
}
// Always add URL as link element - Google prefers this format
// Even if [link] tag exists with itemprop, this ensures URL is always present
if (!empty($event_url)) {
$schema_meta .= '<link itemprop="url" href="' . esc_url($event_url) . '" />';
}

// Offers: make optional and filterable to avoid incorrect pricing/currency.
// Only emit default offers if explicitly enabled via filter to prevent incorrect structured data
if (apply_filters('simcal_emit_default_offers', false, $event)) {
$schema_meta .= '<div itemprop="offers" itemscope itemtype="https://schema.org/Offer">';
$schema_meta .= '<meta itemprop="price" content="0" />';
if (!empty($event_url)) {
$schema_meta .= '<meta itemprop="url" content="' . esc_url($event_url) . '" />';
} else {
$schema_meta .= '<meta itemprop="url" content="' . esc_url(home_url()) . '" />';
}
$schema_meta .= '<meta itemprop="priceCurrency" content="USD" />';
$schema_meta .= '<meta itemprop="availability" content="https://schema.org/InStock" />';
// Add validFrom (when the offer becomes valid - use a past date to ensure offer is always valid)
$valid_from_date = Carbon::now($event->timezone)->subDay(); // Use yesterday to ensure it's always valid
$valid_from = $valid_from_date->toIso8601String();
$schema_meta .= '<meta itemprop="validFrom" content="' . esc_attr($valid_from) . '" />';
$schema_meta .= '</div>';
}

// Add image (fallback to site logo or default image)
$image_url = $this->get_event_image_url($event);
if ($image_url) {
$schema_meta .= '<meta itemprop="image" content="' . esc_url($image_url) . '" />';
}

// Add performer (use organizer data if available, otherwise use site name)
$performer_name = $this->get_event_performer($event);
if ($performer_name) {
$schema_meta .= '<div itemprop="performer" itemscope itemtype="https://schema.org/Person">';
$schema_meta .= '<meta itemprop="name" content="' . esc_attr($performer_name) . '" />';
$schema_meta .= '</div>';
}

// Ensure organizer is present if not already added
$organizer_data = method_exists($event, 'get_organizer') ? $event->get_organizer() : [];
if (empty($organizer_data)) {
$schema_meta .= '<div itemprop="organizer" itemscope itemtype="https://schema.org/Organization">';
$schema_meta .= '<meta itemprop="name" content="' . esc_attr(get_bloginfo('name')) . '" />';
$schema_meta .= '<meta itemprop="url" content="' . esc_url(home_url()) . '" />';
$schema_meta .= '</div>';
}

// Add description if missing (use event title as fallback)
if (empty($event->description)) {
$schema_meta .= '<meta itemprop="description" content="' . esc_attr($event->title) . '" />';
}

return $schema_meta;
}

/**
* Get event image URL with fallbacks.
*
* @since 3.0.0
* @access private
*
* @param Event $event
*
* @return string|false
*/
private function get_event_image_url(Event $event)
{
// Try to get custom logo first
$custom_logo_id = get_theme_mod('custom_logo');
if ($custom_logo_id) {
$logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
if ($logo_url) {
return $logo_url;
}
}

// Fallback to site icon
$site_icon_url = get_site_icon_url();
if ($site_icon_url) {
return $site_icon_url;
}

// Fallback to a default image URL (you can customize this)
return false;
}
Comment on lines +1305 to +1334
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unused parameter and missing event-specific image sources.

The $event parameter is unused (flagged by PHPMD), and the method doesn't attempt to use event-specific images like attachments or cover images before falling back to site-level defaults.

Consider this enhancement to utilize event-specific images:

 private function get_event_image_url(Event $event)
 {
+    // Try event's cover image first
+    $cover_image = $event->get_cover_image();
+    if (!empty($cover_image)) {
+        // Extract URL from HTML if needed
+        if (preg_match('/src=["\']([^"\']+)["\']/', $cover_image, $matches)) {
+            return $matches[1];
+        }
+    }
+    
+    // Try event attachments for images
+    $attachments = $event->get_attachments();
+    if (!empty($attachments)) {
+        foreach ($attachments as $attachment) {
+            if (!empty($attachment['icon']) && preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $attachment['icon'])) {
+                return $attachment['icon'];
+            }
+        }
+    }
+    
     // Try to get custom logo first
     $custom_logo_id = get_theme_mod('custom_logo');

This would make the $event parameter meaningful and provide more relevant images for schema markup.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 PHPMD (2.15.0)

1305-1305: Avoid unused parameters such as '$event'. (undefined)

(UnusedFormalParameter)


/**
* Get event performer name.
*
* @since 3.0.0
* @access private
*
* @param Event $event
*
* @return string
*/
private function get_event_performer(Event $event)
{
// Use organizer name if available
if (method_exists($event, 'get_organizer')) {
$org = $event->get_organizer();
if (!empty($org) && !empty($org['name'])) {
return $org['name'];
}
}

// Fallback to site name
return get_bloginfo('name');
}

/**
* Retrieve the event builder tag regular expression for searching.
*
Expand Down
64 changes: 64 additions & 0 deletions includes/functions/shared.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,74 @@ function simcal_default_event_template()
$content .= "\n";
$content .= '<div>' . '[description]' . '</div>';
$content .= "\n" . '[link newwindow="yes"]' . __('See more details', 'google-calendar-events') . '[/link]';
$content .= "\n" . '[schema-meta]';

return apply_filters('simcal_default_event_template', $content);
}

/**
* Update existing calendar templates to include schema-meta tag.
*
* This function can be called to update existing calendars that don't have
* the schema-meta tag in their event templates.
*
* @since 3.0.0
*
* @return int Number of calendars updated
*/
function simcal_update_calendar_templates_for_schema()
{
$calendars = get_posts([
'post_type' => 'calendar',
'posts_per_page' => -1,
'post_status' => 'any',
]);

$updated_count = 0;

foreach ($calendars as $calendar) {
// Skip if user doesn't have permission to edit this calendar
if (!current_user_can('edit_post', $calendar->ID)) {
continue;
}

$content = $calendar->post_content;

// Only update if the template doesn't already contain schema-meta
if (!empty($content) && strpos($content, '[schema-meta]') === false) {
// Add schema-meta tag at the end of the template
$updated_content = $content . "\n" . '[schema-meta]';

$update_result = wp_update_post([
'ID' => $calendar->ID,
'post_content' => $updated_content,
]);

// Handle wp_update_post errors (returns WP_Error or 0 on failure)
if (is_wp_error($update_result)) {
$error_message = sprintf(
'Failed to update calendar template for calendar ID %d: %s',
$calendar->ID,
$update_result->get_error_message()
);
simcal_log_error($error_message);
} elseif (empty($update_result) || $update_result === 0) {
$error_message = sprintf(
'Failed to update calendar template for calendar ID %d: wp_update_post returned 0 or empty',
$calendar->ID
);
simcal_log_error($error_message);
} else {
// Success: increment counter and clear related caches
$updated_count++;
simcal_delete_feed_transients($calendar->ID);
}
}
}

return $updated_count;
}

/**
* Get day, month, year order in a datetime format string.
*
Expand Down