403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/repo/sites/dev/wp-content/plugins/tsai-plugin/includes/class-tsai-shortcodes.php
<?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">&#x1F4AC;</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">&#x1F4AC;</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">&#x26A0;</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();

Youez - 2016 - github.com/yon3zu
LinuXploit