| Server IP : 3.147.158.171 / Your IP : 216.73.216.229 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/repo/sites/dev/wp-content/plugins/tsai-plugin/includes/ |
Upload File : |
<?php
if (!defined('ABSPATH')) {
exit;
}
class TSAI_Shortcodes {
const SCHEDULE_TRANSIENT = 'tsai_schedule_characters';
const SCHEDULE_SLUG_TRANSIENT = 'tsai_schedule_site_slug';
const SCHEDULE_HISTORY_TRANSIENT = 'tsai_schedule_history';
public function __construct() {
add_shortcode('tsai_banner', array($this, 'banner_shortcode'));
add_shortcode('tsai_sas', array($this, 'sas_shortcode'));
add_shortcode('tsai_player', array($this, 'player_shortcode'));
add_shortcode('tsai_quiz', array($this, 'quiz_shortcode'));
add_shortcode('tsai_quiz_leaderboard', array($this, 'quiz_leaderboard_shortcode'));
add_shortcode('tsai_quiz_ticker', array($this, 'quiz_ticker_shortcode'));
add_shortcode('tsai_schedule', array($this, 'schedule_shortcode'));
add_shortcode('tsai_character_link', array($this, 'character_link_shortcode'));
add_shortcode('tsai_status', array($this, 'status_shortcode'));
// Backward compatibility
add_shortcode('banner2', array($this, 'banner2_compat'));
add_shortcode('tsai_chat_buttons', array($this, 'chat_buttons_compat'));
add_shortcode('tsai_sales_button', array($this, 'sales_button_compat'));
add_shortcode('tsai_support_button', array($this, 'support_button_compat'));
add_action('wp_enqueue_scripts', array($this, 'enqueue_assets'));
}
/**
* Build the app URL for a given app name with query parameters.
*/
private function build_url($app, $params = array()) {
// Use the directory URL (with trailing slash). Apache's SPA-fallback
// .htaccess serves index.html for the request. Including index.html
// explicitly leaks into Angular Router as a non-matching route.
$url = content_url("uploads/tsai/{$app}/browser/");
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $url;
}
/**
* Append WordPress user state parameters.
*/
private function append_user_params(&$params) {
if (is_user_logged_in()) {
$user = wp_get_current_user();
$roles = $user->roles;
$params['logged_in'] = '1';
$params['role'] = !empty($roles) ? $roles[0] : '';
} else {
$params['logged_in'] = '0';
}
}
/**
* Render based on display mode.
*/
private function render($mode, $url, $atts, $label) {
switch ($mode) {
case 'modal':
TSAI_Modal::instance()->set_needed($url);
$id = 'tsai-trigger-' . uniqid();
return sprintf(
'<button id="%s" class="tsai-modal-trigger" data-tsai-url="%s" data-tsai-width="%s" data-tsai-height="%s"><span class="tsai-icon">💬</span> %s</button>',
esc_attr($id),
esc_url($url),
esc_attr($atts['width']),
esc_attr($atts['height']),
esc_html($label)
);
case 'newtab':
return sprintf(
'<a href="%s" target="_blank" rel="noopener" class="tsai-newtab-link"><span class="tsai-icon">💬</span> %s</a>',
esc_url($url),
esc_html($label)
);
case 'inline':
default:
return $this->render_inline($url, $atts);
}
}
/**
* Render an inline iframe with optional auto-resize.
*/
private function render_inline($url, $atts) {
$iframe_id = 'tsai-iframe-' . uniqid();
$auto_resize = ($atts['height'] === 'auto');
$style = sprintf('width:%s; border:none; overflow:hidden; display:block;', esc_attr($atts['width']));
if (!$auto_resize) {
$style .= sprintf(' height:%s;', esc_attr($atts['height']));
}
$iframe = sprintf(
'<iframe id="%s" src="%s" style="%s" frameborder="0" scrolling="no"></iframe>',
esc_attr($iframe_id),
esc_url($url),
$style
);
if ($auto_resize) {
$iframe .= sprintf(
'<script>(function(){var f=document.getElementById("%s");if(!f)return;f.addEventListener("load",function(){var r=function(){try{var d=f.contentDocument||f.contentWindow.document;f.style.height="0";f.style.height=d.documentElement.scrollHeight+"px";}catch(e){}};r();try{var d=f.contentDocument||f.contentWindow.document;new ResizeObserver(r).observe(d.documentElement);}catch(e){}});})();</script>',
esc_js($iframe_id)
);
}
return $iframe;
}
// ----------------------------------------------------------------
// Shortcode handlers
// ----------------------------------------------------------------
/**
* [tsai_banner] - Banner app
*/
public function banner_shortcode($atts) {
$atts = shortcode_atts(array(
'mode' => 'inline',
'banner_id' => 'affiliates',
'theme' => 'dark',
'width' => '100%',
'height' => 'auto',
'label' => 'Affiliates',
), $atts, 'tsai_banner');
$params = array(
'mode' => $atts['theme'],
'banner_id' => $atts['banner_id'],
);
$this->append_user_params($params);
$url = $this->build_url('banner', $params);
return $this->render($atts['mode'], $url, $atts, $atts['label']);
}
/**
* [tsai_sas] - Sales & Support app
*/
public function sas_shortcode($atts) {
$atts = shortcode_atts(array(
'mode' => 'modal',
'name' => 'alma',
'width' => '600px',
'height' => '800px',
'label' => 'Chat',
), $atts, 'tsai_sas');
$params = array('name' => $atts['name']);
$url = $this->build_url('sas', $params);
return $this->render($atts['mode'], $url, $atts, $atts['label']);
}
/**
* [tsai_player] - Player app
*/
public function player_shortcode($atts) {
$atts = shortcode_atts(array(
'mode' => 'inline',
'route' => '',
'width' => '100%',
'height' => '800px',
'label' => 'Player',
), $atts, 'tsai_player');
$params = array();
$base_path = 'uploads/tsai/player/browser/';
if (!empty($atts['route'])) {
$base_path .= ltrim($atts['route'], '/');
}
$url = content_url($base_path);
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $this->render($atts['mode'], $url, $atts, $atts['label']);
}
/**
* [tsai_quiz] - Quiz player gated behind a "Take a Quiz" button. Renders
* nothing unless the current post has _tsai_quiz_enabled meta set to truthy.
*
* The iframe loads its src lazily (data-src) only when the button is clicked,
* so the player doesn't boot or call the API until the reader opts in. The
* click handler lives in assets/js/tsai-embed.js (data-tsai-quiz-reveal).
*/
public function quiz_shortcode($atts) {
$atts = shortcode_atts(array(
'character' => 'scike',
'width' => '100%',
'height' => '700px',
'label' => 'Take the Quiz',
'retake_label' => 'Retake the Quiz',
'image' => '',
), $atts, 'tsai_quiz');
$post_id = get_the_ID();
if (!$post_id) {
return '';
}
if (!get_post_meta($post_id, '_tsai_quiz_enabled', true)) {
return '';
}
$params = array('character' => $atts['character']);
$this->append_user_params($params);
// For logged-in users, hand a signed identity to the quiz iframe so the
// cross-origin score endpoint can credit the WordPress user without
// trusting client-supplied ids. See TSAI_Quiz_Scores::sign_user().
// We can also tell server-side whether they've already passed, so the
// CTA renders as "Retake" immediately (anonymous passes are detected
// client-side from localStorage by tsai-embed.js).
$already_passed = false;
if (is_user_logged_in() && class_exists('TSAI_Quiz_Scores')) {
$user = wp_get_current_user();
$params['uid'] = (string) $user->ID;
$params['uname'] = $user->display_name;
$params['sig'] = TSAI_Quiz_Scores::sign_user((int) $post_id, (int) $user->ID);
$already_passed = TSAI_Quiz_Scores::user_has_passed((int) $post_id, (int) $user->ID);
}
$base_path = sprintf('uploads/tsai/player/browser/quiz/%d', (int) $post_id);
$url = content_url($base_path);
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
$frame_id = 'tsai-quiz-frame-' . uniqid();
$style = sprintf(
'width:%s; height:%s; border:none; overflow:auto; display:none;',
esc_attr($atts['width']),
esc_attr($atts['height'])
);
// The mascot image lives on the CMS (Drupal) host. When not overridden,
// derive it from the configured API host (api.* -> cms.*) so the same
// plugin serves the right host on tsai and scike.
$image_url = $this->resolve_quiz_image_url($atts['image']);
$mascot = '';
if ($image_url !== '') {
$mascot = sprintf(
'<img class="tsai-quiz-mascot" src="%s" alt="" aria-hidden="true" />',
esc_url($image_url)
);
}
// data-retake-label lets the embed JS swap the label client-side when a
// pass is detected from localStorage (anonymous readers) or reported
// live by the iframe after passing.
$button = sprintf(
'<button type="button" class="tsai-quiz-reveal" data-tsai-quiz-reveal data-target="%s" data-retake-label="%s" aria-controls="%s">%s</button>',
esc_attr($frame_id),
esc_attr($atts['retake_label']),
esc_attr($frame_id),
esc_html($already_passed ? $atts['retake_label'] : $atts['label'])
);
// Shown only once the quiz has been passed (server-side for logged-in
// users via the is-passed class, or client-side via tsai-embed.js).
$note = sprintf(
'<p class="tsai-quiz-note">%s</p>',
esc_html__("You've already earned 100 points for this quiz — feel free to retake it anytime just for fun.", 'tsai')
);
$iframe = sprintf(
'<iframe id="%s" data-src="%s" style="%s" frameborder="0"></iframe>',
esc_attr($frame_id),
esc_url($url),
$style
);
// Wrap in a stable anchor so [tsai_article_flags]'s quiz icon can link
// here (the iframe's own id is a random uniqid, unusable as an anchor).
// The mascot + button form a single CTA group the embed JS observes
// and animates together. data-post-id lets the embed JS match the live
// "passed" message from the iframe to this CTA.
//
// The heading + intro + card panel make the quiz read as its own
// standout section (placed below the post tags). The gradient card
// brings its own background, so the section stays legible on light or
// dark host themes.
$container_class = 'tsai-quiz' . ($already_passed ? ' is-passed' : '');
$heading = sprintf(
'<h2 class="tsai-quiz-heading">%s</h2>',
esc_html__('Test Your Knowledge', 'tsai')
);
$intro = sprintf(
'<p class="tsai-quiz-intro">%s</p>',
esc_html__('Think you absorbed it all? Take the quiz and earn 100 points.', 'tsai')
);
return sprintf(
'<section id="tsai-quiz" class="%s" data-post-id="%d">'
. '<div class="tsai-quiz-card">%s%s'
. '<div class="tsai-quiz-cta">%s%s</div>%s%s</div></section>',
esc_attr($container_class),
(int) $post_id,
$heading,
$intro,
$mascot,
$button,
$note,
$iframe
);
}
/**
* Resolve the URL of the quiz mascot image. An explicit override (shortcode
* "image" attribute) wins; otherwise serve the mascot bundled with the
* plugin. Serving it same-origin from the plugin avoids the cross-host CMS
* URL, which broke on satellite domains (e.g. johnturman.net) that share
* the tsai Drupal but have no valid cert on their own derived cms.* host.
*/
private function resolve_quiz_image_url($override) {
$override = trim((string) $override);
if ($override !== '') {
return $override;
}
return TSAI_PLUGIN_URL . 'assets/images/quiz-mascot.jpeg';
}
/**
* [tsai_quiz_leaderboard] - Per-site quiz leaderboard. Rendered server-side
* from this site's wp_tsai_quiz_scores table (no API round-trip), so it
* always reflects live results. Attributes: limit (default 20) and title.
*/
public function quiz_leaderboard_shortcode($atts) {
$atts = shortcode_atts(array(
'limit' => '20',
'title' => 'Quiz Leaderboard',
), $atts, 'tsai_quiz_leaderboard');
if (!class_exists('TSAI_Quiz_Scores')) {
return '';
}
$limit = max(1, (int) $atts['limit']);
$rows = TSAI_Quiz_Scores::get_leaderboard_rows($limit);
$heading = trim((string) $atts['title']);
$title_html = $heading !== ''
? sprintf('<h3 class="tsai-leaderboard__title">%s</h3>', esc_html($heading))
: '';
if (empty($rows)) {
return sprintf(
'<div class="tsai-leaderboard">%s<p class="tsai-leaderboard__empty">%s</p></div>',
$title_html,
esc_html__('No one has passed a quiz yet — be the first!', 'tsai')
);
}
$body = '';
$rank = 0;
foreach ($rows as $row) {
$rank++;
$name = trim((string) $row['display_name']);
if ($name === '') {
$name = __('Anonymous', 'tsai');
}
$body .= sprintf(
'<tr>'
. '<td class="tsai-leaderboard__rank">%d</td>'
. '<td class="tsai-leaderboard__name">%s</td>'
. '<td class="tsai-leaderboard__quizzes">%d</td>'
. '<td class="tsai-leaderboard__points">%s</td>'
. '</tr>',
$rank,
esc_html($name),
(int) $row['quizzes_completed'],
esc_html(number_format_i18n((int) $row['total_points']))
);
}
return sprintf(
'<div class="tsai-leaderboard">%s'
. '<table class="tsai-leaderboard__table">'
. '<thead><tr><th>%s</th><th>%s</th><th>%s</th><th>%s</th></tr></thead>'
. '<tbody>%s</tbody>'
. '</table></div>',
$title_html,
esc_html__('#', 'tsai'),
esc_html__('Reader', 'tsai'),
esc_html__('Quizzes', 'tsai'),
esc_html__('Points', 'tsai'),
$body
);
}
/**
* [tsai_quiz_ticker] - Horizontally-scrolling marquee of quiz leaders.
*
* Attributes:
* scope - 'site' (default): whole-site leaders by total points.
* 'article': leaders for the current post's quiz only.
* limit - max leaders to include (default 25).
* title - optional label rendered before the ticker.
*
* Rendered server-side from this site's wp_tsai_quiz_scores table. The track
* content is duplicated once so the CSS marquee can loop seamlessly; the
* duplicate is aria-hidden so screen readers announce each leader once.
*/
public function quiz_ticker_shortcode($atts) {
$atts = shortcode_atts(array(
'scope' => 'site',
'limit' => '25',
'title' => '',
), $atts, 'tsai_quiz_ticker');
if (!class_exists('TSAI_Quiz_Scores')) {
return '';
}
$limit = max(1, (int) $atts['limit']);
$scope = ($atts['scope'] === 'article') ? 'article' : 'site';
// Visibility is gated by the per-placement toggles on the TSAI settings
// page (both off by default). See TSAI_Quiz_Ticker_Settings.
$post_id = 0;
if ($scope === 'article') {
if (!get_option(TSAI_Quiz_Ticker_Settings::ARTICLE_OPTION, 0)) {
return '';
}
$post_id = (int) get_the_ID();
if (!$post_id) {
return '';
}
// Suppress the ticker wherever the quiz itself is suppressed, so it
// never appears on articles that don't allow quizzing.
if (!get_post_meta($post_id, '_tsai_quiz_enabled', true)) {
return '';
}
$rows = TSAI_Quiz_Scores::get_article_leaderboard_rows($post_id, $limit);
} else {
if (!get_option(TSAI_Quiz_Ticker_Settings::HOME_OPTION, 0)) {
return '';
}
$rows = TSAI_Quiz_Scores::get_leaderboard_rows($limit);
// Site scope renders nothing until at least one reader has scored —
// an empty whole-site ticker would just clutter the home page.
if (empty($rows)) {
return '';
}
}
$heading = trim((string) $atts['title']);
$title_html = $heading !== ''
? sprintf('<span class="tsai-ticker__label">%s</span>', esc_html($heading))
: '';
// Article scope shows the ticker even with no scores yet, scrolling an
// invitation to be the first taker. The embed JS swaps this for live
// rows (matched by data-post-id) the moment a reader records a score.
if (empty($rows)) {
$empty = sprintf(
'<span class="tsai-ticker__empty">%s</span>',
esc_html__('No scores yet — be the first quiz taker!', 'tsai')
);
return sprintf(
'<div class="tsai-ticker tsai-ticker--empty" data-scope="article" data-post-id="%d">%s'
. '<div class="tsai-ticker__viewport">'
. '<div class="tsai-ticker__track">'
. '<div class="tsai-ticker__run">%s</div>'
. '</div></div></div>',
$post_id,
$title_html,
$empty
);
}
$items = $this->quiz_ticker_items_html($rows);
// Duplicate the run of items for a seamless -50% translate loop; the copy
// is hidden from assistive tech. Article scope carries data-post-id so the
// embed JS can re-fetch and re-render this ticker after a live score.
$post_attr = $scope === 'article' ? sprintf(' data-post-id="%d"', $post_id) : '';
return sprintf(
'<div class="tsai-ticker" data-scope="%s"%s>%s'
. '<div class="tsai-ticker__viewport">'
. '<div class="tsai-ticker__track">'
. '<div class="tsai-ticker__run">%s</div>'
. '<div class="tsai-ticker__run" aria-hidden="true">%s</div>'
. '</div></div></div>',
esc_attr($scope),
$post_attr,
$title_html,
$items,
$items
);
}
/**
* Build the ticker's leader-item anchors from leaderboard rows. Kept as the
* single source of truth for the item markup, mirrored by the live-rebuild
* code in tsai-embed.js. Each item links to the site-wide /quiz-results
* leaderboard page (TSAI_Quiz_Results_Page).
*/
private function quiz_ticker_items_html($rows) {
$results_url = esc_url(home_url('/quiz-results'));
$items = '';
$rank = 0;
foreach ((array) $rows as $row) {
$rank++;
$name = trim((string) $row['display_name']);
if ($name === '') {
$name = __('Anonymous', 'tsai');
}
$points = number_format_i18n((int) $row['total_points']);
$items .= sprintf(
'<a class="tsai-ticker__item" href="%s">'
. '<span class="tsai-ticker__rank">#%d</span>'
. '<span class="tsai-ticker__name">%s</span>'
. '<span class="tsai-ticker__points">%s pts</span>'
. '</a>',
$results_url,
$rank,
esc_html($name),
esc_html($points)
);
}
return $items;
}
/**
* [tsai_character_link] - Chat-bubble icon linking into the Creator app's
* character view. With no args, resolves the current post author's
* character via the tsai_character_uuid user meta and looks up the
* character's current slug from the API. Pass slug="..." to override.
*/
public function character_link_shortcode($atts) {
if (!is_user_logged_in()) {
return '';
}
$atts = shortcode_atts(array(
'slug' => '',
'label' => '',
), $atts, 'tsai_character_link');
$chat_url = TSAI_Site_Index::get_chat_url();
if (!$chat_url) {
return '';
}
$slug = sanitize_title($atts['slug']);
if ($slug === '') {
// Resolve the current post's author -> tsai_character_uuid -> slug.
// Try the Loop's $authordata first (set by setup_postdata in post-template
// contexts); fall back to the queried post's author so the shortcode also
// works on singular templates where the shortcode block is rendered
// outside any wp:post-template.
$author_id = (int) get_the_author_meta('ID');
if ($author_id <= 0) {
$post_id = get_the_ID();
if ($post_id) {
$author_id = (int) get_post_field('post_author', $post_id);
}
}
if ($author_id <= 0) {
return '';
}
$uuid = get_user_meta($author_id, TSAI_Character_Meta::META_KEY, true);
if (!is_string($uuid) || $uuid === '') {
return '';
}
$slug = self::resolve_character_slug_from_uuid($uuid);
if ($slug === '') {
return '';
}
}
$href = trailingslashit($chat_url) . 'en-US/character/' . rawurlencode($slug);
$tooltip = __('Open in Chatbot Creator', 'tsai');
$icon = '<svg class="tsai-character-link__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/></svg>';
$label_html = $atts['label'] !== ''
? '<span class="tsai-character-link__label">' . esc_html($atts['label']) . '</span>'
: '';
return sprintf(
'<a class="tsai-character-link" href="%s" target="_blank" rel="noopener" title="%s" aria-label="%s">%s%s</a>',
esc_url($href),
esc_attr($tooltip),
esc_attr($tooltip),
$icon,
$label_html
);
}
/**
* [tsai_status] - Sitewide status banner. Renders nothing unless a message
* has been set on the Settings > TSAI Status page. The message text comes
* from the option, not shortcode attributes, so it can be toggled without
* editing templates.
*/
public function status_shortcode($atts) {
$message = trim((string) get_option(TSAI_Status_Settings::MESSAGE_OPTION, ''));
if ($message === '') {
return '';
}
return sprintf(
'<div class="tsai-status-banner" role="status">'
. '<span class="tsai-status-banner__icon" aria-hidden="true">⚠</span>'
. '<span class="tsai-status-banner__text">%s</span>'
. '</div>',
esc_html($message)
);
}
/**
* Resolve a Drupal character UUID to its current slug via the TSAI API.
* Cached per-UUID for an hour to avoid hammering the API on listing pages
* that render the shortcode for every post.
*/
private static function resolve_character_slug_from_uuid($uuid) {
$cache_key = 'tsai_character_slug_' . md5($uuid);
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached === '' ? '' : (string) $cached;
}
$api_url = trim((string) get_option(TSAI_Api_Settings::URL_OPTION, ''));
if ($api_url === '') {
return '';
}
$endpoint = trailingslashit($api_url) . 'cms/character/by-uuid/' . rawurlencode($uuid) . '/slug';
$response = wp_remote_get($endpoint, array('timeout' => 5));
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
// Briefly cache misses so a broken/missing character doesn't hammer the API.
set_transient($cache_key, '', 5 * MINUTE_IN_SECONDS);
return '';
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$slug = is_array($body) && isset($body['slug']) ? (string) $body['slug'] : '';
if ($slug === '') {
set_transient($cache_key, '', 5 * MINUTE_IN_SECONDS);
return '';
}
set_transient($cache_key, $slug, HOUR_IN_SECONDS);
return $slug;
}
// ----------------------------------------------------------------
// Backward compatibility
// ----------------------------------------------------------------
/**
* [banner2] -> [tsai_banner mode="inline"]
*/
public function banner2_compat($atts) {
$atts = shortcode_atts(array(
'width' => '100%',
'mode' => 'dark',
), $atts);
return $this->banner_shortcode(array(
'mode' => 'inline',
'theme' => $atts['mode'],
'width' => $atts['width'],
'height' => 'auto',
));
}
/**
* [tsai_chat_buttons] -> two SAS modal buttons
*/
public function chat_buttons_compat($atts) {
$output = '<div class="tsai-chat-buttons-wrapper">';
$output .= $this->sas_shortcode(array('mode' => 'modal', 'name' => 'alma', 'label' => 'Sales'));
$output .= $this->sas_shortcode(array('mode' => 'modal', 'name' => 'roberto', 'label' => 'Support'));
$output .= '</div>';
return $output;
}
/**
* [tsai_sales_button] -> [tsai_sas mode="modal" name="alma"]
*/
public function sales_button_compat($atts) {
return $this->sas_shortcode(array('mode' => 'modal', 'name' => 'alma', 'label' => 'Sales'));
}
/**
* [tsai_support_button] -> [tsai_sas mode="modal" name="roberto"]
*/
public function support_button_compat($atts) {
return $this->sas_shortcode(array('mode' => 'modal', 'name' => 'roberto', 'label' => 'Support'));
}
// ----------------------------------------------------------------
// Schedule
// ----------------------------------------------------------------
/**
* [tsai_schedule] - Author posting schedule for this site. Mirrors the
* Creator app's ScheduleDialog: an "Upcoming calendar posts (next 30 days)"
* list grouped by date.
*
* Characters (with their article/comment schedules) are fetched from the
* FastAPI backend's cms/characters/ endpoint, scoped to this site via an
* Origin header matching the site's chat host. Degrades to empty states if
* the API is unreachable.
*/
public function schedule_shortcode($atts) {
$characters = $this->get_schedule_characters();
$upcoming_html = $this->schedule_upcoming_html($characters);
$calendar_links_html = $this->schedule_calendar_links_html();
// "Recent posts" — what actually published (schedule_characters() runs
// first so the site slug is cached for the history fetch).
$recent_html = $this->schedule_recent_html($this->get_schedule_history());
return sprintf(
'<div class="tsai-schedule">'
. '<h2 class="tsai-schedule__heading">%s</h2>%s%s'
. '<hr class="tsai-schedule__divider" />'
. '<h2 class="tsai-schedule__heading">%s</h2>%s'
. '</div>',
esc_html__('Upcoming calendar posts (next 30 days)', 'tsai'),
$calendar_links_html,
$upcoming_html,
esc_html__('Recent posts', 'tsai'),
$recent_html
);
}
/**
* Build the "Upcoming calendar posts" section: for each of the next 30 days
* match each character's article/comment schedules (calendar mode, with day
* constraints) and group matching entries by date. Ports the upcomingEvents
* computed signal from schedule-dialog.ts.
*/
private function schedule_upcoming_html($characters) {
$empty = sprintf(
'<p class="tsai-schedule__empty">%s</p>',
esc_html__('No characters are scheduled to post in the next 30 days.', 'tsai')
);
if (empty($characters)) {
return $empty;
}
$tz = wp_timezone();
$today = new DateTime('now', $tz);
$today->setTime(0, 0, 0);
$days_html = '';
for ($i = 0; $i < 30; $i++) {
$date = clone $today;
if ($i > 0) {
$date->modify('+' . $i . ' day');
}
$entries = array();
foreach ($characters as $character) {
$name = isset($character['name']) ? (string) $character['name'] : '';
$article = $this->match_schedule_for_day(
isset($character['articleSchedule']) ? $character['articleSchedule'] : null,
$date
);
if ($article !== null) {
$entries[] = array('name' => $name, 'type' => 'article', 'hours' => $article);
}
$comment = $this->match_schedule_for_day(
isset($character['commentSchedule']) ? $character['commentSchedule'] : null,
$date
);
if ($comment !== null) {
$entries[] = array('name' => $name, 'type' => 'comment', 'hours' => $comment);
}
}
if (empty($entries)) {
continue;
}
usort($entries, function ($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
$items = '';
foreach ($entries as $entry) {
$hours_html = '';
if (!empty($entry['hours'])) {
$hours_html = sprintf(
'<span class="tsai-schedule__hours"> — %s</span>',
esc_html($this->format_hours($entry['hours']))
);
}
$items .= sprintf(
'<li><strong>%s</strong> <span class="tsai-schedule__type">(%s)</span>%s</li>',
esc_html($entry['name']),
esc_html($entry['type']),
$hours_html
);
}
$days_html .= sprintf(
'<div class="tsai-schedule__day"><h3>%s</h3><ul>%s</ul></div>',
esc_html(wp_date('l, F j', $date->getTimestamp(), $tz)),
$items
);
}
return $days_html !== '' ? $days_html : $empty;
}
/**
* Build the "Recent posts" section from real posting history returned by the
* backend (cms/schedule/history). Shows successful posts only — this page is
* reader-facing — grouped by day, newest first, each linking to the
* published article/comment. Failures are intentionally omitted here (they
* surface in the Creator admin dialog and the .ics feed).
*/
private function schedule_recent_html($history) {
$empty = sprintf(
'<p class="tsai-schedule__empty">%s</p>',
esc_html__('No recent posting activity.', 'tsai')
);
if (empty($history) || !is_array($history)) {
return $empty;
}
$tz = wp_timezone();
// Group successful posts by day, preserving the API's newest-first order.
$by_day = array();
foreach ($history as $rec) {
if (!is_array($rec)) {
continue;
}
$status = isset($rec['status']) ? (string) $rec['status'] : '';
if ($status !== 'success') {
continue; // reader-facing page: successful posts only
}
$date = isset($rec['date']) ? (string) $rec['date'] : '';
if ($date === '') {
continue;
}
if (!isset($by_day[$date])) {
$by_day[$date] = array();
}
$by_day[$date][] = $rec;
}
if (empty($by_day)) {
return $empty;
}
$days_html = '';
foreach ($by_day as $date => $records) {
$items = '';
foreach ($records as $rec) {
$name = isset($rec['character']) ? (string) $rec['character'] : '';
$kind = isset($rec['kind']) ? (string) $rec['kind'] : 'post';
$url = isset($rec['url']) ? (string) $rec['url'] : '';
$title = isset($rec['title']) ? (string) $rec['title'] : '';
if ($title === '') {
$title = ($kind === 'comment')
? __('View comment', 'tsai')
: __('View post', 'tsai');
}
if ($url !== '') {
$link_html = sprintf(
'<a href="%s" target="_blank" rel="noopener">%s</a>',
esc_url($url),
esc_html($title)
);
} else {
$link_html = esc_html($title);
}
$items .= sprintf(
'<li><strong>%s</strong> <span class="tsai-schedule__type">(%s)</span> — %s</li>',
esc_html($name),
esc_html($kind),
$link_html
);
}
$dt = DateTime::createFromFormat('Y-m-d', $date, $tz);
$label = $dt ? wp_date('l, F j', $dt->getTimestamp(), $tz) : $date;
$days_html .= sprintf(
'<div class="tsai-schedule__day"><h3>%s</h3><ul>%s</ul></div>',
esc_html($label),
$items
);
}
return $days_html !== '' ? $days_html : $empty;
}
/**
* Return sorted preferred hours for a schedule if it posts on the given
* date, or null otherwise. A schedule contributes only when it has at least
* one day constraint and the date matches a configured weekday (0=Sunday) or
* day-of-month. Ports matchScheduleForDay from schedule-dialog.ts.
*/
private function match_schedule_for_day($schedule, DateTime $date) {
if (!is_array($schedule)) {
return null;
}
$days_of_week = isset($schedule['daysOfWeek']) && is_array($schedule['daysOfWeek'])
? $schedule['daysOfWeek'] : array();
$days_of_month = isset($schedule['daysOfMonth']) && is_array($schedule['daysOfMonth'])
? $schedule['daysOfMonth'] : array();
if (empty($days_of_week) && empty($days_of_month)) {
return null;
}
// date('w') / format('w') returns 0=Sunday..6=Saturday, matching the
// schedule convention used by the Angular component (JS getDay()).
$weekday = (int) $date->format('w');
$day_of_month = (int) $date->format('j');
$matches_week = in_array($weekday, array_map('intval', $days_of_week), true);
$matches_month = in_array($day_of_month, array_map('intval', $days_of_month), true);
if (!$matches_week && !$matches_month) {
return null;
}
$hours = isset($schedule['preferredHours']) && is_array($schedule['preferredHours'])
? array_map('intval', $schedule['preferredHours']) : array();
sort($hours);
return $hours;
}
/**
* Collapse a sorted list of 24h integers into 12h ranges, e.g.
* [9,10,11,14] -> "9AM-11AM, 2PM". Empty -> "Any time". Ports formatHours.
*/
private function format_hours($hours) {
if (empty($hours)) {
return __('Any time', 'tsai');
}
sort($hours);
$ranges = array();
$start = $hours[0];
$end = $hours[0];
$count = count($hours);
for ($i = 1; $i < $count; $i++) {
if ($hours[$i] === $end + 1) {
$end = $hours[$i];
} else {
$ranges[] = $this->format_hour_range($start, $end);
$start = $hours[$i];
$end = $hours[$i];
}
}
$ranges[] = $this->format_hour_range($start, $end);
return implode(', ', $ranges);
}
private function format_hour_range($start, $end) {
if ($start === $end) {
return $this->format_hour($start);
}
return $this->format_hour($start) . '-' . $this->format_hour($end);
}
private function format_hour($h) {
if ($h === 0) {
return '12AM';
}
if ($h === 12) {
return '12PM';
}
if ($h < 12) {
return $h . 'AM';
}
return ($h - 12) . 'PM';
}
/**
* Fetch this site's characters (with schedules) from the FastAPI backend,
* cached in a transient. Scopes the request to this site by sending an
* Origin header matching the site's chat host, which the API resolves to a
* site config. Returns an empty array (and logs) on any failure so the
* page renders empty states rather than erroring.
*/
private function get_schedule_characters() {
$cached = get_transient(self::SCHEDULE_TRANSIENT);
if (is_array($cached)) {
return $cached;
}
$api_url = trim((string) get_option(TSAI_Api_Settings::URL_OPTION, ''));
if ($api_url === '') {
error_log('[TSAI] schedule: tsai_api_url not configured');
return array();
}
$chat_url = class_exists('TSAI_Site_Index') ? TSAI_Site_Index::get_chat_url() : null;
if (!$chat_url) {
error_log('[TSAI] schedule: could not resolve chat URL for Origin header');
return array();
}
$endpoint = trailingslashit($api_url) . 'cms/characters/';
$response = wp_remote_get($endpoint, array(
'timeout' => 8,
'headers' => array('Origin' => $chat_url),
));
if (is_wp_error($response)) {
error_log('[TSAI] schedule: characters request failed: ' . $response->get_error_message());
return array();
}
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
error_log('[TSAI] schedule: characters returned HTTP ' . $code);
return array();
}
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || !isset($data['data']) || !is_array($data['data'])) {
error_log('[TSAI] schedule: characters returned unexpected body');
return array();
}
// Cache the site slug alongside the characters so the calendar
// subscribe/download links can be built without a second lookup.
$slug = isset($data['site_slug']) ? (string) $data['site_slug'] : '';
set_transient(self::SCHEDULE_SLUG_TRANSIENT, $slug, 10 * MINUTE_IN_SECONDS);
set_transient(self::SCHEDULE_TRANSIENT, $data['data'], 10 * MINUTE_IN_SECONDS);
return $data['data'];
}
/**
* Fetch this site's recent posting history (successes + failures) from the
* backend's cms/schedule/history endpoint, cached in a transient. Slug-based
* (public), matching the .ics feed; the slug is resolved from the transient
* that get_schedule_characters() populates, so callers must invoke that
* first (the schedule shortcode does). Returns an empty array on any failure.
*/
private function get_schedule_history() {
$cached = get_transient(self::SCHEDULE_HISTORY_TRANSIENT);
if (is_array($cached)) {
return $cached;
}
$api_url = trim((string) get_option(TSAI_Api_Settings::URL_OPTION, ''));
$slug = $this->get_schedule_site_slug();
if ($api_url === '' || $slug === '') {
return array();
}
$endpoint = trailingslashit($api_url) . 'cms/schedule/history?site=' . rawurlencode($slug);
$response = wp_remote_get($endpoint, array('timeout' => 8));
if (is_wp_error($response)) {
error_log('[TSAI] schedule: history request failed: ' . $response->get_error_message());
return array();
}
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
error_log('[TSAI] schedule: history returned HTTP ' . $code);
return array();
}
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || !isset($data['data']) || !is_array($data['data'])) {
error_log('[TSAI] schedule: history returned unexpected body');
return array();
}
set_transient(self::SCHEDULE_HISTORY_TRANSIENT, $data['data'], 10 * MINUTE_IN_SECONDS);
return $data['data'];
}
/**
* Site slug for this install, cached by get_schedule_characters() from the
* cms/characters/ response. Callers must invoke that first (the schedule
* shortcode always does); returns '' if the fetch failed or is stale.
*/
private function get_schedule_site_slug() {
$slug = get_transient(self::SCHEDULE_SLUG_TRANSIENT);
return is_string($slug) ? $slug : '';
}
/**
* "Add to your calendar" links for the schedule page: a webcal:// subscribe
* URL (auto-updating in the calendar app) plus an https download of the same
* live .ics feed served by the FastAPI backend. Returns '' if the API URL or
* site slug can't be resolved.
*/
private function schedule_calendar_links_html() {
$api_url = trim((string) get_option(TSAI_Api_Settings::URL_OPTION, ''));
$slug = $this->get_schedule_site_slug();
if ($api_url === '' || $slug === '') {
return '';
}
$https_url = trailingslashit($api_url) . 'cms/schedule.ics?site=' . rawurlencode($slug);
// webcal:// asks the OS/calendar app to subscribe rather than download.
$webcal_url = preg_replace('#^https?://#i', 'webcal://', $https_url);
return sprintf(
'<p class="tsai-schedule__calendar-links">'
. '<a class="tsai-schedule__subscribe" href="%s">%s</a>'
. '<a class="tsai-schedule__download" href="%s">%s</a>'
. '</p>',
esc_url($webcal_url, array('webcal', 'http', 'https')),
esc_html__('Subscribe in your calendar', 'tsai'),
esc_url($https_url),
esc_html__('Download .ics', 'tsai')
);
}
// ----------------------------------------------------------------
// Asset enqueuing
// ----------------------------------------------------------------
public function enqueue_assets() {
// has_shortcode($post->post_content, ...) misses shortcodes rendered
// via block themes / patterns / template parts, where post_content is
// empty even though the shortcode runs. Enqueue unconditionally — the
// assets are small and the previous gate left buttons unstyled on
// block-theme home pages.
wp_enqueue_style('tsai-embed', TSAI_PLUGIN_URL . 'assets/css/tsai-embed.css', array(), '2.9.0');
wp_enqueue_script('tsai-embed', TSAI_PLUGIN_URL . 'assets/js/tsai-embed.js', array(), '2.4.0', true);
}
}
new TSAI_Shortcodes();