diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7aabd09 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,89 @@ +name: Tests + +on: + push: + branches: [ master, main, develop ] + pull_request: + branches: [ master, main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2'] + wordpress-version: ['6.2', '6.4', 'latest'] + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, mysqli + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Install subversion + run: sudo apt-get update && sudo apt-get install -y subversion + + - name: Install WordPress test suite + run: | + bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 ${{ matrix.wordpress-version }} true + env: + WP_TESTS_DIR: /tmp/wordpress-tests-lib + WP_CORE_DIR: /tmp/wordpress + + - name: Run PHPUnit tests + run: composer test + env: + WP_TESTS_DIR: /tmp/wordpress-tests-lib + WP_CORE_DIR: /tmp/wordpress + + # Code style check (optional) + phpcs: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: phpcs + + - name: Check PHP syntax errors + run: find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0181ca1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Composer +/vendor/ +composer.lock + +# PHPUnit +.phpunit.result.cache +/coverage/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# WordPress test files (local) +/tmp/ + +# Logs +*.log diff --git a/README.md b/README.md index 349f687..95a2a69 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,17 @@ The plugin provides a function `cablecast_get_schedules($channel_id, $date_start |`channel_post_id`| | |`schedule_item_id`| | +**Important: Timezone Handling** + +The `cablecast_get_schedules()` function automatically handles timezone conversion. Schedule times are stored in UTC in the database and converted to your WordPress site's configured timezone (Settings > General > Timezone) when retrieved. + +Always use `cablecast_get_schedules()` to query schedules rather than direct database queries. This ensures: +- Dates you pass in are interpreted in your site's timezone +- Returned `run_date_time` values are in your site's timezone +- Correct schedule items are returned for the requested date range + +Make sure your WordPress timezone is configured correctly in Settings > General for accurate schedule display. + ### How do I show the live stream for a channel? The plugin sets a custom meta property of `cablecast_channel_live_embed_code` to the channel for the embed code to watch the live stream. If this property isn't set it's likely your Cablecast isn't configured correctly, or you don't have a live streaming server. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..04314e0 --- /dev/null +++ b/TODO.md @@ -0,0 +1,42 @@ +# wp-cablecast Plugin TODO + +## Future Data Sources + +### Show Agendas + +The theme template `show-agenda.php` supports displaying PDF agendas for shows. Currently this is a placeholder template that needs a data source. + +**Required implementation:** +- Add meta field `cablecast_show_agenda_url` to store the URL of the agenda PDF +- Option A: Sync from Cablecast API if agenda attachments are available +- Option B: Allow manual entry via WordPress admin +- Option C: Look for agenda in Cablecast show attachments during sync + +**Meta field name:** `cablecast_show_agenda_url` +**Expected value:** Full URL to PDF file (string) + +### Show Chapters + +The theme template `show-chapters.php` supports displaying chapter markers/timestamps for shows. Currently this is a placeholder template. + +**Required implementation:** +- Add meta field `cablecast_show_chapters` to store chapter data +- Data format: Array of associative arrays with `timestamp` and `title` keys +- Option A: Sync from Cablecast API if chapter data is available +- Option B: Allow manual entry via WordPress admin +- Option C: Parse from video metadata during sync + +**Meta field name:** `cablecast_show_chapters` +**Expected value:** Serialized array +```php +[ + ['timestamp' => '00:00:00', 'title' => 'Introduction'], + ['timestamp' => '00:05:30', 'title' => 'Main Topic'], + ['timestamp' => '00:15:00', 'title' => 'Q&A'], +] +``` + +## Notes + +- Both features require corresponding changes in `includes/sync.php` if sourcing from Cablecast API +- Theme templates are ready to consume this data once meta fields are populated diff --git a/assets/css/cablecast-home.css b/assets/css/cablecast-home.css new file mode 100644 index 0000000..88ede88 --- /dev/null +++ b/assets/css/cablecast-home.css @@ -0,0 +1,416 @@ +/** + * Cablecast Home Page Styles + * + * "Broadcast Modern" design system for the home page shortcode. + * Professional, clean layout inspired by streaming services and modern news broadcasts. + */ + +/* ========================================================================== + CSS Custom Properties (Theme Variables) + ========================================================================== */ + +.cablecast-home { + /* Typography */ + --cablecast-font-display: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; + --cablecast-font-body: 'Source Sans 3', -apple-system, BlinkMacSystemFont, sans-serif; + + /* Colors - Light Mode */ + --cablecast-home-bg: #f8fafc; + --cablecast-home-surface: #ffffff; + --cablecast-home-text: #1e293b; + --cablecast-home-text-secondary: #64748b; + --cablecast-home-muted: #94a3b8; + --cablecast-home-accent: #f59e0b; + --cablecast-home-accent-bg: rgba(245, 158, 11, 0.1); + --cablecast-home-live: #ef4444; + --cablecast-home-border: #e2e8f0; + --cablecast-home-border-light: #f1f5f9; + + /* Spacing */ + --cablecast-section-gap: 3rem; + + /* Shadows */ + --cablecast-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --cablecast-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + --cablecast-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + + /* Borders */ + --cablecast-radius: 0.5rem; + --cablecast-radius-lg: 0.75rem; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .cablecast-home { + --cablecast-home-bg: #0f172a; + --cablecast-home-surface: #1e293b; + --cablecast-home-text: #f1f5f9; + --cablecast-home-text-secondary: #94a3b8; + --cablecast-home-muted: #64748b; + --cablecast-home-border: #334155; + --cablecast-home-border-light: #1e293b; + } +} + +/* ========================================================================== + Google Fonts + ========================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Source+Sans+3:wght@400;500;600&display=swap'); + +/* ========================================================================== + Base Layout + ========================================================================== */ + +.cablecast-home { + max-width: 1280px; + margin: 0 auto; + padding: 2rem 1rem; + font-family: var(--cablecast-font-body); + color: var(--cablecast-home-text); + background: var(--cablecast-home-bg); +} + +/* ========================================================================== + Section Styling + ========================================================================== */ + +.cablecast-home__section { + margin-bottom: var(--cablecast-section-gap); +} + +.cablecast-home__section:last-child { + margin-bottom: 0; +} + +.cablecast-home__section-heading { + font-family: var(--cablecast-font-display); + font-size: 1.5rem; + font-weight: 700; + color: var(--cablecast-home-text); + margin: 0 0 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--cablecast-home-border); + letter-spacing: -0.025em; +} + +/* ========================================================================== + Now Playing Section + ========================================================================== */ + +.cablecast-home__section--now-playing { + /* Hero styling */ +} + +.cablecast-home__channel-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.cablecast-home__channel-tab { + padding: 0.5rem 1rem; + background: var(--cablecast-home-surface); + border: 1px solid var(--cablecast-home-border); + border-radius: 9999px; + font-family: var(--cablecast-font-body); + font-size: 0.875rem; + font-weight: 500; + color: var(--cablecast-home-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.cablecast-home__channel-tab:hover { + background: var(--cablecast-home-border-light); + border-color: var(--cablecast-home-muted); +} + +.cablecast-home__channel-tab--active { + background: var(--cablecast-home-accent); + border-color: var(--cablecast-home-accent); + color: white; +} + +.cablecast-home__channel-tab--active:hover { + background: var(--cablecast-home-accent); + border-color: var(--cablecast-home-accent); +} + +.cablecast-home__now-playing-content { + /* Container for the now_playing shortcode output */ +} + +/* Enhanced now playing card styling for home page */ +.cablecast-home__section--now-playing .cablecast-now-playing { + gap: 1.5rem; +} + +.cablecast-home__section--now-playing .cablecast-now-playing__card { + background: var(--cablecast-home-surface); + border-radius: var(--cablecast-radius-lg); + box-shadow: var(--cablecast-shadow); + border-top: 4px solid var(--cablecast-home-border); +} + +.cablecast-home__section--now-playing .cablecast-now-playing__card--now { + border-top-color: var(--cablecast-home-live); + position: relative; + overflow: hidden; +} + +/* Live badge enhancement */ +.cablecast-home__section--now-playing .cablecast-now-playing__badge--live { + background: var(--cablecast-home-live); + color: white; + animation: cablecast-live-pulse 1.5s ease-in-out infinite; +} + +@keyframes cablecast-live-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); + } + 50% { + box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); + } +} + +.cablecast-home__section--now-playing .cablecast-now-playing__card--next { + border-top-color: var(--cablecast-home-accent); +} + +/* ========================================================================== + Schedule Section + ========================================================================== */ + +.cablecast-home__section--schedule { + /* Weekly guide wrapper */ +} + +/* Enhanced weekly guide for home page */ +.cablecast-home__section--schedule .cablecast-weekly-guide { + background: var(--cablecast-home-surface); + border-radius: var(--cablecast-radius-lg); + box-shadow: var(--cablecast-shadow); + overflow: hidden; +} + +.cablecast-home__section--schedule .cablecast-weekly-guide__channel-switcher { + background: var(--cablecast-home-border-light); + border-bottom: 1px solid var(--cablecast-home-border); +} + +.cablecast-home__section--schedule .cablecast-weekly-guide__day--today { + background: var(--cablecast-home-accent-bg); +} + +.cablecast-home__section--schedule .cablecast-weekly-guide__day--today .cablecast-weekly-guide__day-header { + background: var(--cablecast-home-accent); + color: white; +} + +.cablecast-home__section--schedule .cablecast-weekly-guide__day--today .cablecast-weekly-guide__day-name, +.cablecast-home__section--schedule .cablecast-weekly-guide__day--today .cablecast-weekly-guide__day-date { + color: inherit; +} + +/* ========================================================================== + Recent Shows Section + ========================================================================== */ + +.cablecast-home__section--recent { + /* Recent shows wrapper */ +} + +/* Enhanced shows grid for home page */ +.cablecast-home__section--recent .cablecast-shows { + gap: 1.25rem; +} + +.cablecast-home__section--recent .cablecast-shows__item { + background: var(--cablecast-home-surface); + border-radius: var(--cablecast-radius); + box-shadow: var(--cablecast-shadow-sm); + transition: all 0.2s ease; +} + +.cablecast-home__section--recent .cablecast-shows__item:hover { + box-shadow: var(--cablecast-shadow-lg); + transform: translateY(-2px); +} + +.cablecast-home__section--recent .cablecast-shows__title { + font-family: var(--cablecast-font-display); +} + +/* View all link */ +.cablecast-home__view-all { + text-align: center; + margin-top: 1.5rem; +} + +.cablecast-home__view-all-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--cablecast-home-surface); + border: 1px solid var(--cablecast-home-border); + border-radius: 9999px; + font-family: var(--cablecast-font-display); + font-size: 0.875rem; + font-weight: 600; + color: var(--cablecast-home-text); + text-decoration: none; + transition: all 0.15s ease; +} + +.cablecast-home__view-all-link:hover { + background: var(--cablecast-home-accent); + border-color: var(--cablecast-home-accent); + color: white; + text-decoration: none; +} + +/* ========================================================================== + Browse Section + ========================================================================== */ + +.cablecast-home__section--browse { + /* Browse section wrapper */ +} + +.cablecast-home__browse-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; +} + +@media (max-width: 1024px) { + .cablecast-home__browse-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .cablecast-home__browse-grid { + grid-template-columns: 1fr; + } +} + +.cablecast-home__browse-section { + background: var(--cablecast-home-surface); + border-radius: var(--cablecast-radius-lg); + padding: 1.5rem; + box-shadow: var(--cablecast-shadow-sm); +} + +.cablecast-home__browse-heading { + font-family: var(--cablecast-font-display); + font-size: 1rem; + font-weight: 600; + color: var(--cablecast-home-text); + margin: 0 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--cablecast-home-border); +} + +.cablecast-home__browse-link { + display: inline-block; + margin-top: 1rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--cablecast-home-accent); + text-decoration: none; +} + +.cablecast-home__browse-link:hover { + text-decoration: underline; +} + +/* Enhanced series cards in browse section */ +.cablecast-home__browse-section .cablecast-series { + gap: 0.75rem; +} + +.cablecast-home__browse-section .cablecast-series--grid { + grid-template-columns: repeat(2, 1fr); +} + +.cablecast-home__browse-section .cablecast-series__item { + box-shadow: none; + border: 1px solid var(--cablecast-home-border); +} + +/* Enhanced producers list in browse section */ +.cablecast-home__browse-section .cablecast-producers { + gap: 0.5rem; +} + +.cablecast-home__browse-section .cablecast-producers__item { + background: transparent; + padding: 0.5rem 0; + box-shadow: none; + border-bottom: 1px solid var(--cablecast-home-border-light); +} + +.cablecast-home__browse-section .cablecast-producers__item:last-child { + border-bottom: none; +} + +/* Enhanced categories in browse section */ +.cablecast-home__browse-section .cablecast-categories { + gap: 0.5rem; +} + +.cablecast-home__browse-section .cablecast-categories__item { + font-size: 0.8125rem; +} + +/* ========================================================================== + Responsive Adjustments + ========================================================================== */ + +@media (max-width: 768px) { + .cablecast-home { + padding: 1.5rem 1rem; + --cablecast-section-gap: 2rem; + } + + .cablecast-home__section-heading { + font-size: 1.25rem; + } +} + +@media (max-width: 480px) { + .cablecast-home { + padding: 1rem 0.75rem; + --cablecast-section-gap: 1.5rem; + } + + .cablecast-home__section-heading { + font-size: 1.125rem; + } + + .cablecast-home__channel-tabs { + justify-content: center; + } +} + +/* ========================================================================== + Print Styles + ========================================================================== */ + +@media print { + .cablecast-home { + background: white; + color: black; + } + + .cablecast-home__channel-tabs, + .cablecast-home__view-all, + .cablecast-home__browse-link { + display: none; + } +} diff --git a/assets/css/fullcalendar-shortcode.css b/assets/css/fullcalendar-shortcode.css new file mode 100644 index 0000000..fbb1714 --- /dev/null +++ b/assets/css/fullcalendar-shortcode.css @@ -0,0 +1,188 @@ +/** + * Cablecast FullCalendar Shortcode Styles + * + * Custom styling for the cablecast_schedule_calendar shortcode + * using FullCalendar. + */ + +/* Container */ +.cablecast-fullcalendar { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + max-width: 100%; + margin: 20px 0; +} + +/* Event styling */ +.cablecast-fullcalendar .fc-event { + cursor: pointer; + font-size: 0.85em; + border-radius: 3px; + padding: 2px 4px; +} + +.cablecast-fullcalendar .fc-event:hover { + opacity: 0.85; +} + +.cablecast-fullcalendar .fc-event-title { + font-weight: 500; +} + +/* Time grid slots */ +.cablecast-fullcalendar .fc-timegrid-slot { + height: 2.5em; +} + +.cablecast-fullcalendar .fc-timegrid-slot-label { + font-size: 0.8em; + color: #666; +} + +/* Today highlight */ +.cablecast-fullcalendar .fc-day-today { + background-color: #fefce8 !important; +} + +.cablecast-fullcalendar .fc-col-header-cell.fc-day-today { + background-color: #fef9c3; +} + +/* Now indicator */ +.cablecast-fullcalendar .fc-timegrid-now-indicator-line { + border-color: #dc2626; + border-width: 2px; +} + +.cablecast-fullcalendar .fc-timegrid-now-indicator-arrow { + border-color: #dc2626; + border-top-color: transparent; + border-bottom-color: transparent; +} + +/* Toolbar */ +.cablecast-fullcalendar .fc-toolbar-title { + font-size: 1.25em; + font-weight: 600; +} + +.cablecast-fullcalendar .fc-button { + text-transform: capitalize; + font-size: 0.9em; +} + +.cablecast-fullcalendar .fc-button-primary { + background-color: #2271b1; + border-color: #2271b1; +} + +.cablecast-fullcalendar .fc-button-primary:hover { + background-color: #135e96; + border-color: #135e96; +} + +.cablecast-fullcalendar .fc-button-primary:not(:disabled).fc-button-active, +.cablecast-fullcalendar .fc-button-primary:not(:disabled):active { + background-color: #135e96; + border-color: #135e96; +} + +/* Day grid (month view) */ +.cablecast-fullcalendar .fc-daygrid-day-number { + padding: 4px 8px; + font-size: 0.9em; +} + +.cablecast-fullcalendar .fc-daygrid-event { + margin-top: 2px; +} + +.cablecast-fullcalendar .fc-daygrid-more-link { + color: #2271b1; + font-weight: 500; +} + +/* List view */ +.cablecast-fullcalendar .fc-list-event:hover td { + background-color: #f5f5f5; +} + +.cablecast-fullcalendar .fc-list-event-time { + white-space: nowrap; +} + +.cablecast-fullcalendar .fc-list-day-cushion { + background-color: #f0f0f1; +} + +.cablecast-fullcalendar .fc-list-event-dot { + border-radius: 50%; +} + +/* Column headers */ +.cablecast-fullcalendar .fc-col-header-cell { + padding: 8px 0; + font-weight: 600; +} + +.cablecast-fullcalendar .fc-col-header-cell-cushion { + color: #1d2327; +} + +/* Scrollbar styling for time grid */ +.cablecast-fullcalendar .fc-scroller { + scrollbar-width: thin; + scrollbar-color: #c3c4c7 transparent; +} + +.cablecast-fullcalendar .fc-scroller::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.cablecast-fullcalendar .fc-scroller::-webkit-scrollbar-thumb { + background-color: #c3c4c7; + border-radius: 4px; +} + +.cablecast-fullcalendar .fc-scroller::-webkit-scrollbar-track { + background-color: transparent; +} + +/* Responsive */ +@media (max-width: 768px) { + .cablecast-fullcalendar .fc-toolbar { + flex-direction: column; + gap: 10px; + } + + .cablecast-fullcalendar .fc-toolbar-chunk { + display: flex; + justify-content: center; + } + + .cablecast-fullcalendar .fc-toolbar-title { + font-size: 1.1em; + } + + .cablecast-fullcalendar .fc-button { + font-size: 0.8em; + padding: 4px 8px; + } + + /* Hide some view buttons on mobile */ + .cablecast-fullcalendar .fc-dayGridMonth-button, + .cablecast-fullcalendar .fc-timeGridWeek-button { + display: none; + } +} + +@media (max-width: 480px) { + .cablecast-fullcalendar .fc-toolbar-chunk:last-child { + flex-wrap: wrap; + gap: 5px; + } + + .cablecast-fullcalendar .fc-timegrid-slot { + height: 2em; + } +} diff --git a/assets/css/shortcode-docs.css b/assets/css/shortcode-docs.css new file mode 100644 index 0000000..e3b0230 --- /dev/null +++ b/assets/css/shortcode-docs.css @@ -0,0 +1,182 @@ +/** + * Cablecast Shortcode Documentation - Admin Styles + */ + +/* Main container */ +.cablecast-shortcode-docs { + max-width: 1200px; +} + +.cablecast-shortcode-docs .nav-tab-wrapper { + margin-bottom: 20px; +} + +.cablecast-shortcode-docs .nav-tab { + font-size: 13px; +} + +/* Description callout */ +.cablecast-docs-description { + background: #fff; + padding: 15px 20px; + border-left: 4px solid #2271b1; + margin-bottom: 25px; + box-shadow: 0 1px 1px rgba(0,0,0,0.04); +} + +.cablecast-docs-description p { + margin: 0; + font-size: 14px; + line-height: 1.6; +} + +/* Index page */ +.cablecast-docs-index p { + font-size: 14px; + margin-bottom: 20px; +} + +.cablecast-docs-index .notice { + margin: 15px 0 20px; +} + +/* Attributes table */ +.cablecast-attributes-table { + margin-bottom: 30px; +} + +.cablecast-attributes-table th { + background: #f0f0f1; + font-weight: 600; +} + +.cablecast-attributes-table td code { + background: #f0f0f1; + padding: 2px 6px; + border-radius: 3px; +} + +.cablecast-required-badge { + display: inline-block; + background: #d63638; + color: #fff; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 3px; + margin-left: 8px; + vertical-align: middle; +} + +/* Code blocks */ +.cablecast-docs-example { + background: #fff; + padding: 20px; + margin-bottom: 20px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.cablecast-docs-example h4 { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; +} + +.cablecast-code-block-wrapper { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.cablecast-code-block { + flex: 1; + background: #23282d; + color: #e6e6e6; + padding: 12px 15px; + border-radius: 4px; + overflow-x: auto; + font-family: Consolas, Monaco, 'Andale Mono', monospace; + font-size: 13px; + line-height: 1.5; + margin: 0; + white-space: pre-wrap; + word-break: break-all; +} + +.cablecast-code-block-wrapper .button { + flex-shrink: 0; +} + +/* Live preview */ +.cablecast-docs-preview { + background: #f6f7f7; + padding: 20px; + border: 1px solid #ddd; + border-radius: 4px; + margin-top: 20px; +} + +.cablecast-docs-preview__content { + background: #fff; + padding: 20px; + border: 1px solid #e0e0e0; + border-radius: 4px; + overflow: auto; + max-height: 500px; +} + +.cablecast-docs-no-preview { + color: #666; + font-style: italic; + margin: 0; +} + +/* Section headings */ +.cablecast-docs-detail h3 { + margin-top: 30px; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 1px solid #ddd; + font-size: 16px; +} + +.cablecast-docs-detail h3:first-child { + margin-top: 0; +} + +/* Copy button states */ +.cablecast-copy-btn { + min-width: 60px; + text-align: center; +} + +/* Responsive */ +@media screen and (max-width: 782px) { + .cablecast-shortcode-docs .nav-tab-wrapper { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + + .cablecast-shortcode-docs .nav-tab { + flex: 0 0 auto; + margin: 0; + } + + .cablecast-code-block-wrapper { + flex-direction: column; + } + + .cablecast-code-block-wrapper .button { + align-self: flex-end; + } + + .cablecast-attributes-table th:nth-child(3), + .cablecast-attributes-table th:nth-child(4), + .cablecast-attributes-table td:nth-child(3), + .cablecast-attributes-table td:nth-child(4) { + display: none; + } +} diff --git a/assets/css/shortcodes.css b/assets/css/shortcodes.css new file mode 100644 index 0000000..9759220 --- /dev/null +++ b/assets/css/shortcodes.css @@ -0,0 +1,1497 @@ +/** + * Cablecast Shortcode Styles + * + * Professional default styles for Cablecast shortcodes. + * Can be disabled in plugin settings for full theme control. + */ + +/* ========================================================================== + Base & Reset + ========================================================================== */ + +.cablecast-error, +.cablecast-no-results { + padding: 1rem; + color: #6b7280; + font-style: italic; +} + +.cablecast-error { + color: #dc2626; +} + +/* ========================================================================== + Broken Image Handling + Hide broken images gracefully instead of showing browser's broken icon + ========================================================================== */ + +.cablecast-schedule__thumbnail img, +.cablecast-now-playing__thumbnail img, +.cablecast-shows__thumbnail img, +.cablecast-show__thumbnail img, +.cablecast-series__thumbnail img { + /* Ensure images have minimum size for loading state */ + min-height: 50px; +} + +/* Hide broken images - the :not([src]) prevents hiding during initial load */ +.cablecast-schedule__thumbnail img:not([src=""]), +.cablecast-now-playing__thumbnail img:not([src=""]), +.cablecast-shows__thumbnail img:not([src=""]), +.cablecast-show__thumbnail img:not([src=""]), +.cablecast-series__thumbnail img:not([src=""]) { + /* Use object-fit to handle images that fail gracefully */ + object-fit: cover; +} + +/* Style broken images with a placeholder background */ +.cablecast-schedule__thumbnail, +.cablecast-now-playing__thumbnail, +.cablecast-shows__thumbnail, +.cablecast-show__thumbnail, +.cablecast-series__thumbnail { + background-color: #f3f4f6; + background-image: linear-gradient(135deg, #e5e7eb 25%, transparent 25%), + linear-gradient(225deg, #e5e7eb 25%, transparent 25%), + linear-gradient(45deg, #e5e7eb 25%, transparent 25%), + linear-gradient(315deg, #e5e7eb 25%, #f3f4f6 25%); + background-size: 20px 20px; + background-position: 0 0, 10px 0, 10px -10px, 0px 10px; +} + +/* ========================================================================== + Schedule Shortcode + ========================================================================== */ + +.cablecast-schedule { + max-width: 800px; +} + +.cablecast-schedule__day-divider { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + padding: 0.75rem 0 0.5rem; + border-bottom: 2px solid #e5e7eb; + margin-bottom: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cablecast-schedule__day-group { + margin-bottom: 1.5rem; +} + +.cablecast-schedule__item { + display: flex; + gap: 1rem; + padding: 0.75rem; + border-left: 4px solid #e5e7eb; + background: #fff; + margin-bottom: 0.5rem; + border-radius: 0 0.25rem 0.25rem 0; + transition: background-color 0.15s ease; +} + +.cablecast-schedule__item:hover { + background: #f9fafb; +} + +.cablecast-schedule__thumbnail { + flex-shrink: 0; + width: 80px; + height: 45px; + overflow: hidden; + border-radius: 0.25rem; + background: #f3f4f6; +} + +.cablecast-schedule__thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cablecast-schedule__content { + flex: 1; + min-width: 0; +} + +.cablecast-schedule__time { + display: block; + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; + margin-bottom: 0.125rem; +} + +.cablecast-schedule__title { + display: block; + font-weight: 600; + color: #111827; + text-decoration: none; + line-height: 1.3; +} + +a.cablecast-schedule__title:hover { + color: #2563eb; +} + +.cablecast-schedule__description { + font-size: 0.875rem; + color: #6b7280; + margin: 0.25rem 0 0; + line-height: 1.4; +} + +/* ========================================================================== + Now Playing Shortcode + ========================================================================== */ + +.cablecast-now-playing { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.cablecast-now-playing__card { + flex: 1; + min-width: 280px; + background: #fff; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-top: 4px solid #e5e7eb; +} + +.cablecast-now-playing__card--now { + border-top-color: #22c55e; +} + +.cablecast-now-playing__card--next { + border-top-color: #3b82f6; +} + +.cablecast-now-playing__badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background: #f3f4f6; + color: #6b7280; + margin-bottom: 0.75rem; +} + +.cablecast-now-playing__badge--live { + background: #dcfce7; + color: #166534; + animation: cablecast-pulse 2s ease-in-out infinite; +} + +@keyframes cablecast-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.cablecast-now-playing__thumbnail { + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 0.375rem; + margin-bottom: 0.75rem; + background: #f3f4f6; +} + +.cablecast-now-playing__thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cablecast-now-playing__content { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.cablecast-now-playing__title { + font-size: 1.125rem; + font-weight: 600; + color: #111827; + text-decoration: none; + line-height: 1.3; +} + +a.cablecast-now-playing__title:hover { + color: #2563eb; +} + +.cablecast-now-playing__time { + font-size: 0.875rem; + color: #6b7280; +} + +.cablecast-now-playing__progress { + height: 4px; + background: #e5e7eb; + border-radius: 2px; + margin-top: 0.75rem; + overflow: hidden; +} + +.cablecast-now-playing__progress-bar { + height: 100%; + background: #22c55e; + border-radius: 2px; + transition: width 0.3s ease; +} + +.cablecast-now-playing__description { + font-size: 0.875rem; + color: #6b7280; + margin: 0.5rem 0 0; + line-height: 1.4; +} + +.cablecast-now-playing__no-show { + color: #6b7280; + font-style: italic; + margin: 0; +} + +/* ========================================================================== + Weekly Guide Shortcode + ========================================================================== */ + +.cablecast-weekly-guide { + background: #fff; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.cablecast-weekly-guide__channel-switcher { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; +} + +.cablecast-weekly-guide__channel-switcher label { + font-weight: 500; + color: #374151; +} + +.cablecast-weekly-guide__channel-switcher select { + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background: #fff; + font-size: 0.875rem; + color: #374151; +} + +.cablecast-weekly-guide__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; + background: #e5e7eb; +} + +@media (max-width: 1024px) { + .cablecast-weekly-guide__grid { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (max-width: 640px) { + .cablecast-weekly-guide__grid { + grid-template-columns: 1fr; + } +} + +.cablecast-weekly-guide__day { + background: #fff; + min-height: 300px; +} + +.cablecast-weekly-guide__day--today { + background: #fefce8; +} + +.cablecast-weekly-guide__day-header { + padding: 0.75rem; + background: #f3f4f6; + border-bottom: 1px solid #e5e7eb; + text-align: center; +} + +.cablecast-weekly-guide__day--today .cablecast-weekly-guide__day-header { + background: #fef08a; +} + +.cablecast-weekly-guide__day-name { + display: block; + font-weight: 600; + color: #111827; + font-size: 0.875rem; +} + +.cablecast-weekly-guide__day-date { + display: block; + font-size: 0.75rem; + color: #6b7280; +} + +.cablecast-weekly-guide__programs { + padding: 0.5rem; +} + +.cablecast-weekly-guide__no-programs { + padding: 1rem; + text-align: center; + color: #9ca3af; + font-size: 0.875rem; +} + +.cablecast-weekly-guide__program { + padding: 0.5rem; + border-left: 3px solid transparent; + border-radius: 0 0.25rem 0.25rem 0; + margin-bottom: 0.25rem; + transition: background-color 0.15s ease; +} + +.cablecast-weekly-guide__program:hover { + background: #f9fafb; +} + +.cablecast-weekly-guide__program--current { + background: #ecfdf5; + border-left-color: #22c55e; +} + +.cablecast-weekly-guide__program-time { + display: block; + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; +} + +.cablecast-weekly-guide__program-title { + display: block; + font-size: 0.8125rem; + font-weight: 500; + color: #111827; + text-decoration: none; + line-height: 1.3; +} + +a.cablecast-weekly-guide__program-title:hover { + color: #2563eb; +} + +.cablecast-weekly-guide__program-desc { + font-size: 0.75rem; + color: #6b7280; + margin: 0.25rem 0 0; + line-height: 1.3; +} + +/* ========================================================================== + Shows Grid/List + ========================================================================== */ + +.cablecast-shows { + display: grid; + gap: 1.5rem; +} + +.cablecast-shows--grid { + grid-template-columns: repeat(4, 1fr); +} + +.cablecast-shows--columns-2 { grid-template-columns: repeat(2, 1fr); } +.cablecast-shows--columns-3 { grid-template-columns: repeat(3, 1fr); } +.cablecast-shows--columns-4 { grid-template-columns: repeat(4, 1fr); } +.cablecast-shows--columns-5 { grid-template-columns: repeat(5, 1fr); } +.cablecast-shows--columns-6 { grid-template-columns: repeat(6, 1fr); } + +@media (max-width: 1024px) { + .cablecast-shows--grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 768px) { + .cablecast-shows--grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .cablecast-shows--grid { + grid-template-columns: 1fr; + } +} + +.cablecast-shows--list { + grid-template-columns: 1fr; +} + +.cablecast-shows--list .cablecast-shows__item { + display: flex; + gap: 1rem; + align-items: center; +} + +.cablecast-shows--list .cablecast-shows__thumbnail { + width: 120px; + flex-shrink: 0; +} + +.cablecast-shows__item { + background: #fff; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.15s ease; +} + +.cablecast-shows__item:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.cablecast-shows__thumbnail { + display: block; + aspect-ratio: 16 / 9; + overflow: hidden; + background: #f3f4f6; +} + +.cablecast-shows__thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.cablecast-shows__item:hover .cablecast-shows__thumbnail img { + transform: scale(1.05); +} + +.cablecast-shows__content { + padding: 0.75rem; +} + +.cablecast-shows__title { + display: block; + font-weight: 600; + color: #111827; + text-decoration: none; + line-height: 1.3; + margin-bottom: 0.25rem; +} + +a.cablecast-shows__title:hover { + color: #2563eb; +} + +.cablecast-shows__runtime { + font-size: 0.75rem; + color: #6b7280; +} + +.cablecast-shows__pagination { + margin-top: 2rem; + text-align: center; +} + +.cablecast-shows__pagination .page-numbers { + display: inline-block; + padding: 0.5rem 0.75rem; + margin: 0 0.125rem; + border: 1px solid #e5e7eb; + border-radius: 0.25rem; + text-decoration: none; + color: #374151; + transition: all 0.15s ease; +} + +.cablecast-shows__pagination .page-numbers:hover { + background: #f3f4f6; +} + +.cablecast-shows__pagination .page-numbers.current { + background: #2563eb; + border-color: #2563eb; + color: #fff; +} + +/* Featured layout */ +.cablecast-shows--featured { + grid-template-columns: repeat(4, 1fr); +} + +.cablecast-shows--featured .cablecast-shows__item--featured { + grid-column: span 2; + grid-row: span 2; +} + +.cablecast-shows--featured .cablecast-shows__item--featured .cablecast-shows__thumbnail { + aspect-ratio: 16 / 9; +} + +.cablecast-shows--featured .cablecast-shows__item--featured .cablecast-shows__content { + padding: 1.25rem; +} + +.cablecast-shows--featured .cablecast-shows__item--featured .cablecast-shows__title { + font-size: 1.25rem; +} + +@media (max-width: 1024px) { + .cablecast-shows--featured { + grid-template-columns: repeat(3, 1fr); + } + + .cablecast-shows--featured .cablecast-shows__item--featured { + grid-column: span 2; + grid-row: span 1; + } +} + +@media (max-width: 768px) { + .cablecast-shows--featured { + grid-template-columns: repeat(2, 1fr); + } + + .cablecast-shows--featured .cablecast-shows__item--featured { + grid-column: span 2; + } +} + +@media (max-width: 480px) { + .cablecast-shows--featured { + grid-template-columns: 1fr; + } + + .cablecast-shows--featured .cablecast-shows__item--featured { + grid-column: span 1; + grid-row: span 1; + } +} + +/* Runtime badge overlay */ +.cablecast-shows__thumbnail { + position: relative; +} + +.cablecast-shows__runtime-badge { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.8); + color: #fff; + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +} + +/* Category tag */ +.cablecast-shows__category { + display: inline-block; + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + border-left: 2px solid transparent; + text-decoration: none; + margin-top: 0.25rem; +} + +.cablecast-shows__category:hover { + background: #e5e7eb; + text-decoration: none; +} + +/* ========================================================================== + Categories Shortcode + ========================================================================== */ + +.cablecast-categories { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.cablecast-categories--grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; +} + +.cablecast-categories--list { + flex-direction: column; + gap: 0.5rem; +} + +.cablecast-categories__item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + background: #f3f4f6; + border-radius: 9999px; + text-decoration: none; + color: #374151; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid transparent; + transition: all 0.15s ease; +} + +.cablecast-categories__item:hover { + background: #e5e7eb; + text-decoration: none; +} + +.cablecast-categories--grid .cablecast-categories__item, +.cablecast-categories--list .cablecast-categories__item { + border-radius: 0.375rem; + padding: 0.75rem 1rem; + border-left: 3px solid #e5e7eb; +} + +.cablecast-categories__color { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.cablecast-categories__name { + flex: 1; +} + +.cablecast-categories__count { + font-size: 0.75rem; + color: #9ca3af; + background: #fff; + padding: 0.125rem 0.375rem; + border-radius: 9999px; +} + +/* ========================================================================== + Single Show + ========================================================================== */ + +.cablecast-show { + max-width: 900px; +} + +.cablecast-show__vod { + aspect-ratio: 16 / 9; + margin-bottom: 1.5rem; + border-radius: 0.5rem; + overflow: hidden; + background: #000; +} + +.cablecast-show__vod iframe, +.cablecast-show__vod video { + width: 100%; + height: 100%; +} + +.cablecast-show__thumbnail { + margin-bottom: 1.5rem; + border-radius: 0.5rem; + overflow: hidden; +} + +.cablecast-show__thumbnail img { + width: 100%; + height: auto; +} + +.cablecast-show__content { + padding: 0; +} + +.cablecast-show__title { + font-size: 1.5rem; + font-weight: 700; + color: #111827; + margin: 0 0 0.75rem; + line-height: 1.2; +} + +.cablecast-show__meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; + font-size: 0.875rem; + color: #6b7280; +} + +.cablecast-show__meta a { + color: #2563eb; + text-decoration: none; +} + +.cablecast-show__meta a:hover { + text-decoration: underline; +} + +.cablecast-show__description { + color: #374151; + line-height: 1.6; +} + +.cablecast-show__description p { + margin: 0 0 1rem; +} + +/* ========================================================================== + VOD Player + ========================================================================== */ + +.cablecast-vod-player { + aspect-ratio: 16 / 9; + border-radius: 0.5rem; + overflow: hidden; + background: #000; +} + +.cablecast-vod-player iframe, +.cablecast-vod-player video { + width: 100%; + height: 100%; +} + +/* ========================================================================== + Producers + ========================================================================== */ + +.cablecast-producers { + display: grid; + gap: 1rem; +} + +.cablecast-producers--grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +} + +.cablecast-producers--list { + grid-template-columns: 1fr; +} + +.cablecast-producers__item { + background: #fff; + padding: 1rem; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.cablecast-producers__name { + display: block; + font-weight: 600; + color: #111827; + text-decoration: none; + margin-bottom: 0.25rem; +} + +a.cablecast-producers__name:hover { + color: #2563eb; +} + +.cablecast-producers__count { + font-size: 0.875rem; + color: #6b7280; +} + +.cablecast-producers__contact { + margin-top: 0.5rem; + font-size: 0.875rem; +} + +.cablecast-producers__contact a { + display: block; + color: #2563eb; + text-decoration: none; +} + +.cablecast-producers__contact a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Series + ========================================================================== */ + +.cablecast-series { + display: grid; + gap: 1.5rem; +} + +.cablecast-series--grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.cablecast-series--list { + grid-template-columns: 1fr; +} + +.cablecast-series--list .cablecast-series__item { + display: flex; + gap: 1rem; + align-items: center; +} + +.cablecast-series--list .cablecast-series__thumbnail { + width: 150px; + flex-shrink: 0; +} + +.cablecast-series__item { + background: #fff; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.15s ease; +} + +.cablecast-series__item:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.cablecast-series__thumbnail { + display: block; + aspect-ratio: 16 / 9; + overflow: hidden; + background: #f3f4f6; +} + +.cablecast-series__thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.cablecast-series__item:hover .cablecast-series__thumbnail img { + transform: scale(1.05); +} + +.cablecast-series__content { + padding: 0.75rem; +} + +.cablecast-series__name { + display: block; + font-weight: 600; + color: #111827; + text-decoration: none; + line-height: 1.3; + margin-bottom: 0.25rem; +} + +a.cablecast-series__name:hover { + color: #2563eb; +} + +.cablecast-series__count { + font-size: 0.75rem; + color: #6b7280; +} + +/* ========================================================================== + Chapters Shortcode + ========================================================================== */ + +.cablecast-chapters { + background: #fff; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.cablecast-chapters__heading { + font-size: 1rem; + font-weight: 600; + color: #374151; + margin: 0 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e5e7eb; +} + +.cablecast-chapters__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 400px; + overflow-y: auto; +} + +.cablecast-chapters__item { + padding: 0.5rem 0; + border-bottom: 1px solid #f3f4f6; + transition: background-color 0.15s ease; +} + +.cablecast-chapters__item:last-child { + border-bottom: none; +} + +.cablecast-chapters__item:hover { + background: #f9fafb; +} + +.cablecast-chapters__item--current { + background: #ecfdf5; + border-left: 3px solid #22c55e; + margin-left: -1rem; + padding-left: calc(1rem - 3px); +} + +.cablecast-chapters__button { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.25rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-family: inherit; + font-size: inherit; + transition: color 0.15s ease; +} + +.cablecast-chapters__button:hover { + color: #2563eb; +} + +.cablecast-chapters__timestamp { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-variant-numeric: tabular-nums; +} + +.cablecast-chapters__item--current .cablecast-chapters__timestamp { + background: #dcfce7; + color: #166534; +} + +.cablecast-chapters__title { + flex: 1; + font-size: 0.875rem; + font-weight: 500; + color: #111827; + line-height: 1.3; +} + +.cablecast-chapters__description { + font-size: 0.8125rem; + color: #6b7280; + margin: 0.25rem 0 0; + padding-left: calc(0.75rem + 3rem); + line-height: 1.4; +} + +/* Compact layout variation */ +.cablecast-chapters--compact .cablecast-chapters__item { + padding: 0.25rem 0; +} + +.cablecast-chapters--compact .cablecast-chapters__description { + display: none; +} + +.cablecast-chapters--compact .cablecast-chapters__list { + max-height: 250px; +} + +/* ========================================================================== + SHOW SINGLE PAGE (Default Template) + ========================================================================== */ + +.cablecast-show-single { + max-width: 1000px; + margin: 0 auto; + padding: 0 1rem; +} + +.cablecast-show-single__header { + margin-bottom: 1.5rem; +} + +.cablecast-show-single__title { + font-size: 2rem; + font-weight: 700; + color: #111827; + margin: 0; + line-height: 1.2; +} + +.cablecast-show-single__player { + margin-bottom: 1.5rem; +} + +.cablecast-show-single__player .cablecast-vod-player { + aspect-ratio: 16 / 9; + border-radius: 0.5rem; + overflow: hidden; + background: #000; +} + +.cablecast-show-single__player .cablecast-vod-player iframe, +.cablecast-show-single__player .cablecast-vod-player video { + width: 100%; + height: 100%; +} + +.cablecast-show-single__poster { + aspect-ratio: 16 / 9; + border-radius: 0.5rem; + overflow: hidden; + background: #f3f4f6; +} + +.cablecast-show-single__poster-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cablecast-show-single__content { + padding: 0; +} + +.cablecast-show-single__description { + font-size: 1.0625rem; + line-height: 1.7; + color: #374151; + margin-bottom: 1.5rem; +} + +.cablecast-show-single__description p:last-child { + margin-bottom: 0; +} + +.cablecast-show-single__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + padding: 1.25rem; + background: #f9fafb; + border-radius: 0.5rem; + margin-bottom: 1.5rem; +} + +.cablecast-show-single__meta-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.cablecast-show-single__meta-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; +} + +.cablecast-show-single__meta-value { + font-size: 0.9375rem; + font-weight: 500; + color: #111827; + text-decoration: none; +} + +a.cablecast-show-single__meta-value:hover { + color: #2563eb; + text-decoration: underline; +} + +/* ========================================================================== + UPCOMING RUNS SHORTCODE + ========================================================================== */ + +.cablecast-upcoming-runs { + background: #fff; + border-radius: 0.5rem; + padding: 1.25rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + margin-top: 1.5rem; +} + +.cablecast-upcoming-runs__heading { + font-size: 1rem; + font-weight: 600; + color: #374151; + margin: 0 0 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e5e7eb; +} + +.cablecast-upcoming-runs__list { + list-style: none; + margin: 0; + padding: 0; +} + +.cablecast-upcoming-runs__item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid #f3f4f6; +} + +.cablecast-upcoming-runs__item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.cablecast-upcoming-runs__date { + font-weight: 600; + color: #111827; + min-width: 150px; +} + +.cablecast-upcoming-runs__time { + color: #6b7280; + font-weight: 500; +} + +.cablecast-upcoming-runs__channel { + margin-left: auto; + font-size: 0.875rem; + color: #2563eb; + text-decoration: none; + padding: 0.25rem 0.75rem; + background: #eff6ff; + border-radius: 9999px; +} + +.cablecast-upcoming-runs__channel:hover { + background: #dbeafe; + text-decoration: none; +} + +@media (max-width: 640px) { + .cablecast-upcoming-runs__item { + flex-direction: column; + align-items: flex-start; + } + + .cablecast-upcoming-runs__channel { + margin-left: 0; + margin-top: 0.25rem; + } +} + +/* ========================================================================== + CHANNEL SINGLE PAGE (Default Template) + ========================================================================== */ + +.cablecast-channel-single { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.cablecast-channel-single__header { + margin-bottom: 1.5rem; +} + +.cablecast-channel-single__title { + font-size: 2rem; + font-weight: 700; + color: #111827; + margin: 0; + line-height: 1.2; +} + +.cablecast-channel-single__player { + margin-bottom: 2rem; + aspect-ratio: 16 / 9; + background: #000; + border-radius: 0.5rem; + overflow: hidden; +} + +.cablecast-channel-single__player iframe, +.cablecast-channel-single__player video { + width: 100%; + height: 100%; +} + +.cablecast-channel-single__now-playing { + margin-bottom: 2rem; +} + +.cablecast-channel-single__description { + font-size: 1rem; + line-height: 1.7; + color: #374151; + margin-bottom: 2rem; + padding: 1.25rem; + background: #f9fafb; + border-radius: 0.5rem; +} + +.cablecast-channel-single__schedule { + margin-top: 2rem; +} + +.cablecast-channel-single__schedule-heading { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + margin: 0 0 1rem; +} + +/* FullCalendar container styling for channel pages */ +.cablecast-channel-single .cablecast-fullcalendar { + background: #fff; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* ========================================================================== + TAXONOMY ARCHIVES + ========================================================================== */ + +.cablecast-taxonomy-archive { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.cablecast-taxonomy-archive__header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #e5e7eb; +} + +.cablecast-taxonomy-archive__title { + font-size: 2rem; + font-weight: 700; + color: #111827; + margin: 0 0 0.5rem; + line-height: 1.2; +} + +.cablecast-taxonomy-archive__description { + font-size: 1rem; + line-height: 1.7; + color: #4b5563; + margin-bottom: 0.5rem; +} + +.cablecast-taxonomy-archive__count { + font-size: 0.875rem; + color: #6b7280; +} + +/* ========================================================================== + SHOWS & CHANNELS ARCHIVES + ========================================================================== */ + +.cablecast-archive { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.cablecast-archive__header { + margin-bottom: 2rem; +} + +.cablecast-archive__title { + font-size: 2rem; + font-weight: 700; + color: #111827; + margin: 0; + line-height: 1.2; +} + +/* Channel cards grid */ +.cablecast-channels-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.cablecast-channel-card { + background: #fff; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.2s ease; +} + +.cablecast-channel-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.cablecast-channel-card__thumbnail { + aspect-ratio: 16 / 9; + overflow: hidden; + background: #f3f4f6; +} + +.cablecast-channel-card__thumbnail-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cablecast-channel-card__content { + padding: 1rem; +} + +.cablecast-channel-card__title { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.5rem; + line-height: 1.3; +} + +.cablecast-channel-card__title a { + color: #111827; + text-decoration: none; +} + +.cablecast-channel-card__title a:hover { + color: #2563eb; +} + +.cablecast-channel-card__excerpt { + font-size: 0.875rem; + color: #6b7280; + line-height: 1.5; + margin-bottom: 1rem; +} + +.cablecast-channel-card__link { + display: inline-block; + font-size: 0.875rem; + font-weight: 500; + color: #2563eb; + text-decoration: none; +} + +.cablecast-channel-card__link:hover { + text-decoration: underline; +} + +/* Show cards (used in content-show.php partial) */ +.cablecast-show-card { + background: #fff; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.2s ease; +} + +.cablecast-show-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.cablecast-show-card__thumbnail { + aspect-ratio: 16 / 9; + overflow: hidden; + background: #f3f4f6; +} + +.cablecast-show-card__thumbnail-img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.cablecast-show-card:hover .cablecast-show-card__thumbnail-img { + transform: scale(1.05); +} + +.cablecast-show-card__content { + padding: 1rem; +} + +.cablecast-show-card__title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.5rem; + line-height: 1.3; +} + +.cablecast-show-card__title a { + color: #111827; + text-decoration: none; +} + +.cablecast-show-card__title a:hover { + color: #2563eb; +} + +.cablecast-show-card__runtime { + font-size: 0.8125rem; + color: #6b7280; + margin-bottom: 0.25rem; +} + +.cablecast-show-card__category { + font-size: 0.75rem; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .cablecast-show-single__title, + .cablecast-channel-single__title, + .cablecast-taxonomy-archive__title, + .cablecast-archive__title { + font-size: 1.5rem; + } + + .cablecast-show-single__meta { + grid-template-columns: repeat(2, 1fr); + } + + .cablecast-channels-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .cablecast-show-single__meta { + grid-template-columns: 1fr; + } +} diff --git a/assets/js/chapters.js b/assets/js/chapters.js new file mode 100644 index 0000000..9b08bc0 --- /dev/null +++ b/assets/js/chapters.js @@ -0,0 +1,308 @@ +/** + * Cablecast Chapters Shortcode JavaScript + * + * Handles chapter click events and postMessage communication with VOD player. + */ +(function() { + 'use strict'; + + // Debug mode - set to true for verbose logging + const DEBUG = false; + + function log() { + if (DEBUG) { + console.log.apply(console, ['[Cablecast Chapters]'].concat(Array.prototype.slice.call(arguments))); + } + } + + function warn() { + console.warn.apply(console, ['[Cablecast Chapters]'].concat(Array.prototype.slice.call(arguments))); + } + + // State per chapter container + var chapterContainers = []; + + /** + * Initialize when DOM is ready. + */ + document.addEventListener('DOMContentLoaded', function() { + log('DOMContentLoaded - initializing chapters'); + initChapters(); + }); + + /** + * Initialize chapters functionality. + */ + function initChapters() { + var containers = document.querySelectorAll('.cablecast-chapters'); + + log('Found', containers.length, 'chapter container(s)'); + + if (containers.length === 0) { + log('No chapter containers found, exiting'); + return; + } + + containers.forEach(function(container, idx) { + log('Setting up container', idx, container); + var state = setupChapterContainer(container); + if (state) { + chapterContainers.push(state); + log('Container', idx, 'setup complete. State:', state); + } + }); + + // Listen for timeupdate messages from the player + log('Adding message listener for player events'); + window.addEventListener('message', handlePlayerMessage); + } + + /** + * Set up a chapter container with click handlers. + * Returns state object for this container. + */ + function setupChapterContainer(container) { + var showId = container.dataset.showId; + var playerSelector = container.dataset.playerSelector; + + log('Setting up container for show:', showId, 'playerSelector:', playerSelector); + + // Find the target iframe + var iframe = findPlayerIframe(container, playerSelector); + if (!iframe) { + warn('No player iframe found for show', showId); + // Still allow display, just without interactivity + } else { + log('Found iframe:', iframe.src); + } + + // Cache chapter elements and offsets + var chapterElements = container.querySelectorAll('.cablecast-chapters__item'); + var chapterOffsets = []; + + log('Found', chapterElements.length, 'chapter elements'); + + chapterElements.forEach(function(item, index) { + var offset = parseInt(item.dataset.offset, 10); + chapterOffsets.push(offset); + log('Chapter', index, '- offset:', offset, 'title:', item.querySelector('.cablecast-chapters__title')?.textContent); + + var button = item.querySelector('.cablecast-chapters__button'); + if (button) { + log('Found button for chapter', index); + if (iframe) { + button.addEventListener('click', function(e) { + log('Chapter', index, 'clicked! Offset:', offset); + seekToChapter(iframe, offset, chapterElements, index); + }); + log('Click handler attached for chapter', index); + } else { + warn('No iframe available, click handler NOT attached for chapter', index); + } + } else { + warn('No button found for chapter', index); + } + }); + + return { + container: container, + iframe: iframe, + chapterElements: chapterElements, + chapterOffsets: chapterOffsets, + currentChapterIndex: -1 + }; + } + + /** + * Find the player iframe. + * + * Strategy: + * 1. If playerSelector is specified, use that + * 2. Look for iframe in .cablecast-show__vod or .cablecast-vod-player + * 3. Fall back to first iframe on the page containing expected player domains + */ + function findPlayerIframe(container, playerSelector) { + var iframe = null; + + log('findPlayerIframe - playerSelector:', playerSelector); + + if (playerSelector) { + log('Trying playerSelector + iframe:', playerSelector + ' iframe'); + iframe = document.querySelector(playerSelector + ' iframe'); + if (iframe) { + log('Found iframe via playerSelector + iframe'); + return iframe; + } + + // Try the selector directly if it's targeting an iframe + log('Trying playerSelector directly:', playerSelector); + iframe = document.querySelector(playerSelector); + if (iframe && iframe.tagName === 'IFRAME') { + log('Found iframe via direct playerSelector'); + return iframe; + } + } + + // Look for VOD player containers near the chapter list + log('Looking for .cablecast-show__vod or .cablecast-vod-player containers'); + var vodContainer = document.querySelector('.cablecast-show__vod, .cablecast-vod-player'); + if (vodContainer) { + log('Found VOD container:', vodContainer.className); + iframe = vodContainer.querySelector('iframe'); + if (iframe) { + log('Found iframe in VOD container:', iframe.src); + return iframe; + } + } + + // Fall back to any iframe that looks like a Cablecast player + log('Falling back to scanning all iframes'); + var iframes = document.querySelectorAll('iframe'); + log('Found', iframes.length, 'total iframes on page'); + + for (var i = 0; i < iframes.length; i++) { + var src = iframes[i].src || ''; + log('Iframe', i, 'src:', src); + // Match common Cablecast/Tightrope player URLs + if (src.indexOf('cablecast') !== -1 || + src.indexOf('trms') !== -1 || + src.indexOf('public.') !== -1 || + src.indexOf('watch') !== -1 || + src.indexOf('vod-embed') !== -1 || + src.indexOf('watch-vod-embed') !== -1) { + log('Iframe', i, 'matches Cablecast pattern'); + return iframes[i]; + } + } + + warn('No matching iframe found'); + return null; + } + + /** + * Seek to a chapter. + */ + function seekToChapter(iframe, offset, chapterElements, index) { + log('seekToChapter called - offset:', offset, 'index:', index); + + if (!iframe || !iframe.contentWindow) { + warn('Cannot seek - no player iframe or contentWindow'); + return; + } + + if (typeof offset !== 'number') { + warn('Cannot seek - offset is not a number:', offset); + return; + } + + // Send postMessage to player + var message = { + type: 'player-cue', + value: offset + }; + log('Sending postMessage to iframe:', message); + log('Iframe src:', iframe.src); + + try { + iframe.contentWindow.postMessage(message, '*'); + log('postMessage sent successfully'); + } catch (e) { + warn('Error sending postMessage:', e); + } + + // Update current chapter highlight immediately for responsive feel + updateCurrentChapterUI(chapterElements, index); + } + + /** + * Handle messages from the player (timeupdate, etc). + */ + function handlePlayerMessage(event) { + var data = event.data; + + // Validate message structure + if (!data || typeof data !== 'object') return; + + // Log all messages for debugging (be selective to avoid noise) + if (data.message || data.type) { + log('Received postMessage:', data); + } + + if (data.message === 'timeupdate' && typeof data.value === 'number') { + log('timeupdate received:', data.value); + updateAllChapterHighlights(data.value); + } + } + + /** + * Update chapter highlighting for all containers based on current playback time. + */ + function updateAllChapterHighlights(currentTime) { + chapterContainers.forEach(function(state) { + updateChapterHighlightForContainer(state, currentTime); + }); + } + + /** + * Update chapter highlight for a specific container. + */ + function updateChapterHighlightForContainer(state, currentTime) { + if (!state.chapterElements || state.chapterOffsets.length === 0) return; + + // Find the current chapter (last chapter whose offset is <= current time) + var newIndex = -1; + for (var i = state.chapterOffsets.length - 1; i >= 0; i--) { + if (state.chapterOffsets[i] <= currentTime) { + newIndex = i; + break; + } + } + + if (newIndex !== state.currentChapterIndex) { + log('Chapter changed from', state.currentChapterIndex, 'to', newIndex); + updateCurrentChapterUI(state.chapterElements, newIndex); + state.currentChapterIndex = newIndex; + } + } + + /** + * Update the visual highlighting of the current chapter. + */ + function updateCurrentChapterUI(chapterElements, newIndex) { + // Remove current class from all + chapterElements.forEach(function(el) { + el.classList.remove('cablecast-chapters__item--current'); + }); + + // Add current class to new + if (newIndex >= 0 && chapterElements[newIndex]) { + chapterElements[newIndex].classList.add('cablecast-chapters__item--current'); + + // Optionally scroll into view if needed + scrollChapterIntoView(chapterElements[newIndex]); + } + } + + /** + * Scroll chapter into view if it's outside the visible area. + */ + function scrollChapterIntoView(element) { + // Only scroll if parent has a fixed height/overflow + var parent = element.closest('.cablecast-chapters__list'); + if (!parent) return; + + var parentStyle = window.getComputedStyle(parent); + if (parentStyle.overflowY !== 'auto' && parentStyle.overflowY !== 'scroll') { + return; + } + + var parentRect = parent.getBoundingClientRect(); + var elementRect = element.getBoundingClientRect(); + + // Check if element is outside visible area + if (elementRect.top < parentRect.top || elementRect.bottom > parentRect.bottom) { + element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + +})(); diff --git a/assets/js/fullcalendar-shortcode.js b/assets/js/fullcalendar-shortcode.js new file mode 100644 index 0000000..0abc903 --- /dev/null +++ b/assets/js/fullcalendar-shortcode.js @@ -0,0 +1,101 @@ +/** + * Cablecast FullCalendar Shortcode + * + * Initializes FullCalendar instances for the cablecast_schedule_calendar shortcode. + */ +document.addEventListener('DOMContentLoaded', function() { + if (!window.cablecastCalendars || !window.FullCalendar) { + return; + } + + window.cablecastCalendars.forEach(function(config) { + var calendarEl = document.getElementById(config.calendarId); + if (!calendarEl) { + return; + } + + // Build header toolbar configuration + var headerToolbar = false; + if (config.showHeader || config.showNav) { + headerToolbar = { + left: config.showNav ? 'prev,next today' : '', + center: 'title', + right: config.showHeader ? 'timeGridWeek,timeGridDay,dayGridMonth,listWeek' : '' + }; + } + + // Initialize FullCalendar + var calendar = new FullCalendar.Calendar(calendarEl, { + initialView: config.initialView, + height: config.height === 'auto' ? 'auto' : parseInt(config.height), + headerToolbar: headerToolbar, + nowIndicator: true, + navLinks: true, + eventClick: function(info) { + // Navigate to show page when clicking an event + if (info.event.url) { + info.jsEvent.preventDefault(); + window.location.href = info.event.url; + } + }, + events: function(fetchInfo, successCallback, failureCallback) { + // Fetch events via AJAX + var url = config.ajaxUrl + + '?action=cablecast_calendar_events' + + '&channel_id=' + encodeURIComponent(config.channelId) + + '&start=' + encodeURIComponent(fetchInfo.startStr) + + '&end=' + encodeURIComponent(fetchInfo.endStr) + + '&nonce=' + encodeURIComponent(config.nonce); + + fetch(url) + .then(function(response) { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(function(data) { + successCallback(data); + }) + .catch(function(error) { + console.error('Error fetching calendar events:', error); + failureCallback(error); + }); + }, + // View-specific options + views: { + timeGridWeek: { + slotMinTime: '06:00:00', + slotMaxTime: '24:00:00', + slotDuration: '00:30:00', + allDaySlot: false + }, + timeGridDay: { + slotMinTime: '06:00:00', + slotMaxTime: '24:00:00', + slotDuration: '00:30:00', + allDaySlot: false + }, + listWeek: { + noEventsContent: 'No programs scheduled' + } + }, + // Custom button text + buttonText: { + today: 'Today', + month: 'Month', + week: 'Week', + day: 'Day', + list: 'List' + }, + // Event display + eventTimeFormat: { + hour: 'numeric', + minute: '2-digit', + meridiem: 'short' + } + }); + + calendar.render(); + }); +}); diff --git a/assets/js/shortcodes.js b/assets/js/shortcodes.js new file mode 100644 index 0000000..ea93d42 --- /dev/null +++ b/assets/js/shortcodes.js @@ -0,0 +1,90 @@ +/** + * Cablecast Shortcode JavaScript + * + * Handles interactive functionality for Cablecast shortcodes. + */ + +(function() { + 'use strict'; + + /** + * Initialize when DOM is ready. + */ + document.addEventListener('DOMContentLoaded', function() { + initWeeklyGuide(); + initNowPlayingProgress(); + }); + + /** + * Initialize weekly guide functionality. + */ + function initWeeklyGuide() { + var guides = document.querySelectorAll('.cablecast-weekly-guide'); + + guides.forEach(function(guide) { + // Scroll to current program on load + scrollToCurrentProgram(guide); + + // Handle channel switcher + var switcher = guide.querySelector('#cablecast-channel-select'); + if (switcher) { + switcher.addEventListener('change', function() { + var url = new URL(window.location.href); + url.searchParams.set('channel', this.value); + window.location.href = url.toString(); + }); + } + }); + } + + /** + * Scroll the weekly guide to show the current program. + */ + function scrollToCurrentProgram(guide) { + var currentProgram = guide.querySelector('.cablecast-weekly-guide__program--current'); + + if (currentProgram) { + // Find the day column containing the current program + var dayColumn = currentProgram.closest('.cablecast-weekly-guide__day'); + var programs = dayColumn ? dayColumn.querySelector('.cablecast-weekly-guide__programs') : null; + + if (programs) { + // Scroll the program into view within its container + var programTop = currentProgram.offsetTop - programs.offsetTop; + programs.scrollTop = Math.max(0, programTop - 50); + } + + // On mobile, scroll the grid to show today's column + var grid = guide.querySelector('.cablecast-weekly-guide__grid'); + if (grid && dayColumn) { + var dayIndex = Array.from(grid.children).indexOf(dayColumn); + if (dayIndex > 0 && window.innerWidth <= 640) { + // On mobile, scroll horizontally to today + grid.scrollLeft = dayColumn.offsetLeft; + } + } + } + } + + /** + * Update progress bars for now playing shortcodes. + */ + function initNowPlayingProgress() { + var progressBars = document.querySelectorAll('.cablecast-now-playing__progress-bar'); + + if (progressBars.length === 0) return; + + // Update progress every 30 seconds + setInterval(function() { + // Reload the page to update now playing info + // In a more sophisticated implementation, this could use AJAX + progressBars.forEach(function(bar) { + var currentWidth = parseFloat(bar.style.width) || 0; + // Estimate progress increase (assuming 30 min show, 30 sec update = ~1.67%) + var newWidth = Math.min(100, currentWidth + 1.67); + bar.style.width = newWidth + '%'; + }); + }, 30000); + } + +})(); diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh new file mode 100755 index 0000000..06a00a8 --- /dev/null +++ b/bin/install-wp-tests.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +TMPDIR=${TMPDIR-/tmp} +TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then + WP_BRANCH=${WP_VERSION%\-*} + WP_TESTS_TAG="branches/$WP_BRANCH" + +elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then + WP_TESTS_TAG="branches/$WP_VERSION" +elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + WP_TESTS_TAG="tags/${WP_VERSION%??}" + else + WP_TESTS_TAG="tags/$WP_VERSION" + fi +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # Assume latest + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p $TMPDIR/wordpress-trunk + rm -rf $TMPDIR/wordpress-trunk/* + svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress + mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then + local ARCHIVE_NAME="wordpress-$WP_VERSION" + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i.bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + rm -rf $WP_TESTS_DIR/{includes,data} + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + fi + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +recreate_db() { + shopt -s nocasematch + if [[ $1 =~ ^(y|yes)$ ]] + then + mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA + create_db + echo "Recreated the database ($DB_NAME)." + else + echo "Leaving the existing database ($DB_NAME) in place." + fi + shopt -u nocasematch +} + +create_db() { + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] + then + echo "Reinstalling will delete the existing test database ($DB_NAME)" + read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB + recreate_db $DELETE_EXISTING_DB + else + create_db + fi +} + +install_wp +install_test_suite +install_db diff --git a/cablecast.php b/cablecast.php index 289ee25..9c4eb3f 100644 --- a/cablecast.php +++ b/cablecast.php @@ -5,10 +5,15 @@ Author URI: https://github.com/raytiley Description: This plugin creates custom post types to store information about shows and schedule information from Tightrope Media Systems Cablecast Automation system. */ +require_once __DIR__ . '/includes/Logger.php'; global $cablecast_db_version; $cablecast_db_version = '1.1'; +// Cablecast API configuration +define('CABLECAST_API_VERSION', 'v1'); +define('CABLECAST_API_BASE', '/cablecastapi/' . CABLECAST_API_VERSION); + function cablecast_deactivate() { $timestamp = wp_next_scheduled( 'cablecast_cron_hook' ); wp_unschedule_event( $timestamp, 'cablecast_cron_hook' ); @@ -19,6 +24,17 @@ function cablecast_install() { // trigger our function that registers the custom post type cablecast_setup_post_types(); + // Set default thumbnail mode based on new vs upgrade install + $existing_options = get_option('cablecast_options'); + if ($existing_options === false) { + // Fresh install - default to remote hosting + update_option('cablecast_options', ['thumbnail_mode' => 'remote']); + } else if (!isset($existing_options['thumbnail_mode'])) { + // Upgrade from older version - preserve old behavior (sync local) + $existing_options['thumbnail_mode'] = 'local'; + update_option('cablecast_options', $existing_options); + } + global $wpdb; global $cablecast_db_version; @@ -49,17 +65,53 @@ function cablecast_install() { } register_activation_hook( __FILE__, 'cablecast_install' ); - +/** + * Check if database needs upgrade and run dbDelta if so. + * This handles adding new columns (like cg_exempt) to existing installations. + */ +function cablecast_maybe_upgrade() { + global $wpdb; + global $cablecast_db_version; + + $installed_ver = get_option('cablecast_db_version'); + + if ($installed_ver !== $cablecast_db_version) { + $table_name = $wpdb->prefix . 'cablecast_schedule_items'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE $table_name ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + run_date_time datetime DEFAULT '0000-00-00 00:00:00' NOT NULL, + show_id int NOT NULL, + show_title varchar(255) DEFAULT '' NOT NULL, + channel_id int NOT NULL, + show_post_id int NOT NULL, + channel_post_id int NOT NULL, + schedule_item_id int NOT NULL, + cg_exempt tinyint(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + ) $charset_collate;"; + + require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); + dbDelta($sql); + + update_option('cablecast_db_version', $cablecast_db_version); + } +} +add_action('plugins_loaded', 'cablecast_maybe_upgrade'); // Load Settings Stuff For Admin Users if ( is_admin() ) { // we are in admin mode require_once( dirname( __FILE__ ) . '/includes/settings.php' ); + require_once( dirname( __FILE__ ) . '/includes/shortcode-docs.php' ); } require_once( dirname( __FILE__ ) . '/includes/sync.php' ); require_once( dirname( __FILE__ ) . '/includes/cron.php' ); require_once( dirname( __FILE__ ) . '/includes/content.php' ); +require_once( dirname( __FILE__ ) . '/includes/shortcodes.php' ); +require_once( dirname( __FILE__ ) . '/includes/template-loader.php' ); require_once( dirname( __FILE__ ) . '/display.php' ); require_once( dirname( __FILE__ ) . '/theme-functions.php' ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a708e5a --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "raytiley/wp-cablecast", + "description": "WordPress plugin for syncing Cablecast data", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "wp-phpunit/wp-phpunit": "^6.4", + "yoast/phpunit-polyfills": "^2.0" + }, + "autoload": { + "psr-4": { + "Cablecast\\": "includes/" + } + }, + "autoload-dev": { + "psr-4": { + "Cablecast\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-html coverage" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/display.php b/display.php index e7c6ecf..332241c 100644 --- a/display.php +++ b/display.php @@ -1,111 +1,276 @@ post_type !== 'show' && $page->post_type !== 'cablecast_channel'; + }); +} + +/** + * Include shows in category and tag archives. + */ add_filter('pre_get_posts', 'cablecast_query_post_type'); function cablecast_query_post_type($query) { - if(is_category() || is_tag()) { - $post_type = get_query_var('post_type'); - if($post_type) { - $post_type = $post_type; - } else { - $post_type = array('post','show'); + if (is_category() || is_tag()) { + $post_type = get_query_var('post_type'); + if ($post_type) { + $post_type = $post_type; + } else { + $post_type = array('post', 'show'); + } + $query->set('post_type', $post_type); + return $query; } - $query->set('post_type',$post_type); - return $query; - } } -add_filter('the_content','cablecast_content_display'); -function cablecast_content_display($content){ +/** + * Check if Cablecast templates are enabled. + * + * Templates are enabled by default. They can be disabled in settings + * for themes that want to provide their own templates without using + * the override system. + * + * @return bool + */ +function cablecast_templates_enabled() { + $options = get_option('cablecast_options', []); + // Default to enabled (true) if not set + return isset($options['enable_templates']) ? (bool) $options['enable_templates'] : true; +} + +/** + * Check if the template loader is handling the current page. + * + * This is used to prevent the legacy content filter from interfering + * with the new template system. + * + * @return bool + */ +function cablecast_is_template_page() { + if (!cablecast_templates_enabled()) { + return false; + } + + // Check if we're on a page that the template loader handles + $is_cablecast_page = is_singular('show') || is_singular('cablecast_channel') || + is_tax('cablecast_producer') || is_tax('cablecast_project') || + is_post_type_archive('show') || is_post_type_archive('cablecast_channel'); + + // Also check for category archives with shows + if (!$is_cablecast_page && is_category() && function_exists('cablecast_category_has_shows')) { + $is_cablecast_page = cablecast_category_has_shows(get_queried_object_id()); + } + + if ($is_cablecast_page) { + // Verify a template file exists + $file = ''; + if (is_singular('show')) { + $file = 'single-show.php'; + } elseif (is_singular('cablecast_channel')) { + $file = 'single-channel.php'; + } elseif (is_tax('cablecast_producer')) { + $file = 'archive-producer.php'; + } elseif (is_tax('cablecast_project')) { + $file = 'archive-series.php'; + } elseif (is_post_type_archive('show')) { + $file = 'archive-show.php'; + } elseif (is_post_type_archive('cablecast_channel')) { + $file = 'archive-channel.php'; + } elseif (is_category()) { + $file = 'archive-category.php'; + } + + if ($file && function_exists('cablecast_locate_template')) { + $template = cablecast_locate_template($file); + return !empty($template); + } + } + + return false; +} + +/** + * Legacy content display filter. + * + * This filter is only active when templates are disabled. + * When templates are enabled, the template files handle display. + * + * @param string $content The post content. + * @return string Modified content. + */ +add_filter('the_content', 'cablecast_content_display'); +function cablecast_content_display($content) { global $post; - if ($post->post_type == "show" && in_the_loop() && is_main_query()) { + + // Skip if templates are handling this page + if (cablecast_is_template_page()) { + return $content; + } + + // Legacy show display (when templates are disabled) + // Only run on single show pages - archives should use theme templates + if ($post != null && $post->post_type == "show" && is_single() && in_the_loop() && is_main_query()) { $show_meta = get_post_custom($post->ID); $vod_url = get_post_meta($post->ID, 'cablecast_vod_url', true); $producer = get_post_meta($post->ID, 'cablecast_producer_name', true); $category = get_post_meta($post->ID, 'cablecast_category_name', true); $project = get_post_meta($post->ID, 'cablecast_project_name', true); $trt = get_post_meta($post->ID, 'cablecast_show_trt', true); - $show_content = "
"; + $vod_poster = get_the_post_thumbnail_url(); + $show_content = "
"; + if (is_single()) { - $vod_poster = get_the_post_thumbnail_url(); - $show_content .= "[video src=\"$vod_url\" poster=\"$vod_poster\" autoplay=\"true\"]"; + $show_content .= '[video src="' . esc_url($vod_url) . '" poster="' . esc_url($vod_poster) . '" autoplay="true"]'; } + $show_content .= "

"; - $show_content .= $post->post_content; + $show_content .= wp_kses_post($post->post_content); $show_content .= "

"; $show_content .= "
    "; + if (is_single()) { - if (empty($trt) == FALSE) { - $show_content .= "
  • Length: "; - $pretty_trt = gmdate('H:i:s', $trt); - $show_content .= "$pretty_trt
  • "; - } - if (empty($producer) == false) { - $producer_link = get_term_link(cablecast_replace_commas_in_tag($producer), 'cablecast_producer'); - $show_content .= "
  • Producer: "; - $show_content .= "$producer
  • "; - } - if (empty($project) == false) { - $project_link = get_term_link(cablecast_replace_commas_in_tag($project), 'cablecast_project'); - $show_content .= "
  • Series: "; - $show_content .= "$project
  • "; - } - if (empty($category) == false) { - $category_link = get_term_link($category, 'category'); - $show_content .= "
  • Category: "; - $show_content .= "$category
  • "; - } - $show_content .= "
"; - $show_content .= "
"; + if (!empty($trt)) { + $show_content .= "
  • Length: "; + $pretty_trt = gmdate('H:i:s', absint($trt)); + $show_content .= esc_html($pretty_trt) . "
  • "; + } + if (!empty($producer)) { + $producer_link = get_term_link(cablecast_replace_commas_in_tag($producer), 'cablecast_producer'); + if (!is_wp_error($producer_link)) { + $show_content .= '
  • Producer: ' . esc_html($producer) . '
  • '; + } + } + if (!empty($project)) { + $project_link = get_term_link(cablecast_replace_commas_in_tag($project), 'cablecast_project'); + if (!is_wp_error($project_link)) { + $show_content .= '
  • Series: ' . esc_html($project) . '
  • '; + } + } + if (!empty($category)) { + $category_link = get_term_link($category, 'category'); + if (!is_wp_error($category_link)) { + $show_content .= '
  • Category: ' . esc_html($category) . '
  • '; + } + } + // Note: Removed duplicate poster display that was in the original + $show_content .= ""; + $show_content .= "
    "; } + return do_shortcode($show_content); - } else if ($post->post_type == 'cablecast_channel' && is_single() && in_the_loop() && is_main_query()) { - $channel_id = get_post_meta($post->ID, 'cablecast_channel_id', true); - $schedule_content = ""; - if (empty($_GET["schedule_date"])) { - $date = current_time('Y-m-d'); - } else { - $date = date('Y-m-d', strtotime($_GET["schedule_date"])); - } - $prev_date = date('Y-m-d', strtotime($date . "-1days")); - $next_date = date('Y-m-d', strtotime($date . "+1days")); - $prev_link = add_query_arg(array('schedule_date' => $prev_date)); - $next_link = add_query_arg(array('schedule_date' => $next_date)); - - $schedule_itmes = cablecast_get_schedules($channel_id, $date); - - $channel_embed_code = get_post_meta($post->ID, 'cablecast_channel_live_embed_code', true); - if (empty($channel_embed_code) == false) { - $schedule_content .= "
    $channel_embed_code
    "; - } - - $schedule_content .= " -

    Schedule For $date

    -
    - -
    -
    - - - -
    -
    -
    - "; - - $schedule_content .= ""; - foreach($schedule_itmes as $item) { - $show_link = get_post_permalink($item->show_post_id); - if (empty($show_link)) { continue; } - $time = (new DateTime($item->run_date_time, new DateTimeZone($timezone))) - ->format('h:i a'); - $title = $item->show_title; - $schedule_content .= ""; - } - $schedule_content .= "
    TimeShow
    $time$item->show_title ($item->run_date_time)
    "; - return $schedule_content; + } + // Legacy channel display (when templates are disabled) + elseif ($post != null && $post->post_type == 'cablecast_channel' && is_single() && in_the_loop() && is_main_query()) { + $channel_id = get_post_meta($post->ID, 'cablecast_channel_id', true); + $schedule_content = ""; + + // Validate and sanitize schedule_date parameter + if (empty($_GET["schedule_date"])) { + $date = current_time('Y-m-d'); + } else { + $raw_date = sanitize_text_field(wp_unslash($_GET["schedule_date"])); + // Strict date format validation (YYYY-MM-DD) + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw_date)) { + $parsed = strtotime($raw_date); + if ($parsed !== false) { + $date = date('Y-m-d', $parsed); + } else { + $date = current_time('Y-m-d'); + } + } else { + $date = current_time('Y-m-d'); + } + } + $prev_date = date('Y-m-d', strtotime($date . "-1days")); + $next_date = date('Y-m-d', strtotime($date . "+1days")); + $prev_link = add_query_arg(array('schedule_date' => $prev_date)); + $next_link = add_query_arg(array('schedule_date' => $next_date)); + + $schedule_items = cablecast_get_schedules($channel_id, $date); + + // Note: embed_code from Cablecast API is trusted HTML - filtered to allow only safe iframe/video tags + $channel_embed_code = get_post_meta($post->ID, 'cablecast_channel_live_embed_code', true); + if (!empty($channel_embed_code)) { + $allowed_html = array( + 'iframe' => array( + 'src' => array(), + 'width' => array(), + 'height' => array(), + 'frameborder' => array(), + 'allowfullscreen' => array(), + 'allow' => array(), + 'style' => array(), + ), + 'video' => array( + 'src' => array(), + 'width' => array(), + 'height' => array(), + 'controls' => array(), + 'autoplay' => array(), + 'style' => array(), + ), + 'source' => array( + 'src' => array(), + 'type' => array(), + ), + ); + $schedule_content .= '
    ' . wp_kses($channel_embed_code, $allowed_html) . '
    '; + } + + $schedule_content .= ' +

    Schedule For ' . esc_html($date) . '

    +
    + +
    +
    + + + +
    +
    +
    + '; + + $schedule_content .= ""; + foreach ($schedule_items as $item) { + $show_link = get_post_permalink($item->show_post_id); + if (empty($show_link)) { + continue; + } + $timezone = wp_timezone_string(); + try { + $time = (new DateTime($item->run_date_time, new DateTimeZone($timezone)))->format('h:i a'); + } catch (Exception $e) { + $time = esc_html($item->run_date_time); + } + $schedule_content .= ''; + } + $schedule_content .= "
    TimeShow
    ' . esc_html($time) . '' . esc_html($item->show_title) . ' (' . esc_html($item->run_date_time) . ')
    "; + + return $schedule_content; } else { - return $content; + return $content; } } diff --git a/includes/Logger.php b/includes/Logger.php new file mode 100644 index 0000000..3f1c759 --- /dev/null +++ b/includes/Logger.php @@ -0,0 +1,102 @@ + self::LEVEL_DEBUG, + 'info' => self::LEVEL_INFO, + 'warning' => self::LEVEL_WARNING, + 'error' => self::LEVEL_ERROR, + ]; + + public static function init() { + // Choose uploads so it's writable and not wiped by updates. + $uploads = wp_upload_dir(null, false); + self::$dir = trailingslashit($uploads['basedir']) . 'cablecast/logs/'; + self::$file = self::$dir . 'cablecast.log'; + + // Make folder on activation and protect it. + add_action('admin_init', [__CLASS__, 'ensure_dir']); + } + + public static function ensure_dir() { + if ( ! file_exists(self::$dir) ) { + wp_mkdir_p(self::$dir); + } + // Prevent direct browsing; downloads go through our authenticated handler. + $htaccess = self::$dir . '.htaccess'; + if ( ! file_exists($htaccess) ) { + file_put_contents($htaccess, "Require all denied\n"); + } + $index = self::$dir . 'index.php'; + if ( ! file_exists($index) ) { + file_put_contents($index, " rotate to .1 + if ( file_exists(self::$file) && filesize(self::$file) > 5 * 1024 * 1024 ) { + @rename(self::$file, self::$file . '.1'); + } + + // Append atomically + file_put_contents(self::$file, $line, FILE_APPEND | LOCK_EX); + @chmod(self::$file, 0640); + } + + public static function path() { return self::$file; } + public static function exists() { return file_exists(self::$file); } +} +Logger::init(); + +// Usage anywhere in your plugin: +// \MyPlugin\Logger::log('info', 'Started job', ['id' => 123]); diff --git a/includes/content.php b/includes/content.php index a3403ed..74c5cb2 100644 --- a/includes/content.php +++ b/includes/content.php @@ -1,9 +1,31 @@ '100x100', + 'medium' => '500x500', + 'large' => '1000x1000', + 'post-thumbnail' => '640x360', + 'full' => '', // no param = original size +]); + +define('CABLECAST_SRCSET_VARIANTS', [ + '320x180' => 320, + '480x270' => 480, + '640x360' => 640, + '960x540' => 960, + '1280x720' => 1280, +]); + function cablecast_setup_post_types() { // register the "book" custom post type register_post_type( 'show', [ 'public' => true, + 'hierarchical' => false, + 'menu_icon' => 'dashicons-video-alt3', 'labels' => [ 'name' => __('Shows'), 'singular_name' => __('Show') @@ -18,6 +40,7 @@ function cablecast_setup_post_types() { register_post_type('cablecast_channel', array( 'public' => true, + 'menu_icon' => 'dashicons-networking', 'labels' => array( 'name' => __('Channels'), 'singular_name' => __('Channel') @@ -113,4 +136,289 @@ function cablecast_register_show_field_taxonomies($definitions) { )); } } -} \ No newline at end of file +} + +// Add taxonomy filters to the custom post type 'show' list in admin +function add_taxonomy_filters_to_shows() { + global $typenow; + + if ($typenow == 'show') { + // Add filters for custom taxonomies + $taxonomies = ['cablecast_project', 'cablecast_producer']; // Add any other taxonomies if needed + + foreach ($taxonomies as $taxonomy) { + $tax = get_taxonomy($taxonomy); + $terms = get_terms($taxonomy); + + if ($terms) { + echo ''; + } + } + } +} +add_action('restrict_manage_posts', 'add_taxonomy_filters_to_shows'); + +// Filter the query by selected taxonomy +function filter_shows_by_taxonomy($query) { + global $pagenow; + $post_type = 'show'; + + if ($pagenow == 'edit.php' && isset($_GET['post_type']) && $_GET['post_type'] == $post_type) { + $taxonomies = ['cablecast_project', 'cablecast_producer']; // Add more taxonomies if needed + + foreach ($taxonomies as $taxonomy) { + if (isset($_GET[$taxonomy]) && $_GET[$taxonomy] != '') { + $query->query_vars[$taxonomy] = $_GET[$taxonomy]; + } + } + } +} +add_filter('parse_query', 'filter_shows_by_taxonomy'); + + + +/** + * Cablecast: external CDN thumbnails for "show" CPT (no local attachments). + * - Front-end renders CDN image via normal thumbnail APIs. + * - Admin can override by setting a real Featured Image (we back off). + */ + +/** + * Build the external CDN URL for a show thumbnail. + * Adjust the mapping/URL to match your CDN. + */ +function cablecast_has_real_featured_image( $post_id ) { + // Safe here; we are NOT inside get_post_metadata for _thumbnail_id. + $thumb_id = (int) get_post_meta( $post_id, '_thumbnail_id', true ); + + if ( $thumb_id <= 0 ) { + return false; + } + + // Optional: ensure it’s a real attachment + $att = get_post( $thumb_id ); + return ( $att && $att->post_type === 'attachment' ); +} + + +function cablecast_show_thumbnail_url( $post_id, $size = 'post-thumbnail' ) { + $options = get_option('cablecast_options'); + $thumbnail_mode = isset($options['thumbnail_mode']) ? $options['thumbnail_mode'] : 'local'; + + // For local mode, check WordPress featured image first + if ( $thumbnail_mode === 'local' ) { + $featured_url = get_the_post_thumbnail_url( $post_id, $size ); + if ( $featured_url ) { + return apply_filters( 'cablecast_show_thumbnail_url', $featured_url, $post_id, $size ); + } + } + + // Check for saved thumbnail URL from API (remote mode or fallback) + $base_thumbnail_url = get_post_meta( $post_id, 'cablecast_thumbnail_url', true ); + + // No thumbnail available - return empty string instead of constructing + // a fallback URL that may not exist on the server + if ( ! $base_thumbnail_url ) { + return ''; + } + + // Support [width, height] arrays or use defined size mappings + if ( is_array( $size ) && isset( $size[0], $size[1] ) ) { + $dimensions = absint( $size[0] ) . 'x' . absint( $size[1] ); + } else { + $dimensions = CABLECAST_THUMBNAIL_SIZES[ $size ] ?? ''; + } + + $url = $base_thumbnail_url; + if ( $dimensions ) { + $url .= '?d=' . $dimensions; + } + + // Allow theme/site overrides. + return apply_filters( 'cablecast_show_thumbnail_url', $url, $post_id, $size ); +} + +/** + * Optional: responsive srcset using CDN variants. + * Tweak to match the sizes your CDN can produce efficiently. + */ +function cablecast_show_thumbnail_srcset( $post_id ) { + $base_thumbnail_url = get_post_meta( $post_id, 'cablecast_thumbnail_url', true ); + if ( ! $base_thumbnail_url ) { + return ''; + } + + $parts = []; + foreach ( CABLECAST_SRCSET_VARIANTS as $wh => $w ) { + $parts[] = esc_url( $base_thumbnail_url . "?d={$wh}" ) . " {$w}w"; + } + + return implode( ', ', $parts ); +} + +/** + * Helper to resolve the current show post ID from various contexts. + */ +function cablecast_current_show_post_id( $maybe_post_id = null ) { + if ( $maybe_post_id ) { + $ptype = get_post_type( $maybe_post_id ); + if ( $ptype === 'show' ) return (int) $maybe_post_id; + } + $global = get_post(); + if ( $global && get_post_type( $global ) === 'show' ) { + return (int) $global->ID; + } + // Support /?show= routing + if ( isset( $_GET['show'] ) ) { + $slug = sanitize_title_for_query( wp_unslash( $_GET['show'] ) ); + if ( $slug ) { + $obj = get_page_by_path( $slug, OBJECT, 'show' ); + if ( $obj ) return (int) $obj->ID; + } + } + return 0; +} + +// Only register CDN thumbnail filters when in remote hosting mode +$cablecast_thumbnail_options = get_option('cablecast_options'); +$cablecast_thumbnail_mode = isset($cablecast_thumbnail_options['thumbnail_mode']) ? $cablecast_thumbnail_options['thumbnail_mode'] : 'local'; + +if ($cablecast_thumbnail_mode === 'remote') : + +/** + * has_post_thumbnail(): true if there's a real Featured Image OR a show_id (CDN). + * (No recursion risk here; we don't touch _thumbnail_id.) + */ +add_filter( 'has_post_thumbnail', function ( $has, $post, $thumb_id ) { + // Divi (and others) may pass an int here. Normalize to a post ID. + $pid = ($post instanceof WP_Post) ? $post->ID : ( is_numeric($post) ? (int) $post : 0 ); + + // Resolve the intended Show post (handles ?show= too, if you use that helper) + $target_id = function_exists('cablecast_current_show_post_id') + ? cablecast_current_show_post_id( $pid ) + : $pid; + + if ( ! $target_id || get_post_type( $target_id ) !== 'show' ) { + return $has; // not a Show context + } + + $has_real = function_exists('cablecast_has_real_featured_image') + ? cablecast_has_real_featured_image( $target_id ) + : ( (int) get_post_meta( $target_id, '_thumbnail_id', true ) > 0 ); + + $has_cdn = (bool) get_post_meta( $target_id, 'cablecast_show_id', true ); + + return $has_real || $has_cdn; +}, 10, 3 ); + +/** + * Fake a _thumbnail_id on the front-end so get_the_post_thumbnail() runs. + * IMPORTANT: Never recurse. Respect real values. Only fake when needed. + */ +add_filter( 'get_post_metadata', function ( $value, $object_id, $meta_key, $single ) { + if ( '_thumbnail_id' !== $meta_key ) { + return $value; // only care about featured image key + } + + // If core already resolved a value (real Featured Image), respect it. + // $value is null when core didn't find anything. + if ( null !== $value ) { + return $value; + } + + // Front-end only; avoid confusing the editor UI. + if ( is_admin() ) { + return $value; + } + + // Only for our CPT. + if ( 'show' !== get_post_type( $object_id ) ) { + return $value; + } + + // Only fake if we actually have an external image source. + $show_id = get_post_meta( $object_id, 'cablecast_show_id', true ); // different key -> safe + if ( ! $show_id ) { + return $value; + } + + // Return a non-zero int so WP thinks there is a thumbnail. + return $single ? -1 : [ -1 ]; +}, 10, 4 ); + +add_filter( 'post_thumbnail_html', function ( $html, $post_id, $thumb_id, $size, $attr ) { + $target_id = cablecast_current_show_post_id( $post_id ); + + if ( cablecast_has_real_featured_image( $target_id ) ) { + \Cablecast\Logger::log('debug', "THUMB_HTML: valid real featured image ($thumb_id) exists, leaving html unchanged"); + return $html; + } + + + + $src = cablecast_show_thumbnail_url( $target_id, $size ); + + if ( ! $src ) { + \Cablecast\Logger::log('debug', "THUMB_HTML: no CDN url built, returning original html"); + return $html; + } + + \Cablecast\Logger::log('debug', "THUMB_HTML: replacing html with CDN img: {$src}"); + + $defaults = [ + 'alt' => get_the_title( $target_id ), + 'loading' => 'lazy', + 'class' => is_string( $size ) ? 'attachment-' . $size . ' size-' . $size : 'attachment-external', + 'srcset' => cablecast_show_thumbnail_srcset( $target_id ), + 'sizes' => '(max-width: 640px) 100vw, 640px', + ]; + $attr = wp_parse_args( $attr, $defaults ); + + $attr_str = ''; + foreach ( $attr as $k => $v ) { + if ( $v === '' || $v === null ) continue; + $attr_str .= ' ' . esc_attr( $k ) . '="' . esc_attr( $v ) . '"'; + } + + return ''; +}, 10, 5 ); + +add_filter( 'post_thumbnail_url', function ( $url, $post, $size ) { + $pid = $post instanceof WP_Post ? $post->ID : ( is_numeric( $post ) ? (int) $post : 0 ); + $target_id = cablecast_current_show_post_id( $pid ); + + if ( cablecast_has_real_featured_image( $target_id ) ) { + \Cablecast\Logger::log('debug', "THUMB_URL: valid real featured image exists, leaving url unchanged"); + return $url; + } + + \Cablecast\Logger::log('debug', "THUMB_URL: called with raw_post=" . (is_object($post)? "WP_Post({$post->ID})" : var_export($post, true)) . + ", resolved target_id={$target_id}, size=" . print_r($size, true) . + ", incoming url=" . var_export($url, true) ); + + if ( ! $target_id ) { + \Cablecast\Logger::log('debug', "THUMB_URL: no target_id, returning original url" ); + return $url; + } + + if ( metadata_exists( 'post', $target_id, '_thumbnail_id' ) && empty($url) == false ) { + \Cablecast\Logger::log('debug', "THUMB_URL: real featured image exists, returning original url: $url" ); + return $url; + } + + $custom = cablecast_show_thumbnail_url( $target_id, $size ); + + \Cablecast\Logger::log('debug', "THUMB_URL: built custom url=" . var_export($custom, true) ); + + return $custom ?: $url; +}, 10, 3 ); + +endif; // End remote thumbnail mode filters diff --git a/includes/cron.php b/includes/cron.php index 790c3fa..72f5436 100644 --- a/includes/cron.php +++ b/includes/cron.php @@ -3,7 +3,7 @@ function cablecast_cron_schedules( $schedules ) { // add a 'weekly' schedule to the existing set $schedules['cablecast-5-minutes'] = array( - 'interval' => 5 * 60, + 'interval' => 1 * 60, 'display' => __('Every 5 Minutes') ); return $schedules; @@ -11,6 +11,7 @@ function cablecast_cron_schedules( $schedules ) { add_filter( 'cron_schedules', 'cablecast_cron_schedules' ); add_action( 'cablecast_cron_hook', 'cablecast_sync_data' ); +add_action( 'cablecast_cron_hook', 'cablecast_cleanup_local_thumbnails' ); if ( ! wp_next_scheduled( 'cablecast_cron_hook' ) ) { wp_schedule_event( time(), 'cablecast-5-minutes', 'cablecast_cron_hook' ); diff --git a/includes/settings.php b/includes/settings.php index dd3268d..8873bd5 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -1,5 +1,112 @@ 'page', + 'post_status' => 'any', + 'meta_query' => [[ + 'key' => '_wp_page_template', + 'value' => 'cablecast-home', + ]], + 'posts_per_page' => 1, + ]); + + if (!empty($existing_page)) { + return; + } + + // Check if there are any shows synced (to avoid showing notice before first sync) + $shows = get_posts([ + 'post_type' => 'show', + 'posts_per_page' => 1, + 'fields' => 'ids', + ]); + + if (empty($shows)) { + return; + } + + $settings_url = admin_url('options-general.php?page=cablecast#cablecast_section_home_page'); + ?> +
    +

    + + + + + +

    +
    + + 'custom', ] ); + + // Thumbnail Settings Section + add_settings_section( + 'cablecast_section_thumbnails', + __('Thumbnail Settings', 'cablecast'), + 'cablecast_section_thumbnails_cb', + 'cablecast' + ); + + add_settings_field( + 'cablecast_field_thumbnail_mode', + __('Thumbnail Hosting', 'cablecast'), + 'cablecast_field_thumbnail_mode_cb', + 'cablecast', + 'cablecast_section_thumbnails' + ); + + add_settings_field( + 'cablecast_field_delete_local_thumbnails', + __('Cleanup Local Thumbnails', 'cablecast'), + 'cablecast_field_delete_local_thumbnails_cb', + 'cablecast', + 'cablecast_section_thumbnails' + ); + + add_settings_field( + 'cablecast_field_cdn_test', + __('CDN Connection Test', 'cablecast'), + 'cablecast_field_cdn_test_cb', + 'cablecast', + 'cablecast_section_thumbnails' + ); + + // Shortcode Settings Section + add_settings_section( + 'cablecast_section_shortcodes', + __('Shortcode Settings', 'cablecast'), + 'cablecast_section_shortcodes_cb', + 'cablecast' + ); + + add_settings_field( + 'cablecast_field_shortcode_styles', + __('Default Styling', 'cablecast'), + 'cablecast_field_shortcode_styles_cb', + 'cablecast', + 'cablecast_section_shortcodes' + ); + + add_settings_field( + 'cablecast_field_filler_keywords', + __('Filler Keywords', 'cablecast'), + 'cablecast_field_filler_keywords_cb', + 'cablecast', + 'cablecast_section_shortcodes' + ); + + add_settings_field( + 'cablecast_field_category_colors', + __('Category Colors', 'cablecast'), + 'cablecast_field_category_colors_cb', + 'cablecast', + 'cablecast_section_shortcodes' + ); + + // Home Page Settings Section + add_settings_section( + 'cablecast_section_home_page', + __('Home Page', 'cablecast'), + 'cablecast_section_home_page_cb', + 'cablecast' + ); + + add_settings_field( + 'cablecast_field_home_page_sections', + __('Home Page Sections', 'cablecast'), + 'cablecast_field_home_page_sections_cb', + 'cablecast', + 'cablecast_section_home_page' + ); + + add_settings_field( + 'cablecast_field_home_page_create', + __('Quick Setup', 'cablecast'), + 'cablecast_field_home_page_create_cb', + 'cablecast', + 'cablecast_section_home_page' + ); + + // Maintenance Section + add_settings_section( + 'cablecast_section_maintenance', + __('Maintenance', 'cablecast'), + 'cablecast_section_maintenance_cb', + 'cablecast' + ); + + add_settings_field( + 'cablecast_field_sync_status', + __('Sync Status', 'cablecast'), + 'cablecast_field_sync_status_cb', + 'cablecast', + 'cablecast_section_maintenance' + ); + + add_settings_field( + 'cablecast_field_reset_sync', + __('Reset Sync', 'cablecast'), + 'cablecast_field_reset_sync_cb', + 'cablecast', + 'cablecast_section_maintenance' + ); + + add_settings_field( + 'cablecast_field_clear_schedule', + __('Clear Schedule', 'cablecast'), + 'cablecast_field_clear_schedule_cb', + 'cablecast', + 'cablecast_section_maintenance' + ); + + // Danger Zone Section + add_settings_section( + 'cablecast_section_danger', + __('Danger Zone', 'cablecast'), + 'cablecast_section_danger_cb', + 'cablecast' + ); + + add_settings_field( + 'cablecast_field_clear_all_content', + __('Clear All Content', 'cablecast'), + 'cablecast_field_clear_all_content_cb', + 'cablecast', + 'cablecast_section_danger' + ); } /** @@ -70,6 +313,1076 @@ function cablecast_field_server_cb($args) +

    + +
    + +

    + +

    +
    + +

    + +

    +
    + 'show', + 'meta_query' => [['key' => '_thumbnail_id', 'compare' => 'EXISTS']], + 'posts_per_page' => -1, + 'fields' => 'ids' + ]); + $count = count($remaining); + ?> +
    +

    + + +

    +
    + + +

    + +

    + 'show', + 'meta_query' => [['key' => '_thumbnail_id', 'compare' => 'EXISTS']], + 'posts_per_page' => -1, + 'fields' => 'ids' + ]); + $count = count($existing); + + if ($count === 0) { + ?> +

    + +

    + + +

    + + +

    + +

    + +

    + +

    + +

    + + + +

    + +

    + + 'show', + 'posts_per_page' => 1, + 'meta_query' => [ + [ + 'key' => 'cablecast_thumbnail_url', + 'compare' => 'EXISTS', + ], + [ + 'key' => 'cablecast_thumbnail_url', + 'value' => '', + 'compare' => '!=', + ], + ], + ]); + + if (empty($shows)) { + // No shows with thumbnails, try testing a sample URL pattern + // Use GET with page_size=1 since some servers don't support HEAD requests + $test_url = rtrim($server, '/') . CABLECAST_API_BASE . '/shows?page_size=1'; + $response = wp_remote_get($test_url, ['timeout' => 10]); + + if (is_wp_error($response)) { + wp_send_json_error(sprintf(__('Cannot reach server: %s', 'cablecast'), $response->get_error_message())); + return; + } + + $code = wp_remote_retrieve_response_code($response); + if ($code >= 200 && $code < 400) { + wp_send_json_success(__('Server reachable. Sync shows to test thumbnail loading.', 'cablecast')); + } else { + wp_send_json_error(sprintf(__('Server returned status %d', 'cablecast'), $code)); + } + return; + } + + // Test loading the thumbnail URL + // Use GET since some servers don't support HEAD requests + $thumbnail_url = get_post_meta($shows[0]->ID, 'cablecast_thumbnail_url', true); + $response = wp_remote_get($thumbnail_url, ['timeout' => 10]); + + if (is_wp_error($response)) { + wp_send_json_error(sprintf(__('Thumbnail load failed: %s', 'cablecast'), $response->get_error_message())); + return; + } + + $code = wp_remote_retrieve_response_code($response); + if ($code >= 200 && $code < 400) { + wp_send_json_success(__('CDN thumbnail loading works correctly!', 'cablecast')); + } else { + wp_send_json_error(sprintf(__('Thumbnail returned status %d', 'cablecast'), $code)); + } +}); + +// Shortcode settings callbacks +function cablecast_section_shortcodes_cb($args) +{ + ?> +

    + +
    + +

    + +

    +
    + + +

    + +

    +

    + +

    + 'category', + 'hide_empty' => true, + 'object_ids' => get_posts([ + 'post_type' => 'show', + 'posts_per_page' => -1, + 'fields' => 'ids', + ]), + ]); + ?> +
    + +

    + +

    +
    + + +
    + + + + + + + + + slug]) ? $category_colors[$category->slug] : ''; + ?> + + + + + + +
    + name); ?> + (count); ?> ) + + + + +
    +

    + +

    +
    + +

    + +

    + + + + $cleared) { + if ($cleared === '1' && isset($value['category_colors'][$slug])) { + unset($value['category_colors'][$slug]); + } + } + unset($value['category_colors_cleared']); + } + + // Remove default/gray colors (user never set them) + if (isset($value['category_colors'])) { + $value['category_colors'] = array_filter($value['category_colors'], function($color) { + return $color && $color !== '#cccccc'; + }); + } + + return $value; +}, 10, 2); + +// Home Page settings callbacks +function cablecast_section_home_page_cb($args) +{ + ?> +

    [cablecast_home] on any page or assign the "Cablecast Home" page template.', 'cablecast'); ?>

    + +
    + + + +

    + +

    + + +

    + +

    + + +
    + 'page', + 'post_status' => 'any', + 'meta_query' => [[ + 'key' => '_wp_page_template', + 'value' => 'cablecast-home', + ]], + 'posts_per_page' => 1, + ]); + + if (!empty($existing_page)) { + $page = $existing_page[0]; + $edit_link = get_edit_post_link($page->ID); + $view_link = get_permalink($page->ID); + ?> +
    +

    + + %s', 'cablecast'), esc_html($page->post_title)); ?> + ( | + ) +

    +
    + + + +

    + Reading.', 'cablecast'); ?> +

    + + + +

    Now Playing

    + + + +[cablecast_now_playing show_up_next="true" show_thumbnail="true" show_description="true" exclude_filler="true"] + + + +

    This Week's Schedule

    + + + +[cablecast_weekly_guide days="7" show_channel_switcher="true" show_category_colors="true"] + + + +

    Recent Shows

    + + + +[cablecast_shows count="12" layout="featured" columns="4" orderby="date" order="DESC"] + + + +

    Browse by Series

    + + + +[cablecast_series count="6" layout="grid" show_thumbnails="true"] + + + +

    Categories

    + + + +[cablecast_categories layout="cloud" show_colors="true" show_counts="true"] + + + +

    Producers

    + + + +[cablecast_producers count="10" orderby="count" layout="list"] + +CONTENT; + + $page_id = wp_insert_post([ + 'post_type' => 'page', + 'post_title' => __('Cablecast Home', 'cablecast'), + 'post_content' => $page_content, + 'post_status' => 'publish', + ]); + + if (is_wp_error($page_id)) { + wp_send_json_error($page_id->get_error_message()); + } + + // Set the page template + update_post_meta($page_id, '_wp_page_template', 'cablecast-home'); + + wp_send_json_success([ + 'page_id' => $page_id, + 'edit_url' => get_edit_post_link($page_id, 'raw'), + ]); +}); + +// Maintenance section callbacks +function cablecast_section_maintenance_cb($args) +{ + ?> +

    + +
    +

    +
    + + 0): ?> + + + + +

    +
    + +
    + + + + +
    +

    + +

    + + prefix . 'cablecast_schedule_items'; + $count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name"); + ?> +
    + + +
    +

    + +

    + + prefix . 'cablecast_schedule_items'; + + // Use TRUNCATE for efficiency (resets auto-increment too) + $result = $wpdb->query("TRUNCATE TABLE $table_name"); + + if ($result !== false) { + wp_send_json_success(__('Schedule cleared. Next sync will rebuild schedule data.', 'cablecast')); + } else { + wp_send_json_error(__('Failed to clear schedule', 'cablecast')); + } +}); + +// Danger Zone section callback +function cablecast_section_danger_cb($args) +{ + ?> +
    +

    + + +

    +
    + publish ?? 0) + ($show_counts->draft ?? 0) + ($show_counts->private ?? 0); + + $channel_counts = wp_count_posts('cablecast_channel'); + $channel_count = ($channel_counts->publish ?? 0) + ($channel_counts->draft ?? 0); + + // Projects and Producers are taxonomies, not post types + $project_count = wp_count_terms(['taxonomy' => 'cablecast_project', 'hide_empty' => false]); + if (is_wp_error($project_count)) { + $project_count = 0; + } + + $producer_count = wp_count_terms(['taxonomy' => 'cablecast_producer', 'hide_empty' => false]); + if (is_wp_error($producer_count)) { + $producer_count = 0; + } + + // Count custom field taxonomy terms + $custom_tax_count = 0; + $custom_taxonomies = cablecast_get_custom_taxonomies(); + foreach ($custom_taxonomies as $tax_name) { + $count = wp_count_terms(['taxonomy' => $tax_name, 'hide_empty' => false]); + if (!is_wp_error($count)) { + $custom_tax_count += $count; + } + } + + global $wpdb; + $table_name = $wpdb->prefix . 'cablecast_schedule_items'; + $schedule_count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name"); + + $total_count = $show_count + $channel_count + $project_count + $producer_count + $custom_tax_count + $schedule_count; + ?> +
    +

    + +

    +
      +
    • +
    • +
    • +
    • + 0) : ?> +
    • + +
    • +
    +

    + +

    + + 0) : ?> +
    + + +
    + +

    + +
    + + + 0, 'channels' => 0, 'series' => 0, 'producers' => 0, 'custom_terms' => 0, 'schedule' => 0]; + + // Delete shows (with thumbnails) + $shows = get_posts([ + 'post_type' => 'show', + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ]); + foreach ($shows as $show_id) { + // Delete thumbnail attachment if exists + $thumbnail_id = get_post_thumbnail_id($show_id); + if ($thumbnail_id) { + wp_delete_attachment($thumbnail_id, true); + } + wp_delete_post($show_id, true); + $deleted['shows']++; + } + + // Delete channels (correct post type is cablecast_channel) + $channels = get_posts([ + 'post_type' => 'cablecast_channel', + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ]); + foreach ($channels as $channel_id) { + wp_delete_post($channel_id, true); + $deleted['channels']++; + } + + // Delete series/project taxonomy terms + $series_terms = get_terms([ + 'taxonomy' => 'cablecast_project', + 'hide_empty' => false, + 'fields' => 'ids', + ]); + if (!is_wp_error($series_terms)) { + foreach ($series_terms as $term_id) { + wp_delete_term($term_id, 'cablecast_project'); + $deleted['series']++; + } + } + + // Delete producer taxonomy terms + $producer_terms = get_terms([ + 'taxonomy' => 'cablecast_producer', + 'hide_empty' => false, + 'fields' => 'ids', + ]); + if (!is_wp_error($producer_terms)) { + foreach ($producer_terms as $term_id) { + wp_delete_term($term_id, 'cablecast_producer'); + $deleted['producers']++; + } + } + + // Delete custom field taxonomy terms (cbl-tax-*) + $custom_taxonomies = cablecast_get_custom_taxonomies(); + foreach ($custom_taxonomies as $tax_name) { + $custom_terms = get_terms([ + 'taxonomy' => $tax_name, + 'hide_empty' => false, + 'fields' => 'ids', + ]); + if (!is_wp_error($custom_terms)) { + foreach ($custom_terms as $term_id) { + wp_delete_term($term_id, $tax_name); + $deleted['custom_terms']++; + } + } + } + + // Clear schedule table + $table_name = $wpdb->prefix . 'cablecast_schedule_items'; + $deleted['schedule'] = $wpdb->get_var("SELECT COUNT(*) FROM $table_name"); + $wpdb->query("TRUNCATE TABLE $table_name"); + + // Reset sync state + update_option('cablecast_sync_since', date('Y-m-d\TH:i:s', strtotime('-1 year'))); + update_option('cablecast_sync_index', 0); + update_option('cablecast_sync_total_result_count', 0); + + $message = sprintf( + __('Deleted %d shows, %d channels, %d series, %d producers, %d custom terms, %d schedule items. Sync state reset.', 'cablecast'), + $deleted['shows'], + $deleted['channels'], + $deleted['series'], + $deleted['producers'], + $deleted['custom_terms'], + $deleted['schedule'] + ); + + wp_send_json_success($message); +}); + /** * top level menu */ @@ -83,8 +1396,56 @@ function cablecast_options_page() 'cablecast', 'cablecast_options_page_html' ); + + // add shortcode documentation submenu + add_submenu_page( + 'cablecast', + __('Shortcode Documentation', 'cablecast'), + __('Shortcode Docs', 'cablecast'), + 'manage_options', + 'cablecast-shortcode-docs', + 'cablecast_shortcode_docs_page' + ); + + add_management_page( + 'Cablecast Logs', + 'Cablecast Logs', + 'manage_options', + 'cablecast-logs', + function () { + if ( ! current_user_can('manage_options') ) return; + $url = wp_nonce_url(admin_url('admin-post.php?action=cablecast_download_log'), 'cablecast_download_log'); + echo '

    Cablecast Logs

    '; + if (\Cablecast\Logger::exists()) { + echo '

    Download current log

    '; + } else { + echo '

    No log file yet.

    '; + } + echo '
    '; + } + ); } +add_action('admin_post_cablecast_download_log', function () { + if ( ! current_user_can('manage_options') ) wp_die('Unauthorized', 403); + check_admin_referer('cablecast_download_log'); + + $path = \Cablecast\Logger::path(); + if ( ! file_exists($path) ) wp_die('No log file found.'); + + // Nice filename with date + $download = 'cablecast-' . wp_date('Ymd-His') . '.log'; + + // Clean output buffers to avoid corrupting download + while (ob_get_level()) { ob_end_clean(); } + + header('Content-Type: text/plain'); + header('Content-Disposition: attachment; filename="' . $download . '"'); + header('Content-Length: ' . filesize($path)); + readfile($path); + exit; +}); + /** * register our cablecast_options_page to the admin_menu action hook */ @@ -121,23 +1482,8 @@ function cablecast_options_page_html() } ?> - -

    -
    -

    There are remaining shows out of shows updated after

    -
    [ + 'name' => 'Schedule', + 'tag' => 'cablecast_schedule', + 'description' => 'Display the schedule for a specific channel.', + 'long_description' => 'Shows a chronological list of programs for a channel, with options to filter by date, show only remaining or upcoming programs, and control thumbnail and description display. Programs are grouped by day with smart labels like "Today" and "Tomorrow".', + 'attributes' => [ + ['name' => 'channel', 'required' => true, 'default' => '', 'options' => 'Channel post ID', 'description' => 'WordPress post ID of the channel to display'], + ['name' => 'date', 'required' => false, 'default' => 'Today', 'options' => 'Y-m-d format', 'description' => 'Specific date to show schedule for (e.g., 2024-01-15)'], + ['name' => 'mode', 'required' => false, 'default' => 'all', 'options' => 'all, remaining, next', 'description' => 'Filter which programs to display'], + ['name' => 'count', 'required' => false, 'default' => '20', 'options' => 'Any number', 'description' => 'Maximum number of programs to show'], + ['name' => 'show_descriptions', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show program descriptions'], + ['name' => 'exclude_filler', 'required' => false, 'default' => 'false', 'options' => 'true, false', 'description' => 'Hide filler content (color bars, station IDs, etc.)'], + ['name' => 'show_thumbnails', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Display program thumbnails'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic Usage', 'atts' => ['channel' => '{channel_id}', 'count' => '5']], + ['title' => 'Upcoming Only', 'atts' => ['channel' => '{channel_id}', 'mode' => 'next', 'count' => '10']], + ['title' => 'No Filler', 'atts' => ['channel' => '{channel_id}', 'exclude_filler' => 'true']], + ], + ], + 'cablecast_now_playing' => [ + 'name' => 'Now Playing', + 'tag' => 'cablecast_now_playing', + 'description' => 'Display the currently airing and upcoming program.', + 'long_description' => 'Shows cards for the current program ("Live Now") and optionally the next program ("Up Next"). Includes a progress bar for the current show and automatically updates based on program runtime.', + 'attributes' => [ + ['name' => 'channel', 'required' => true, 'default' => '', 'options' => 'Channel post ID', 'description' => 'WordPress post ID of the channel'], + ['name' => 'show_up_next', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the next program card'], + ['name' => 'show_thumbnail', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Display program thumbnails'], + ['name' => 'show_description', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show program descriptions'], + ['name' => 'exclude_filler', 'required' => false, 'default' => 'false', 'options' => 'true, false', 'description' => 'Skip filler content when finding current/next'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic Usage', 'atts' => ['channel' => '{channel_id}']], + ['title' => 'Current Only', 'atts' => ['channel' => '{channel_id}', 'show_up_next' => 'false']], + ['title' => 'Minimal', 'atts' => ['channel' => '{channel_id}', 'show_thumbnail' => 'false', 'show_description' => 'false']], + ], + ], + 'cablecast_weekly_guide' => [ + 'name' => 'Weekly Guide', + 'tag' => 'cablecast_weekly_guide', + 'description' => 'Display a multi-day schedule grid.', + 'long_description' => 'Shows a responsive grid with one column per day, displaying all programs for each day. Includes a channel switcher dropdown and highlights the current program. Uses a single database query for optimal performance.', + 'attributes' => [ + ['name' => 'channel', 'required' => false, 'default' => 'First channel', 'options' => 'Channel post ID', 'description' => 'WordPress post ID of the channel (auto-selects first if not specified)'], + ['name' => 'days', 'required' => false, 'default' => '7', 'options' => '1-14', 'description' => 'Number of days to display'], + ['name' => 'show_channel_switcher', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the channel dropdown selector'], + ['name' => 'show_category_colors', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Apply category colors to programs'], + ['name' => 'show_descriptions', 'required' => false, 'default' => 'false', 'options' => 'true, false', 'description' => 'Show program descriptions (can make grid very tall)'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic (7 days)', 'atts' => []], + ['title' => '3-Day Guide', 'atts' => ['days' => '3']], + ['title' => 'Specific Channel', 'atts' => ['channel' => '{channel_id}', 'show_channel_switcher' => 'false']], + ], + ], + 'cablecast_shows' => [ + 'name' => 'Shows', + 'tag' => 'cablecast_shows', + 'description' => 'Display a grid or list of shows.', + 'long_description' => 'Shows a filterable collection of shows with thumbnails. Can filter by category, producer, or series. Supports grid, list, and featured layouts with configurable columns and pagination.', + 'attributes' => [ + ['name' => 'category', 'required' => false, 'default' => '', 'options' => 'Slug or ID', 'description' => 'Filter by category slug or term ID'], + ['name' => 'producer', 'required' => false, 'default' => '', 'options' => 'Slug or ID', 'description' => 'Filter by producer slug or term ID'], + ['name' => 'series', 'required' => false, 'default' => '', 'options' => 'Slug or ID', 'description' => 'Filter by series/project slug or term ID'], + ['name' => 'count', 'required' => false, 'default' => '12', 'options' => 'Any number', 'description' => 'Number of shows to display'], + ['name' => 'orderby', 'required' => false, 'default' => 'date', 'options' => 'date, title, event_date', 'description' => 'How to sort the shows'], + ['name' => 'order', 'required' => false, 'default' => 'DESC', 'options' => 'ASC, DESC', 'description' => 'Sort direction'], + ['name' => 'layout', 'required' => false, 'default' => 'grid', 'options' => 'grid, list, featured', 'description' => 'Display layout style. Featured makes first show larger.'], + ['name' => 'columns', 'required' => false, 'default' => '4', 'options' => '2-6', 'description' => 'Number of columns (grid/featured layout)'], + ['name' => 'show_pagination', 'required' => false, 'default' => 'false', 'options' => 'true, false', 'description' => 'Show pagination links'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Recent Shows', 'atts' => ['count' => '8', 'columns' => '4']], + ['title' => 'Featured Layout', 'atts' => ['layout' => 'featured', 'count' => '9']], + ['title' => 'By Producer', 'atts' => ['producer' => '{producer_slug}', 'count' => '6']], + ['title' => 'List Layout', 'atts' => ['layout' => 'list', 'count' => '10']], + ], + ], + 'cablecast_show' => [ + 'name' => 'Single Show', + 'tag' => 'cablecast_show', + 'description' => 'Display a single show with full details.', + 'long_description' => 'Shows complete information for a specific show including VOD player (if available), thumbnail, producer, runtime, category, and full description.', + 'attributes' => [ + ['name' => 'id', 'required' => true, 'default' => '', 'options' => 'Show post ID', 'description' => 'WordPress post ID of the show'], + ['name' => 'show_vod', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Display the VOD video player'], + ['name' => 'show_thumbnail', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Display thumbnail (only if no VOD)'], + ['name' => 'show_meta', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show producer, runtime, and category'], + ['name' => 'show_description', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the full description'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Full Display', 'atts' => ['id' => '{show_id}']], + ['title' => 'No Video', 'atts' => ['id' => '{show_id}', 'show_vod' => 'false']], + ['title' => 'Minimal', 'atts' => ['id' => '{show_id}', 'show_meta' => 'false', 'show_description' => 'false']], + ], + ], + 'cablecast_vod_player' => [ + 'name' => 'VOD Player', + 'tag' => 'cablecast_vod_player', + 'description' => 'Display just the video player for a show.', + 'long_description' => 'A minimal shortcode that displays only the VOD embed code for a show. Use this when you want to embed the video player without any surrounding content.', + 'attributes' => [ + ['name' => 'id', 'required' => true, 'default' => '', 'options' => 'Show post ID', 'description' => 'WordPress post ID of the show'], + ['name' => 'poster', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show poster image (reserved for future use)'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic Usage', 'atts' => ['id' => '{show_id}']], + ], + ], + 'cablecast_chapters' => [ + 'name' => 'Chapters', + 'tag' => 'cablecast_chapters', + 'description' => 'Display interactive chapters for a show\'s VOD.', + 'long_description' => 'Shows a clickable list of chapters for the show\'s VOD. Clicking a chapter seeks the embedded player to that timestamp using postMessage. The current chapter is automatically highlighted as the video plays. Requires a VOD embed on the same page.', + 'attributes' => [ + ['name' => 'id', 'required' => false, 'default' => 'Current post', 'options' => 'Show post ID', 'description' => 'WordPress post ID of the show (auto-detects in show context)'], + ['name' => 'player', 'required' => false, 'default' => 'Auto-detect', 'options' => 'CSS selector', 'description' => 'CSS selector for the player container (for pages with multiple players)'], + ['name' => 'show_descriptions', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show chapter descriptions'], + ['name' => 'show_timestamps', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show formatted timestamps'], + ['name' => 'layout', 'required' => false, 'default' => 'list', 'options' => 'list, compact', 'description' => 'Display layout style'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic Usage', 'atts' => ['id' => '{show_with_chapters_id}']], + ['title' => 'Compact (no descriptions)', 'atts' => ['id' => '{show_with_chapters_id}', 'layout' => 'compact']], + ['title' => 'Timestamps Only', 'atts' => ['id' => '{show_with_chapters_id}', 'show_descriptions' => 'false']], + ], + ], + 'cablecast_producers' => [ + 'name' => 'Producers', + 'tag' => 'cablecast_producers', + 'description' => 'Display a directory of producers.', + 'long_description' => 'Shows a list or grid of all producers with show counts. Can optionally display contact information. Links to producer archive pages.', + 'attributes' => [ + ['name' => 'count', 'required' => false, 'default' => '0 (all)', 'options' => 'Any number', 'description' => 'Number of producers to display (0 = all)'], + ['name' => 'orderby', 'required' => false, 'default' => 'name', 'options' => 'name, count', 'description' => 'Sort by name or show count'], + ['name' => 'layout', 'required' => false, 'default' => 'list', 'options' => 'grid, list', 'description' => 'Display layout style'], + ['name' => 'show_contact', 'required' => false, 'default' => 'false', 'options' => 'true, false', 'description' => 'Show producer email and website'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'All Producers', 'atts' => []], + ['title' => 'Top 10 by Shows', 'atts' => ['count' => '10', 'orderby' => 'count']], + ['title' => 'With Contact Info', 'atts' => ['show_contact' => 'true']], + ], + ], + 'cablecast_series' => [ + 'name' => 'Series', + 'tag' => 'cablecast_series', + 'description' => 'Display a directory of series/projects.', + 'long_description' => 'Shows a list or grid of all series with episode counts and thumbnails. Thumbnails are pulled from the first show in each series. Links to series archive pages.', + 'attributes' => [ + ['name' => 'count', 'required' => false, 'default' => '0 (all)', 'options' => 'Any number', 'description' => 'Number of series to display (0 = all)'], + ['name' => 'orderby', 'required' => false, 'default' => 'name', 'options' => 'name, count', 'description' => 'Sort by name or episode count'], + ['name' => 'layout', 'required' => false, 'default' => 'grid', 'options' => 'grid, list', 'description' => 'Display layout style'], + ['name' => 'show_thumbnails', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Display series thumbnails'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'All Series', 'atts' => []], + ['title' => 'Top 6 by Episodes', 'atts' => ['count' => '6', 'orderby' => 'count']], + ['title' => 'List without Thumbnails', 'atts' => ['layout' => 'list', 'show_thumbnails' => 'false']], + ], + ], + 'cablecast_schedule_calendar' => [ + 'name' => 'Schedule Calendar', + 'tag' => 'cablecast_schedule_calendar', + 'description' => 'Interactive calendar view powered by FullCalendar.', + 'long_description' => 'Displays channel schedule in an interactive calendar using FullCalendar.io. Users can switch between week, day, month, and list views. Events are color-coded by category and clicking navigates to show pages. Includes navigation buttons, current time indicator, and responsive design.', + 'attributes' => [ + ['name' => 'channel', 'required' => true, 'default' => '', 'options' => 'Channel post ID', 'description' => 'WordPress post ID of the channel to display'], + ['name' => 'view', 'required' => false, 'default' => 'timeGridWeek', 'options' => 'timeGridWeek, timeGridDay, dayGridMonth, listWeek', 'description' => 'Initial calendar view'], + ['name' => 'height', 'required' => false, 'default' => 'auto', 'options' => 'auto, number', 'description' => 'Calendar height (auto or pixels)'], + ['name' => 'header', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show view switching buttons in toolbar'], + ['name' => 'nav', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show prev/next/today navigation buttons'], + ['name' => 'show_category_colors', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Color events by show category'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic (Week View)', 'atts' => ['channel' => '{channel_id}']], + ['title' => 'Month View', 'atts' => ['channel' => '{channel_id}', 'view' => 'dayGridMonth']], + ['title' => 'List View', 'atts' => ['channel' => '{channel_id}', 'view' => 'listWeek', 'header' => 'false']], + ], + ], + 'cablecast_upcoming_runs' => [ + 'name' => 'Upcoming Runs', + 'tag' => 'cablecast_upcoming_runs', + 'description' => 'Display upcoming airings for a show across all channels.', + 'long_description' => 'Shows a list of upcoming scheduled airings for a specific show, displaying the date, time, and channel for each airing. Useful on show detail pages to let viewers know when they can watch. Automatically detects the current show when used on a show page.', + 'attributes' => [ + ['name' => 'id', 'required' => false, 'default' => 'Current post', 'options' => 'Show post ID', 'description' => 'WordPress post ID of the show (auto-detects in show context)'], + ['name' => 'count', 'required' => false, 'default' => '5', 'options' => 'Any number', 'description' => 'Number of upcoming runs to display'], + ['name' => 'show_channel', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the channel name with link'], + ['name' => 'show_date', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the date and time'], + ['name' => 'days_ahead', 'required' => false, 'default' => '14', 'options' => 'Any number', 'description' => 'How many days ahead to look for scheduled runs'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Basic Usage', 'atts' => ['id' => '{show_id}']], + ['title' => 'More Results', 'atts' => ['id' => '{show_id}', 'count' => '10', 'days_ahead' => '30']], + ['title' => 'Channel Only', 'atts' => ['id' => '{show_id}', 'show_date' => 'false']], + ], + ], + 'cablecast_categories' => [ + 'name' => 'Categories', + 'tag' => 'cablecast_categories', + 'description' => 'Display categories that have shows.', + 'long_description' => 'Shows a cloud, grid, or list of categories that have shows assigned to them. Each category displays its name, show count, and links to the category archive. Categories can be color-coded based on settings.', + 'attributes' => [ + ['name' => 'layout', 'required' => false, 'default' => 'cloud', 'options' => 'cloud, grid, list', 'description' => 'Display layout style'], + ['name' => 'show_colors', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Apply category colors from settings'], + ['name' => 'show_counts', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show number of shows in each category'], + ['name' => 'count', 'required' => false, 'default' => '0', 'options' => '0 for all', 'description' => 'Maximum number of categories to show'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Tag Cloud', 'atts' => ['layout' => 'cloud']], + ['title' => 'Grid View', 'atts' => ['layout' => 'grid', 'count' => '8']], + ['title' => 'List View', 'atts' => ['layout' => 'list', 'show_colors' => 'false']], + ], + ], + 'cablecast_home' => [ + 'name' => 'Home Page', + 'tag' => 'cablecast_home', + 'description' => 'Display a complete home page with all sections.', + 'long_description' => 'Creates a full home page layout with Now Playing hero, weekly schedule grid, recent shows gallery, and browse section. Perfect for creating a TV station home page out-of-the-box. Settings can be customized in the Cablecast settings page.', + 'attributes' => [ + ['name' => 'show_now_playing', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the Now Playing hero section'], + ['name' => 'show_schedule', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the weekly schedule grid'], + ['name' => 'schedule_days', 'required' => false, 'default' => '7', 'options' => '1-14', 'description' => 'Number of days in schedule'], + ['name' => 'show_recent', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the recent shows gallery'], + ['name' => 'recent_count', 'required' => false, 'default' => '12', 'options' => 'Any number', 'description' => 'Number of recent shows to display'], + ['name' => 'show_browse', 'required' => false, 'default' => 'true', 'options' => 'true, false', 'description' => 'Show the browse section (series, categories, producers)'], + ['name' => 'class', 'required' => false, 'default' => '', 'options' => 'CSS class name', 'description' => 'Additional CSS class for custom styling'], + ], + 'examples' => [ + ['title' => 'Full Home Page', 'atts' => []], + ['title' => 'Schedule Only', 'atts' => ['show_now_playing' => 'false', 'show_recent' => 'false', 'show_browse' => 'false']], + ['title' => 'Minimal', 'atts' => ['show_schedule' => 'false', 'show_browse' => 'false', 'recent_count' => '8']], + ], + ], + ]; +} + +/** + * Get dynamic IDs for live examples based on actual site data. + * + * @return array + */ +function cablecast_get_example_ids() { + $ids = [ + 'channel_id' => null, + 'show_id' => null, + 'show_with_chapters_id' => null, + 'producer_slug' => null, + 'series_slug' => null, + ]; + + // Get first available channel + $channels = get_posts([ + 'post_type' => 'cablecast_channel', + 'posts_per_page' => 1, + 'orderby' => 'title', + 'order' => 'ASC', + ]); + if (!empty($channels)) { + $ids['channel_id'] = $channels[0]->ID; + } + + // Get a show with a thumbnail + $shows = get_posts([ + 'post_type' => 'show', + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + ]); + if (!empty($shows)) { + $ids['show_id'] = $shows[0]->ID; + } + + // Get a show with VOD chapters + $shows_with_chapters = get_posts([ + 'post_type' => 'show', + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => [ + [ + 'key' => 'cablecast_vod_chapters', + 'compare' => 'EXISTS', + ], + [ + 'key' => 'cablecast_vod_chapters', + 'value' => '', + 'compare' => '!=', + ], + ], + ]); + if (!empty($shows_with_chapters)) { + $ids['show_with_chapters_id'] = $shows_with_chapters[0]->ID; + } + + // Get producer with most shows + $producers = get_terms([ + 'taxonomy' => 'cablecast_producer', + 'hide_empty' => true, + 'number' => 1, + 'orderby' => 'count', + 'order' => 'DESC', + ]); + if (!empty($producers) && !is_wp_error($producers)) { + $ids['producer_slug'] = $producers[0]->slug; + } + + // Get series with most episodes + $series = get_terms([ + 'taxonomy' => 'cablecast_project', + 'hide_empty' => true, + 'number' => 1, + 'orderby' => 'count', + 'order' => 'DESC', + ]); + if (!empty($series) && !is_wp_error($series)) { + $ids['series_slug'] = $series[0]->slug; + } + + return $ids; +} + +/** + * Generate a shortcode string from tag and attributes. + * + * @param string $tag Shortcode tag + * @param array $atts Attributes + * @param array $example_ids Dynamic IDs to substitute + * @return string + */ +function cablecast_generate_shortcode_string($tag, $atts, $example_ids = []) { + $atts_string = ''; + foreach ($atts as $key => $value) { + // Substitute placeholders with actual IDs + if ($value === '{channel_id}' && !empty($example_ids['channel_id'])) { + $value = $example_ids['channel_id']; + } elseif ($value === '{show_id}' && !empty($example_ids['show_id'])) { + $value = $example_ids['show_id']; + } elseif ($value === '{show_with_chapters_id}' && !empty($example_ids['show_with_chapters_id'])) { + $value = $example_ids['show_with_chapters_id']; + } elseif ($value === '{producer_slug}' && !empty($example_ids['producer_slug'])) { + $value = $example_ids['producer_slug']; + } elseif ($value === '{series_slug}' && !empty($example_ids['series_slug'])) { + $value = $example_ids['series_slug']; + } + + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + + return '[' . $tag . $atts_string . ']'; +} + +/** + * Render a live example with injected CSS. + * + * @param string $shortcode_string Full shortcode string + * @return string HTML output + */ +function cablecast_render_live_example($shortcode_string) { + ob_start(); + + // Inject shortcode CSS + $css_file = plugin_dir_path(__FILE__) . '../assets/css/shortcodes.css'; + if (file_exists($css_file)) { + echo ''; + } + + // Render the shortcode + echo '
    '; + echo do_shortcode($shortcode_string); + echo '
    '; + + return ob_get_clean(); +} + +/** + * Render the attributes table for a shortcode. + * + * @param array $attributes + */ +function cablecast_render_attributes_table($attributes) { + ?> + + + + + + + + + + + + + + + + + + + +
    + + + + +
    + +
    +

    + + +
    +

    +

    +
    + + + + + + + + + + + + $shortcode): ?> + + + + + + + +
    + + [] + + + +
    +
    + +
    +
    +

    +
    + +

    + + +

    + +
    +

    + +
    +
    + +
    +
    + + +

    +
    + +

    + +

    +
    +
    + +
    + +

    + +

    + +
    +
    + +
    +

    + + + +
    + +
    +
    + + + slug])) { + return $category_colors[$term->slug]; + } + } + + return null; +} + +/** + * Check if a show title indicates filler content. + * + * @param string $title Show title + * @return bool + */ +function cablecast_is_filler($title) { + $options = get_option('cablecast_options'); + + // Get filler keywords from settings or use defaults + if (!empty($options['filler_keywords'])) { + $keywords = array_map('trim', explode(',', $options['filler_keywords'])); + } else { + $keywords = CABLECAST_DEFAULT_FILLER_KEYWORDS; + } + + $title_lower = strtolower($title); + + foreach ($keywords as $keyword) { + $keyword = strtolower(trim($keyword)); + if (empty($keyword)) continue; + + if ($title_lower === $keyword || strpos($title_lower, $keyword) !== false) { + return true; + } + } + + return false; +} + +/** + * Get channel post ID from Cablecast channel ID. + * + * @param int $cablecast_channel_id Cablecast channel ID + * @return int|null WordPress post ID or null + */ +function cablecast_get_channel_post_id($cablecast_channel_id) { + global $wpdb; + + $post_id = $wpdb->get_var($wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} + WHERE meta_key = 'cablecast_channel_id' AND meta_value = %s + LIMIT 1", + $cablecast_channel_id + )); + + return $post_id ? (int) $post_id : null; +} + +/** + * Get all channels. + * + * @return array Array of channel posts + */ +function cablecast_get_all_channels() { + return get_posts([ + 'post_type' => 'cablecast_channel', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC' + ]); +} + +/** + * Format a runtime (in seconds) to human-readable format. + * + * @param int $seconds Runtime in seconds + * @return string Formatted runtime (e.g., "1h 30m" or "45m") + */ +function cablecast_format_runtime($seconds) { + if (!$seconds || $seconds <= 0) { + return ''; + } + + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + + if ($hours > 0) { + return $hours . 'h ' . $minutes . 'm'; + } + + return $minutes . 'm'; +} + +/** + * Get the show post from a schedule item. + * + * @param object $schedule_item Schedule item from database + * @return WP_Post|null + */ +function cablecast_get_show_from_schedule($schedule_item) { + if (!empty($schedule_item->show_post_id)) { + return get_post($schedule_item->show_post_id); + } + return null; +} + +// ============================================================================ +// SCHEDULE SHORTCODE +// ============================================================================ + +/** + * [cablecast_schedule] - Display schedule for a channel. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_schedule_shortcode($atts) { + cablecast_mark_shortcode_used('schedule'); + + $atts = shortcode_atts([ + 'channel' => '', + 'date' => '', + 'mode' => 'all', // all, remaining, next + 'count' => 20, + 'show_descriptions' => 'true', + 'exclude_filler' => 'false', + 'show_thumbnails' => 'true', + 'class' => '', + ], $atts, 'cablecast_schedule'); + + // Validate channel + $channel_id = absint($atts['channel']); + if (!$channel_id) { + return '

    ' . __('Please specify a channel ID.', 'cablecast') . '

    '; + } + + // Get Cablecast channel ID from post meta + $cablecast_channel_id = get_post_meta($channel_id, 'cablecast_channel_id', true); + if (!$cablecast_channel_id) { + return '

    ' . __('Invalid channel.', 'cablecast') . '

    '; + } + + // Parse options + $show_descriptions = filter_var($atts['show_descriptions'], FILTER_VALIDATE_BOOLEAN); + $exclude_filler = filter_var($atts['exclude_filler'], FILTER_VALIDATE_BOOLEAN); + $show_thumbnails = filter_var($atts['show_thumbnails'], FILTER_VALIDATE_BOOLEAN); + $count = absint($atts['count']) ?: 20; + $mode = in_array($atts['mode'], ['all', 'remaining', 'next']) ? $atts['mode'] : 'all'; + + // Determine date range + $timezone = get_option('timezone_string') ?: 'America/New_York'; + $now = new DateTime('now', new DateTimeZone($timezone)); + + if (!empty($atts['date'])) { + $date_start = $atts['date']; + $date_end = date('Y-m-d', strtotime($date_start . ' +1 day')); + } else { + $date_start = $now->format('Y-m-d'); + // For "next" mode, get 2 days to ensure we find upcoming shows + $date_end = $mode === 'next' ? date('Y-m-d', strtotime($date_start . ' +2 days')) : date('Y-m-d', strtotime($date_start . ' +1 day')); + } + + // Fetch schedule items + $items = cablecast_get_schedules($cablecast_channel_id, $date_start, $date_end); + + if (empty($items)) { + return '

    ' . __('No schedule data available.', 'cablecast') . '

    '; + } + + // Filter based on mode + $now_timestamp = $now->getTimestamp(); + $filtered_items = []; + + foreach ($items as $item) { + // Exclude filler if requested + if ($exclude_filler && cablecast_is_filler($item->show_title)) { + continue; + } + + $item_time = strtotime($item->run_date_time); + + switch ($mode) { + case 'remaining': + // Only shows that haven't ended yet (assuming 30min default) + if ($item_time + 1800 > $now_timestamp) { + $filtered_items[] = $item; + } + break; + case 'next': + // Only future shows + if ($item_time > $now_timestamp) { + $filtered_items[] = $item; + } + break; + default: + $filtered_items[] = $item; + } + + if (count($filtered_items) >= $count) { + break; + } + } + + if (empty($filtered_items)) { + return '

    ' . __('No upcoming programs.', 'cablecast') . '

    '; + } + + // Build output + $classes = ['cablecast-schedule']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + $current_date = ''; + foreach ($filtered_items as $item) { + $item_date = date('Y-m-d', strtotime($item->run_date_time)); + $item_time = date('g:i A', strtotime($item->run_date_time)); + + // Add day divider if date changes + if ($item_date !== $current_date) { + if ($current_date !== '') { + $output .= '
    '; // Close previous day group + } + $day_label = date('l, F j', strtotime($item->run_date_time)); + if ($item_date === $now->format('Y-m-d')) { + $day_label = __('Today', 'cablecast'); + } elseif ($item_date === date('Y-m-d', strtotime('+1 day'))) { + $day_label = __('Tomorrow', 'cablecast'); + } + $output .= '
    ' . esc_html($day_label) . '
    '; + $output .= '
    '; + $current_date = $item_date; + } + + $show = cablecast_get_show_from_schedule($item); + $color = $show ? cablecast_get_show_category_color($show->ID) : null; + $style = $color ? ' style="border-left-color: ' . esc_attr($color) . ';"' : ''; + + $output .= '
    '; + + // Thumbnail + if ($show_thumbnails && $show) { + $thumbnail_url = cablecast_show_thumbnail_url($show->ID, 'thumbnail'); + if ($thumbnail_url) { + $output .= '
    '; + $output .= '' . esc_attr($item->show_title) . ''; + $output .= '
    '; + } + } + + $output .= '
    '; + $output .= '' . esc_html($item_time) . ''; + + // Title with link if show exists + if ($show) { + $output .= ''; + $output .= esc_html($item->show_title); + $output .= ''; + } else { + $output .= '' . esc_html($item->show_title) . ''; + } + + // Description + if ($show_descriptions && $show) { + $description = get_post_meta($show->ID, 'cablecast_show_comments', true); + if ($description) { + $output .= '

    ' . esc_html(wp_trim_words($description, 20)) . '

    '; + } + } + + $output .= '
    '; // content + $output .= '
    '; // item + } + + if ($current_date !== '') { + $output .= '
    '; // Close last day group + } + + $output .= '
    '; // schedule + + return $output; +} + +// ============================================================================ +// NOW PLAYING SHORTCODE +// ============================================================================ + +/** + * [cablecast_now_playing] - Display current and next program. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_now_playing_shortcode($atts) { + cablecast_mark_shortcode_used('now_playing'); + + $atts = shortcode_atts([ + 'channel' => '', + 'show_up_next' => 'true', + 'show_thumbnail' => 'true', + 'show_description' => 'true', + 'exclude_filler' => 'false', + 'class' => '', + ], $atts, 'cablecast_now_playing'); + + // Validate channel + $channel_id = absint($atts['channel']); + if (!$channel_id) { + return '

    ' . __('Please specify a channel ID.', 'cablecast') . '

    '; + } + + $cablecast_channel_id = get_post_meta($channel_id, 'cablecast_channel_id', true); + if (!$cablecast_channel_id) { + return '

    ' . __('Invalid channel.', 'cablecast') . '

    '; + } + + // Parse options + $show_up_next = filter_var($atts['show_up_next'], FILTER_VALIDATE_BOOLEAN); + $show_thumbnail = filter_var($atts['show_thumbnail'], FILTER_VALIDATE_BOOLEAN); + $show_description = filter_var($atts['show_description'], FILTER_VALIDATE_BOOLEAN); + $exclude_filler = filter_var($atts['exclude_filler'], FILTER_VALIDATE_BOOLEAN); + + // Get current time + $timezone = get_option('timezone_string') ?: 'America/New_York'; + $now = new DateTime('now', new DateTimeZone($timezone)); + $now_timestamp = $now->getTimestamp(); + + // Fetch schedule for today and tomorrow + $date_start = $now->format('Y-m-d'); + $date_end = date('Y-m-d', strtotime($date_start . ' +2 days')); + $items = cablecast_get_schedules($cablecast_channel_id, $date_start, $date_end); + + if (empty($items)) { + return '

    ' . __('No schedule data available.', 'cablecast') . '

    '; + } + + // Filter filler if requested + if ($exclude_filler) { + $items = array_filter($items, function($item) { + return !cablecast_is_filler($item->show_title); + }); + $items = array_values($items); + } + + // Find current and next shows + $current_show = null; + $next_show = null; + + for ($i = 0; $i < count($items); $i++) { + $item = $items[$i]; + $item_start = strtotime($item->run_date_time); + + // Get runtime from show meta if available + $show = cablecast_get_show_from_schedule($item); + $runtime = $show ? (int) get_post_meta($show->ID, 'cablecast_show_trt', true) : 0; + $runtime = $runtime > 0 ? $runtime : 1800; // Default 30 minutes + + $item_end = $item_start + $runtime; + + if ($item_start <= $now_timestamp && $item_end > $now_timestamp) { + $current_show = $item; + $current_show->runtime = $runtime; + $current_show->start_time = $item_start; + $current_show->end_time = $item_end; + + // Find next show + if (isset($items[$i + 1])) { + $next_show = $items[$i + 1]; + } + break; + } elseif ($item_start > $now_timestamp && !$current_show) { + // No current show, this is the next one + $next_show = $item; + break; + } + } + + // Build output + $classes = ['cablecast-now-playing']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + // Current show card + if ($current_show) { + $output .= cablecast_render_now_playing_card($current_show, 'now', $show_thumbnail, $show_description); + } else { + $output .= '
    '; + $output .= '
    ' . __('Now', 'cablecast') . '
    '; + $output .= '

    ' . __('No program currently airing.', 'cablecast') . '

    '; + $output .= '
    '; + } + + // Up next card + if ($show_up_next && $next_show) { + $output .= cablecast_render_now_playing_card($next_show, 'next', $show_thumbnail, $show_description); + } + + $output .= '
    '; + + return $output; +} + +/** + * Render a now playing card. + */ +function cablecast_render_now_playing_card($item, $type, $show_thumbnail, $show_description) { + $show = cablecast_get_show_from_schedule($item); + $color = $show ? cablecast_get_show_category_color($show->ID) : null; + + $badge_class = $type === 'now' ? 'cablecast-now-playing__badge--live' : ''; + $badge_text = $type === 'now' ? __('Live Now', 'cablecast') : __('Up Next', 'cablecast'); + $time_text = date('g:i A', strtotime($item->run_date_time)); + + $card_style = $color ? ' style="border-top-color: ' . esc_attr($color) . ';"' : ''; + + $output = '
    '; + $output .= '
    ' . $badge_text . '
    '; + + if ($show_thumbnail && $show) { + $thumbnail_url = cablecast_show_thumbnail_url($show->ID, 'medium'); + if ($thumbnail_url) { + $output .= '
    '; + $output .= '' . esc_attr($item->show_title) . ''; + $output .= '
    '; + } + } + + $output .= '
    '; + + // Title + if ($show) { + $output .= ''; + $output .= esc_html($item->show_title); + $output .= ''; + } else { + $output .= '' . esc_html($item->show_title) . ''; + } + + // Time + $output .= '' . esc_html($time_text) . ''; + + // Progress bar for current show + if ($type === 'now' && isset($item->start_time) && isset($item->end_time)) { + $total_duration = $item->end_time - $item->start_time; + $elapsed = time() - $item->start_time; + $progress = min(100, max(0, ($elapsed / $total_duration) * 100)); + + $output .= '
    '; + $output .= '
    '; + $output .= '
    '; + } + + // Description + if ($show_description && $show) { + $description = get_post_meta($show->ID, 'cablecast_show_comments', true); + if ($description) { + $output .= '

    ' . esc_html(wp_trim_words($description, 15)) . '

    '; + } + } + + $output .= '
    '; // content + $output .= '
    '; // card + + return $output; +} + +// ============================================================================ +// WEEKLY GUIDE SHORTCODE +// ============================================================================ + +/** + * [cablecast_weekly_guide] - Display a 7-day schedule grid. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_weekly_guide_shortcode($atts) { + cablecast_mark_shortcode_used('weekly_guide'); + + $atts = shortcode_atts([ + 'channel' => '', + 'days' => 7, + 'show_channel_switcher' => 'true', + 'show_category_colors' => 'true', + 'show_descriptions' => 'false', + 'class' => '', + ], $atts, 'cablecast_weekly_guide'); + + // Get channel from URL param or attribute + $channel_id = absint($atts['channel']); + if (!$channel_id && isset($_GET['channel'])) { + $channel_id = absint($_GET['channel']); + } + + // If still no channel, get the first one + $channels = cablecast_get_all_channels(); + if (!$channel_id && !empty($channels)) { + $channel_id = $channels[0]->ID; + } + + if (!$channel_id) { + return '

    ' . __('No channels available.', 'cablecast') . '

    '; + } + + $cablecast_channel_id = get_post_meta($channel_id, 'cablecast_channel_id', true); + if (!$cablecast_channel_id) { + return '

    ' . __('Invalid channel.', 'cablecast') . '

    '; + } + + // Parse options + $days = min(14, max(1, absint($atts['days']))); + $show_channel_switcher = filter_var($atts['show_channel_switcher'], FILTER_VALIDATE_BOOLEAN); + $show_category_colors = filter_var($atts['show_category_colors'], FILTER_VALIDATE_BOOLEAN); + $show_descriptions = filter_var($atts['show_descriptions'], FILTER_VALIDATE_BOOLEAN); + + // Get current time + $timezone = get_option('timezone_string') ?: 'America/New_York'; + $now = new DateTime('now', new DateTimeZone($timezone)); + + // Fetch schedule for date range - SINGLE DATABASE QUERY + $date_start = $now->format('Y-m-d'); + $date_end = date('Y-m-d', strtotime($date_start . ' +' . $days . ' days')); + $items = cablecast_get_schedules($cablecast_channel_id, $date_start, $date_end); + + // Group items by date + $schedule_by_date = []; + foreach ($items as $item) { + $date = date('Y-m-d', strtotime($item->run_date_time)); + if (!isset($schedule_by_date[$date])) { + $schedule_by_date[$date] = []; + } + $schedule_by_date[$date][] = $item; + } + + // Build output + $classes = ['cablecast-weekly-guide']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + // Channel switcher + if ($show_channel_switcher && count($channels) > 1) { + $current_url = remove_query_arg('channel'); + $output .= '
    '; + $output .= ''; + $output .= ''; + $output .= '
    '; + } + + // Day columns + $output .= '
    '; + + for ($d = 0; $d < $days; $d++) { + $date = date('Y-m-d', strtotime($date_start . ' +' . $d . ' days')); + $day_items = isset($schedule_by_date[$date]) ? $schedule_by_date[$date] : []; + + $is_today = ($date === $now->format('Y-m-d')); + $day_class = $is_today ? 'cablecast-weekly-guide__day cablecast-weekly-guide__day--today' : 'cablecast-weekly-guide__day'; + + $output .= '
    '; + + // Day header + $day_label = date('D', strtotime($date)); + $date_label = date('M j', strtotime($date)); + if ($is_today) { + $day_label = __('Today', 'cablecast'); + } + + $output .= '
    '; + $output .= '' . esc_html($day_label) . ''; + $output .= '' . esc_html($date_label) . ''; + $output .= '
    '; + + // Programs + $output .= '
    '; + + if (empty($day_items)) { + $output .= '
    '; + $output .= __('No programs', 'cablecast'); + $output .= '
    '; + } else { + foreach ($day_items as $item) { + $show = cablecast_get_show_from_schedule($item); + $item_time = date('g:i A', strtotime($item->run_date_time)); + + // Determine if this is the current program + $item_timestamp = strtotime($item->run_date_time); + $is_current = false; + if ($is_today) { + $runtime = $show ? (int) get_post_meta($show->ID, 'cablecast_show_trt', true) : 1800; + $runtime = $runtime > 0 ? $runtime : 1800; + $item_end = $item_timestamp + $runtime; + $is_current = ($item_timestamp <= $now->getTimestamp() && $item_end > $now->getTimestamp()); + } + + $program_class = 'cablecast-weekly-guide__program'; + if ($is_current) { + $program_class .= ' cablecast-weekly-guide__program--current'; + } + + // Category color + $style = ''; + if ($show_category_colors && $show) { + $color = cablecast_get_show_category_color($show->ID); + if ($color) { + $style = ' style="border-left-color: ' . esc_attr($color) . ';"'; + } + } + + $output .= '
    '; + $output .= '' . esc_html($item_time) . ''; + + if ($show) { + $output .= ''; + $output .= esc_html($item->show_title); + $output .= ''; + } else { + $output .= '' . esc_html($item->show_title) . ''; + } + + if ($show_descriptions && $show) { + $description = get_post_meta($show->ID, 'cablecast_show_comments', true); + if ($description) { + $output .= '

    ' . esc_html(wp_trim_words($description, 10)) . '

    '; + } + } + + $output .= '
    '; + } + } + + $output .= '
    '; // programs + $output .= '
    '; // day + } + + $output .= '
    '; // grid + $output .= '
    '; // weekly-guide + + return $output; +} + +// ============================================================================ +// SHOWS SHORTCODE +// ============================================================================ + +/** + * [cablecast_shows] - Display a grid/list of shows. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_shows_shortcode($atts) { + cablecast_mark_shortcode_used('shows'); + + $atts = shortcode_atts([ + 'category' => '', + 'producer' => '', + 'series' => '', + 'count' => 12, + 'orderby' => 'date', + 'order' => 'DESC', + 'layout' => 'grid', + 'columns' => 4, + 'show_pagination' => 'false', + 'class' => '', + ], $atts, 'cablecast_shows'); + + // Build query args + $query_args = [ + 'post_type' => 'show', + 'posts_per_page' => absint($atts['count']) ?: 12, + 'orderby' => in_array($atts['orderby'], ['title', 'date', 'event_date']) ? $atts['orderby'] : 'date', + 'order' => in_array(strtoupper($atts['order']), ['ASC', 'DESC']) ? strtoupper($atts['order']) : 'DESC', + ]; + + // Handle event_date ordering + if ($atts['orderby'] === 'event_date') { + $query_args['meta_key'] = 'cablecast_show_event_date'; + $query_args['orderby'] = 'meta_value'; + } + + // Tax queries + $tax_query = []; + + if (!empty($atts['category'])) { + $tax_query[] = [ + 'taxonomy' => 'category', + 'field' => is_numeric($atts['category']) ? 'term_id' : 'slug', + 'terms' => $atts['category'], + ]; + } + + if (!empty($atts['producer'])) { + $tax_query[] = [ + 'taxonomy' => 'cablecast_producer', + 'field' => is_numeric($atts['producer']) ? 'term_id' : 'slug', + 'terms' => $atts['producer'], + ]; + } + + if (!empty($atts['series'])) { + $tax_query[] = [ + 'taxonomy' => 'cablecast_project', + 'field' => is_numeric($atts['series']) ? 'term_id' : 'slug', + 'terms' => $atts['series'], + ]; + } + + if (!empty($tax_query)) { + $query_args['tax_query'] = $tax_query; + } + + // Pagination + $show_pagination = filter_var($atts['show_pagination'], FILTER_VALIDATE_BOOLEAN); + if ($show_pagination) { + $query_args['paged'] = get_query_var('paged') ?: 1; + } + + $query = new WP_Query($query_args); + + if (!$query->have_posts()) { + return '

    ' . __('No shows found.', 'cablecast') . '

    '; + } + + // Build output + $layout = in_array($atts['layout'], ['grid', 'list', 'featured']) ? $atts['layout'] : 'grid'; + $columns = min(6, max(2, absint($atts['columns']))); + + $classes = ['cablecast-shows', 'cablecast-shows--' . $layout]; + if ($layout === 'grid' || $layout === 'featured') { + $classes[] = 'cablecast-shows--columns-' . $columns; + } + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + $item_index = 0; + while ($query->have_posts()) { + $query->the_post(); + $show_id = get_the_ID(); + + // Featured layout: first item is large + $item_classes = ['cablecast-shows__item']; + if ($layout === 'featured' && $item_index === 0) { + $item_classes[] = 'cablecast-shows__item--featured'; + } + $item_index++; + + $output .= '
    '; + + // Thumbnail - use larger size for featured item + $is_featured_item = ($layout === 'featured' && $item_index === 1); + $thumb_size = $is_featured_item ? 'large' : 'medium'; + $thumbnail_url = cablecast_show_thumbnail_url($show_id, $thumb_size); + if ($thumbnail_url) { + $output .= ''; + $output .= '' . esc_attr(get_the_title()) . ''; + + // Runtime badge overlay + $runtime = (int) get_post_meta($show_id, 'cablecast_show_trt', true); + if ($runtime > 0) { + $output .= '' . cablecast_format_runtime($runtime) . ''; + } + + $output .= ''; + } + + $output .= '
    '; + $output .= '' . esc_html(get_the_title()) . ''; + + // Runtime (text version, for list layout) + $runtime = (int) get_post_meta($show_id, 'cablecast_show_trt', true); + if ($runtime > 0 && $layout === 'list') { + $output .= '' . cablecast_format_runtime($runtime) . ''; + } + + // Category tag + $categories = get_the_terms($show_id, 'category'); + if ($categories && !is_wp_error($categories)) { + $cat = $categories[0]; + $color = cablecast_get_show_category_color($show_id); + $style = $color ? ' style="border-color: ' . esc_attr($color) . ';"' : ''; + $output .= ''; + $output .= esc_html($cat->name); + $output .= ''; + } + + $output .= '
    '; // content + $output .= '
    '; // item + } + + $output .= '
    '; // shows + + // Pagination + if ($show_pagination && $query->max_num_pages > 1) { + $output .= '
    '; + $output .= paginate_links([ + 'total' => $query->max_num_pages, + 'current' => max(1, get_query_var('paged')), + ]); + $output .= '
    '; + } + + wp_reset_postdata(); + + return $output; +} + +// ============================================================================ +// SINGLE SHOW SHORTCODE +// ============================================================================ + +/** + * [cablecast_show] - Display a single show. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_show_shortcode($atts) { + cablecast_mark_shortcode_used('show'); + + $atts = shortcode_atts([ + 'id' => '', + 'show_vod' => 'true', + 'show_thumbnail' => 'true', + 'show_meta' => 'true', + 'show_description' => 'true', + 'class' => '', + ], $atts, 'cablecast_show'); + + $show_id = absint($atts['id']); + if (!$show_id) { + return '

    ' . __('Please specify a show ID.', 'cablecast') . '

    '; + } + + $show = get_post($show_id); + if (!$show || $show->post_type !== 'show') { + return '

    ' . __('Show not found.', 'cablecast') . '

    '; + } + + // Parse options + $show_vod = filter_var($atts['show_vod'], FILTER_VALIDATE_BOOLEAN); + $show_thumbnail = filter_var($atts['show_thumbnail'], FILTER_VALIDATE_BOOLEAN); + $show_meta = filter_var($atts['show_meta'], FILTER_VALIDATE_BOOLEAN); + $show_description = filter_var($atts['show_description'], FILTER_VALIDATE_BOOLEAN); + + // Build output + $classes = ['cablecast-show']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + // VOD Player + if ($show_vod) { + $vod_embed = get_post_meta($show_id, 'cablecast_vod_embed', true); + if ($vod_embed) { + $output .= '
    ' . $vod_embed . '
    '; + } + } + + // Thumbnail (if no VOD) + if ($show_thumbnail && !$show_vod) { + $thumbnail_url = cablecast_show_thumbnail_url($show_id, 'large'); + if ($thumbnail_url) { + $output .= '
    '; + $output .= '' . esc_attr($show->post_title) . ''; + $output .= '
    '; + } + } + + $output .= '
    '; + + // Title + $output .= '

    ' . esc_html($show->post_title) . '

    '; + + // Meta + if ($show_meta) { + $output .= '
    '; + + // Producer + $producers = get_the_terms($show_id, 'cablecast_producer'); + if ($producers && !is_wp_error($producers)) { + $output .= ''; + $output .= __('By', 'cablecast') . ' '; + $producer_links = []; + foreach ($producers as $producer) { + $producer_links[] = '' . esc_html($producer->name) . ''; + } + $output .= implode(', ', $producer_links); + $output .= ''; + } + + // Runtime + $runtime = (int) get_post_meta($show_id, 'cablecast_show_trt', true); + if ($runtime > 0) { + $output .= '' . cablecast_format_runtime($runtime) . ''; + } + + // Category + $categories = get_the_terms($show_id, 'category'); + if ($categories && !is_wp_error($categories)) { + $output .= ''; + $cat_links = []; + foreach ($categories as $cat) { + $cat_links[] = '' . esc_html($cat->name) . ''; + } + $output .= implode(', ', $cat_links); + $output .= ''; + } + + $output .= '
    '; // meta + } + + // Description + if ($show_description) { + $description = get_post_meta($show_id, 'cablecast_show_comments', true); + if ($description) { + $output .= '
    ' . wpautop(esc_html($description)) . '
    '; + } + } + + $output .= '
    '; // content + $output .= '
    '; // show + + return $output; +} + +// ============================================================================ +// VOD PLAYER SHORTCODE +// ============================================================================ + +/** + * [cablecast_vod_player] - Display just the VOD player for a show. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_vod_player_shortcode($atts) { + cablecast_mark_shortcode_used('vod_player'); + + $atts = shortcode_atts([ + 'id' => '', + 'poster' => 'true', + 'class' => '', + ], $atts, 'cablecast_vod_player'); + + $show_id = absint($atts['id']); + if (!$show_id) { + return '

    ' . __('Please specify a show ID.', 'cablecast') . '

    '; + } + + $show = get_post($show_id); + if (!$show || $show->post_type !== 'show') { + return '

    ' . __('Show not found.', 'cablecast') . '

    '; + } + + $vod_embed = get_post_meta($show_id, 'cablecast_vod_embed', true); + if (!$vod_embed) { + return '

    ' . __('No video available for this show.', 'cablecast') . '

    '; + } + + $classes = ['cablecast-vod-player']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + return '
    ' . $vod_embed . '
    '; +} + +// ============================================================================ +// CHAPTERS SHORTCODE +// ============================================================================ + +/** + * [cablecast_chapters] - Display interactive chapters for a show's VOD. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_chapters_shortcode($atts) { + cablecast_mark_shortcode_used('chapters'); + + $atts = shortcode_atts([ + 'id' => '', // Show post ID (optional, defaults to current post) + 'player' => '', // Target player element selector (for multiple players) + 'show_descriptions' => 'true', // Show chapter descriptions + 'show_timestamps' => 'true', // Show formatted timestamps + 'layout' => 'list', // list or compact + 'class' => '', // Additional CSS class + ], $atts, 'cablecast_chapters'); + + // Determine show ID + $show_id = absint($atts['id']); + if (!$show_id) { + // Try to get from current post context + $show_id = cablecast_current_show_post_id(); + } + + if (!$show_id) { + return '

    ' . __('Please specify a show ID or use within a show context.', 'cablecast') . '

    '; + } + + $show = get_post($show_id); + if (!$show || $show->post_type !== 'show') { + return '

    ' . __('Show not found.', 'cablecast') . '

    '; + } + + // Check if show has VOD + $vod_embed = get_post_meta($show_id, 'cablecast_vod_embed', true); + if (!$vod_embed) { + return ''; // Silent fail - no VOD means no chapters to display + } + + // Get chapters + $chapters = get_post_meta($show_id, 'cablecast_vod_chapters', true); + if (empty($chapters)) { + return ''; // Silent fail - no chapters available + } + + // Ensure chapters is an array (handles both serialized and unserialized cases) + if (is_string($chapters)) { + $chapters = maybe_unserialize($chapters); + } + + if (!is_array($chapters) || empty($chapters)) { + return ''; + } + + // Parse options + $show_descriptions = filter_var($atts['show_descriptions'], FILTER_VALIDATE_BOOLEAN); + $show_timestamps = filter_var($atts['show_timestamps'], FILTER_VALIDATE_BOOLEAN); + $layout = in_array($atts['layout'], ['list', 'compact']) ? $atts['layout'] : 'list'; + $player_selector = sanitize_text_field($atts['player']); + + // Build output + $classes = ['cablecast-chapters', 'cablecast-chapters--' . $layout]; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + // Data attributes for JS + $data_attrs = ' data-show-id="' . esc_attr($show_id) . '"'; + if ($player_selector) { + $data_attrs .= ' data-player-selector="' . esc_attr($player_selector) . '"'; + } + + $output = '
    '; + + $output .= '

    ' . __('Chapters', 'cablecast') . '

    '; + $output .= '
      '; + + foreach ($chapters as $index => $chapter) { + $offset = (int) $chapter['offset']; + $timestamp = cablecast_format_chapter_timestamp($offset); + + $item_class = 'cablecast-chapters__item'; + + $output .= '
    • '; + + $output .= ''; + + if ($show_descriptions && !empty($chapter['body'])) { + $output .= '

      ' . esc_html($chapter['body']) . '

      '; + } + + $output .= '
    • '; + } + + $output .= '
    '; + $output .= '
    '; + + return $output; +} + +/** + * Format seconds to HH:MM:SS or MM:SS timestamp. + * + * @param int $seconds Total seconds + * @return string Formatted timestamp + */ +function cablecast_format_chapter_timestamp($seconds) { + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + $secs = $seconds % 60; + + if ($hours > 0) { + return sprintf('%d:%02d:%02d', $hours, $minutes, $secs); + } + + return sprintf('%d:%02d', $minutes, $secs); +} + +// ============================================================================ +// PRODUCERS SHORTCODE +// ============================================================================ + +/** + * [cablecast_producers] - Display a list of producers. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_producers_shortcode($atts) { + cablecast_mark_shortcode_used('producers'); + + $atts = shortcode_atts([ + 'count' => 0, + 'orderby' => 'name', + 'layout' => 'list', + 'show_contact' => 'false', + 'class' => '', + ], $atts, 'cablecast_producers'); + + $terms = get_terms([ + 'taxonomy' => 'cablecast_producer', + 'hide_empty' => true, + 'number' => absint($atts['count']) ?: 0, + 'orderby' => in_array($atts['orderby'], ['name', 'count']) ? $atts['orderby'] : 'name', + 'order' => $atts['orderby'] === 'count' ? 'DESC' : 'ASC', + ]); + + if (empty($terms) || is_wp_error($terms)) { + return '

    ' . __('No producers found.', 'cablecast') . '

    '; + } + + $layout = in_array($atts['layout'], ['grid', 'list']) ? $atts['layout'] : 'list'; + $show_contact = filter_var($atts['show_contact'], FILTER_VALIDATE_BOOLEAN); + + $classes = ['cablecast-producers', 'cablecast-producers--' . $layout]; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + foreach ($terms as $term) { + $output .= '
    '; + $output .= ''; + $output .= esc_html($term->name); + $output .= ''; + + $output .= '' . sprintf(_n('%d show', '%d shows', $term->count, 'cablecast'), $term->count) . ''; + + if ($show_contact) { + $email = get_term_meta($term->term_id, 'cablecast_producer_email', true); + $website = get_term_meta($term->term_id, 'cablecast_producer_website', true); + + if ($email || $website) { + $output .= '
    '; + if ($email) { + $output .= '' . esc_html($email) . ''; + } + if ($website) { + $output .= '' . esc_html($website) . ''; + } + $output .= '
    '; + } + } + + $output .= '
    '; + } + + $output .= '
    '; + + return $output; +} + +// ============================================================================ +// SERIES SHORTCODE +// ============================================================================ + +/** + * [cablecast_series] - Display a list of series/projects. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_series_shortcode($atts) { + cablecast_mark_shortcode_used('series'); + + $atts = shortcode_atts([ + 'count' => 0, + 'orderby' => 'name', + 'layout' => 'grid', + 'show_thumbnails' => 'true', + 'class' => '', + ], $atts, 'cablecast_series'); + + $terms = get_terms([ + 'taxonomy' => 'cablecast_project', + 'hide_empty' => true, + 'number' => absint($atts['count']) ?: 0, + 'orderby' => in_array($atts['orderby'], ['name', 'count']) ? $atts['orderby'] : 'name', + 'order' => $atts['orderby'] === 'count' ? 'DESC' : 'ASC', + ]); + + if (empty($terms) || is_wp_error($terms)) { + return '

    ' . __('No series found.', 'cablecast') . '

    '; + } + + $layout = in_array($atts['layout'], ['grid', 'list']) ? $atts['layout'] : 'grid'; + $show_thumbnails = filter_var($atts['show_thumbnails'], FILTER_VALIDATE_BOOLEAN); + + $classes = ['cablecast-series', 'cablecast-series--' . $layout]; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + foreach ($terms as $term) { + $output .= '
    '; + + // Get a thumbnail from a show in this series + // Try multiple shows to find one with a valid thumbnail + if ($show_thumbnails) { + $shows = get_posts([ + 'post_type' => 'show', + 'posts_per_page' => 5, // Try up to 5 shows + 'tax_query' => [[ + 'taxonomy' => 'cablecast_project', + 'field' => 'term_id', + 'terms' => $term->term_id, + ]], + ]); + + $thumbnail_url = ''; + foreach ($shows as $show) { + $thumbnail_url = cablecast_show_thumbnail_url($show->ID, 'medium'); + if ($thumbnail_url) { + break; // Found a valid thumbnail + } + } + + if ($thumbnail_url) { + $output .= ''; + $output .= '' . esc_attr($term->name) . ''; + $output .= ''; + } + } + + $output .= '
    '; + $output .= ''; + $output .= esc_html($term->name); + $output .= ''; + $output .= '' . sprintf(_n('%d episode', '%d episodes', $term->count, 'cablecast'), $term->count) . ''; + $output .= '
    '; + + $output .= '
    '; + } + + $output .= '
    '; + + return $output; +} + +// ============================================================================ +// SCHEDULE CALENDAR SHORTCODE (FullCalendar) +// ============================================================================ + +/** + * AJAX handler for FullCalendar events. + */ +function cablecast_calendar_events_ajax() { + check_ajax_referer('cablecast_calendar_nonce', 'nonce'); + + $channel_id = intval($_GET['channel_id']); + $start = sanitize_text_field($_GET['start']); + $end = sanitize_text_field($_GET['end']); + + // Get schedule items from database + $items = cablecast_get_schedules($channel_id, $start, $end); + + // Get category colors + $options = get_option('cablecast_options'); + $category_colors = isset($options['category_colors']) ? $options['category_colors'] : []; + + // Convert to FullCalendar event format + $events = []; + foreach ($items as $item) { + $show = cablecast_get_show_from_schedule($item); + $show_url = $show ? get_permalink($show) : ''; + + // Get show category for color + $color = '#3788d8'; // Default blue + if ($show) { + $categories = get_the_terms($show->ID, 'category'); + if ($categories && !is_wp_error($categories)) { + foreach ($categories as $cat) { + if (isset($category_colors[$cat->slug])) { + $color = $category_colors[$cat->slug]; + break; + } + } + } + } + + // Calculate end time (default 30 min if no runtime) + $start_time = strtotime($item->run_date_time); + $runtime = $show ? (int) get_post_meta($show->ID, 'cablecast_show_trt', true) : 0; + $end_time = $start_time + ($runtime > 0 ? $runtime : 1800); + + $events[] = [ + 'id' => $item->schedule_item_id, + 'title' => $item->show_title, + 'start' => date('c', $start_time), + 'end' => date('c', $end_time), + 'url' => $show_url, + 'backgroundColor' => $color, + 'borderColor' => $color, + ]; + } + + wp_send_json($events); +} + +/** + * [cablecast_schedule_calendar] - Display schedule using FullCalendar. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_schedule_calendar_shortcode($atts) { + cablecast_mark_shortcode_used('schedule_calendar'); + + $atts = shortcode_atts([ + 'channel' => '', + 'view' => 'timeGridWeek', + 'height' => 'auto', + 'header' => 'true', + 'nav' => 'true', + 'show_category_colors' => 'true', + 'class' => '', + ], $atts, 'cablecast_schedule_calendar'); + + // Validate channel + $channel_id = absint($atts['channel']); + if (!$channel_id) { + return '

    ' . __('Please specify a channel ID.', 'cablecast') . '

    '; + } + + // Get Cablecast channel ID from post meta + $cablecast_channel_id = get_post_meta($channel_id, 'cablecast_channel_id', true); + if (!$cablecast_channel_id) { + return '

    ' . __('Invalid channel.', 'cablecast') . '

    '; + } + + // Enqueue FullCalendar from CDN + wp_enqueue_script( + 'fullcalendar', + 'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js', + [], + '6.1.11', + true + ); + + wp_enqueue_script( + 'cablecast-fullcalendar', + plugins_url('../assets/js/fullcalendar-shortcode.js', __FILE__), + ['fullcalendar'], + filemtime(plugin_dir_path(__FILE__) . '../assets/js/fullcalendar-shortcode.js'), + true + ); + + wp_enqueue_style( + 'cablecast-fullcalendar', + plugins_url('../assets/css/fullcalendar-shortcode.css', __FILE__), + [], + filemtime(plugin_dir_path(__FILE__) . '../assets/css/fullcalendar-shortcode.css') + ); + + // Generate unique ID for this calendar instance + $calendar_id = 'cablecast-calendar-' . uniqid(); + + // Parse boolean options + $show_header = filter_var($atts['header'], FILTER_VALIDATE_BOOLEAN); + $show_nav = filter_var($atts['nav'], FILTER_VALIDATE_BOOLEAN); + + // Build config for JS + $config = [ + 'calendarId' => $calendar_id, + 'channelId' => $cablecast_channel_id, + 'initialView' => $atts['view'], + 'height' => $atts['height'], + 'showHeader' => $show_header, + 'showNav' => $show_nav, + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('cablecast_calendar_nonce'), + ]; + + // Inline the config + wp_add_inline_script( + 'cablecast-fullcalendar', + 'window.cablecastCalendars = window.cablecastCalendars || []; window.cablecastCalendars.push(' . wp_json_encode($config) . ');', + 'before' + ); + + // Build output + $classes = ['cablecast-fullcalendar']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + return '
    '; +} + +// ============================================================================ +// UPCOMING RUNS SHORTCODE +// ============================================================================ + +/** + * [cablecast_upcoming_runs] - Display upcoming airings for a show across all channels. + * + * Shows a list of upcoming scheduled airings for a specific show, + * displaying the date, time, and channel for each airing. + * + * @param array $atts Shortcode attributes: + * - id: Show post ID (optional, defaults to current post) + * - count: Number of upcoming runs to display (default: 5) + * - show_channel: Display channel name with link (default: true) + * - show_date: Display date and time (default: true) + * - days_ahead: How many days ahead to look (default: 14) + * - class: Additional CSS class + * @return string HTML output + */ +function cablecast_upcoming_runs_shortcode($atts) { + cablecast_mark_shortcode_used('upcoming_runs'); + + $atts = shortcode_atts([ + 'id' => '', + 'count' => 5, + 'show_channel' => 'true', + 'show_date' => 'true', + 'days_ahead' => 14, + 'class' => '', + ], $atts, 'cablecast_upcoming_runs'); + + // Determine show ID + $show_id = absint($atts['id']); + if (!$show_id) { + $show_id = cablecast_current_show_post_id(); + } + + if (!$show_id) { + return ''; // Silent fail - no context + } + + // Verify it's a show post + $post = get_post($show_id); + if (!$post || $post->post_type !== 'show') { + return ''; + } + + global $wpdb; + $table = $wpdb->prefix . 'cablecast_schedule_items'; + + // Get timezone + $timezone = get_option('timezone_string'); + if (empty($timezone)) { + $timezone = 'UTC'; + } + + // Calculate date range in UTC + try { + $now = new DateTime('now', new DateTimeZone($timezone)); + $now->setTimezone(new DateTimeZone('UTC')); + $now_utc = $now->format('Y-m-d H:i:s'); + + $end = new DateTime('now', new DateTimeZone($timezone)); + $end->modify('+' . absint($atts['days_ahead']) . ' days'); + $end->setTimezone(new DateTimeZone('UTC')); + $end_utc = $end->format('Y-m-d H:i:s'); + } catch (Exception $e) { + return ''; + } + + $count = absint($atts['count']) ?: 5; + + /** + * Filter the query arguments for upcoming runs. + * + * @param array $args Query parameters. + * @param int $show_id The show post ID. + */ + $query_args = apply_filters('cablecast_upcoming_runs_args', [ + 'show_post_id' => $show_id, + 'start_utc' => $now_utc, + 'end_utc' => $end_utc, + 'count' => $count, + ], $show_id); + + // Query upcoming runs + $runs = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM $table + WHERE show_post_id = %d + AND cg_exempt = 0 + AND run_date_time >= %s + AND run_date_time < %s + ORDER BY run_date_time ASC + LIMIT %d", + $query_args['show_post_id'], + $query_args['start_utc'], + $query_args['end_utc'], + $query_args['count'] + )); + + if (empty($runs)) { + return ''; // Silent fail - no upcoming runs + } + + // Parse boolean options + $show_channel = filter_var($atts['show_channel'], FILTER_VALIDATE_BOOLEAN); + $show_date = filter_var($atts['show_date'], FILTER_VALIDATE_BOOLEAN); + + // Build output + $classes = ['cablecast-upcoming-runs']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + /** + * Action before upcoming runs heading. + * + * @param int $show_id The show post ID. + * @param array $runs The array of run objects. + */ + do_action('cablecast_before_upcoming_runs_heading', $show_id, $runs); + + $output .= '

    ' . esc_html__('Upcoming Airings', 'cablecast') . '

    '; + + /** + * Action after upcoming runs heading. + * + * @param int $show_id The show post ID. + * @param array $runs The array of run objects. + */ + do_action('cablecast_after_upcoming_runs_heading', $show_id, $runs); + + $output .= '
      '; + + foreach ($runs as $run) { + // Convert UTC time to local timezone for display + try { + $run_utc = new DateTime($run->run_date_time, new DateTimeZone('UTC')); + $run_utc->setTimezone(new DateTimeZone($timezone)); + $run_local = $run_utc; + } catch (Exception $e) { + continue; + } + + $channel = get_post($run->channel_post_id); + + $output .= '
    • '; + + if ($show_date) { + $date_str = $run_local->format('l, F j'); // e.g., "Monday, January 15" + $time_str = $run_local->format('g:i A'); // e.g., "7:00 PM" + $output .= '' . esc_html($date_str) . ''; + $output .= '' . esc_html($time_str) . ''; + } + + if ($show_channel && $channel) { + $channel_url = get_permalink($channel->ID); + $output .= ''; + $output .= esc_html($channel->post_title); + $output .= ''; + } + + $output .= '
    • '; + } + + $output .= '
    '; + + /** + * Action after upcoming runs list. + * + * @param int $show_id The show post ID. + * @param array $runs The array of run objects. + */ + do_action('cablecast_after_upcoming_runs_list', $show_id, $runs); + + $output .= '
    '; + + return $output; +} + +// ============================================================================ +// CATEGORIES SHORTCODE +// ============================================================================ + +/** + * [cablecast_categories] - Display categories that have shows. + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_categories_shortcode($atts) { + cablecast_mark_shortcode_used('categories'); + + $atts = shortcode_atts([ + 'layout' => 'cloud', // cloud, grid, list + 'show_colors' => 'true', + 'show_counts' => 'true', + 'count' => 0, // 0 = all + 'class' => '', + ], $atts, 'cablecast_categories'); + + // Get categories that have shows + $categories = get_terms([ + 'taxonomy' => 'category', + 'hide_empty' => true, + 'number' => absint($atts['count']) ?: 0, + 'orderby' => 'name', + 'order' => 'ASC', + ]); + + if (empty($categories) || is_wp_error($categories)) { + return ''; + } + + // Filter to only categories with shows + $categories_with_shows = []; + foreach ($categories as $cat) { + $show_count = new WP_Query([ + 'post_type' => 'show', + 'tax_query' => [[ + 'taxonomy' => 'category', + 'field' => 'term_id', + 'terms' => $cat->term_id, + ]], + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => false, + ]); + + if ($show_count->found_posts > 0) { + $cat->show_count = $show_count->found_posts; + $categories_with_shows[] = $cat; + } + wp_reset_postdata(); + } + + if (empty($categories_with_shows)) { + return ''; + } + + // Parse options + $layout = in_array($atts['layout'], ['cloud', 'grid', 'list']) ? $atts['layout'] : 'cloud'; + $show_colors = filter_var($atts['show_colors'], FILTER_VALIDATE_BOOLEAN); + $show_counts = filter_var($atts['show_counts'], FILTER_VALIDATE_BOOLEAN); + + // Get category colors from settings + $options = get_option('cablecast_options'); + $category_colors = isset($options['category_colors']) ? $options['category_colors'] : []; + + // Build output + $classes = ['cablecast-categories', 'cablecast-categories--' . $layout]; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + foreach ($categories_with_shows as $cat) { + $color = isset($category_colors[$cat->slug]) ? $category_colors[$cat->slug] : null; + + $item_style = ''; + if ($show_colors && $color) { + if ($layout === 'cloud') { + $item_style = ' style="background-color: ' . esc_attr($color) . '20; border-color: ' . esc_attr($color) . '; color: ' . esc_attr($color) . ';"'; + } else { + $item_style = ' style="border-left-color: ' . esc_attr($color) . ';"'; + } + } + + $output .= ''; + + if ($show_colors && $color && $layout !== 'cloud') { + $output .= ''; + } + + $output .= '' . esc_html($cat->name) . ''; + + if ($show_counts) { + $output .= '' . $cat->show_count . ''; + } + + $output .= ''; + } + + $output .= '
    '; + + return $output; +} + +// ============================================================================ +// HOME PAGE SHORTCODE +// ============================================================================ + +/** + * [cablecast_home] - Display a complete home page with all sections. + * + * Composes multiple shortcodes into a cohesive home page layout: + * - Now Playing hero section + * - Weekly schedule grid + * - Recent shows gallery + * - Browse by series/category + * + * @param array $atts Shortcode attributes + * @return string HTML output + */ +function cablecast_home_shortcode($atts) { + cablecast_mark_shortcode_used('home'); + + // Get settings + $options = get_option('cablecast_options', []); + $home_settings = isset($options['home_page']) ? $options['home_page'] : []; + + $atts = shortcode_atts([ + 'show_now_playing' => isset($home_settings['show_now_playing']) ? $home_settings['show_now_playing'] : 'true', + 'show_schedule' => isset($home_settings['show_schedule']) ? $home_settings['show_schedule'] : 'true', + 'schedule_days' => isset($home_settings['schedule_days']) ? $home_settings['schedule_days'] : 7, + 'show_recent' => isset($home_settings['show_recent']) ? $home_settings['show_recent'] : 'true', + 'recent_count' => isset($home_settings['recent_count']) ? $home_settings['recent_count'] : 12, + 'show_browse' => isset($home_settings['show_browse']) ? $home_settings['show_browse'] : 'true', + 'class' => '', + ], $atts, 'cablecast_home'); + + // Parse booleans + $show_now_playing = filter_var($atts['show_now_playing'], FILTER_VALIDATE_BOOLEAN); + $show_schedule = filter_var($atts['show_schedule'], FILTER_VALIDATE_BOOLEAN); + $show_recent = filter_var($atts['show_recent'], FILTER_VALIDATE_BOOLEAN); + $show_browse = filter_var($atts['show_browse'], FILTER_VALIDATE_BOOLEAN); + + // Get channels + $channels = cablecast_get_all_channels(); + $default_channel = !empty($channels) ? $channels[0]->ID : 0; + + // Get section headings from settings + $now_playing_heading = isset($home_settings['now_playing_heading']) ? $home_settings['now_playing_heading'] : __('Now Playing', 'cablecast'); + $schedule_heading = isset($home_settings['schedule_heading']) ? $home_settings['schedule_heading'] : __("This Week's Schedule", 'cablecast'); + $recent_heading = isset($home_settings['recent_heading']) ? $home_settings['recent_heading'] : __('Recent Shows', 'cablecast'); + $browse_heading = isset($home_settings['browse_heading']) ? $home_settings['browse_heading'] : __('Browse', 'cablecast'); + + // Build output + $classes = ['cablecast-home']; + if (!empty($atts['class'])) { + $classes[] = esc_attr($atts['class']); + } + + $output = '
    '; + + // Section 1: Now Playing Hero + if ($show_now_playing && $default_channel) { + $output .= '
    '; + $output .= '

    ' . esc_html($now_playing_heading) . '

    '; + + // If multiple channels, show tabs + if (count($channels) > 1) { + $output .= '
    '; + foreach ($channels as $index => $channel) { + $active = $index === 0 ? ' cablecast-home__channel-tab--active' : ''; + $output .= ''; + } + $output .= '
    '; + } + + $output .= '
    '; + $output .= do_shortcode('[cablecast_now_playing channel="' . $default_channel . '" show_up_next="true" show_thumbnail="true" show_description="true" exclude_filler="true"]'); + $output .= '
    '; + $output .= '
    '; + } + + // Section 2: Weekly Schedule + if ($show_schedule && $default_channel) { + $schedule_days = min(14, max(1, absint($atts['schedule_days']))); + + $output .= '
    '; + $output .= '

    ' . esc_html($schedule_heading) . '

    '; + $output .= do_shortcode('[cablecast_weekly_guide channel="' . $default_channel . '" days="' . $schedule_days . '" show_channel_switcher="true" show_category_colors="true"]'); + $output .= '
    '; + } + + // Section 3: Recent Shows + if ($show_recent) { + $recent_count = absint($atts['recent_count']) ?: 12; + + $output .= '
    '; + $output .= '

    ' . esc_html($recent_heading) . '

    '; + $output .= do_shortcode('[cablecast_shows count="' . $recent_count . '" layout="featured" columns="4" orderby="date" order="DESC"]'); + + // View all link + $shows_archive = get_post_type_archive_link('show'); + if ($shows_archive) { + $output .= ''; + } + + $output .= '
    '; + } + + // Section 4: Browse + if ($show_browse) { + $output .= '
    '; + $output .= '

    ' . esc_html($browse_heading) . '

    '; + + $output .= '
    '; + + // Series + $output .= '
    '; + $output .= '

    ' . esc_html__('Series', 'cablecast') . '

    '; + $output .= do_shortcode('[cablecast_series count="6" layout="grid" show_thumbnails="true"]'); + + $series_archive = get_post_type_archive_link('show'); + if ($series_archive) { + $output .= ''; + $output .= __('All Series', 'cablecast') . ' →'; + $output .= ''; + } + $output .= '
    '; + + // Categories + $output .= '
    '; + $output .= '

    ' . esc_html__('Categories', 'cablecast') . '

    '; + $output .= do_shortcode('[cablecast_categories layout="cloud" show_colors="true" show_counts="true"]'); + $output .= '
    '; + + // Producers + $output .= '
    '; + $output .= '

    ' . esc_html__('Producers', 'cablecast') . '

    '; + $output .= do_shortcode('[cablecast_producers count="10" orderby="count" layout="list"]'); + + $producers_link = get_term_link('cablecast_producer'); + // Note: get_term_link with just taxonomy returns WP_Error, so we link to shows archive instead + $output .= ''; + $output .= __('All Producers', 'cablecast') . ' →'; + $output .= ''; + $output .= '
    '; + + $output .= '
    '; // browse-grid + $output .= '
    '; + } + + $output .= '
    '; // cablecast-home + + return $output; +} diff --git a/includes/sync.php b/includes/sync.php index dbae2a3..47d0e85 100644 --- a/includes/sync.php +++ b/includes/sync.php @@ -1,40 +1,66 @@ fieldDefinitions) && isset($field_definitions->showFields)) { - update_option('cablecast_custom_taxonomy_definitions', $field_definitions); + if (get_transient($lock_key)) { + \Cablecast\Logger::log('warning', 'Sync already in progress, skipping this run'); + return; } - $channels = cablecast_get_resources("$server/cablecastapi/v1/channels", 'channels'); - $live_streams = cablecast_get_resources("$server/cablecastapi/v1/livestreams", 'liveStreams'); - $categories = cablecast_get_resources("$server/cablecastapi/v1/categories", 'categories'); - $producers = cablecast_get_resources("$server/cablecastapi/v1/producers", 'producers'); - $projects = cablecast_get_resources("$server/cablecastapi/v1/projects", 'projects'); - $show_fields = cablecast_get_resources("$server/cablecastapi/v1/showfields", 'showFields'); - $field_definitions = cablecast_get_resources("$server/cablecastapi/v1/showfields", 'fieldDefinitions'); - - $two_days_ago = date('Y-m-d', strtotime("-2days")); - $schedule_sync_url = "$server/cablecastapi/v1/scheduleitems?start=$two_days_ago&include_deleted=true"; - $schedule_items = cablecast_get_resources($schedule_sync_url, 'scheduleItems', TRUE); - - $shows_payload = cablecast_get_shows_payload(); + // Acquire the lock + set_transient($lock_key, true, $lock_timeout); - cablecast_sync_channels($channels, $live_streams); - cablecast_sync_projects($projects); - cablecast_sync_producers($producers); - cablecast_sync_categories($categories); + try { + $options = get_option('cablecast_options'); + $server = $options["server"]; + \Cablecast\Logger::log('info', "Syncing data for $server"); + + $field_response = wp_remote_get("$server" . CABLECAST_API_BASE . "/showfields", array('timeout' => 30)); + if (!is_wp_error($field_response) && wp_remote_retrieve_response_code($field_response) === 200) { + $field_definitions = json_decode(wp_remote_retrieve_body($field_response)); + if (isset($field_definitions->fieldDefinitions) && isset($field_definitions->showFields)) { + update_option('cablecast_custom_taxonomy_definitions', $field_definitions); + } + } else { + \Cablecast\Logger::log('error', 'Failed to fetch show field definitions from API'); + } - cablecast_sync_shows($shows_payload, $categories, $projects, $producers, $show_fields, $field_definitions); - cablecast_sync_schedule($schedule_items); - cablecast_log( "Finished"); + $channels = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/channels", 'channels'); + $live_streams = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/livestreams", 'liveStreams'); + $categories = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/categories", 'categories'); + $producers = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/producers", 'producers'); + $projects = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/projects", 'projects'); + $show_fields = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/showfields", 'showFields'); + $field_definitions = cablecast_get_resources("$server" . CABLECAST_API_BASE . "/showfields", 'fieldDefinitions'); + + $today = date('Y-m-d', strtotime("now")); + $two_weeks_from_now = date('Y-m-d', strtotime('+2 weeks')); + $schedule_sync_url = "$server" . CABLECAST_API_BASE . "/scheduleitems?start=$today&end=$two_weeks_from_now&include_cg_exempt=false&page_size=2000"; + $schedule_items = cablecast_get_resources($schedule_sync_url, 'scheduleItems'); + + $shows_payload = cablecast_get_shows_payload(); + + cablecast_sync_channels($channels, $live_streams); + cablecast_sync_projects($projects); + cablecast_sync_producers($producers); + cablecast_sync_categories($categories); + + cablecast_sync_shows($shows_payload, $categories, $projects, $producers, $show_fields, $field_definitions); + cablecast_sync_schedule($schedule_items, $categories, $projects, $producers, $show_fields, $field_definitions); + \Cablecast\Logger::log('info', "Sync finished successfully"); + } catch (Exception $e) { + \Cablecast\Logger::log('error', "Sync failed with exception: " . $e->getMessage()); + } finally { + // Always release the lock when done, even if an error occurred + delete_transient($lock_key); + } } function cablecast_get_shows_payload() { + $batch_size = 100; $options = get_option('cablecast_options'); $since = get_option('cablecast_sync_since'); if ($since == FALSE) { @@ -45,22 +71,38 @@ function cablecast_get_shows_payload() { $sync_index = 0; } $server = $options["server"]; - cablecast_log ("Getting shows since: $since" ); + \Cablecast\Logger::log('info', "Getting shows since: $since"); $json_search = "{\"savedShowSearch\":{\"query\":{\"groups\":[{\"orAnd\":\"and\",\"filters\":[{\"field\":\"lastModified\",\"operator\":\"greaterThan\",\"searchValue\":\"$since\"}]}],\"sortOptions\":[{\"field\":\"lastModified\",\"descending\":false},{\"field\":\"title\",\"descending\":false}]},\"name\":\"\"}}"; - $opts = array('http' => - array( - 'method' => 'POST', - 'header' => 'Content-Type: application/json', - 'content' => $json_search, - 'ignore_errors' => true - ) - ); - $context = stream_context_create($opts); - $result = file_get_contents("$server/cablecastapi/v1/shows/search/advanced", false, $context); - $result = json_decode($result); + // Use wp_remote_post instead of file_get_contents for proper timeout handling + $search_response = wp_remote_post("$server" . CABLECAST_API_BASE . "/shows/search/advanced", array( + 'timeout' => 30, + 'headers' => array('Content-Type' => 'application/json'), + 'body' => $json_search, + )); + + if (is_wp_error($search_response)) { + \Cablecast\Logger::log('error', 'Failed to search shows: ' . $search_response->get_error_message()); + $response = new stdClass(); + $response->shows = []; + return $response; + } + if (wp_remote_retrieve_response_code($search_response) !== 200) { + \Cablecast\Logger::log('error', 'Show search API returned status: ' . wp_remote_retrieve_response_code($search_response)); + $response = new stdClass(); + $response->shows = []; + return $response; + } + + $result = json_decode(wp_remote_retrieve_body($search_response)); + if (!$result || !isset($result->savedShowSearch->results)) { + \Cablecast\Logger::log('error', 'Invalid JSON response from show search API'); + $response = new stdClass(); + $response->shows = []; + return $response; + } $total_result_count = count($result->savedShowSearch->results); if ($total_result_count <= $sync_index) { @@ -68,23 +110,52 @@ function cablecast_get_shows_payload() { update_option('cablecast_sync_index', $sync_index); } - $ids = array_slice($result->savedShowSearch->results, $sync_index, 100); + if ($total_result_count == 0) { + \Cablecast\Logger::log('info', "No shows to sync"); + $response = new stdClass(); + $response->shows = []; + return $response; + } + + $ids = array_slice($result->savedShowSearch->results, $sync_index, $batch_size); $processing_result_count = count($ids); $end_index = $sync_index + $processing_result_count; update_option('cablecast_sync_total_result_count', $total_result_count); - cablecast_log("Processing $sync_index through $end_index out of $total_result_count results for search"); + \Cablecast\Logger::log('info', "Processing $sync_index through $end_index out of $total_result_count results for search"); $id_query = ""; foreach ($ids as $id) { $id_query .= "&ids[]=$id"; } - $url = "$server/cablecastapi/v1/shows?page_size=100&include=reel,vod,webfile$id_query"; - cablecast_log("Retreving shows from using: $url"); + $url = "$server" . CABLECAST_API_BASE . "/shows?page_size=$batch_size&include=reel,vod,webfile,thumbnail$id_query"; + \Cablecast\Logger::log('info', "Retreving shows from using: $url"); + + // Use wp_remote_get instead of file_get_contents for proper timeout handling + $shows_response = wp_remote_get($url, array('timeout' => 30)); + + if (is_wp_error($shows_response)) { + \Cablecast\Logger::log('error', 'Failed to fetch shows: ' . $shows_response->get_error_message()); + $response = new stdClass(); + $response->shows = []; + return $response; + } + + if (wp_remote_retrieve_response_code($shows_response) !== 200) { + \Cablecast\Logger::log('error', 'Shows API returned status: ' . wp_remote_retrieve_response_code($shows_response)); + $response = new stdClass(); + $response->shows = []; + return $response; + } - $shows_json = file_get_contents($url); - $shows_payload = json_decode($shows_json); + $shows_payload = json_decode(wp_remote_retrieve_body($shows_response)); + if (!$shows_payload) { + \Cablecast\Logger::log('error', 'Invalid JSON response from shows API'); + $response = new stdClass(); + $response->shows = []; + return $response; + } return $shows_payload; } @@ -99,172 +170,258 @@ function cablecast_get_resources($url, $key, $ensure_all_loaded = FALSE) { $paged_url = "$url&page_size=$page_size"; } - cablecast_log("Retreiving $key from $paged_url"); - $result = json_decode(file_get_contents($paged_url)); + \Cablecast\Logger::log('info', "Retreiving $key from $paged_url"); + + // Use wp_remote_get instead of file_get_contents for proper timeout handling + $response = wp_remote_get($paged_url, array('timeout' => 30)); + + if (is_wp_error($response)) { + \Cablecast\Logger::log('error', "Failed to fetch $key: " . $response->get_error_message()); + return $resources; + } + + if (wp_remote_retrieve_response_code($response) !== 200) { + \Cablecast\Logger::log('error', "API returned status " . wp_remote_retrieve_response_code($response) . " for $key"); + return $resources; + } + + $result = json_decode(wp_remote_retrieve_body($response)); + if (!$result) { + \Cablecast\Logger::log('error', "Invalid JSON response for $key"); + return $resources; + } - if ($ensure_all_loaded && $result->meta->count > $result->meta->pageSize) { - cablecast_log("Not enough schedule items loaded. Increase page size"); + if ($ensure_all_loaded && isset($result->meta) && $result->meta->count > $result->meta->pageSize) { + \Cablecast\Logger::log('info', "Not enough schedule items loaded. Increase page size"); $page_size = $result->meta->count + 10; $paged_url = "$url&page_size=$page_size"; - cablecast_log("Retreiving $key from $paged_url"); - $result = json_decode(file_get_contents($paged_url)); + \Cablecast\Logger::log('info', "Retreiving $key from $paged_url"); + + $response = wp_remote_get($paged_url, array('timeout' => 60)); // longer timeout for large payloads + if (is_wp_error($response)) { + \Cablecast\Logger::log('error', "Failed to fetch $key (expanded): " . $response->get_error_message()); + return $resources; + } + if (wp_remote_retrieve_response_code($response) !== 200) { + \Cablecast\Logger::log('error', "API returned status " . wp_remote_retrieve_response_code($response) . " for $key (expanded)"); + return $resources; + } + $result = json_decode(wp_remote_retrieve_body($response)); + } + + if (isset($result->$key)) { + $resources = $result->$key; } - $resources = $result->$key; } catch (Exception $e) { - cablecast_log("Error retreiving \"$key\"" . $e->message); + \Cablecast\Logger::log('error', "Error retreiving \"$key\": " . $e->getMessage()); } return $resources; } -function cablecast_sync_shows($shows_payload, $categories, $projects, $producers, $show_fields, $field_definitions) { - $sync_total_result_count = get_option('cablecast_sync_total_result_count'); - $sync_index = get_option('cablecast_sync_index'); - if ($sync_index == FALSE) { - $sync_index = 0; - } +/** + * Sync a single show to WordPress. + * + * @param object $show Show data from API + * @param object $shows_payload Full payload (reels, vods, thumbnails, webFiles) + * @param array $categories Categories payload + * @param array $projects Projects payload + * @param array $producers Producers payload + * @param array $show_fields Show fields payload + * @param array $field_definitions Field definitions payload + * @return int|false Post ID on success, false on failure + */ +function cablecast_sync_single_show($show, $shows_payload, $categories, $projects, $producers, $show_fields, $field_definitions) { + \Cablecast\Logger::log('debug', "Syncing Show: ($show->id) $show->title"); - foreach($shows_payload->shows as $show) { - cablecast_log ("Syncing Show: ($show->id) $show->title"); - $args = array( - 'meta_key' => 'cablecast_show_id', - 'meta_value' => $show->id, - 'post_type' => 'show', - 'post_status' => 'any', - 'posts_per_page' => 1 + // Get thumbnail mode setting + $options = get_option('cablecast_options'); + $thumbnail_mode = isset($options['thumbnail_mode']) ? $options['thumbnail_mode'] : 'local'; + + $args = array( + 'meta_key' => 'cablecast_show_id', + 'meta_value' => $show->id, + 'post_type' => 'show', + 'post_status' => 'any', + 'posts_per_page' => 1 + ); + + $posts = get_posts($args); + if (count($posts)) { + $post = $posts[0]; + + $update_params = array( + 'ID' => $post->ID, + 'post_title' => isset($show->cgTitle) ? $show->cgTitle : $show->title, + 'post_content' => isset($show->comments) ? $show->comments : '', + 'post_date' => $show->eventDate ); - $posts = get_posts($args); - if (count($posts)) { - $post = $posts[0]; + wp_update_post($update_params); - $update_params = array( - 'ID' => $post->ID, + } else { + $post = array( 'post_title' => isset($show->cgTitle) ? $show->cgTitle : $show->title, 'post_content' => isset($show->comments) ? $show->comments : '', - 'post_date' => $show->eventDate - ); + 'post_date' => $show->eventDate, + 'post_status' => 'publish', + 'post_type' => 'show', + 'meta_input' => array( + 'cablecast_show_id' => $show->id + ) + ); + $post = get_post(wp_insert_post( $post )); + } - wp_update_post($update_params); - - } else { - $post = array( - 'post_title' => isset($show->cgTitle) ? $show->cgTitle : $show->title, - 'post_content' => isset($show->comments) ? $show->comments : '', - 'post_date' => $show->eventDate, - 'post_status' => 'publish', - 'post_type' => 'show', - 'meta_input' => array( - 'cablecast_show_id' => $show->id - ) - ); - $post = get_post(wp_insert_post( $post )); - } + if (!$post) { + return false; + } - $lastModified = get_metadata('post', $post->ID, 'cablecast_last_modified', true); - if ($lastModified == $show->lastModified) { - //print "Skipping $show->id: It has not been modified\n"; - //continue; + $id = $post->ID; + + if (isset($show->vods) && count($show->vods)) { + $vod = cablecast_extract_id($show->vods[0], $shows_payload->vods); + if ($vod != NULL) { + cablecast_upsert_post_meta($id, "cablecast_vod_url", $vod->url); + cablecast_upsert_post_meta($id, "cablecast_vod_embed", $vod->embedCode); + + // Fetch and store chapters for this VOD + $server = $options["server"]; + $chapters = cablecast_fetch_vod_chapters($server, $vod->id); + if (!empty($chapters)) { + cablecast_upsert_post_meta($id, "cablecast_vod_chapters", $chapters); + } else { + // Clear chapters if none exist (VOD may have had chapters removed) + delete_post_meta($id, "cablecast_vod_chapters"); + } } + } else { + // No VOD - clear any existing chapter data + delete_post_meta($id, "cablecast_vod_chapters"); + } - $id = $post->ID; - - if (isset($show->thumbnailImage) && isset($show->thumbnailImage->url)) { - $thumbnail_id = cablecast_insert_attachment_from_url($show->thumbnailImage->url, $id, true); - set_post_thumbnail( $id, $thumbnail_id ); + if (empty($show->producer) == FALSE) { + $producer = cablecast_extract_id($show->producer, $producers); + if ($producer != NULL) { + cablecast_upsert_post_meta($id, "cablecast_producer_name", $producer->name); + cablecast_upsert_post_meta($id, "cablecast_producer_id", $producer->id); + $processed_producer = cablecast_replace_commas_in_tag($producer->name); + wp_set_post_terms( $id, $processed_producer, 'cablecast_producer'); } + } - if (isset($show->showThumbnailOriginal)) { - $webFile = cablecast_extract_id($show->showThumbnailOriginal, $shows_payload->webFiles); - if ($webFile != NULL) { - $thumbnail_id = cablecast_insert_attachment_from_url($webFile->url, $id); - set_post_thumbnail( $id, $thumbnail_id ); - } + if (empty($show->project) == FALSE) { + $project = cablecast_extract_id($show->project, $projects); + if ($project != NULL) { + cablecast_upsert_post_meta($id, "cablecast_project_name", $project->name); + cablecast_upsert_post_meta($id, "cablecast_project_id", $project->id); + $processed_project = cablecast_replace_commas_in_tag($project->name); + wp_set_post_terms( $id, $processed_project, 'cablecast_project'); } + } - if (isset($show->vods) && count($show->vods)) { - $vod = cablecast_extract_id($show->vods[0], $shows_payload->vods); - if ($vod != NULL) { - cablecast_upsert_post_meta($id, "cablecast_vod_url", $vod->url); - cablecast_upsert_post_meta($id, "cablecast_vod_embed", $vod->embedCode); - } + if (empty($show->category) == FALSE) { + $category = cablecast_extract_id($show->category, $categories); + if ($category != NULL) { + cablecast_upsert_post_meta($id, "cablecast_category_name", $category->name); + cablecast_upsert_post_meta($id, "cablecast_category_id", $category->id); + $term = get_cat_ID( $category->name); + wp_set_post_terms($id, $term, 'category', true); } + } + cablecast_upsert_post_meta($id, "cablecast_show_id", $show->id); + cablecast_upsert_post_meta($id, "cablecast_show_title", $show->title); + cablecast_upsert_post_meta($id, "cablecast_show_cg_title", $show->cgTitle); + cablecast_upsert_post_meta($id, "cablecast_show_comments", $show->comments); + cablecast_upsert_post_meta($id, "cablecast_show_custom_1", $show->custom1); + cablecast_upsert_post_meta($id, "cablecast_show_custom_2", $show->custom2); + cablecast_upsert_post_meta($id, "cablecast_show_custom_3", $show->custom3); + cablecast_upsert_post_meta($id, "cablecast_show_custom_4", $show->custom4); + cablecast_upsert_post_meta($id, "cablecast_show_custom_5", $show->custom5); + cablecast_upsert_post_meta($id, "cablecast_show_custom_6", $show->custom6); + cablecast_upsert_post_meta($id, "cablecast_show_custom_7", $show->custom7); + cablecast_upsert_post_meta($id, "cablecast_show_custom_8", $show->custom8); + + if (isset($show->customFields)) { + $terms_to_set = []; + + foreach ($show->customFields as $custom_field) { + // Look up name of field + $show_field = cablecast_extract_id($custom_field->showField, $show_fields); + $field_definition = cablecast_extract_id($show_field->fieldDefinition, $field_definitions); + $tax_name = "cbl-tax-" . $custom_field->showField; + + if (taxonomy_exists($tax_name)) { + if (!isset($terms_to_set[$tax_name])) { + $terms_to_set[$tax_name] = []; + } + // Append new terms to the taxonomy array + $terms_to_set[$tax_name][] = $custom_field->fieldValueString; + } - if (empty($show->producer) == FALSE) { - $producer = cablecast_extract_id($show->producer, $producers); - if ($producer != NULL) { - cablecast_upsert_post_meta($id, "cablecast_producer_name", $producer->name); - cablecast_upsert_post_meta($id, "cablecast_producer_id", $producer->id); - $processed_producer = cablecast_replace_commas_in_tag($producer->name); - wp_set_post_terms( $id, $processed_producer, 'cablecast_producer'); - } + cablecast_upsert_post_meta($id, $field_definition->name, $custom_field->value); } - if (empty($show->project) == FALSE) { - $project = cablecast_extract_id($show->project, $projects); - if ($project != NULL) { - cablecast_upsert_post_meta($id, "cablecast_project_name", $project->name); - cablecast_upsert_post_meta($id, "cablecast_project_id", $project->id); - $processed_project = cablecast_replace_commas_in_tag($project->name); - wp_set_post_terms( $id, $processed_project, 'cablecast_project'); - } + // Set all collected terms for each taxonomy + foreach ($terms_to_set as $taxonomy => $terms) { + // Use array_values to ensure the terms are correctly formatted as an array + wp_set_post_terms($id, array_values($terms), $taxonomy); } + } + + cablecast_upsert_post_meta($id, "cablecast_show_event_date", $show->eventDate); + cablecast_upsert_post_meta($id, "cablecast_show_location_id", $show->location); + cablecast_upsert_post_meta($id, "cablecast_last_modified", $show->lastModified); + + $trt = cablecast_calculate_trt($show, $shows_payload->reels); + cablecast_upsert_post_meta($id, "cablecast_show_trt", $trt); - if (empty($show->category) == FALSE) { - $category = cablecast_extract_id($show->category, $categories); - if ($category != NULL) { - cablecast_upsert_post_meta($id, "cablecast_category_name", $category->name); - cablecast_upsert_post_meta($id, "cablecast_category_id", $category->id); - $term = get_cat_ID( $category->name); - wp_set_post_terms($id, $term, 'category', true); + // Handle thumbnails based on mode setting + if ($thumbnail_mode === 'local') { + // Original behavior - download thumbnails as WordPress attachments + if (isset($show->thumbnailImage) && isset($show->thumbnailImage->url)) { + // Validate URL before downloading + $thumbnail_url = esc_url_raw($show->thumbnailImage->url); + if (wp_http_validate_url($thumbnail_url)) { + $thumbnail_id = cablecast_insert_attachment_from_url($thumbnail_url, $id, true); + if ($thumbnail_id) { + set_post_thumbnail($id, $thumbnail_id); + } + } else { + \Cablecast\Logger::log('warning', "Invalid thumbnail URL for show $show->id: " . $show->thumbnailImage->url); } } - cablecast_upsert_post_meta($id, "cablecast_show_id", $show->id); - cablecast_upsert_post_meta($id, "cablecast_show_title", $show->title); - cablecast_upsert_post_meta($id, "cablecast_show_cg_title", $show->cgTitle); - cablecast_upsert_post_meta($id, "cablecast_show_comments", $show->comments); - cablecast_upsert_post_meta($id, "cablecast_show_custom_1", $show->custom1); - cablecast_upsert_post_meta($id, "cablecast_show_custom_2", $show->custom2); - cablecast_upsert_post_meta($id, "cablecast_show_custom_3", $show->custom3); - cablecast_upsert_post_meta($id, "cablecast_show_custom_4", $show->custom4); - cablecast_upsert_post_meta($id, "cablecast_show_custom_5", $show->custom5); - cablecast_upsert_post_meta($id, "cablecast_show_custom_6", $show->custom6); - cablecast_upsert_post_meta($id, "cablecast_show_custom_7", $show->custom7); - cablecast_upsert_post_meta($id, "cablecast_show_custom_8", $show->custom8); - - if (isset($show->customFields)) { - $terms_to_set = []; - - foreach ($show->customFields as $custom_field) { - // Look up name of field - $show_field = cablecast_extract_id($custom_field->showField, $show_fields); - $field_definition = cablecast_extract_id($show_field->fieldDefinition, $field_definitions); - $tax_name = "cbl-tax-" . $custom_field->showField; - - if (taxonomy_exists($tax_name)) { - if (!isset($terms_to_set[$tax_name])) { - $terms_to_set[$tax_name] = []; - } - // Append new terms to the taxonomy array - $terms_to_set[$tax_name][] = $custom_field->fieldValueString; - } - - cablecast_upsert_post_meta($id, $field_definition->name, $custom_field->value); - } - - // Set all collected terms for each taxonomy - foreach ($terms_to_set as $taxonomy => $terms) { - // Use array_values to ensure the terms are correctly formatted as an array - wp_set_post_terms($id, array_values($terms), $taxonomy); + } else { + // Remote hosting - save URL to meta for CDN-based display + if (isset($show->thumbnailImage) && isset($show->thumbnailImage->url)) { + // Validate URL before saving to prevent storing malicious URLs + $thumbnail_url = esc_url_raw($show->thumbnailImage->url); + if (wp_http_validate_url($thumbnail_url)) { + cablecast_upsert_post_meta($id, "cablecast_thumbnail_url", $thumbnail_url); + } else { + \Cablecast\Logger::log('warning', "Invalid thumbnail URL for show $show->id: " . $show->thumbnailImage->url); } + } } - cablecast_upsert_post_meta($id, "cablecast_show_event_date", $show->eventDate); - cablecast_upsert_post_meta($id, "cablecast_show_location_id", $show->location); - cablecast_upsert_post_meta($id, "cablecast_last_modified", $show->lastModified); + return $id; +} + +function cablecast_sync_shows($shows_payload, $categories, $projects, $producers, $show_fields, $field_definitions) { + $sync_total_result_count = get_option('cablecast_sync_total_result_count'); + $sync_index = get_option('cablecast_sync_index'); + if ($sync_index == FALSE) { + $sync_index = 0; + } - $trt = cablecast_calculate_trt($show, $shows_payload->reels); - cablecast_upsert_post_meta($id, "cablecast_show_trt", $trt); + foreach($shows_payload->shows as $show) { + // Sync the show using the helper function + $post_id = cablecast_sync_single_show($show, $shows_payload, $categories, $projects, $producers, $show_fields, $field_definitions); + if ($post_id === false) { + continue; + } + + // Update sync cursor (this happens per-show for resume capability) $since = get_option('cablecast_sync_since'); $sync_index = $sync_index + 1; update_option('cablecast_sync_index', $sync_index); @@ -273,8 +430,159 @@ function cablecast_sync_shows($shows_payload, $categories, $projects, $producers update_option('cablecast_sync_total_result_count', 0); update_option('cablecast_sync_index', 0); update_option('cablecast_sync_since', $since); + + // Run orphan detection after a full sync cycle completes + cablecast_detect_orphan_posts(); + } + } +} + +/** + * Sync specific shows by ID without affecting the regular sync cursor. + * Used to prioritize shows appearing in the upcoming schedule. + * + * @param array $show_ids Array of Cablecast show IDs to sync + * @param array $categories Categories payload + * @param array $projects Projects payload + * @param array $producers Producers payload + * @param array $show_fields Show fields payload + * @param array $field_definitions Field definitions payload + * @return int Number of shows synced + */ +function cablecast_sync_priority_shows($show_ids, $categories, $projects, $producers, $show_fields, $field_definitions) { + if (empty($show_ids)) { + return 0; + } + + $options = get_option('cablecast_options'); + $server = $options["server"] ?? ''; + + if (empty($server)) { + \Cablecast\Logger::log('error', 'Cannot priority sync shows: no server configured'); + return 0; + } + + $batch_size = 100; + $synced_count = 0; + + // Process in batches of 100 + $batches = array_chunk($show_ids, $batch_size); + + foreach ($batches as $batch) { + // Build query string for batch + $id_query = ""; + foreach ($batch as $id) { + $id_query .= "&ids[]=" . intval($id); + } + + $url = "$server" . CABLECAST_API_BASE . "/shows?page_size=$batch_size&include=reel,vod,webfile,thumbnail$id_query"; + \Cablecast\Logger::log('info', "Priority syncing " . count($batch) . " shows from: $url"); + + $response = wp_remote_get($url, array('timeout' => 30)); + + if (is_wp_error($response)) { + \Cablecast\Logger::log('error', 'Failed to fetch priority shows: ' . $response->get_error_message()); + continue; + } + + if (wp_remote_retrieve_response_code($response) !== 200) { + \Cablecast\Logger::log('error', 'Priority shows API returned status: ' . wp_remote_retrieve_response_code($response)); + continue; + } + + $shows_payload = json_decode(wp_remote_retrieve_body($response)); + if (!$shows_payload || !isset($shows_payload->shows)) { + \Cablecast\Logger::log('error', 'Invalid JSON response from priority shows API'); + continue; + } + + // Sync each show in the batch + foreach ($shows_payload->shows as $show) { + $post_id = cablecast_sync_single_show( + $show, + $shows_payload, + $categories, + $projects, + $producers, + $show_fields, + $field_definitions + ); + + if ($post_id !== false) { + $synced_count++; + } } } + + \Cablecast\Logger::log('info', "Priority sync complete: $synced_count shows synced"); + return $synced_count; +} + +/** + * Detect shows in WordPress that may no longer exist in Cablecast. + * Logs warnings for potential orphans but does not auto-delete. + */ +function cablecast_detect_orphan_posts() { + $options = get_option('cablecast_options'); + $server = $options["server"] ?? ''; + + if (empty($server)) { + return; + } + + // Only run orphan detection once per day to avoid excessive API calls + $last_check = get_option('cablecast_orphan_check_last_run', 0); + $one_day_ago = time() - DAY_IN_SECONDS; + + if ($last_check > $one_day_ago) { + return; + } + + update_option('cablecast_orphan_check_last_run', time()); + + // Get all Cablecast show IDs from the API + $api_url = "$server" . CABLECAST_API_BASE . "/shows?page_size=10000&fields=id"; + $response = wp_remote_get($api_url, array('timeout' => 60)); + + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { + \Cablecast\Logger::log('warning', 'Could not fetch show IDs for orphan detection'); + return; + } + + $body = json_decode(wp_remote_retrieve_body($response)); + if (!$body || !isset($body->shows)) { + return; + } + + // Extract all Cablecast show IDs + $api_show_ids = array_map(function($show) { + return (int) $show->id; + }, $body->shows); + + // Get all WordPress show posts with cablecast_show_id + $wp_shows = get_posts(array( + 'post_type' => 'show', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'meta_key' => 'cablecast_show_id', + 'fields' => 'ids', + )); + + $orphan_count = 0; + foreach ($wp_shows as $post_id) { + $cablecast_id = (int) get_post_meta($post_id, 'cablecast_show_id', true); + if ($cablecast_id && !in_array($cablecast_id, $api_show_ids, true)) { + $orphan_count++; + $post_title = get_the_title($post_id); + \Cablecast\Logger::log('warning', "Potential orphan: Show '$post_title' (WP ID: $post_id, Cablecast ID: $cablecast_id) not found in Cablecast API"); + } + } + + if ($orphan_count > 0) { + \Cablecast\Logger::log('info', "Orphan detection complete: $orphan_count potential orphan(s) found"); + } else { + \Cablecast\Logger::log('info', "Orphan detection complete: No orphans found"); + } } function cablecast_calculate_trt($show, $reels_payload) { @@ -357,61 +665,183 @@ function cablecast_get_schedule_item_by_id($id) { return $post; } -function cablecast_sync_schedule($scheduleItems) { +/** + * Sync Cablecast schedule items into WP DB with global pruning: + * After syncing, delete any rows whose schedule_item_id isn't in the payload (global scope). + * + * Priority syncs any shows referenced in schedule that don't exist locally before processing. + * + * @param array|object $scheduleItems Schedule items from API + * @param array $categories Categories payload for priority sync + * @param array $projects Projects payload for priority sync + * @param array $producers Producers payload for priority sync + * @param array $show_fields Show fields payload for priority sync + * @param array $field_definitions Field definitions payload for priority sync + * @return bool True if work ran (hash changed or no prior hash), false if skipped. + */ +function cablecast_sync_schedule($scheduleItems, $categories = [], $projects = [], $producers = [], $show_fields = [], $field_definitions = []) { global $wpdb; - foreach($scheduleItems as $item) { + + // ---- Early-exit guard: compare payload hashes ---- + $payload_json = wp_json_encode($scheduleItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $new_hash = md5($payload_json); + $option_key = 'cablecast_schedule_items_hash'; + + $prev_hash = get_option($option_key, ''); + if (!empty($prev_hash) && hash_equals($prev_hash, $new_hash)) { + \Cablecast\Logger::log('info', "Schedule items unchanged; skipping DB sync."); + return false; // unchanged payload; skip DB work + } + + // ---- Priority sync: detect and sync missing shows from schedule ---- + $schedule_show_ids = []; + foreach ($scheduleItems as $item) { + if (!empty($item->show)) { + $schedule_show_ids[] = (int)$item->show; + } + } + $schedule_show_ids = array_unique($schedule_show_ids); + + // Find which shows are missing locally + $missing_show_ids = []; + foreach ($schedule_show_ids as $show_id) { + if (!cablecast_get_show_post_by_id($show_id)) { + $missing_show_ids[] = $show_id; + } + } + + // Priority sync missing shows before processing schedule + if (!empty($missing_show_ids)) { + \Cablecast\Logger::log('info', "Found " . count($missing_show_ids) . " shows in schedule that need priority sync"); + cablecast_sync_priority_shows($missing_show_ids, $categories, $projects, $producers, $show_fields, $field_definitions); + } + + $table = $wpdb->prefix . 'cablecast_schedule_items'; + + // Collect *all* schedule_item_ids from the payload to use for global pruning + $all_payload_ids = []; + + foreach ($scheduleItems as $item) { if (!$item->show) { continue; } - $table = $wpdb->prefix . 'cablecast_schedule_items'; - $existing_row = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE schedule_item_id=%d", $item->id)); - $show = cablecast_get_show_post_by_id($item->show); - $run_date_time = new DateTime($item->runDateTime); - $run_date_time->setTimezone(new DateTimeZone('UTC')); - $run_date_time_str = $run_date_time->format('Y-m-d H:i:s'); // Convert DateTime to string + $schedule_item_id = (int)$item->id; + $all_payload_ids[] = $schedule_item_id; + + $is_deleted = isset($item->deleted) ? (bool)$item->deleted : false; + + // Lookup existing row + $existing_row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE schedule_item_id = %d", $schedule_item_id) + ); + + // Map show post + $show = cablecast_get_show_post_by_id($item->show); if (!$show) { continue; } - if (empty($existing_row) && $item->deleted == FALSE) { + + // Normalize time to UTC + try { + $run_date_time = new DateTime($item->runDateTime); + $run_date_time->setTimezone(new DateTimeZone('UTC')); + $run_date_time_str = $run_date_time->format('Y-m-d H:i:s'); + } catch (Exception $e) { + continue; // skip bad datetime + } + + if (empty($existing_row) && $is_deleted === false) { + // Insert $wpdb->insert( - $table, - array( - 'run_date_time' => $run_date_time_str, - 'show_id' => $item->show, - 'show_title' => $show->post_title, - 'show_post_id' => $show->ID, - 'channel_id' => $item->channel, - 'channel_post_id' => 0, - 'schedule_item_id' => $item->id, - 'cg_exempt' => $item->cgExempt - ) + $table, + array( + 'run_date_time' => $run_date_time_str, + 'show_id' => (int)$item->show, + 'show_title' => $show->post_title, + 'show_post_id' => (int)$show->ID, + 'channel_id' => (int)$item->channel, + 'channel_post_id' => 0, + 'schedule_item_id' => $schedule_item_id, + 'cg_exempt' => (int)!empty($item->cgExempt), + ), + array('%s','%d','%s','%d','%d','%d','%d','%d') ); - } else if ($item->deleted == FALSE){ + } else if ($is_deleted === false) { + // Update $wpdb->update( $table, array( - 'run_date_time' => $run_date_time_str, - 'show_id' => $item->show, - 'show_title' => $show->post_title, - 'show_post_id' => $show->ID, - 'channel_id' => $item->channel, - 'channel_post_id' => 99, - 'schedule_item_id' => $item->id, - 'cg_exempt' => $item->cgExempt + 'run_date_time' => $run_date_time_str, + 'show_id' => (int)$item->show, + 'show_title' => $show->post_title, + 'show_post_id' => (int)$show->ID, + 'channel_id' => (int)$item->channel, + 'channel_post_id' => 99, + 'schedule_item_id' => $schedule_item_id, + 'cg_exempt' => (int)!empty($item->cgExempt), ), - array( - 'schedule_item_id' => $item->id - ) + array('schedule_item_id' => $schedule_item_id), + array('%s','%d','%s','%d','%d','%d','%d','%d'), + array('%d') ); } else { - $wpdb->delete( - $table, - array( - 'schedule_item_id' => $item->id - ) + // Delete (explicitly flagged as deleted from remote) + $wpdb->delete($table, array('schedule_item_id' => $schedule_item_id), array('%d')); + } + } + + // ---- Global prune: delete any DB rows not in the payload ---- + // Deduplicate incoming IDs + $all_payload_ids = array_values(array_unique(array_map('intval', $all_payload_ids))); + + if (empty($all_payload_ids)) { + // If the payload is empty and represents the complete dataset, wipe the table. + // (This matches your requirement: delete any id not in the change set — here that's "all".) + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query("DELETE FROM {$table}"); + } else { + // Get current IDs from DB + $existing_ids = $wpdb->get_col("SELECT schedule_item_id FROM {$table}"); + $existing_ids = array_map('intval', $existing_ids); + + // Compute rows to delete + $to_delete = array_values(array_diff($existing_ids, $all_payload_ids)); + + // Delete in chunks to keep placeholder lists reasonable + $chunk_size = 500; + for ($i = 0; $i < count($to_delete); $i += $chunk_size) { + $chunk = array_slice($to_delete, $i, $chunk_size); + if (empty($chunk)) { continue; } + + $placeholders = implode(',', array_fill(0, count($chunk), '%d')); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $sql = $wpdb->prepare( + "DELETE FROM {$table} WHERE schedule_item_id IN ($placeholders)", + $chunk ); + $wpdb->query($sql); } } + + // ---- Persist the new hash after successful processing ---- + if ($prev_hash === '') { + add_option($option_key, $new_hash, '', 'no'); + } else { + update_option($option_key, $new_hash, 'no'); + } + + return true; } function cablecast_sync_categories($categories) { + // ---- Early-exit guard: compare payload hashes ---- + $payload_json = wp_json_encode($categories, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $new_hash = md5($payload_json); + $option_key = 'cablecast_categories_items_hash'; + + $prev_hash = get_option($option_key, ''); + if (!empty($prev_hash) && hash_equals($prev_hash, $new_hash)) { + \Cablecast\Logger::log('info', "Category items unchanged; skipping DB sync."); + return false; // unchanged payload; skip DB work + } + foreach ($categories as $category) { $term = term_exists( $category->name, 'category' ); // array is returned if taxonomy is given if ($term == NULL) { @@ -421,9 +851,28 @@ function cablecast_sync_categories($categories) { ); } } + + // ---- Persist the new hash after successful processing ---- + if ($prev_hash === '') { + add_option($option_key, $new_hash, '', 'no'); + } else { + update_option($option_key, $new_hash, 'no'); + } } function cablecast_sync_projects($projects) { + // ---- Early-exit guard: compare payload hashes ---- + $payload_json = wp_json_encode($projects, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $new_hash = md5($payload_json); + $option_key = 'cablecast_projects_items_hash'; + + $prev_hash = get_option($option_key, ''); + if (!empty($prev_hash) && hash_equals($prev_hash, $new_hash)) { + \Cablecast\Logger::log('info', "Project items unchanged; skipping DB sync."); + return false; // unchanged payload; skip DB work + } + + foreach ($projects as $project) { $processed = cablecast_replace_commas_in_tag($project->name); $term = term_exists( $processed, 'cablecast_project' ); // array is returned if taxonomy is given @@ -441,9 +890,27 @@ function cablecast_sync_projects($projects) { )); } } + + // ---- Persist the new hash after successful processing ---- + if ($prev_hash === '') { + add_option($option_key, $new_hash, '', 'no'); + } else { + update_option($option_key, $new_hash, 'no'); + } } function cablecast_sync_producers($producers) { + // ---- Early-exit guard: compare payload hashes ---- + $payload_json = wp_json_encode($producers, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $new_hash = md5($payload_json); + $option_key = 'cablecast_producers_items_hash'; + + $prev_hash = get_option($option_key, ''); + if (!empty($prev_hash) && hash_equals($prev_hash, $new_hash)) { + \Cablecast\Logger::log('info', "Producer items unchanged; skipping DB sync."); + return false; // unchanged payload; skip DB work + } + foreach ($producers as $producer) { $processed = cablecast_replace_commas_in_tag($producer->name); if (empty($processed)) { return; } @@ -468,6 +935,13 @@ function cablecast_sync_producers($producers) { cablecast_upsert_term_meta($term['term_id'], 'cablecast_producer_phone_two', $producer->phoneTwo); cablecast_upsert_term_meta($term['term_id'], 'cablecast_producer_website', $producer->website); } + + // ---- Persist the new hash after successful processing ---- + if ($prev_hash === '') { + add_option($option_key, $new_hash, '', 'no'); + } else { + update_option($option_key, $new_hash, 'no'); + } } function cablecast_replace_commas_in_tag($tag) { @@ -577,6 +1051,100 @@ function cablecast_upsert_term_meta($id, $name, $value) { } } +/** + * @deprecated Use \Cablecast\Logger::log() instead + */ function cablecast_log ($message) { - error_log("[Cablecast] $message"); + \Cablecast\Logger::log('info', $message); +} + +/** + * Fetch chapters for a VOD from the Cablecast API. + * + * @param string $server Server base URL + * @param int $vod_id VOD ID to fetch chapters for + * @return array Array of chapter data or empty array + */ +function cablecast_fetch_vod_chapters($server, $vod_id) { + $chapters_url = "$server" . CABLECAST_API_BASE . "/chapters?vod=$vod_id"; + + $response = wp_remote_get($chapters_url, array('timeout' => 15)); + + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { + \Cablecast\Logger::log('debug', "Failed to fetch chapters for VOD $vod_id"); + return []; + } + + $body = json_decode(wp_remote_retrieve_body($response)); + + if (!$body || !isset($body->chapters)) { + return []; + } + + // Filter out deleted and hidden chapters, normalize the data + $chapters = []; + foreach ($body->chapters as $chapter) { + if (!empty($chapter->deleted) || !empty($chapter->hidden)) { + continue; + } + + $chapters[] = [ + 'id' => $chapter->id, + 'title' => $chapter->title, + 'body' => $chapter->body ?? '', + 'offset' => (int) $chapter->offset, + ]; + } + + // Sort by offset ascending + usort($chapters, function($a, $b) { + return $a['offset'] - $b['offset']; + }); + + return $chapters; +} + +/** + * Cleanup local thumbnails when user switches to remote hosting. + * Runs in batches during cron to avoid timeout issues. + */ +function cablecast_cleanup_local_thumbnails() { + $options = get_option('cablecast_options'); + + // Only run if deletion requested AND in remote mode + if (empty($options['delete_local_thumbnails']) || ($options['thumbnail_mode'] ?? 'local') !== 'remote') { + return; + } + + $batch_size = 25; + + // Find show posts with featured images + $args = [ + 'post_type' => 'show', + 'meta_query' => [ + ['key' => '_thumbnail_id', 'compare' => 'EXISTS'] + ], + 'posts_per_page' => $batch_size, + 'fields' => 'ids' + ]; + $posts = get_posts($args); + + if (empty($posts)) { + // Done - clear the flag + $options['delete_local_thumbnails'] = false; + update_option('cablecast_options', $options); + \Cablecast\Logger::log('info', "Thumbnail cleanup complete"); + return; + } + + foreach ($posts as $post_id) { + $thumbnail_id = get_post_thumbnail_id($post_id); + if ($thumbnail_id) { + wp_delete_attachment($thumbnail_id, true); + delete_post_meta($post_id, '_thumbnail_id'); + \Cablecast\Logger::log('info', "Deleted thumbnail $thumbnail_id for show $post_id"); + } + } + + \Cablecast\Logger::log('info', "Processed $batch_size thumbnails for deletion, more may remain"); } diff --git a/includes/template-loader.php b/includes/template-loader.php new file mode 100644 index 0000000..5bbbf54 --- /dev/null +++ b/includes/template-loader.php @@ -0,0 +1,561 @@ + 'show', + 'tax_query' => [[ + 'taxonomy' => 'category', + 'field' => 'term_id', + 'terms' => $category_id, + ]], + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ]); + + $cache[$category_id] = $query->have_posts(); + wp_reset_postdata(); + + return $cache[$category_id]; +} + +/** + * Get the path to the plugin's templates directory. + * + * @return string + */ +function cablecast_get_templates_dir() { + return plugin_dir_path(dirname(__FILE__)) . 'templates/'; +} + +/** + * Get the theme's template path for Cablecast templates. + * + * @return string + */ +function cablecast_get_theme_template_path() { + return apply_filters('cablecast_theme_template_path', 'cablecast/'); +} + +/** + * Locate a template file. + * + * Search order: + * 1. yourtheme/cablecast/$template_name + * 2. yourtheme/$template_name (for backwards compatibility) + * 3. wp-cablecast/templates/$template_name + * + * @param string $template_name Template file name (e.g., 'single-show.php') + * @param string $template_path Optional. Theme subdirectory to check. Default 'cablecast/'. + * @param string $default_path Optional. Plugin templates directory. Default plugin's templates dir. + * @return string Full path to template file, or empty string if not found. + */ +function cablecast_locate_template($template_name, $template_path = '', $default_path = '') { + if (!$template_path) { + $template_path = cablecast_get_theme_template_path(); + } + + if (!$default_path) { + $default_path = cablecast_get_templates_dir(); + } + + // Look in theme/cablecast/ first + $template = locate_template([ + trailingslashit($template_path) . $template_name, + $template_name, // Backwards compatibility - check theme root too + ]); + + // If not found in theme, use plugin default + if (!$template && file_exists($default_path . $template_name)) { + $template = $default_path . $template_name; + } + + /** + * Filter the located template path. + * + * @param string $template The located template path. + * @param string $template_name The template file name. + * @param string $template_path The theme template path. + */ + return apply_filters('cablecast_locate_template', $template, $template_name, $template_path); +} + +/** + * Get and include a template file. + * + * @param string $template_name Template file name (e.g., 'single-show.php') + * @param array $args Variables to pass to the template. + * @param string $template_path Optional. Theme subdirectory to check. + * @param string $default_path Optional. Plugin templates directory. + */ +function cablecast_get_template($template_name, $args = [], $template_path = '', $default_path = '') { + $template = cablecast_locate_template($template_name, $template_path, $default_path); + + if (!$template) { + return; + } + + /** + * Filter the template file path before including. + * + * @param string $template The template path. + * @param string $template_name The template name. + * @param array $args Template arguments. + */ + $template = apply_filters('cablecast_get_template', $template, $template_name, $args); + + /** + * Action before template is loaded. + * + * @param string $template_name The template name. + * @param string $template_path The template path in theme. + * @param string $template The full template path. + * @param array $args Template arguments. + */ + do_action('cablecast_before_template', $template_name, $template_path, $template, $args); + + // Extract args to make them available as variables in the template + if (!empty($args) && is_array($args)) { + extract($args); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract + } + + include $template; + + /** + * Action after template is loaded. + * + * @param string $template_name The template name. + * @param string $template_path The template path in theme. + * @param string $template The full template path. + * @param array $args Template arguments. + */ + do_action('cablecast_after_template', $template_name, $template_path, $template, $args); +} + +/** + * Get a template part and return it as a string. + * + * @param string $template_name Template file name. + * @param array $args Variables to pass to the template. + * @param string $template_path Optional. Theme subdirectory to check. + * @param string $default_path Optional. Plugin templates directory. + * @return string Template output. + */ +function cablecast_get_template_html($template_name, $args = [], $template_path = '', $default_path = '') { + ob_start(); + cablecast_get_template($template_name, $args, $template_path, $default_path); + return ob_get_clean(); +} + +/** + * Main template loader hook. + * + * Intercepts WordPress template loading for Cablecast post types and taxonomies. + * + * @param string $template The current template path. + * @return string The template path to use. + */ +function cablecast_template_loader($template) { + // Check if templates are enabled (default to enabled) + $options = get_option('cablecast_options', []); + $templates_enabled = isset($options['enable_templates']) ? (bool) $options['enable_templates'] : true; + + if (!$templates_enabled) { + return $template; + } + + $file = ''; + + // Single show page + if (is_singular('show')) { + $file = 'single-show.php'; + + /** + * Filter the single show template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_single_show_template', $file); + } + // Single channel page + elseif (is_singular('cablecast_channel')) { + $file = 'single-channel.php'; + + /** + * Filter the single channel template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_single_channel_template', $file); + } + // Producer taxonomy archive + elseif (is_tax('cablecast_producer')) { + $file = 'archive-producer.php'; + + /** + * Filter the producer archive template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_archive_producer_template', $file); + } + // Series/Project taxonomy archive + elseif (is_tax('cablecast_project')) { + $file = 'archive-series.php'; + + /** + * Filter the series archive template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_archive_series_template', $file); + } + // Shows archive + elseif (is_post_type_archive('show')) { + $file = 'archive-show.php'; + + /** + * Filter the shows archive template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_archive_show_template', $file); + } + // Channels archive + elseif (is_post_type_archive('cablecast_channel')) { + $file = 'archive-channel.php'; + + /** + * Filter the channels archive template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_archive_channel_template', $file); + } + // Category archive (only if category has shows) + elseif (is_category() && cablecast_category_has_shows(get_queried_object_id())) { + $file = 'archive-category.php'; + + /** + * Filter the category archive template file. + * + * @param string $file The template file name. + */ + $file = apply_filters('cablecast_archive_category_template', $file); + } + + // If we have a template file to load, try to locate it + if ($file) { + $located = cablecast_locate_template($file); + + if ($located) { + /** + * Filter the final template path before returning. + * + * @param string $located The located template path. + * @param string $file The template file name. + * @param string $template The original template path. + */ + return apply_filters('cablecast_template_loader_file', $located, $file, $template); + } + } + + return $template; +} +add_filter('template_include', 'cablecast_template_loader', 10); + +/** + * Get show meta data for display. + * + * @param int|WP_Post $post Optional. Post ID or post object. Defaults to current post. + * @return array Array of meta data with labels and values. + */ +function cablecast_get_show_meta($post = null) { + $post = get_post($post); + + if (!$post || $post->post_type !== 'show') { + return []; + } + + $meta = []; + + // Runtime + $trt = get_post_meta($post->ID, 'cablecast_show_trt', true); + if (!empty($trt)) { + $meta['runtime'] = [ + 'label' => __('Runtime', 'cablecast'), + 'value' => cablecast_format_runtime(absint($trt)), + 'raw' => $trt, + ]; + } + + // Producer + $producer = get_post_meta($post->ID, 'cablecast_producer_name', true); + if (!empty($producer)) { + $producer_link = get_term_link(cablecast_replace_commas_in_tag($producer), 'cablecast_producer'); + $meta['producer'] = [ + 'label' => __('Producer', 'cablecast'), + 'value' => $producer, + 'link' => !is_wp_error($producer_link) ? $producer_link : '', + ]; + } + + // Series + $project = get_post_meta($post->ID, 'cablecast_project_name', true); + if (!empty($project)) { + $project_link = get_term_link(cablecast_replace_commas_in_tag($project), 'cablecast_project'); + $meta['series'] = [ + 'label' => __('Series', 'cablecast'), + 'value' => $project, + 'link' => !is_wp_error($project_link) ? $project_link : '', + ]; + } + + // Category + $category = get_post_meta($post->ID, 'cablecast_category_name', true); + if (!empty($category)) { + $category_link = get_term_link($category, 'category'); + $meta['category'] = [ + 'label' => __('Category', 'cablecast'), + 'value' => $category, + 'link' => !is_wp_error($category_link) ? $category_link : '', + ]; + } + + /** + * Filter the show meta data. + * + * @param array $meta Array of meta data. + * @param WP_Post $post The post object. + */ + return apply_filters('cablecast_show_meta', $meta, $post); +} + +/** + * Check if show has VOD available. + * + * @param int|WP_Post $post Optional. Post ID or post object. + * @return bool + */ +function cablecast_has_vod($post = null) { + $post = get_post($post); + + if (!$post || $post->post_type !== 'show') { + return false; + } + + $vod_embed = get_post_meta($post->ID, 'cablecast_vod_embed', true); + $vod_url = get_post_meta($post->ID, 'cablecast_vod_url', true); + + return !empty($vod_embed) || !empty($vod_url); +} + +/** + * Check if show has VOD chapters. + * + * @param int|WP_Post $post Optional. Post ID or post object. + * @return bool + */ +function cablecast_has_chapters($post = null) { + $post = get_post($post); + + if (!$post || $post->post_type !== 'show') { + return false; + } + + $chapters = get_post_meta($post->ID, 'cablecast_vod_chapters', true); + + return !empty($chapters); +} + +/** + * Get VOD embed code for a show. + * + * @param int|WP_Post $post Optional. Post ID or post object. + * @return string VOD embed HTML or empty string. + */ +function cablecast_get_vod_embed($post = null) { + $post = get_post($post); + + if (!$post || $post->post_type !== 'show') { + return ''; + } + + // Prefer embed code (supports chapters and more features) + $vod_embed = get_post_meta($post->ID, 'cablecast_vod_embed', true); + if (!empty($vod_embed)) { + return $vod_embed; + } + + // Fall back to direct URL with WordPress video shortcode + $vod_url = get_post_meta($post->ID, 'cablecast_vod_url', true); + if (!empty($vod_url)) { + $poster = get_the_post_thumbnail_url($post->ID, 'large'); + $shortcode = '[video src="' . esc_url($vod_url) . '"'; + if ($poster) { + $shortcode .= ' poster="' . esc_url($poster) . '"'; + } + $shortcode .= ']'; + return do_shortcode($shortcode); + } + + return ''; +} + +/** + * Get channel live embed code. + * + * @param int|WP_Post $post Optional. Post ID or post object. + * @return string Live embed HTML or empty string. + */ +function cablecast_get_channel_live_embed($post = null) { + $post = get_post($post); + + if (!$post || $post->post_type !== 'cablecast_channel') { + return ''; + } + + $embed_code = get_post_meta($post->ID, 'cablecast_channel_live_embed_code', true); + + if (empty($embed_code)) { + return ''; + } + + // Allow only safe HTML elements + $allowed_html = [ + 'iframe' => [ + 'src' => [], + 'width' => [], + 'height' => [], + 'frameborder' => [], + 'allowfullscreen' => [], + 'allow' => [], + 'style' => [], + ], + 'video' => [ + 'src' => [], + 'width' => [], + 'height' => [], + 'controls' => [], + 'autoplay' => [], + 'style' => [], + ], + 'source' => [ + 'src' => [], + 'type' => [], + ], + ]; + + return wp_kses($embed_code, $allowed_html); +} + +/** + * Register custom page templates. + * + * This allows users to select "Cablecast Home" as a page template + * without needing to add it to their theme. + * + * @param array $templates Array of page templates. + * @return array Modified templates array. + */ +function cablecast_register_page_templates($templates) { + $templates['cablecast-home'] = __('Cablecast Home', 'cablecast'); + return $templates; +} +add_filter('theme_page_templates', 'cablecast_register_page_templates'); + +/** + * Load custom page template from plugin directory. + * + * @param string $template The path to the template file. + * @return string Modified template path. + */ +function cablecast_load_page_template($template) { + global $post; + + if (!$post) { + return $template; + } + + $page_template = get_page_template_slug($post->ID); + + if ($page_template === 'cablecast-home') { + // First check if theme has an override + $theme_template = cablecast_locate_template('page-cablecast-home.php'); + if ($theme_template) { + return $theme_template; + } + + // Use plugin template + $plugin_template = cablecast_get_templates_dir() . 'page-cablecast-home.php'; + if (file_exists($plugin_template)) { + return $plugin_template; + } + } + + return $template; +} +add_filter('template_include', 'cablecast_load_page_template', 5); + +/** + * Enqueue template styles when needed. + */ +function cablecast_enqueue_template_styles() { + // Check for category archives with shows + $is_show_category = is_category() && cablecast_category_has_shows(get_queried_object_id()); + + // Only enqueue on Cablecast pages + if (is_singular('show') || is_singular('cablecast_channel') || + is_tax('cablecast_producer') || is_tax('cablecast_project') || + is_post_type_archive('show') || is_post_type_archive('cablecast_channel') || + $is_show_category) { + + // Mark shortcodes as used so their CSS loads + if (function_exists('cablecast_mark_shortcode_used')) { + if (is_singular('show')) { + cablecast_mark_shortcode_used('show'); + cablecast_mark_shortcode_used('chapters'); + cablecast_mark_shortcode_used('upcoming_runs'); + } elseif (is_singular('cablecast_channel')) { + cablecast_mark_shortcode_used('now_playing'); + cablecast_mark_shortcode_used('schedule_calendar'); + } else { + cablecast_mark_shortcode_used('shows'); + } + } + } +} +add_action('wp_enqueue_scripts', 'cablecast_enqueue_template_styles', 5); diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d94f32a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + ./tests/ + + + + + + + + ./includes/ + ./cablecast.php + ./display.php + ./theme-functions.php + + + diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..7e28972 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,258 @@ +# Cablecast Templates + +This directory contains the default templates used by the Cablecast plugin. These templates can be overridden by your theme to customize the appearance of shows, channels, and taxonomy archives. + +## Template Override System + +The Cablecast plugin follows WordPress best practices for template overriding, similar to WooCommerce. + +### How to Override Templates + +1. **Create a cablecast folder** in your theme: + ``` + yourtheme/cablecast/ + ``` + +2. **Copy the template** you want to customize from: + ``` + wp-content/plugins/wp-cablecast/templates/ + ``` + +3. **Paste it into your theme's cablecast folder**: + ``` + yourtheme/cablecast/single-show.php + ``` + +4. **Edit your copy** - it will be used instead of the plugin's default. + +### Search Order + +Templates are loaded in this order: +1. `yourtheme/cablecast/{template-name}.php` +2. `yourtheme/{template-name}.php` (backwards compatibility) +3. `wp-cablecast/templates/{template-name}.php` (default) + +## Available Templates + +| Template | Purpose | Used On | +|----------|---------|---------| +| `single-show.php` | Individual show page | `/show/{show-name}/` | +| `single-channel.php` | Channel page with schedule | `/channel/{channel-name}/` | +| `archive-show.php` | Shows listing page | `/shows/` | +| `archive-channel.php` | Channels listing page | `/channels/` | +| `archive-producer.php` | Shows by producer | `/producers/{producer-name}/` | +| `archive-series.php` | Shows in a series | `/series/{series-name}/` | +| `content-show.php` | Show card partial | Used by other templates | + +## Available Hooks + +### Show Single Page + +```php +// Before show content +do_action('cablecast_before_single_show'); +do_action('cablecast_before_show_title', $post); +do_action('cablecast_after_show_title', $post); +do_action('cablecast_before_show_player', $post); +do_action('cablecast_after_show_player', $post); +do_action('cablecast_before_show_chapters', $post); +do_action('cablecast_after_show_chapters', $post); +do_action('cablecast_before_show_description', $post); +do_action('cablecast_after_show_description', $post); +do_action('cablecast_before_show_meta', $post); +do_action('cablecast_after_show_meta', $post); +do_action('cablecast_before_show_upcoming_runs', $post); +do_action('cablecast_after_show_upcoming_runs', $post); +do_action('cablecast_after_single_show'); +``` + +### Channel Single Page + +```php +do_action('cablecast_before_single_channel'); +do_action('cablecast_before_channel_title', $post); +do_action('cablecast_after_channel_title', $post); +do_action('cablecast_before_channel_player', $post); +do_action('cablecast_after_channel_player', $post); +do_action('cablecast_before_channel_now_playing', $post); +do_action('cablecast_after_channel_now_playing', $post); +do_action('cablecast_before_channel_description', $post); +do_action('cablecast_after_channel_description', $post); +do_action('cablecast_before_channel_schedule', $post); +do_action('cablecast_after_channel_schedule', $post); +do_action('cablecast_after_single_channel'); +``` + +### Taxonomy Archives + +```php +// Producer archive +do_action('cablecast_before_archive_producer', $term); +do_action('cablecast_before_archive_producer_header', $term); +do_action('cablecast_after_archive_producer_header', $term); +do_action('cablecast_before_archive_producer_shows', $term); +do_action('cablecast_after_archive_producer_shows', $term); +do_action('cablecast_after_archive_producer', $term); + +// Series archive +do_action('cablecast_before_archive_series', $term); +do_action('cablecast_before_archive_series_header', $term); +do_action('cablecast_after_archive_series_header', $term); +do_action('cablecast_before_archive_series_shows', $term); +do_action('cablecast_after_archive_series_shows', $term); +do_action('cablecast_after_archive_series', $term); +``` + +### Show Card + +```php +do_action('cablecast_before_show_card', $post); +do_action('cablecast_after_show_card', $post); +``` + +### Channel Card + +```php +do_action('cablecast_before_channel_card', $channel); +do_action('cablecast_after_channel_card', $channel); +``` + +## Available Filters + +### Show Meta Data + +```php +// Modify the show meta array before display +add_filter('cablecast_show_meta', function($meta, $post) { + // Add custom meta + $meta['custom'] = [ + 'label' => 'My Label', + 'value' => 'My Value', + 'link' => 'https://example.com', + ]; + return $meta; +}, 10, 2); +``` + +### Schedule Calendar Attributes + +```php +// Customize calendar settings on channel pages +add_filter('cablecast_schedule_calendar_atts', function($atts, $channel_id) { + $atts['view'] = 'dayGridMonth'; // Default to month view + return $atts; +}, 10, 2); +``` + +### Now Playing Attributes + +```php +// Customize now playing widget on channel pages +add_filter('cablecast_channel_now_playing_atts', function($atts, $channel_id) { + $atts['show_up_next'] = 'false'; + return $atts; +}, 10, 2); +``` + +### Shows Grid on Archives + +```php +// Customize shows grid on producer archive +add_filter('cablecast_archive_producer_shows_atts', function($atts, $term) { + $atts['columns'] = 3; + $atts['count'] = 12; + return $atts; +}, 10, 2); + +// Customize shows grid on series archive +add_filter('cablecast_archive_series_shows_atts', function($atts, $term) { + $atts['orderby'] = 'title'; + return $atts; +}, 10, 2); +``` + +### Upcoming Runs Query + +```php +// Modify the upcoming runs query +add_filter('cablecast_upcoming_runs_args', function($args, $show_id) { + $args['count'] = 10; + $args['days_ahead'] = 30; + return $args; +}, 10, 2); +``` + +### Template Location + +```php +// Change where the plugin looks for templates in themes +add_filter('cablecast_theme_template_path', function($path) { + return 'my-custom-cablecast/'; +}); + +// Filter the located template path +add_filter('cablecast_locate_template', function($template, $template_name, $template_path) { + // Custom logic to determine template + return $template; +}, 10, 3); +``` + +## Helper Functions + +These functions are available for use in your templates: + +```php +// Check if show has VOD +cablecast_has_vod($post); + +// Check if show has chapters +cablecast_has_chapters($post); + +// Get VOD embed HTML +cablecast_get_vod_embed($post); + +// Get show meta array +cablecast_get_show_meta($post); + +// Format runtime (seconds to H:MM:SS) +cablecast_format_runtime($seconds); + +// Get channel live embed HTML +cablecast_get_channel_live_embed($post); + +// Load a template part +cablecast_get_template('content-show.php', ['post' => $post]); + +// Get template HTML as string +$html = cablecast_get_template_html('content-show.php', ['post' => $post]); + +// Locate a template file +$path = cablecast_locate_template('single-show.php'); +``` + +## Disabling Templates + +If your theme provides its own templates without using the override system, you can disable the plugin's template system: + +1. Go to **Settings > Cablecast** +2. Uncheck "Enable Templates" + +Or programmatically: + +```php +add_filter('cablecast_templates_enabled', '__return_false'); +``` + +## CSS Classes + +All templates use BEM-style CSS classes for easy customization: + +- `.cablecast-show-single` - Show single page wrapper +- `.cablecast-channel-single` - Channel single page wrapper +- `.cablecast-taxonomy-archive` - Taxonomy archive wrapper +- `.cablecast-archive` - General archive wrapper +- `.cablecast-show-card` - Show card component +- `.cablecast-channel-card` - Channel card component +- `.cablecast-upcoming-runs` - Upcoming runs component + +The default styles are in `assets/css/shortcodes.css` and can be disabled in settings. diff --git a/templates/archive-category.php b/templates/archive-category.php new file mode 100644 index 0000000..abdbeb7 --- /dev/null +++ b/templates/archive-category.php @@ -0,0 +1,154 @@ + +> + + + + + +> + + + +
    + + + +
    +

    name); ?>

    + + description)) : ?> +
    + description)); ?> +
    + + +
    + 'show', + 'tax_query' => [[ + 'taxonomy' => 'category', + 'field' => 'term_id', + 'terms' => $term->term_id, + ]], + 'posts_per_page' => 1, + 'fields' => 'ids', + ]); + $count = $show_count->found_posts; + /* translators: %d: number of shows */ + printf(esc_html(_n('%d show', '%d shows', $count, 'cablecast')), $count); + wp_reset_postdata(); + ?> +
    +
    + + $term->slug, + 'count' => 24, + 'columns' => 4, + 'show_pagination' => 'true', + 'orderby' => 'date', + 'order' => 'DESC', + ], $term); + + $atts_string = ''; + foreach ($shows_atts as $key => $value) { + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + ?> + +
    + +
    + + + +
    + + + + + +> + + + + + +> + + + +
    + + + +
    +

    +
    + + + +
    +
    + 'cablecast_channel', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ]); + + if (!empty($channels)) : + foreach ($channels as $channel) : + /** + * Hook: cablecast_before_channel_card + * + * @hooked None by default + */ + do_action('cablecast_before_channel_card', $channel); + ?> + + +

    + +
    +
    + + + +
    + + + + + +> + + + + + +> + + + +
    + + + +
    +

    + name)); + ?> +

    + + description)) : ?> +
    + description)); ?> +
    + + +
    + count, 'cablecast')), $term->count); + ?> +
    +
    + + $term->slug, + 'count' => 24, + 'columns' => 4, + 'show_pagination' => 'true', + ], $term); + + $atts_string = ''; + foreach ($shows_atts as $key => $value) { + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + ?> + +
    + +
    + + + +
    + + + + + +> + + + + + +> + + + +
    + + + +
    +

    name); ?>

    + + description)) : ?> +
    + description)); ?> +
    + + +
    + count, 'cablecast')), $term->count); + ?> +
    +
    + + $term->slug, + 'count' => 24, + 'columns' => 4, + 'show_pagination' => 'true', + 'orderby' => 'date', + 'order' => 'DESC', + ], $term); + + $atts_string = ''; + foreach ($shows_atts as $key => $value) { + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + ?> + +
    + +
    + + + +
    + + + + + +> + + + + + +> + + + +
    + + + +
    +

    +
    + + 24, + 'columns' => 4, + 'show_pagination' => 'true', + ]); + + $atts_string = ''; + foreach ($shows_atts as $key => $value) { + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + ?> + +
    + +
    + + + +
    + + + + + + + + + +> + + + + + +> + + + +
    + + + +
    + + + + + +> + + + + + +> + + + +
    > + + + +
    +

    +
    + + +
    + +
    + + +
    + get_the_ID(), + 'exclude_filler' => 'true', + ], get_the_ID()); + + $atts_string = ''; + foreach ($now_playing_atts as $key => $value) { + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + echo do_shortcode('[cablecast_now_playing' . $atts_string . ']'); + ?> +
    + + +
    + +
    + + +
    +

    + get_the_ID(), + 'view' => 'timeGridWeek', + ], get_the_ID()); + + $atts_string = ''; + foreach ($calendar_atts as $key => $value) { + $atts_string .= ' ' . $key . '="' . esc_attr($value) . '"'; + } + echo do_shortcode('[cablecast_schedule_calendar' . $atts_string . ']'); + ?> +
    + + + +
    + + + + + +> + + + + + +> + + + +
    > + + + +
    +

    +
    + + + +
    + '; + echo cablecast_get_vod_embed($post); + echo '
    '; + } elseif (has_post_thumbnail()) { + // Show poster image only if no VOD (prevents duplicate image) + echo '
    '; + the_post_thumbnail('large', ['class' => 'cablecast-show-single__poster-img']); + echo '
    '; + } + ?> + + + + +
    + +
    + +
    + +
    + $item) : ?> +
    + + + + + + + + +
    + +
    + +
    + +
    + + + + + true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + delete_option('cablecast_options'); + delete_option('cablecast_sync_since'); + delete_option('cablecast_sync_index'); + delete_option('cablecast_sync_total_result_count'); + delete_transient('cablecast_sync_lock'); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + delete_option('cablecast_options'); + delete_option('cablecast_sync_since'); + delete_option('cablecast_sync_index'); + delete_option('cablecast_sync_total_result_count'); + delete_transient('cablecast_sync_lock'); + remove_all_filters('pre_http_request'); + parent::tearDown(); + } + + /** + * Test that API timeout returns empty shows. + */ + public function test_api_timeout_returns_empty_payload() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Mock HTTP request to return WP_Error (timeout) + add_filter('pre_http_request', function() { + return new WP_Error('http_request_failed', 'Connection timed out'); + }); + + $result = cablecast_get_shows_payload(); + + $this->assertIsObject($result); + $this->assertObjectHasProperty('shows', $result); + $this->assertIsArray($result->shows); + $this->assertEmpty($result->shows); + } + + /** + * Test that API 500 error returns empty shows. + */ + public function test_api_500_error_returns_empty_payload() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Mock HTTP request to return 500 error + add_filter('pre_http_request', function() { + return [ + 'response' => ['code' => 500], + 'body' => 'Internal Server Error', + ]; + }); + + $result = cablecast_get_shows_payload(); + + $this->assertIsObject($result); + $this->assertObjectHasProperty('shows', $result); + $this->assertEmpty($result->shows); + } + + /** + * Test that API 404 error returns empty shows. + */ + public function test_api_404_error_returns_empty_payload() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Mock HTTP request to return 404 error + add_filter('pre_http_request', function() { + return [ + 'response' => ['code' => 404], + 'body' => 'Not Found', + ]; + }); + + $result = cablecast_get_shows_payload(); + + $this->assertIsObject($result); + $this->assertObjectHasProperty('shows', $result); + $this->assertEmpty($result->shows); + } + + /** + * Test that invalid JSON returns empty payload. + */ + public function test_invalid_json_returns_empty_payload() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Mock HTTP request to return invalid JSON + add_filter('pre_http_request', function() { + return [ + 'response' => ['code' => 200], + 'body' => 'not valid json {{{', + ]; + }); + + $result = cablecast_get_shows_payload(); + + $this->assertIsObject($result); + $this->assertObjectHasProperty('shows', $result); + $this->assertEmpty($result->shows); + } + + /** + * Test that get_resources handles timeout gracefully. + */ + public function test_get_resources_handles_timeout() { + // Mock HTTP request to return WP_Error + add_filter('pre_http_request', function() { + return new WP_Error('http_request_failed', 'Connection timed out'); + }); + + $result = cablecast_get_resources('https://example.cablecast.net/api/test', 'items'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test that get_resources handles 500 error. + */ + public function test_get_resources_handles_server_error() { + // Mock HTTP request to return 500 + add_filter('pre_http_request', function() { + return [ + 'response' => ['code' => 500], + 'body' => 'Internal Server Error', + ]; + }); + + $result = cablecast_get_resources('https://example.cablecast.net/api/test', 'items'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test URL validation rejects invalid URLs. + */ + public function test_invalid_thumbnail_url_not_saved() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote', + ]); + update_option('cablecast_sync_total_result_count', 1); + + // Create payload with invalid thumbnail URL + $payload = (object)[ + 'shows' => [(object)[ + 'id' => 99999, + 'title' => 'Test Show', + 'cgTitle' => 'Test Show', + 'eventDate' => '2024-01-01 12:00:00', + 'comments' => '', + 'lastModified' => '2024-01-01T12:00:00', + 'reels' => [], + 'thumbnailImage' => (object)[ + 'url' => 'javascript:alert(1)' // Invalid/malicious URL + ], + 'custom1' => '', + 'custom2' => '', + 'custom3' => '', + 'custom4' => '', + 'custom5' => '', + 'custom6' => '', + 'custom7' => '', + 'custom8' => '', + 'location' => null, + ]], + 'vods' => [], + 'reels' => [], + 'webFiles' => [], + ]; + + cablecast_sync_shows($payload, [], [], [], [], []); + + $posts = get_posts([ + 'post_type' => 'show', + 'meta_key' => 'cablecast_show_id', + 'meta_value' => 99999, + ]); + + $this->assertCount(1, $posts); + + // Should not have saved the invalid URL + $thumbnail_url = get_post_meta($posts[0]->ID, 'cablecast_thumbnail_url', true); + $this->assertEmpty($thumbnail_url, 'Invalid URL should not be saved'); + } +} diff --git a/tests/ChapterSyncTest.php b/tests/ChapterSyncTest.php new file mode 100644 index 0000000..e2b4a34 --- /dev/null +++ b/tests/ChapterSyncTest.php @@ -0,0 +1,219 @@ + true, + ]); + } + + // Set up options + update_option('cablecast_options', [ + 'server' => 'https://test.cablecast.net', + ]); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + delete_option('cablecast_options'); + parent::tearDown(); + } + + /** + * Test cablecast_fetch_vod_chapters returns empty array on API error. + */ + public function test_fetch_chapters_api_error() { + // Mock a failing API call by using an invalid server + update_option('cablecast_options', [ + 'server' => 'https://invalid-server-that-does-not-exist.local', + ]); + + $chapters = cablecast_fetch_vod_chapters('https://invalid-server-that-does-not-exist.local', 12345); + + $this->assertIsArray($chapters); + $this->assertEmpty($chapters); + } + + /** + * Test that chapters are stored correctly during sync. + */ + public function test_chapters_stored_as_array() { + // Create a show with VOD + $show_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_vod_embed' => '', + ], + ]); + + // Simulate storing chapters + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => 'Description 1', 'offset' => 0], + ['id' => 2, 'title' => 'Chapter 2', 'body' => 'Description 2', 'offset' => 60], + ]; + + update_post_meta($show_id, 'cablecast_vod_chapters', $chapters); + + // Retrieve and verify + $stored = get_post_meta($show_id, 'cablecast_vod_chapters', true); + + $this->assertIsArray($stored); + $this->assertCount(2, $stored); + $this->assertEquals('Chapter 1', $stored[0]['title']); + $this->assertEquals(60, $stored[1]['offset']); + + wp_delete_post($show_id, true); + } + + /** + * Test that deleted/hidden chapters are filtered out. + */ + public function test_chapters_filter_deleted_hidden() { + // This tests the filtering logic in cablecast_fetch_vod_chapters + // We can't easily mock the API response, but we can verify the function exists + $this->assertTrue(function_exists('cablecast_fetch_vod_chapters')); + } + + /** + * Test that chapters are sorted by offset. + */ + public function test_chapters_sorted_by_offset() { + $show_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_vod_embed' => '', + ], + ]); + + // Store chapters in wrong order + $chapters = [ + ['id' => 3, 'title' => 'Chapter 3', 'body' => '', 'offset' => 300], + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ['id' => 2, 'title' => 'Chapter 2', 'body' => '', 'offset' => 120], + ]; + + // Sort them like the sync function does + usort($chapters, function($a, $b) { + return $a['offset'] - $b['offset']; + }); + + update_post_meta($show_id, 'cablecast_vod_chapters', $chapters); + + $stored = get_post_meta($show_id, 'cablecast_vod_chapters', true); + + $this->assertEquals(0, $stored[0]['offset']); + $this->assertEquals(120, $stored[1]['offset']); + $this->assertEquals(300, $stored[2]['offset']); + + wp_delete_post($show_id, true); + } + + /** + * Test that chapters are cleared when VOD is removed. + */ + public function test_chapters_cleared_when_no_vod() { + $show_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_vod_embed' => '', + 'cablecast_vod_chapters' => [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ], + ], + ]); + + // Verify chapters exist + $chapters = get_post_meta($show_id, 'cablecast_vod_chapters', true); + $this->assertNotEmpty($chapters); + + // Clear chapters (as sync would do when VOD is removed) + delete_post_meta($show_id, 'cablecast_vod_chapters'); + + // Verify chapters are gone + $chapters = get_post_meta($show_id, 'cablecast_vod_chapters', true); + $this->assertEmpty($chapters); + + wp_delete_post($show_id, true); + } + + /** + * Test chapter data structure integrity. + */ + public function test_chapter_data_structure() { + $show_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_vod_embed' => '', + ], + ]); + + $chapters = [ + [ + 'id' => 123, + 'title' => 'Test Title', + 'body' => 'Test Body Content', + 'offset' => 456, + ], + ]; + + update_post_meta($show_id, 'cablecast_vod_chapters', $chapters); + + $stored = get_post_meta($show_id, 'cablecast_vod_chapters', true); + + // Verify all fields are preserved + $this->assertArrayHasKey('id', $stored[0]); + $this->assertArrayHasKey('title', $stored[0]); + $this->assertArrayHasKey('body', $stored[0]); + $this->assertArrayHasKey('offset', $stored[0]); + + $this->assertEquals(123, $stored[0]['id']); + $this->assertEquals('Test Title', $stored[0]['title']); + $this->assertEquals('Test Body Content', $stored[0]['body']); + $this->assertEquals(456, $stored[0]['offset']); + + wp_delete_post($show_id, true); + } + + /** + * Test empty chapters array is handled correctly. + */ + public function test_empty_chapters_array() { + $show_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_vod_embed' => '', + 'cablecast_vod_chapters' => [], + ], + ]); + + $chapters = get_post_meta($show_id, 'cablecast_vod_chapters', true); + + // Empty array stored should return empty + $this->assertEmpty($chapters); + + wp_delete_post($show_id, true); + } +} diff --git a/tests/DisplayFilterTest.php b/tests/DisplayFilterTest.php new file mode 100644 index 0000000..d6b057a --- /dev/null +++ b/tests/DisplayFilterTest.php @@ -0,0 +1,169 @@ + true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + if (!post_type_exists('cablecast_channel')) { + register_post_type('cablecast_channel', [ + 'public' => true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + // Create test posts + $this->show_post_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'post_content' => 'Show description', + 'meta_input' => [ + 'cablecast_show_id' => 12345, + 'cablecast_vod_url' => 'https://example.com/vod/12345', + 'cablecast_producer_name' => 'Test Producer', + 'cablecast_category_name' => 'Test Category', + 'cablecast_project_name' => 'Test Project', + 'cablecast_show_trt' => 3600, // 1 hour + ], + ]); + + $this->channel_post_id = wp_insert_post([ + 'post_title' => 'Test Channel', + 'post_type' => 'cablecast_channel', + 'post_status' => 'publish', + 'post_content' => 'Channel description', + 'meta_input' => [ + 'cablecast_channel_id' => 1, + ], + ]); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + if ($this->show_post_id) { + wp_delete_post($this->show_post_id, true); + } + if ($this->channel_post_id) { + wp_delete_post($this->channel_post_id, true); + } + parent::tearDown(); + } + + /** + * Test that show post type exists and has correct properties. + * + * Note: The cablecast_content_display filter requires in_the_loop() and is_main_query() + * to return true, which cannot be reliably simulated in unit tests. Instead, we test + * the post type setup and metadata storage. + */ + public function test_show_post_type_exists() { + $this->assertTrue(post_type_exists('show')); + } + + /** + * Test that show metadata is stored correctly. + */ + public function test_show_metadata_stored() { + $vod_url = get_post_meta($this->show_post_id, 'cablecast_vod_url', true); + $this->assertEquals('https://example.com/vod/12345', $vod_url); + + $producer = get_post_meta($this->show_post_id, 'cablecast_producer_name', true); + $this->assertEquals('Test Producer', $producer); + + $trt = get_post_meta($this->show_post_id, 'cablecast_show_trt', true); + $this->assertEquals(3600, $trt); + } + + /** + * Test TRT formatting function. + */ + public function test_trt_gmdate_formatting() { + // Test that gmdate formats TRT correctly + $trt = 3600; // 1 hour + $formatted = gmdate('H:i:s', $trt); + $this->assertEquals('01:00:00', $formatted); + + $trt = 5400; // 1.5 hours + $formatted = gmdate('H:i:s', $trt); + $this->assertEquals('01:30:00', $formatted); + } + + /** + * Test date validation logic. + */ + public function test_date_validation_logic() { + // Valid date format + $valid_date = '2024-06-15'; + $this->assertTrue((bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $valid_date)); + + // Invalid date formats should fail regex + $invalid_dates = [ + '">', + '2024/06/15', + '06-15-2024', + 'not-a-date', + ]; + + foreach ($invalid_dates as $invalid) { + $this->assertFalse( + (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $invalid), + "Date '$invalid' should fail validation" + ); + } + } + + /** + * Test that channel post type exists. + */ + public function test_channel_post_type_exists() { + $this->assertTrue(post_type_exists('cablecast_channel')); + } + + /** + * Test channel metadata storage. + */ + public function test_channel_metadata_stored() { + $channel_id = get_post_meta($this->channel_post_id, 'cablecast_channel_id', true); + $this->assertEquals(1, $channel_id); + } + + /** + * Test thumbnail size constant is accessible. + */ + public function test_thumbnail_size_constant_defined() { + $this->assertTrue(defined('CABLECAST_THUMBNAIL_SIZES')); + $this->assertIsArray(CABLECAST_THUMBNAIL_SIZES); + $this->assertArrayHasKey('thumbnail', CABLECAST_THUMBNAIL_SIZES); + $this->assertArrayHasKey('medium', CABLECAST_THUMBNAIL_SIZES); + $this->assertArrayHasKey('large', CABLECAST_THUMBNAIL_SIZES); + $this->assertArrayHasKey('post-thumbnail', CABLECAST_THUMBNAIL_SIZES); + } + + /** + * Test srcset variants constant is accessible. + */ + public function test_srcset_variants_constant_defined() { + $this->assertTrue(defined('CABLECAST_SRCSET_VARIANTS')); + $this->assertIsArray(CABLECAST_SRCSET_VARIANTS); + $this->assertCount(5, CABLECAST_SRCSET_VARIANTS); + } +} diff --git a/tests/ShortcodesTest.php b/tests/ShortcodesTest.php new file mode 100644 index 0000000..ba2cbcd --- /dev/null +++ b/tests/ShortcodesTest.php @@ -0,0 +1,1242 @@ +schedule_table = $wpdb->prefix . 'cablecast_schedule_items'; + + // Ensure post types are registered + if (!post_type_exists('show')) { + register_post_type('show', [ + 'public' => true, + 'supports' => ['title', 'editor', 'thumbnail'], + 'taxonomies' => ['category', 'cablecast_project', 'cablecast_producer'], + ]); + } + if (!post_type_exists('cablecast_channel')) { + register_post_type('cablecast_channel', [ + 'public' => true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + // Register taxonomies if not exists + if (!taxonomy_exists('cablecast_producer')) { + register_taxonomy('cablecast_producer', ['show'], ['public' => true]); + } + if (!taxonomy_exists('cablecast_project')) { + register_taxonomy('cablecast_project', ['show'], ['public' => true]); + } + + // Create test channel + $this->channel_post_id = wp_insert_post([ + 'post_title' => 'Test Channel', + 'post_type' => 'cablecast_channel', + 'post_status' => 'publish', + 'post_content' => 'Channel description', + 'meta_input' => [ + 'cablecast_channel_id' => 1, + 'cablecast_channel_live_embed_code' => '', + ], + ]); + + // Create test show + $this->show_post_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + 'post_content' => 'Show description', + 'meta_input' => [ + 'cablecast_show_id' => 12345, + 'cablecast_vod_url' => 'https://example.com/vod/12345', + 'cablecast_vod_embed' => '', + 'cablecast_producer_name' => 'Test Producer', + 'cablecast_category_name' => 'Test Category', + 'cablecast_project_name' => 'Test Project', + 'cablecast_show_trt' => 3600, // 1 hour + 'cablecast_show_comments' => 'This is the show description.', + 'cablecast_thumbnail_url' => 'https://example.com/thumb/12345.jpg', + ], + ]); + + // Set category + wp_set_object_terms($this->show_post_id, 'news', 'category'); + + // Create schedule item for today + $this->create_schedule_item(); + } + + /** + * Create a test schedule item. + */ + private function create_schedule_item() { + global $wpdb; + + // Create schedule table if it doesn't exist + $charset_collate = $wpdb->get_charset_collate(); + $wpdb->query("CREATE TABLE IF NOT EXISTS {$this->schedule_table} ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + run_date_time datetime DEFAULT '0000-00-00 00:00:00' NOT NULL, + show_id int NOT NULL, + show_title varchar(255) DEFAULT '' NOT NULL, + channel_id int NOT NULL, + show_post_id int NOT NULL, + channel_post_id int NOT NULL, + schedule_item_id int NOT NULL, + cg_exempt tinyint(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + ) $charset_collate;"); + + // Insert schedule items for today + $now = current_time('mysql'); + $wpdb->insert($this->schedule_table, [ + 'run_date_time' => $now, + 'show_id' => 12345, + 'show_title' => 'Test Show', + 'channel_id' => 1, + 'show_post_id' => $this->show_post_id, + 'channel_post_id' => $this->channel_post_id, + 'schedule_item_id' => 1001, + 'cg_exempt' => 0, + ]); + + // Insert a future schedule item + $future = date('Y-m-d H:i:s', strtotime('+2 hours')); + $wpdb->insert($this->schedule_table, [ + 'run_date_time' => $future, + 'show_id' => 12346, + 'show_title' => 'Future Show', + 'channel_id' => 1, + 'show_post_id' => $this->show_post_id, + 'channel_post_id' => $this->channel_post_id, + 'schedule_item_id' => 1002, + 'cg_exempt' => 0, + ]); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + global $wpdb; + + if ($this->show_post_id) { + wp_delete_post($this->show_post_id, true); + } + if ($this->channel_post_id) { + wp_delete_post($this->channel_post_id, true); + } + + // Clean up schedule items + $wpdb->query("TRUNCATE TABLE {$this->schedule_table}"); + + // Reset options + delete_option('cablecast_options'); + + parent::tearDown(); + } + + // ========================================================================= + // Shortcode Registration Tests + // ========================================================================= + + /** + * Test that all shortcodes are registered. + */ + public function test_shortcodes_are_registered() { + global $shortcode_tags; + + $expected_shortcodes = [ + 'cablecast_schedule', + 'cablecast_now_playing', + 'cablecast_weekly_guide', + 'cablecast_shows', + 'cablecast_show', + 'cablecast_vod_player', + 'cablecast_chapters', + 'cablecast_producers', + 'cablecast_series', + ]; + + foreach ($expected_shortcodes as $shortcode) { + $this->assertArrayHasKey( + $shortcode, + $shortcode_tags, + "Shortcode [$shortcode] should be registered" + ); + } + } + + // ========================================================================= + // Helper Function Tests + // ========================================================================= + + /** + * Test cablecast_is_filler() with default keywords. + */ + public function test_is_filler_default_keywords() { + $this->assertTrue(cablecast_is_filler('Color Bars')); + $this->assertTrue(cablecast_is_filler('COLORBARS')); + $this->assertTrue(cablecast_is_filler('Test Pattern Display')); + $this->assertTrue(cablecast_is_filler('Off Air')); + $this->assertTrue(cablecast_is_filler('Station ID')); + $this->assertTrue(cablecast_is_filler('Technical Difficulties')); + } + + /** + * Test cablecast_is_filler() with non-filler content. + */ + public function test_is_filler_non_filler() { + $this->assertFalse(cablecast_is_filler('City Council Meeting')); + $this->assertFalse(cablecast_is_filler('News at Six')); + $this->assertFalse(cablecast_is_filler('Documentary: Nature')); + } + + /** + * Test cablecast_is_filler() with custom keywords from settings. + */ + public function test_is_filler_custom_keywords() { + update_option('cablecast_options', [ + 'filler_keywords' => 'custom filler, test content' + ]); + + $this->assertTrue(cablecast_is_filler('Custom Filler Content')); + $this->assertTrue(cablecast_is_filler('test content here')); + // Default keywords should no longer match when custom are set + $this->assertFalse(cablecast_is_filler('Color Bars')); + } + + /** + * Test cablecast_format_runtime() function. + */ + public function test_format_runtime() { + $this->assertEquals('1h 30m', cablecast_format_runtime(5400)); // 1.5 hours + $this->assertEquals('2h 0m', cablecast_format_runtime(7200)); // 2 hours + $this->assertEquals('45m', cablecast_format_runtime(2700)); // 45 minutes + $this->assertEquals('5m', cablecast_format_runtime(300)); // 5 minutes + $this->assertEquals('', cablecast_format_runtime(0)); + $this->assertEquals('', cablecast_format_runtime(-100)); + } + + /** + * Test cablecast_category_colors_enabled() function. + */ + public function test_category_colors_enabled() { + // Default should be disabled + $this->assertFalse(cablecast_category_colors_enabled()); + + // Enable category colors + update_option('cablecast_options', [ + 'enable_category_colors' => true, + ]); + $this->assertTrue(cablecast_category_colors_enabled()); + + // Disable again + update_option('cablecast_options', [ + 'enable_category_colors' => false, + ]); + $this->assertFalse(cablecast_category_colors_enabled()); + } + + /** + * Test cablecast_get_show_category_color() function. + */ + public function test_get_show_category_color() { + // Should return null when colors disabled + $this->assertNull(cablecast_get_show_category_color($this->show_post_id)); + + // Enable colors with a mapping + update_option('cablecast_options', [ + 'enable_category_colors' => true, + 'category_colors' => [ + 'news' => '#3b82f6', + ], + ]); + + $color = cablecast_get_show_category_color($this->show_post_id); + $this->assertEquals('#3b82f6', $color); + } + + /** + * Test cablecast_get_show_category_color() returns null for unmapped category. + */ + public function test_get_show_category_color_unmapped() { + update_option('cablecast_options', [ + 'enable_category_colors' => true, + 'category_colors' => [ + 'sports' => '#f97316', // Different category than show has + ], + ]); + + $color = cablecast_get_show_category_color($this->show_post_id); + $this->assertNull($color); + } + + /** + * Test cablecast_get_channel_post_id() function. + */ + public function test_get_channel_post_id() { + $post_id = cablecast_get_channel_post_id(1); + $this->assertEquals($this->channel_post_id, $post_id); + + // Non-existent channel + $post_id = cablecast_get_channel_post_id(999); + $this->assertNull($post_id); + } + + /** + * Test cablecast_get_all_channels() function. + */ + public function test_get_all_channels() { + $channels = cablecast_get_all_channels(); + $this->assertIsArray($channels); + $this->assertCount(1, $channels); + $this->assertEquals('Test Channel', $channels[0]->post_title); + } + + // ========================================================================= + // Schedule Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_schedule] requires channel attribute. + */ + public function test_schedule_requires_channel() { + $output = do_shortcode('[cablecast_schedule]'); + $this->assertStringContainsString('Please specify a channel ID', $output); + } + + /** + * Test [cablecast_schedule] with invalid channel. + */ + public function test_schedule_invalid_channel() { + $output = do_shortcode('[cablecast_schedule channel="99999"]'); + $this->assertStringContainsString('Invalid channel', $output); + } + + /** + * Test [cablecast_schedule] basic output structure. + */ + public function test_schedule_basic_output() { + $output = do_shortcode('[cablecast_schedule channel="' . $this->channel_post_id . '"]'); + + $this->assertStringContainsString('cablecast-schedule', $output); + $this->assertStringContainsString('Test Show', $output); + $this->assertStringContainsString('cablecast-schedule__item', $output); + } + + /** + * Test [cablecast_schedule] with count attribute. + */ + public function test_schedule_count_attribute() { + $output = do_shortcode('[cablecast_schedule channel="' . $this->channel_post_id . '" count="1"]'); + + // Should contain the first show + $this->assertStringContainsString('Test Show', $output); + } + + /** + * Test [cablecast_schedule] with show_descriptions="false". + */ + public function test_schedule_hide_descriptions() { + $output = do_shortcode('[cablecast_schedule channel="' . $this->channel_post_id . '" show_descriptions="false"]'); + + $this->assertStringContainsString('cablecast-schedule', $output); + $this->assertStringNotContainsString('cablecast-schedule__description', $output); + } + + /** + * Test [cablecast_schedule] with custom class. + */ + public function test_schedule_custom_class() { + $output = do_shortcode('[cablecast_schedule channel="' . $this->channel_post_id . '" class="my-custom-class"]'); + + $this->assertStringContainsString('my-custom-class', $output); + } + + // ========================================================================= + // Now Playing Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_now_playing] requires channel attribute. + */ + public function test_now_playing_requires_channel() { + $output = do_shortcode('[cablecast_now_playing]'); + $this->assertStringContainsString('Please specify a channel ID', $output); + } + + /** + * Test [cablecast_now_playing] basic output structure. + */ + public function test_now_playing_basic_output() { + $output = do_shortcode('[cablecast_now_playing channel="' . $this->channel_post_id . '"]'); + + $this->assertStringContainsString('cablecast-now-playing', $output); + $this->assertStringContainsString('cablecast-now-playing__card', $output); + } + + /** + * Test [cablecast_now_playing] shows up next when enabled. + */ + public function test_now_playing_shows_up_next() { + $output = do_shortcode('[cablecast_now_playing channel="' . $this->channel_post_id . '" show_up_next="true"]'); + + // Should have both now and next cards + $this->assertStringContainsString('cablecast-now-playing__card--now', $output); + } + + /** + * Test [cablecast_now_playing] hides up next when disabled. + */ + public function test_now_playing_hides_up_next() { + $output = do_shortcode('[cablecast_now_playing channel="' . $this->channel_post_id . '" show_up_next="false"]'); + + // Should not have the next card class + $this->assertStringNotContainsString('cablecast-now-playing__card--next', $output); + } + + // ========================================================================= + // Weekly Guide Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_weekly_guide] uses first channel when none specified. + */ + public function test_weekly_guide_default_channel() { + $output = do_shortcode('[cablecast_weekly_guide]'); + + $this->assertStringContainsString('cablecast-weekly-guide', $output); + $this->assertStringContainsString('cablecast-weekly-guide__grid', $output); + } + + /** + * Test [cablecast_weekly_guide] with specific channel. + */ + public function test_weekly_guide_specific_channel() { + $output = do_shortcode('[cablecast_weekly_guide channel="' . $this->channel_post_id . '"]'); + + $this->assertStringContainsString('cablecast-weekly-guide', $output); + } + + /** + * Test [cablecast_weekly_guide] channel switcher. + */ + public function test_weekly_guide_channel_switcher() { + // Channel switcher only shows when there are multiple channels + $second_channel = wp_insert_post([ + 'post_title' => 'Second Channel', + 'post_type' => 'cablecast_channel', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_channel_id' => 2, + ], + ]); + + $output = do_shortcode('[cablecast_weekly_guide show_channel_switcher="true"]'); + + $this->assertStringContainsString('cablecast-weekly-guide__channel-switcher', $output); + $this->assertStringContainsString('cablecast-channel-select', $output); + + wp_delete_post($second_channel, true); + } + + /** + * Test [cablecast_weekly_guide] hides channel switcher when disabled. + */ + public function test_weekly_guide_hide_channel_switcher() { + $output = do_shortcode('[cablecast_weekly_guide show_channel_switcher="false"]'); + + $this->assertStringNotContainsString('cablecast-weekly-guide__channel-switcher', $output); + } + + /** + * Test [cablecast_weekly_guide] days attribute. + */ + public function test_weekly_guide_days_attribute() { + $output = do_shortcode('[cablecast_weekly_guide days="3"]'); + + // Count day columns - should have 3 + $this->assertStringContainsString('cablecast-weekly-guide__day', $output); + } + + // ========================================================================= + // Shows Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_shows] basic output. + */ + public function test_shows_basic_output() { + $output = do_shortcode('[cablecast_shows]'); + + $this->assertStringContainsString('cablecast-shows', $output); + $this->assertStringContainsString('Test Show', $output); + } + + /** + * Test [cablecast_shows] grid layout. + */ + public function test_shows_grid_layout() { + $output = do_shortcode('[cablecast_shows layout="grid"]'); + + $this->assertStringContainsString('cablecast-shows--grid', $output); + } + + /** + * Test [cablecast_shows] list layout. + */ + public function test_shows_list_layout() { + $output = do_shortcode('[cablecast_shows layout="list"]'); + + $this->assertStringContainsString('cablecast-shows--list', $output); + } + + /** + * Test [cablecast_shows] columns attribute. + */ + public function test_shows_columns() { + $output = do_shortcode('[cablecast_shows columns="3"]'); + + $this->assertStringContainsString('cablecast-shows--columns-3', $output); + } + + /** + * Test [cablecast_shows] category filter. + */ + public function test_shows_category_filter() { + $output = do_shortcode('[cablecast_shows category="news"]'); + + $this->assertStringContainsString('Test Show', $output); + } + + /** + * Test [cablecast_shows] with non-existent category. + */ + public function test_shows_empty_category() { + $output = do_shortcode('[cablecast_shows category="nonexistent-category"]'); + + $this->assertStringContainsString('No shows found', $output); + } + + // ========================================================================= + // Single Show Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_show] requires id attribute. + */ + public function test_show_requires_id() { + $output = do_shortcode('[cablecast_show]'); + + $this->assertStringContainsString('Please specify a show ID', $output); + } + + /** + * Test [cablecast_show] with invalid id. + */ + public function test_show_invalid_id() { + $output = do_shortcode('[cablecast_show id="99999"]'); + + $this->assertStringContainsString('Show not found', $output); + } + + /** + * Test [cablecast_show] basic output. + */ + public function test_show_basic_output() { + $output = do_shortcode('[cablecast_show id="' . $this->show_post_id . '"]'); + + $this->assertStringContainsString('cablecast-show', $output); + $this->assertStringContainsString('Test Show', $output); + } + + /** + * Test [cablecast_show] includes VOD player. + */ + public function test_show_includes_vod() { + $output = do_shortcode('[cablecast_show id="' . $this->show_post_id . '" show_vod="true"]'); + + $this->assertStringContainsString('cablecast-show__vod', $output); + $this->assertStringContainsString('iframe', $output); + } + + /** + * Test [cablecast_show] hides VOD when disabled. + */ + public function test_show_hides_vod() { + $output = do_shortcode('[cablecast_show id="' . $this->show_post_id . '" show_vod="false"]'); + + $this->assertStringNotContainsString('cablecast-show__vod', $output); + } + + // ========================================================================= + // VOD Player Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_vod_player] requires id attribute. + */ + public function test_vod_player_requires_id() { + $output = do_shortcode('[cablecast_vod_player]'); + + $this->assertStringContainsString('Please specify a show ID', $output); + } + + /** + * Test [cablecast_vod_player] basic output. + */ + public function test_vod_player_basic_output() { + $output = do_shortcode('[cablecast_vod_player id="' . $this->show_post_id . '"]'); + + $this->assertStringContainsString('cablecast-vod-player', $output); + $this->assertStringContainsString('iframe', $output); + } + + // ========================================================================= + // Chapters Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_chapters] returns empty for show without chapters. + */ + public function test_chapters_no_chapters() { + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '"]'); + + // Should return empty when no chapters exist + $this->assertEmpty($output); + } + + /** + * Test [cablecast_chapters] returns empty for show without VOD. + */ + public function test_chapters_no_vod() { + // Create show without VOD + $show_no_vod = wp_insert_post([ + 'post_title' => 'Show Without VOD', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_vod_chapters' => [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ], + ], + ]); + + $output = do_shortcode('[cablecast_chapters id="' . $show_no_vod . '"]'); + + // Should return empty when no VOD embed exists + $this->assertEmpty($output); + + wp_delete_post($show_no_vod, true); + } + + /** + * Test [cablecast_chapters] basic output with chapters. + */ + public function test_chapters_basic_output() { + // Add chapters to the test show + $chapters = [ + ['id' => 1, 'title' => 'Introduction', 'body' => 'Welcome to the show', 'offset' => 0], + ['id' => 2, 'title' => 'Main Content', 'body' => 'The main topic', 'offset' => 120], + ['id' => 3, 'title' => 'Conclusion', 'body' => 'Wrapping up', 'offset' => 300], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '"]'); + + $this->assertStringContainsString('cablecast-chapters', $output); + $this->assertStringContainsString('cablecast-chapters__list', $output); + $this->assertStringContainsString('Introduction', $output); + $this->assertStringContainsString('Main Content', $output); + $this->assertStringContainsString('Conclusion', $output); + } + + /** + * Test [cablecast_chapters] includes data attributes for JS. + */ + public function test_chapters_data_attributes() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ['id' => 2, 'title' => 'Chapter 2', 'body' => '', 'offset' => 60], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '"]'); + + // Should have data-show-id attribute + $this->assertStringContainsString('data-show-id="' . $this->show_post_id . '"', $output); + + // Should have data-offset attributes on items + $this->assertStringContainsString('data-offset="0"', $output); + $this->assertStringContainsString('data-offset="60"', $output); + } + + /** + * Test [cablecast_chapters] shows timestamps by default. + */ + public function test_chapters_shows_timestamps() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ['id' => 2, 'title' => 'Chapter 2', 'body' => '', 'offset' => 125], // 2:05 + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '"]'); + + $this->assertStringContainsString('cablecast-chapters__timestamp', $output); + $this->assertStringContainsString('0:00', $output); + $this->assertStringContainsString('2:05', $output); + } + + /** + * Test [cablecast_chapters] hides timestamps when disabled. + */ + public function test_chapters_hides_timestamps() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '" show_timestamps="false"]'); + + $this->assertStringNotContainsString('cablecast-chapters__timestamp', $output); + } + + /** + * Test [cablecast_chapters] shows descriptions by default. + */ + public function test_chapters_shows_descriptions() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => 'This is the description', 'offset' => 0], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '"]'); + + $this->assertStringContainsString('cablecast-chapters__description', $output); + $this->assertStringContainsString('This is the description', $output); + } + + /** + * Test [cablecast_chapters] hides descriptions when disabled. + */ + public function test_chapters_hides_descriptions() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => 'This is the description', 'offset' => 0], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '" show_descriptions="false"]'); + + $this->assertStringNotContainsString('cablecast-chapters__description', $output); + } + + /** + * Test [cablecast_chapters] compact layout. + */ + public function test_chapters_compact_layout() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '" layout="compact"]'); + + $this->assertStringContainsString('cablecast-chapters--compact', $output); + } + + /** + * Test [cablecast_chapters] with custom class. + */ + public function test_chapters_custom_class() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '" class="my-custom-class"]'); + + $this->assertStringContainsString('my-custom-class', $output); + } + + /** + * Test [cablecast_chapters] with player selector attribute. + */ + public function test_chapters_player_selector() { + $chapters = [ + ['id' => 1, 'title' => 'Chapter 1', 'body' => '', 'offset' => 0], + ]; + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', $chapters); + + $output = do_shortcode('[cablecast_chapters id="' . $this->show_post_id . '" player="#my-player"]'); + + $this->assertStringContainsString('data-player-selector="#my-player"', $output); + } + + /** + * Test [cablecast_chapters] requires valid show ID. + */ + public function test_chapters_requires_valid_id() { + $output = do_shortcode('[cablecast_chapters id="99999"]'); + + $this->assertStringContainsString('Show not found', $output); + } + + /** + * Test [cablecast_chapters] requires id when not in show context. + */ + public function test_chapters_requires_id() { + $output = do_shortcode('[cablecast_chapters]'); + + $this->assertStringContainsString('specify a show ID', $output); + } + + /** + * Test cablecast_format_chapter_timestamp() helper. + */ + public function test_format_chapter_timestamp() { + $this->assertEquals('0:00', cablecast_format_chapter_timestamp(0)); + $this->assertEquals('1:30', cablecast_format_chapter_timestamp(90)); + $this->assertEquals('10:05', cablecast_format_chapter_timestamp(605)); + $this->assertEquals('1:00:00', cablecast_format_chapter_timestamp(3600)); + $this->assertEquals('1:30:45', cablecast_format_chapter_timestamp(5445)); + } + + // ========================================================================= + // Producers Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_producers] with no producers returns message. + */ + public function test_producers_no_results() { + $output = do_shortcode('[cablecast_producers]'); + + $this->assertStringContainsString('No producers found', $output); + } + + /** + * Test [cablecast_producers] with producers. + */ + public function test_producers_with_results() { + // Create a producer term and assign to show + $term = wp_insert_term('Test Producer', 'cablecast_producer'); + if (!is_wp_error($term)) { + wp_set_object_terms($this->show_post_id, $term['term_id'], 'cablecast_producer'); + + $output = do_shortcode('[cablecast_producers]'); + + $this->assertStringContainsString('cablecast-producers', $output); + $this->assertStringContainsString('Test Producer', $output); + + wp_delete_term($term['term_id'], 'cablecast_producer'); + } + } + + /** + * Test [cablecast_producers] list layout. + */ + public function test_producers_list_layout() { + $term = wp_insert_term('Test Producer', 'cablecast_producer'); + if (!is_wp_error($term)) { + wp_set_object_terms($this->show_post_id, $term['term_id'], 'cablecast_producer'); + + $output = do_shortcode('[cablecast_producers layout="list"]'); + + $this->assertStringContainsString('cablecast-producers--list', $output); + + wp_delete_term($term['term_id'], 'cablecast_producer'); + } + } + + // ========================================================================= + // Series Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_series] with no series returns message. + */ + public function test_series_no_results() { + $output = do_shortcode('[cablecast_series]'); + + $this->assertStringContainsString('No series found', $output); + } + + /** + * Test [cablecast_series] with series. + */ + public function test_series_with_results() { + // Create a series term and assign to show + $term = wp_insert_term('Test Series', 'cablecast_project'); + if (!is_wp_error($term)) { + wp_set_object_terms($this->show_post_id, $term['term_id'], 'cablecast_project'); + + $output = do_shortcode('[cablecast_series]'); + + $this->assertStringContainsString('cablecast-series', $output); + $this->assertStringContainsString('Test Series', $output); + + wp_delete_term($term['term_id'], 'cablecast_project'); + } + } + + /** + * Test [cablecast_series] grid layout. + */ + public function test_series_grid_layout() { + $term = wp_insert_term('Test Series', 'cablecast_project'); + if (!is_wp_error($term)) { + wp_set_object_terms($this->show_post_id, $term['term_id'], 'cablecast_project'); + + $output = do_shortcode('[cablecast_series layout="grid"]'); + + $this->assertStringContainsString('cablecast-series--grid', $output); + + wp_delete_term($term['term_id'], 'cablecast_project'); + } + } + + // ========================================================================= + // Asset Loading Tests + // ========================================================================= + + /** + * Test that shortcode usage is tracked. + */ + public function test_shortcode_usage_tracking() { + global $cablecast_shortcodes_used; + $cablecast_shortcodes_used = []; + + do_shortcode('[cablecast_shows]'); + $this->assertContains('shows', $cablecast_shortcodes_used); + + do_shortcode('[cablecast_weekly_guide]'); + $this->assertContains('weekly_guide', $cablecast_shortcodes_used); + } + + // ========================================================================= + // XSS/Security Tests + // ========================================================================= + + /** + * Test that class attribute is properly escaped. + */ + public function test_class_attribute_escaping() { + $output = do_shortcode('[cablecast_shows class=""]'); + + $this->assertStringNotContainsString(', test' + ]); + + // This shouldn't cause any issues - just test it doesn't crash + $result = cablecast_is_filler(''); + $this->assertTrue($result); + } + + // ========================================================================= + // Upcoming Runs Shortcode Tests + // ========================================================================= + + /** + * Test [cablecast_upcoming_runs] is registered. + */ + public function test_upcoming_runs_registered() { + $this->assertTrue(shortcode_exists('cablecast_upcoming_runs')); + } + + /** + * Test [cablecast_upcoming_runs] returns empty with no runs. + */ + public function test_upcoming_runs_no_results() { + global $wpdb; + + // Ensure schedule table exists (empty) to avoid database error output + $wpdb->query("CREATE TABLE IF NOT EXISTS {$this->schedule_table} ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + run_date_time datetime DEFAULT '0000-00-00 00:00:00' NOT NULL, + show_id int NOT NULL, + show_title varchar(255) DEFAULT '' NOT NULL, + channel_id int NOT NULL, + show_post_id int NOT NULL, + channel_post_id int NOT NULL, + schedule_item_id int NOT NULL, + cg_exempt tinyint(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + )"); + + // Clear any data from previous tests + $wpdb->query("TRUNCATE TABLE {$this->schedule_table}"); + + $output = do_shortcode('[cablecast_upcoming_runs id="' . $this->show_post_id . '"]'); + + // Should return empty when no upcoming runs exist + $this->assertEmpty($output); + } + + /** + * Test [cablecast_upcoming_runs] with scheduled runs. + */ + public function test_upcoming_runs_with_results() { + global $wpdb; + + // Ensure schedule table exists + $wpdb->query("CREATE TABLE IF NOT EXISTS {$this->schedule_table} ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + run_date_time datetime DEFAULT '0000-00-00 00:00:00' NOT NULL, + show_id int NOT NULL, + show_title varchar(255) DEFAULT '' NOT NULL, + channel_id int NOT NULL, + show_post_id int NOT NULL, + channel_post_id int NOT NULL, + schedule_item_id int NOT NULL, + cg_exempt tinyint(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + )"); + + // Insert a future schedule item + $future_date = date('Y-m-d H:i:s', strtotime('+1 day')); + $wpdb->insert($this->schedule_table, [ + 'run_date_time' => $future_date, + 'show_id' => 12345, + 'show_title' => 'Test Show', + 'channel_id' => 1, + 'show_post_id' => $this->show_post_id, + 'channel_post_id' => $this->channel_post_id, + 'schedule_item_id' => 1, + 'cg_exempt' => 0, + ]); + + $output = do_shortcode('[cablecast_upcoming_runs id="' . $this->show_post_id . '"]'); + + $this->assertStringContainsString('cablecast-upcoming-runs', $output); + $this->assertStringContainsString('Upcoming Airings', $output); + $this->assertStringContainsString('Test Channel', $output); + + // Clean up + $wpdb->query("DELETE FROM {$this->schedule_table} WHERE show_post_id = {$this->show_post_id}"); + } + + /** + * Test [cablecast_upcoming_runs] respects count attribute. + */ + public function test_upcoming_runs_count_attribute() { + global $wpdb; + + // Insert multiple future schedule items + for ($i = 1; $i <= 10; $i++) { + $future_date = date('Y-m-d H:i:s', strtotime("+{$i} day")); + $wpdb->insert($this->schedule_table, [ + 'run_date_time' => $future_date, + 'show_id' => 12345, + 'show_title' => 'Test Show', + 'channel_id' => 1, + 'show_post_id' => $this->show_post_id, + 'channel_post_id' => $this->channel_post_id, + 'schedule_item_id' => $i, + 'cg_exempt' => 0, + ]); + } + + $output = do_shortcode('[cablecast_upcoming_runs id="' . $this->show_post_id . '" count="3"]'); + + // Count the number of list items + $count = substr_count($output, 'cablecast-upcoming-runs__item'); + $this->assertEquals(3, $count); + + // Clean up + $wpdb->query("DELETE FROM {$this->schedule_table} WHERE show_post_id = {$this->show_post_id}"); + } + + /** + * Test [cablecast_upcoming_runs] hides channel when disabled. + */ + public function test_upcoming_runs_hide_channel() { + global $wpdb; + + $future_date = date('Y-m-d H:i:s', strtotime('+1 day')); + $wpdb->insert($this->schedule_table, [ + 'run_date_time' => $future_date, + 'show_id' => 12345, + 'show_title' => 'Test Show', + 'channel_id' => 1, + 'show_post_id' => $this->show_post_id, + 'channel_post_id' => $this->channel_post_id, + 'schedule_item_id' => 1, + 'cg_exempt' => 0, + ]); + + $output = do_shortcode('[cablecast_upcoming_runs id="' . $this->show_post_id . '" show_channel="false"]'); + + $this->assertStringNotContainsString('cablecast-upcoming-runs__channel', $output); + + // Clean up + $wpdb->query("DELETE FROM {$this->schedule_table} WHERE show_post_id = {$this->show_post_id}"); + } + + /** + * Test [cablecast_upcoming_runs] requires valid show ID. + */ + public function test_upcoming_runs_invalid_id() { + $output = do_shortcode('[cablecast_upcoming_runs id="99999"]'); + + // Should return empty for invalid ID + $this->assertEmpty($output); + } + + /** + * Test [cablecast_upcoming_runs] with custom class. + */ + public function test_upcoming_runs_custom_class() { + global $wpdb; + + $future_date = date('Y-m-d H:i:s', strtotime('+1 day')); + $wpdb->insert($this->schedule_table, [ + 'run_date_time' => $future_date, + 'show_id' => 12345, + 'show_title' => 'Test Show', + 'channel_id' => 1, + 'show_post_id' => $this->show_post_id, + 'channel_post_id' => $this->channel_post_id, + 'schedule_item_id' => 1, + 'cg_exempt' => 0, + ]); + + $output = do_shortcode('[cablecast_upcoming_runs id="' . $this->show_post_id . '" class="my-custom-class"]'); + + $this->assertStringContainsString('my-custom-class', $output); + + // Clean up + $wpdb->query("DELETE FROM {$this->schedule_table} WHERE show_post_id = {$this->show_post_id}"); + } + + // ========================================================================= + // Template Loader Tests + // ========================================================================= + + /** + * Test template loader functions exist. + */ + public function test_template_loader_functions_exist() { + $this->assertTrue(function_exists('cablecast_get_template')); + $this->assertTrue(function_exists('cablecast_locate_template')); + $this->assertTrue(function_exists('cablecast_get_templates_dir')); + } + + /** + * Test cablecast_get_templates_dir returns valid path. + */ + public function test_templates_dir_exists() { + $dir = cablecast_get_templates_dir(); + $this->assertNotEmpty($dir); + $this->assertStringContainsString('templates', $dir); + } + + /** + * Test cablecast_locate_template finds plugin templates. + */ + public function test_locate_template_finds_plugin_templates() { + $template = cablecast_locate_template('single-show.php'); + $this->assertNotEmpty($template); + $this->assertStringContainsString('single-show.php', $template); + } + + /** + * Test cablecast_has_vod helper function. + */ + public function test_has_vod_helper() { + // Show with VOD + $this->assertTrue(cablecast_has_vod($this->show_post_id)); + + // Show without VOD + $show_without_vod = wp_insert_post([ + 'post_title' => 'No VOD Show', + 'post_type' => 'show', + 'post_status' => 'publish', + ]); + $this->assertFalse(cablecast_has_vod($show_without_vod)); + wp_delete_post($show_without_vod, true); + } + + /** + * Test cablecast_has_chapters helper function. + */ + public function test_has_chapters_helper() { + // Show without chapters + $this->assertFalse(cablecast_has_chapters($this->show_post_id)); + + // Show with chapters + update_post_meta($this->show_post_id, 'cablecast_vod_chapters', [ + ['id' => 1, 'title' => 'Chapter 1', 'offset' => 0], + ]); + $this->assertTrue(cablecast_has_chapters($this->show_post_id)); + } + + /** + * Test cablecast_get_show_meta helper function. + */ + public function test_get_show_meta_helper() { + $meta = cablecast_get_show_meta($this->show_post_id); + + $this->assertIsArray($meta); + $this->assertArrayHasKey('runtime', $meta); + $this->assertArrayHasKey('producer', $meta); + $this->assertArrayHasKey('category', $meta); + $this->assertArrayHasKey('series', $meta); + + $this->assertEquals('Test Producer', $meta['producer']['value']); + $this->assertEquals('Test Category', $meta['category']['value']); + $this->assertEquals('Test Project', $meta['series']['value']); + } + + /** + * Test cablecast_format_runtime helper function. + */ + public function test_format_runtime_helper() { + // Function returns empty for 0 or negative values + $this->assertEquals('', cablecast_format_runtime(0)); + $this->assertEquals('', cablecast_format_runtime(-10)); + + // Minutes only (< 1 hour) + $this->assertEquals('1m', cablecast_format_runtime(90)); // 1.5 min -> 1m + $this->assertEquals('10m', cablecast_format_runtime(605)); // 10.08 min -> 10m + $this->assertEquals('30m', cablecast_format_runtime(1800)); // 30 min + + // Hours and minutes + $this->assertEquals('1h 0m', cablecast_format_runtime(3600)); // 1 hour + $this->assertEquals('1h 30m', cablecast_format_runtime(5445)); // 1h 30m 45s -> 1h 30m + } +} diff --git a/tests/SyncLockTest.php b/tests/SyncLockTest.php new file mode 100644 index 0000000..f068ded --- /dev/null +++ b/tests/SyncLockTest.php @@ -0,0 +1,127 @@ + true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + delete_option('cablecast_options'); + delete_transient('cablecast_sync_lock'); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + delete_option('cablecast_options'); + delete_transient('cablecast_sync_lock'); + parent::tearDown(); + } + + /** + * Test that sync lock prevents concurrent execution. + */ + public function test_sync_lock_prevents_concurrent_execution() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Set up lock manually + set_transient('cablecast_sync_lock', true, 300); + + // Mock the HTTP request to track if it was called + $request_made = false; + add_filter('pre_http_request', function() use (&$request_made) { + $request_made = true; + return [ + 'response' => ['code' => 200], + 'body' => json_encode(['shows' => []]), + ]; + }); + + // Try to run sync - should be skipped due to lock + cablecast_sync_data(); + + // The HTTP request should not have been made because sync was skipped + $this->assertFalse($request_made, 'Sync should be skipped when lock is present'); + } + + /** + * Test that sync lock is acquired during sync. + */ + public function test_sync_acquires_lock() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Track lock state during sync + $lock_was_set = false; + + add_filter('pre_http_request', function() use (&$lock_was_set) { + // Check if lock exists during the request + $lock_was_set = (bool) get_transient('cablecast_sync_lock'); + return [ + 'response' => ['code' => 200], + 'body' => json_encode([ + 'shows' => [], + 'showFields' => [], + 'fieldDefinitions' => [], + ]), + ]; + }); + + // Run sync + cablecast_sync_data(); + + $this->assertTrue($lock_was_set, 'Lock should be set during sync'); + } + + /** + * Test that sync lock is released after sync completes. + */ + public function test_sync_releases_lock_after_completion() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + ]); + + // Mock all HTTP requests + add_filter('pre_http_request', function() { + return [ + 'response' => ['code' => 200], + 'body' => json_encode([ + 'shows' => [], + 'channels' => [], + 'liveStreams' => [], + 'categories' => [], + 'producers' => [], + 'projects' => [], + 'showFields' => [], + 'fieldDefinitions' => [], + 'scheduleItems' => [], + 'savedShowSearch' => ['results' => []], + ]), + ]; + }); + + // Run sync + cablecast_sync_data(); + + // Lock should be released + $lock = get_transient('cablecast_sync_lock'); + $this->assertFalse($lock, 'Lock should be released after sync completes'); + } +} diff --git a/tests/SyncThumbnailTest.php b/tests/SyncThumbnailTest.php new file mode 100644 index 0000000..9d2f04d --- /dev/null +++ b/tests/SyncThumbnailTest.php @@ -0,0 +1,242 @@ + true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + delete_option('cablecast_options'); + delete_option('cablecast_sync_total_result_count'); + delete_option('cablecast_sync_index'); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + // Clean up any created posts + $posts = get_posts([ + 'post_type' => 'show', + 'posts_per_page' => -1, + 'fields' => 'ids', + ]); + foreach ($posts as $post_id) { + wp_delete_post($post_id, true); + } + + delete_option('cablecast_options'); + delete_option('cablecast_sync_total_result_count'); + delete_option('cablecast_sync_index'); + parent::tearDown(); + } + + /** + * Create a mock shows payload for testing. + */ + private function create_mock_payload($show_data = []) { + $default = [ + 'id' => 12345, + 'title' => 'Test Show', + 'cgTitle' => 'Test Show CG', + 'eventDate' => '2024-01-01 12:00:00', + 'comments' => 'Test description', + 'lastModified' => '2024-01-01T12:00:00', + 'reels' => [], + 'thumbnailImage' => (object)[ + 'url' => 'https://example.cablecast.net/cablecastapi/dynamicthumbnails/99999' + ], + 'custom1' => '', + 'custom2' => '', + 'custom3' => '', + 'custom4' => '', + 'custom5' => '', + 'custom6' => '', + 'custom7' => '', + 'custom8' => '', + 'location' => null, + ]; + + $show = (object) array_merge($default, $show_data); + + return (object)[ + 'shows' => [$show], + 'vods' => [], + 'reels' => [], + 'webFiles' => [], + ]; + } + + /** + * Test that remote mode saves thumbnail URL to meta. + */ + public function test_remote_mode_saves_thumbnail_url_meta() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote', + ]); + update_option('cablecast_sync_total_result_count', 1); + + $payload = $this->create_mock_payload(); + + // Run sync + cablecast_sync_shows($payload, [], [], [], [], []); + + // Find the created post + $posts = get_posts([ + 'post_type' => 'show', + 'meta_key' => 'cablecast_show_id', + 'meta_value' => 12345, + ]); + + $this->assertCount(1, $posts); + $post = $posts[0]; + + // Should have thumbnail URL in meta + $thumbnail_url = get_post_meta($post->ID, 'cablecast_thumbnail_url', true); + $this->assertEquals( + 'https://example.cablecast.net/cablecastapi/dynamicthumbnails/99999', + $thumbnail_url + ); + + // Should NOT have a featured image attachment + $this->assertFalse(has_post_thumbnail($post->ID)); + } + + /** + * Test that show data is synced correctly regardless of thumbnail mode. + */ + public function test_sync_creates_show_post_with_metadata() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote', + ]); + update_option('cablecast_sync_total_result_count', 1); + + $payload = $this->create_mock_payload([ + 'id' => 67890, + 'title' => 'My Test Show', + 'cgTitle' => 'My Test Show CG Title', + 'comments' => 'This is the show description', + 'eventDate' => '2024-06-15 14:30:00', + ]); + + cablecast_sync_shows($payload, [], [], [], [], []); + + $posts = get_posts([ + 'post_type' => 'show', + 'meta_key' => 'cablecast_show_id', + 'meta_value' => 67890, + ]); + + $this->assertCount(1, $posts); + $post = $posts[0]; + + // Check post data + $this->assertEquals('My Test Show CG Title', $post->post_title); + $this->assertEquals('This is the show description', $post->post_content); + + // Check metadata + $this->assertEquals(67890, get_post_meta($post->ID, 'cablecast_show_id', true)); + $this->assertEquals('My Test Show', get_post_meta($post->ID, 'cablecast_show_title', true)); + $this->assertEquals('My Test Show CG Title', get_post_meta($post->ID, 'cablecast_show_cg_title', true)); + } + + /** + * Test that sync updates existing posts. + */ + public function test_sync_updates_existing_post() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote', + ]); + + // Create an existing post + $existing_id = wp_insert_post([ + 'post_title' => 'Old Title', + 'post_type' => 'show', + 'post_status' => 'publish', + 'meta_input' => [ + 'cablecast_show_id' => 11111, + ], + ]); + + update_option('cablecast_sync_total_result_count', 1); + + $payload = $this->create_mock_payload([ + 'id' => 11111, + 'cgTitle' => 'Updated Title', + ]); + + cablecast_sync_shows($payload, [], [], [], [], []); + + // Refresh post data + $post = get_post($existing_id); + + $this->assertEquals('Updated Title', $post->post_title); + } + + /** + * Test sync handles shows without thumbnail gracefully. + */ + public function test_sync_handles_missing_thumbnail() { + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote', + ]); + update_option('cablecast_sync_total_result_count', 1); + + // Create payload without thumbnailImage + $payload = (object)[ + 'shows' => [(object)[ + 'id' => 55555, + 'title' => 'No Thumbnail Show', + 'cgTitle' => 'No Thumbnail Show', + 'eventDate' => '2024-01-01 12:00:00', + 'comments' => '', + 'lastModified' => '2024-01-01T12:00:00', + 'reels' => [], + 'custom1' => '', + 'custom2' => '', + 'custom3' => '', + 'custom4' => '', + 'custom5' => '', + 'custom6' => '', + 'custom7' => '', + 'custom8' => '', + 'location' => null, + ]], + 'vods' => [], + 'reels' => [], + 'webFiles' => [], + ]; + + // Should not throw an error + cablecast_sync_shows($payload, [], [], [], [], []); + + $posts = get_posts([ + 'post_type' => 'show', + 'meta_key' => 'cablecast_show_id', + 'meta_value' => 55555, + ]); + + $this->assertCount(1, $posts); + + // Should not have thumbnail URL + $thumbnail_url = get_post_meta($posts[0]->ID, 'cablecast_thumbnail_url', true); + $this->assertEmpty($thumbnail_url); + } +} diff --git a/tests/ThumbnailCleanupTest.php b/tests/ThumbnailCleanupTest.php new file mode 100644 index 0000000..e9c5d62 --- /dev/null +++ b/tests/ThumbnailCleanupTest.php @@ -0,0 +1,183 @@ + true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + // Create test show posts with featured images + for ($i = 0; $i < 5; $i++) { + $show_id = wp_insert_post([ + 'post_title' => "Test Show {$i}", + 'post_type' => 'show', + 'post_status' => 'publish', + ]); + $this->show_post_ids[] = $show_id; + + // Create a mock attachment + $attachment_id = wp_insert_attachment([ + 'post_title' => "Thumbnail {$i}", + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + 'post_status' => 'inherit', + ]); + $this->attachment_ids[] = $attachment_id; + + // Set as featured image using direct meta update for reliability in tests + update_post_meta($show_id, '_thumbnail_id', $attachment_id); + } + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + foreach ($this->show_post_ids as $id) { + wp_delete_post($id, true); + } + foreach ($this->attachment_ids as $id) { + wp_delete_attachment($id, true); + } + delete_option('cablecast_options'); + parent::tearDown(); + } + + /** + * Test cleanup does nothing when not in remote mode. + */ + public function test_cleanup_skips_when_local_mode() { + update_option('cablecast_options', [ + 'thumbnail_mode' => 'local', + 'delete_local_thumbnails' => true, + ]); + + // Run cleanup + cablecast_cleanup_local_thumbnails(); + + // All thumbnails should still exist + foreach ($this->show_post_ids as $show_id) { + $this->assertTrue(has_post_thumbnail($show_id), "Show {$show_id} should still have thumbnail"); + } + } + + /** + * Test cleanup does nothing when delete flag is not set. + */ + public function test_cleanup_skips_without_delete_flag() { + update_option('cablecast_options', [ + 'thumbnail_mode' => 'remote', + 'delete_local_thumbnails' => false, + ]); + + // Run cleanup + cablecast_cleanup_local_thumbnails(); + + // All thumbnails should still exist + foreach ($this->show_post_ids as $show_id) { + $this->assertTrue(has_post_thumbnail($show_id), "Show {$show_id} should still have thumbnail"); + } + } + + /** + * Test cleanup processes thumbnails in batches. + */ + public function test_cleanup_processes_batch() { + update_option('cablecast_options', [ + 'thumbnail_mode' => 'remote', + 'delete_local_thumbnails' => true, + ]); + + // Run cleanup once (should process up to 25 items) + cablecast_cleanup_local_thumbnails(); + + // Count remaining thumbnails + $remaining = 0; + foreach ($this->show_post_ids as $show_id) { + if (has_post_thumbnail($show_id)) { + $remaining++; + } + } + + // All 5 should be deleted (batch size is 25, we only have 5) + $this->assertEquals(0, $remaining, "All thumbnails should be deleted"); + } + + /** + * Test cleanup clears the delete flag when complete. + */ + public function test_cleanup_clears_flag_when_complete() { + update_option('cablecast_options', [ + 'thumbnail_mode' => 'remote', + 'delete_local_thumbnails' => true, + ]); + + // Run cleanup - first call deletes thumbnails + cablecast_cleanup_local_thumbnails(); + + // Run cleanup again - second call finds no more posts and clears the flag + cablecast_cleanup_local_thumbnails(); + + // Flag should be cleared + $options = get_option('cablecast_options'); + $this->assertFalse($options['delete_local_thumbnails']); + } + + /** + * Test cleanup deletes the attachment file. + */ + public function test_cleanup_deletes_attachments() { + update_option('cablecast_options', [ + 'thumbnail_mode' => 'remote', + 'delete_local_thumbnails' => true, + ]); + + $attachment_id = $this->attachment_ids[0]; + + // Verify attachment exists before cleanup + $this->assertNotNull(get_post($attachment_id)); + + // Run cleanup + cablecast_cleanup_local_thumbnails(); + + // Attachment should be deleted + $this->assertNull(get_post($attachment_id)); + } + + /** + * Test cleanup removes _thumbnail_id meta. + */ + public function test_cleanup_removes_thumbnail_meta() { + update_option('cablecast_options', [ + 'thumbnail_mode' => 'remote', + 'delete_local_thumbnails' => true, + ]); + + $show_id = $this->show_post_ids[0]; + + // Verify meta exists before cleanup + $this->assertNotEmpty(get_post_meta($show_id, '_thumbnail_id', true)); + + // Run cleanup + cablecast_cleanup_local_thumbnails(); + + // Meta should be removed + $this->assertEmpty(get_post_meta($show_id, '_thumbnail_id', true)); + } +} diff --git a/tests/ThumbnailSettingsTest.php b/tests/ThumbnailSettingsTest.php new file mode 100644 index 0000000..74a0a12 --- /dev/null +++ b/tests/ThumbnailSettingsTest.php @@ -0,0 +1,71 @@ +assertFalse(get_option('cablecast_options')); + + // Run install hook + cablecast_install(); + + $options = get_option('cablecast_options'); + $this->assertIsArray($options); + $this->assertEquals('remote', $options['thumbnail_mode']); + } + + /** + * Test that upgrades from older versions default to local mode. + */ + public function test_upgrade_defaults_to_local_mode() { + // Simulate existing install without thumbnail_mode setting + update_option('cablecast_options', ['server' => 'https://example.cablecast.net']); + + // Run install hook (simulating upgrade) + cablecast_install(); + + $options = get_option('cablecast_options'); + $this->assertEquals('local', $options['thumbnail_mode']); + // Ensure existing settings are preserved + $this->assertEquals('https://example.cablecast.net', $options['server']); + } + + /** + * Test that existing thumbnail_mode setting is preserved on reinstall. + */ + public function test_existing_thumbnail_mode_preserved() { + // Set up existing options with remote mode + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote' + ]); + + // Run install hook + cablecast_install(); + + $options = get_option('cablecast_options'); + $this->assertEquals('remote', $options['thumbnail_mode']); + } +} diff --git a/tests/ThumbnailUrlTest.php b/tests/ThumbnailUrlTest.php new file mode 100644 index 0000000..c450523 --- /dev/null +++ b/tests/ThumbnailUrlTest.php @@ -0,0 +1,171 @@ + true, + 'supports' => ['title', 'editor', 'thumbnail'], + ]); + } + + // Create a test show post + $this->show_post_id = wp_insert_post([ + 'post_title' => 'Test Show', + 'post_type' => 'show', + 'post_status' => 'publish', + ]); + + // Set cablecast metadata + update_post_meta($this->show_post_id, 'cablecast_show_id', 12345); + update_post_meta($this->show_post_id, 'cablecast_thumbnail_url', 'https://example.cablecast.net/cablecastapi/dynamicthumbnails/99999'); + + // Set up options for remote mode + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'remote' + ]); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + wp_delete_post($this->show_post_id, true); + delete_option('cablecast_options'); + parent::tearDown(); + } + + /** + * Test thumbnail URL generation with saved URL. + */ + public function test_thumbnail_url_uses_saved_url() { + $url = cablecast_show_thumbnail_url($this->show_post_id, 'medium'); + + $this->assertStringContainsString('https://example.cablecast.net/cablecastapi/dynamicthumbnails/99999', $url); + $this->assertStringContainsString('?d=500x500', $url); + } + + /** + * Test thumbnail URL generation for different sizes. + */ + public function test_thumbnail_url_size_mappings() { + $sizes = [ + 'thumbnail' => '100x100', + 'medium' => '500x500', + 'large' => '1000x1000', + 'post-thumbnail' => '640x360', + ]; + + foreach ($sizes as $size => $expected_dimension) { + $url = cablecast_show_thumbnail_url($this->show_post_id, $size); + $this->assertStringContainsString("?d={$expected_dimension}", $url, "Failed for size: {$size}"); + } + } + + /** + * Test thumbnail URL with array size. + */ + public function test_thumbnail_url_with_array_size() { + $url = cablecast_show_thumbnail_url($this->show_post_id, [800, 600]); + + $this->assertStringContainsString('?d=800x600', $url); + } + + /** + * Test thumbnail URL returns empty for non-show posts. + */ + public function test_thumbnail_url_empty_for_non_show() { + // Create a regular post + $post_id = wp_insert_post([ + 'post_title' => 'Regular Post', + 'post_type' => 'post', + 'post_status' => 'publish', + ]); + + $url = cablecast_show_thumbnail_url($post_id, 'medium'); + + $this->assertEmpty($url); + + wp_delete_post($post_id, true); + } + + /** + * Test thumbnail URL returns empty when no saved URL exists. + * + * Previously the function would construct a fallback URL, but this caused + * broken images when shows don't have thumbnails on the server. + */ + public function test_thumbnail_url_empty_without_saved_url() { + // Remove the saved thumbnail URL + delete_post_meta($this->show_post_id, 'cablecast_thumbnail_url'); + + $url = cablecast_show_thumbnail_url($this->show_post_id, 'medium'); + + // Should return empty - no fallback URL construction + $this->assertEmpty($url); + } + + /** + * Test thumbnail URL uses remote URL even in local mode when no featured image. + */ + public function test_thumbnail_url_remote_fallback_in_local_mode() { + // Switch to local mode + update_option('cablecast_options', [ + 'server' => 'https://example.cablecast.net', + 'thumbnail_mode' => 'local' + ]); + + // With cablecast_thumbnail_url meta but no WordPress featured image, + // should fall back to the remote URL + $url = cablecast_show_thumbnail_url($this->show_post_id, 'medium'); + + $this->assertStringContainsString('https://example.cablecast.net/cablecastapi/dynamicthumbnails/99999', $url); + } + + /** + * Test thumbnail srcset generation. + */ + public function test_thumbnail_srcset_generation() { + $srcset = cablecast_show_thumbnail_srcset($this->show_post_id); + + $this->assertStringContainsString('320w', $srcset); + $this->assertStringContainsString('480w', $srcset); + $this->assertStringContainsString('640w', $srcset); + $this->assertStringContainsString('960w', $srcset); + $this->assertStringContainsString('1280w', $srcset); + } + + /** + * Test srcset returns empty when no thumbnail URL exists. + */ + public function test_srcset_empty_without_thumbnail_url() { + delete_post_meta($this->show_post_id, 'cablecast_thumbnail_url'); + + $srcset = cablecast_show_thumbnail_srcset($this->show_post_id); + + $this->assertEmpty($srcset); + } + + /** + * Test full size returns URL without dimension parameter. + */ + public function test_thumbnail_url_full_size_no_dimension() { + $url = cablecast_show_thumbnail_url($this->show_post_id, 'full'); + + $this->assertStringContainsString('https://example.cablecast.net/cablecastapi/dynamicthumbnails/99999', $url); + $this->assertStringNotContainsString('?d=', $url); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ef23c49 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,38 @@ +prefix . 'cablecast_schedule_items'; +$wpdb->query("DROP TABLE IF EXISTS {$table_name}"); + +// 2. Delete all plugin options +$options_to_delete = array( + 'cablecast_options', + 'cablecast_sync_since', + 'cablecast_sync_index', + 'cablecast_sync_total_result_count', + 'cablecast_schedule_items_hash', + 'cablecast_categories_items_hash', + 'cablecast_projects_items_hash', + 'cablecast_producers_items_hash', + 'cablecast_custom_taxonomy_definitions', + 'cablecast_orphan_check_last_run', +); + +foreach ($options_to_delete as $option) { + delete_option($option); +} + +// 3. Delete transients +delete_transient('cablecast_sync_lock'); + +// 4. Optionally delete all show and channel posts +// Uncomment the following lines if you want to remove all synced content on uninstall: +/* +$post_types = array('show', 'cablecast_channel'); +foreach ($post_types as $post_type) { + $posts = get_posts(array( + 'post_type' => $post_type, + 'post_status' => 'any', + 'numberposts' => -1, + 'fields' => 'ids', + )); + foreach ($posts as $post_id) { + wp_delete_post($post_id, true); + } +} +*/ + +// 5. Delete custom taxonomies terms (optional) +// Note: WordPress will keep taxonomy terms but they will be orphaned +// Uncomment if you want to clean them up: +/* +$taxonomies = array('cablecast_producer', 'cablecast_project'); +foreach ($taxonomies as $taxonomy) { + $terms = get_terms(array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'ids', + )); + if (!is_wp_error($terms)) { + foreach ($terms as $term_id) { + wp_delete_term($term_id, $taxonomy); + } + } +} +*/ + +// 6. Delete log directory +$log_dir = WP_CONTENT_DIR . '/cablecast-logs'; +if (is_dir($log_dir)) { + $files = glob($log_dir . '/*'); + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($log_dir); +}