diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d87113f..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: CI — Lint & Test - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - workflow_call: - -permissions: - contents: read - -jobs: - # ── PHP Syntax Check ───────────────────────────────────────────── - lint-php: - name: PHP Lint (${{ matrix.php }}) - runs-on: ubuntu-latest - strategy: - matrix: - php: ['7.4', '8.1', '8.3'] - steps: - - uses: actions/checkout@v4 - - - name: Set up PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: composer - - - name: Syntax check all PHP files - run: find . -name '*.php' -not -path './vendor/*' -not -path './peptide-news-plugin/*' -print0 | xargs -0 -n1 php -l - - # ── PHPCS Coding Standards ─────────────────────────────────────── - phpcs: - name: PHPCS - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - tools: composer - - - name: Install dependencies - run: composer install --prefer-dist --no-progress - - - name: Run PHPCS - run: vendor/bin/phpcs --standard=.phpcs.xml.dist - - # ── PHPUnit Tests ──────────────────────────────────────────────── - phpunit: - name: PHPUnit (${{ matrix.php }}) - runs-on: ubuntu-latest - strategy: - matrix: - php: ['7.4', '8.1', '8.3'] - steps: - - uses: actions/checkout@v4 - - - name: Set up PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: composer - coverage: xdebug - - - name: Install dependencies - run: composer install --prefer-dist --no-progress - - - name: Run PHPUnit - run: vendor/bin/phpunit --testdox - - # ── JavaScript Lint ────────────────────────────────────────────── - lint-js: - name: JS Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Check JS syntax - run: | - for f in $(find . -name '*.js' -not -path './vendor/*' -not -path './node_modules/*' -not -path './peptide-news-plugin/*'); do - node --check "$f" || exit 1 - done diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index dbb4256..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Deploy to WordPress - -on: - push: - branches: [ main ] - -permissions: - contents: write - -# Cancel previous deploys if a new push arrives while one is running. -concurrency: - group: deploy-production - cancel-in-progress: true - -jobs: - # ── Run tests first ────────────────────────────────────────────── - test: - name: Pre-deploy tests - uses: ./.github/workflows/ci.yml - - # ── Push clean plugin files to the deploy branch ───────────────── - # Hostinger's built-in GIT integration watches the `deploy` branch - # and auto-pulls via webhook when it changes. This avoids the - # firewall issues with push-based SCP/FTP from GitHub Actions. - deploy: - name: Update deploy branch - runs-on: ubuntu-latest - needs: test - environment: production - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Sync deploy branch - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Fetch the remote deploy branch (may not exist on first run). - git fetch origin deploy:deploy 2>/dev/null || true - - # If deploy branch exists, check it out; otherwise create it. - if git rev-parse --verify deploy >/dev/null 2>&1; then - git checkout deploy - else - git checkout --orphan deploy - git rm -rf --cached . > /dev/null 2>&1 || true - fi - - # Remove all tracked files so we start clean each time. - git rm -rf . > /dev/null 2>&1 || true - - # Checkout only the plugin runtime files from main. - git checkout main -- \ - admin/ \ - includes/ \ - languages/ \ - public/ \ - peptide-news-plugin.php - - # Commit only if there are actual changes. - git add -A - if git diff-index --quiet HEAD -- 2>/dev/null; then - echo "No changes to deploy." - else - git commit -m "Deploy ${GITHUB_SHA::7} from main" - fi - - # Normal push (fast-forward compatible with Hostinger's git pull). - git push origin deploy - - - name: Purge LiteSpeed Cache - if: success() - run: | - # Give Hostinger a moment to pull the new code. - sleep 10 - - # Trigger a cache purge via the WordPress REST API. - if [ -n "${{ secrets.WP_APP_PASSWORD }}" ]; then - curl -s -X POST \ - -u "${{ secrets.WP_ADMIN_USER }}:${{ secrets.WP_APP_PASSWORD }}" \ - "${{ secrets.WP_SITE_URL }}/wp-json/peptide-news/v1/cache-purge" \ - --max-time 15 || echo "Cache purge skipped (endpoint not available)" - else - echo "No WP_APP_PASSWORD set — skipping remote cache purge." - fi - - - name: Deploy summary - run: | - echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Method:** Hostinger GIT auto-deploy (deploy branch)" >> $GITHUB_STEP_SUMMARY - echo "- **Deployed to:** ${{ secrets.WP_SITE_URL || 'peptiderepo.com' }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 6518275..0000000 --- a/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# WordPress -wp-config.php -wp-content/uploads/ - -# Dependencies -node_modules/ -vendor/ - -# Logs -*.log -debug.log - -# Build artifacts -*.zip diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist deleted file mode 100644 index 893aebd..0000000 --- a/.phpcs.xml.dist +++ /dev/null @@ -1,50 +0,0 @@ - - - PHPCS rules for the Peptide News WordPress plugin. - - - ./peptide-news-plugin.php - ./includes - ./admin - ./public - - - ./vendor/* - ./node_modules/* - ./tests/* - ./peptide-news-plugin/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md deleted file mode 100644 index aab8ea9..0000000 --- a/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# Peptide News Aggregator - -A WordPress plugin that aggregates and displays the latest peptide research news from multiple sources, with built-in click analytics and trend reporting. - -## Features - -- **Multi-Source Fetching** — Pulls articles from RSS/Atom feeds (Google News, PubMed, custom) and NewsAPI.org -- **Configurable Refresh** — Admin-controlled cron intervals from 15 minutes to daily -- **Shortcode & Wigget** — `[peptide_news count="10"]` shortcode with card/compact/list layouts, plus a sidebar widget -- **Click Analytics** — Tracks every outbound click with session tracking, device detection, and GDPR-friendly IP anonymization -- **Analytics Dashboard** — Chart.js-powered admin dashboard with trend lines, device breakdown, source performance, and top articles -- **REST API** — Full JSON API at `/wp-json/peptide-news/v1/` for external analytics tools -- **Topic Analysis** — Aggregates clicks by article category to surface popular research topics -- **CSV Export** — Export raw click data for offline analysis -- **Dark Mode** — Automatically adapts to visitor's system theme preference -- **Performance** — Transient caching, deduplication via SHA-256 hashing, and paginated DB queries - -## Requirements - -- WordPress 5.8+ -- PHP 7.4+ -- MySQL 5.7+ or MariaDB 10.3+ - -## Installation - -1. Download or clone this repository into `wp-content/plugins/`: - ```bash - cd wp-content/plugins/ - git clone https://github.com/peptiderepo/peptide-news-plugin.git - ``` -2. Activate the plugin in **Plugins → Installed Plugins** -3. Navigate to **Peptide News → Settings** to configure your sources and fetch interval -4. Click **Fetch Articles Now** to pull the first batch -5. Add `[peptide_news]` to any page or post, or add the widget via **Appearance → Widgets** - -## Configuration - -### Settings (Peptide News → Settings) - -| Setting | Description | Default | -|---------|-------------|---------| -| Fetch Interval | How often to check for new articles | Twice daily | -| Articles to Display | Number shown in shortcode/widget | 10 | -| Search Keywords | Comma-separated terms for NewsAPI | peptide, peptides, BPC-157... | -| RSS Feeds | One URL per line | Google News + PubMed | -| NewsAPI Key | Optional API key from newsapi.org | — | -| Data Retention | Days to keep analytics data | 365 | -| Anonymize IPs | Zero out last IP octet (GDPR) | Enabled | - -### Shortcode Usage - -``` -[peptide_news] -[peptide_news count="5"] -[peptide_news count="8" layout="list"] -[peptide_news layout="compact"] -``` - -## REST API Endpoints - -All analytics endpoints require admin authentication (cookie or Application Password). - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/wp-json/peptide-news/v1/articles` | List stored articles (public) | -| GET | `/wp-json/peptide-news/v1/analytics/top` | Top articles by clicks | -| GET | `/wp-json/peptide-news/v1/analytics/trends` | Daily click trend data | -| GET | `/wp-json/peptide-news/v1/analytics/topics` | Popular topics/categories | -| GET | `/wp-json/peptide-news/v1/analytics/devices` | Device breakdown | -| GET | `/wp-json/peptide-news/v1/analytics/sources` | Source performance | -| GET | `/wp-json/peptide-news/v1/analytics/export` | Raw click data for CSV | - -Query parameters: `start_date`, `end_date` (Y-m-d), `limit` (int). - -## Database Schema - -The plugin creates three custom tables on activation: - -- `wp_peptide_news_articles` — Stores all fetched article data (title, excerpt, content, author, thumbnail, categories, source, etc.) -- `wp_peptide_news_clicks` — Raw click events with session, device, referrer data -- `wp_peptide_news_daily_stats` — Pre-aggregated daily stats for fast dashboard queries - -All tables are dropped on plugin **uninstall** (not deactivation — data is preserved if you temporarily disable the plugin). - -## Architecture - -``` -peptide-news-plugin/ -├── peptide-news-plugin.php # Bootstrap & hooks -├── uninstall.php # Cleanup on uninstall -├── includes/ -│ ├── class-peptide-news.php # Core orchestrator -│ ├── class-peptide-news-loader.php -│ ├── class-peptide-news-activator.php -│ ├── class-peptide-news-deactivator.php -│ ├── class-peptide-news-fetcher.php # Multi-source fetcher -│ ├── class-peptide-news-analytics.php # Click tracking & reports -│ └── class-peptide-news-rest-api.php # REST endpoints -├── admin/ -│ ├── class-peptide-news-admin.php # Settings & dashboard -│ ├── css/admin-style.css -│ ├── js/admin-script.js # Chart.js dashboard -│ └── partials/ -│ ├── dashboard.php -│ └── articles-list.php -├── public/ -│ ├── class-peptide-news-public.php # Shortcode, widget, AJAX -│ ├── css/public-style.css -│ └── js/public-script.js # Click tracker (sendBeacon) -└── languages/ # i18n ready -``` - -## License - -GPL-2.0+ — See [LICENSE](LICENSE) for details. diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php index c327f93..177dbd8 100644 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -211,7 +211,7 @@ class Peptide_News_Admin { $this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); - $this->add_setting( 'llm_image_model', __( 'Image Generation Model', 'peptide-news' ), 'render_llm_image_model_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); } @@ -265,9 +265,7 @@ class Peptide_News_Admin { 'openrouter_api_key' => 'sanitize_text_field', 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), 'llm_summary_model' => array( $this, 'sanitize_model_id' ), - 'llm_image_model' => array( $this, 'sanitize_model_id' ), 'llm_max_articles' => 'absint', - 'thumbnail_fallback' => 'esc_url_raw', ); return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; @@ -532,15 +530,6 @@ class Peptide_News_Admin { echo '

' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '

'; } - public function render_llm_image_model_field() { - $value = get_option( 'peptide_news_llm_image_model', '' ); - printf( - '', - esc_attr( $value ) - ); - echo '

' . esc_html__( 'OpenRouter model ID for AI thumbnail generation (e.g., openai/dall-e-3, stabilityai/stable-diffusion-xl). Leave blank to disable AI thumbnails.', 'peptide-news' ) . '

'; - } - public function render_llm_max_articles_field() { $value = get_option( 'peptide_news_llm_max_articles', 10 ); printf( @@ -579,13 +568,6 @@ class Peptide_News_Admin { echo ''; echo ''; - // Backfill Thumbnails button. - echo '
'; - echo ''; - echo ''; - echo '
'; ?> ID - - + + - + - - + @@ -72,21 +71,6 @@ $total_pages = ceil( $total / $per_page ); published_at ) ) ); ?> fetched_at ) ) ); ?> - - thumbnail_url ) && '_no_image' !== $article->thumbnail_url ) { - $thumb_src = $article->thumbnail_url; - } elseif ( ! empty( $article->thumbnail_local ) ) { - $upload_dir = wp_upload_dir(); - $thumb_src = $upload_dir['baseurl'] . '/' . $article->thumbnail_local; - } - if ( $thumb_src ) : ?> - - - - - is_active ? '✓' : '✗'; ?> diff --git a/composer.json b/composer.json deleted file mode 100644 index eb532a1..0000000 --- a/composer.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "peptiderepo/peptide-news-plugin", - "description": "WordPress plugin for peptide research news aggregation with click analytics.", - "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", - "squizlabs/php_codesniffer": "^3.9", - "wp-coding-standards/wpcs": "^3.1", - "phpcompatibility/phpcompatibility-wp": "^2.1", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "yoast/phpunit-polyfills": "^2.0" - }, - "autoload-dev": { - "psr-4": { - "PeptideNews\\Tests\\": "tests/" - } - }, - "scripts": { - "test": "phpunit", - "lint": "phpcs", - "lint:fix": "phpcbf", - "check": [ - "@lint", - "@test" - ] - }, - "config": { - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true - } - } -} diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php index c8d6a7a..2f33708 100644 --- a/includes/class-peptide-news-fetcher.php +++ b/includes/class-peptide-news-fetcher.php @@ -89,23 +89,15 @@ class Peptide_News_Fetcher { 'new_stored' => $stored, ) ); - // Scrape OG images for articles missing thumbnails. - $og_scraped = $this->scrape_missing_thumbnails(); - // Run AI analysis on newly fetched articles (keywords + summary). if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { $llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); $llm_processed = Peptide_News_LLM::process_unanalyzed( $llm_batch_size ); - // Generate AI thumbnails for articles still missing images. - $ai_thumbs = Peptide_News_LLM::generate_missing_thumbnails( $llm_batch_size ); - // Append LLM stats to the fetch log. $fetch_log = get_option( 'peptide_news_last_fetch' ); if ( is_array( $fetch_log ) ) { - $fetch_log['ai_processed'] = $llm_processed; - $fetch_log['og_scraped'] = $og_scraped; - $fetch_log['ai_thumbnails'] = $ai_thumbs; + $fetch_log['ai_processed'] = $llm_processed; update_option( 'peptide_news_last_fetch', $fetch_log ); } } @@ -143,19 +135,6 @@ class Peptide_News_Fetcher { $items = $feed->get_items( 0, $max_items ); foreach ( $items as $item ) { - $thumbnail = ''; - - // Try enclosure first. - $enclosure = $item->get_enclosure(); - if ( $enclosure && $enclosure->get_link() ) { - $thumbnail = $enclosure->get_link(); - } - - // Try media:thumbnail or media:content. - if ( empty( $thumbnail ) ) { - $thumbnail = $this->extract_image_from_content( $item->get_content() ); - } - $pub_date = $item->get_date( 'Y-m-d H:i:s' ); if ( empty( $pub_date ) ) { $pub_date = current_time( 'mysql' ); @@ -171,7 +150,7 @@ class Peptide_News_Fetcher { 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), 'content' => wp_kses_post( $item->get_content() ), 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), - 'thumbnail_url' => esc_url_raw( $thumbnail ), + 'thumbnail_url' => '', 'published_at' => $pub_date, 'categories' => $this->extract_categories( $item ), 'tags' => '', @@ -230,7 +209,7 @@ class Peptide_News_Fetcher { 'excerpt' => wp_trim_words( sanitize_text_field( $item['description'] ?? '' ), 40 ), 'content' => wp_kses_post( $item['content'] ?? '' ), 'author' => sanitize_text_field( $item['author'] ?? '' ), - 'thumbnail_url' => esc_url_raw( $item['urlToImage'] ?? '' ), + 'thumbnail_url' => '', 'published_at' => $pub_date, 'categories' => '', 'tags' => '', @@ -303,270 +282,6 @@ class Peptide_News_Fetcher { ) ); } - /** - * Scrape Open Graph images for articles that have no thumbnail. - * - * Fetches the article URL and extracts og:image, twitter:image, - * or other meta image tags. - * - * @return int Number of articles updated with scraped thumbnails. - */ - private function scrape_missing_thumbnails() { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - $articles = $wpdb->get_results( - "SELECT id, source, source_url, title - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - ORDER BY fetched_at DESC - LIMIT 20" - ); - - if ( empty( $articles ) ) { - return 0; - } - - $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); - $updated = 0; - - foreach ( $articles as $article ) { - $domain = $this->extract_domain( $article->source_url ); - - // Skip aggregator URLs — they can't be scraped for OG images. - // These will be handled by AI image generation instead. - if ( in_array( $domain, $aggregators, true ) ) { - $wpdb->update( - $table, - array( 'thumbnail_url' => '_no_image' ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - continue; - } - - // Direct OG scrape for non-aggregator URLs. - $image_url = $this->scrape_og_image( $article->source_url ); - - if ( ! empty( $image_url ) ) { - $local_path = $this->download_thumbnail( $image_url, $article->id, $article->title ); - - $update_data = array( 'thumbnail_url' => $image_url ); - if ( $local_path ) { - $update_data['thumbnail_local'] = $local_path; - } - - $wpdb->update( $table, $update_data, array( 'id' => $article->id ) ); - $updated++; - } else { - $wpdb->update( - $table, - array( 'thumbnail_url' => '_no_image' ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - } - - usleep( 300000 ); - } - - if ( $updated > 0 ) { - $this->clear_article_cache(); - } - - return $updated; - } - - /** - * Scrape Open Graph / meta image tags from a URL. - * - * @param string $url The article URL to scrape. - * @return string Image URL or empty string. - */ - private function scrape_og_image( $url ) { - if ( empty( $url ) ) { - return ''; - } - - $response = wp_remote_get( $url, array( - 'timeout' => 15, - 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', - 'headers' => array( - 'Accept' => 'text/html', - ), - // Only fetch the first ~200KB for efficiency. - 'stream' => false, - 'limit_response_size' => 200000, - ) ); - - if ( is_wp_error( $response ) ) { - $this->log_error( 'OG scrape failed for ' . $url . ': ' . $response->get_error_message() ); - return ''; - } - - $status = wp_remote_retrieve_response_code( $response ); - if ( $status < 200 || $status >= 400 ) { - return ''; - } - - $html = wp_remote_retrieve_body( $response ); - - if ( empty( $html ) ) { - return ''; - } - - // Try meta tags in priority order. - $patterns = array( - // Open Graph image. - '/]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']/is', - '/]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']/is', - // Twitter card image. - '/]+name=["\']twitter:image["\'][^>]+content=["\']([^"\']+)["\']/is', - '/]+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:image["\']/is', - // Schema.org image. - '/]+itemprop=["\']image["\'][^>]+content=["\']([^"\']+)["\']/is', - ); - - foreach ( $patterns as $pattern ) { - if ( preg_match( $pattern, $html, $matches ) ) { - $image_url = trim( $matches[1] ); - - // Validate it looks like an image URL. - if ( $this->is_valid_image_url( $image_url ) ) { - return esc_url_raw( $image_url ); - } - } - } - - return ''; - } - - /** - * Check if a URL appears to be a valid image. - * - * @param string $url - * @return bool - */ - private function is_valid_image_url( $url ) { - if ( empty( $url ) || strlen( $url ) < 10 ) { - return false; - } - - // Must start with http(s). - if ( ! preg_match( '/^https?:\/\//i', $url ) ) { - return false; - } - - // Reject obvious non-image URLs (tracking pixels, spacers, etc.) - $blocklist = array( '1x1', 'pixel', 'spacer', 'blank', 'transparent', 'data:image' ); - $url_lower = strtolower( $url ); - foreach ( $blocklist as $blocked ) { - if ( strpos( $url_lower, $blocked ) !== false ) { - return false; - } - } - - return true; - } - - /** - * Download an image and store it in the WP uploads directory. - * - * @param string $image_url External image URL. - * @param int $article_id Article ID for unique naming. - * @param string $title Article title for alt text / file naming. - * @return string|false Local relative path on success, false on failure. - */ - private function download_thumbnail( $image_url, $article_id, $title = '' ) { - // SSRF prevention: validate URL before fetching. - if ( ! wp_http_validate_url( $image_url ) ) { - $this->log_error( 'Invalid image URL (SSRF protection) for article ' . $article_id . ': ' . $image_url ); - return false; - } - - $upload_dir = wp_upload_dir(); - $target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs'; - - // Create directory if it doesn't exist. - if ( ! file_exists( $target_dir ) ) { - wp_mkdir_p( $target_dir ); - } - - $max_file_size = 5 * 1024 * 1024; // 5 MB. - - $response = wp_remote_get( $image_url, array( - 'timeout' => 20, - 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', - 'limit_response_size' => $max_file_size, - ) ); - - if ( is_wp_error( $response ) ) { - $this->log_error( 'Thumbnail download failed for article ' . $article_id . ': ' . $response->get_error_message() ); - return false; - } - - $body = wp_remote_retrieve_body( $response ); - if ( empty( $body ) || strlen( $body ) < 1000 || strlen( $body ) > $max_file_size ) { - // Too small to be a real image, or exceeds size limit. - return false; - } - - $content_type = wp_remote_retrieve_header( $response, 'content-type' ); - $extension = $this->get_extension_from_content_type( $content_type ); - - if ( ! $extension ) { - // Try to detect from the image data. - $finfo = new finfo( FILEINFO_MIME_TYPE ); - $mime = $finfo->buffer( $body ); - $extension = $this->get_extension_from_content_type( $mime ); - } - - if ( ! $extension ) { - return false; - } - - $filename = 'pn-thumb-' . $article_id . '.' . $extension; - $filepath = $target_dir . '/' . $filename; - - // Write the file. - $result = file_put_contents( $filepath, $body ); - - if ( false === $result ) { - $this->log_error( 'Failed to write thumbnail file: ' . $filepath ); - return false; - } - - // Set secure file permissions. - chmod( $filepath, 0644 ); - - // Return the relative path from uploads base. - return 'peptide-news-thumbs/' . $filename; - } - - /** - * Map content type to file extension. - * - * @param string $content_type - * @return string|false - */ - private function get_extension_from_content_type( $content_type ) { - $map = array( - 'image/jpeg' => 'jpg', - 'image/jpg' => 'jpg', - 'image/png' => 'png', - 'image/gif' => 'gif', - 'image/webp' => 'webp', - 'image/svg+xml' => 'svg', - ); - - $content_type = strtolower( trim( explode( ';', $content_type )[0] ) ); - - return isset( $map[ $content_type ] ) ? $map[ $content_type ] : false; - } - /** * Clear all article transient caches. */ @@ -583,29 +298,6 @@ class Peptide_News_Fetcher { ); } - /** - * Try to extract an image URL from HTML content. - * - * @param string $content - * @return string - */ - private function extract_image_from_content( $content ) { - if ( empty( $content ) ) { - return ''; - } - - if ( preg_match( '/]+src=["\']([^"\']+)["\']/', $content, $matches ) ) { - return $matches[1]; - } - - // Check for media:content or media:thumbnail. - if ( preg_match( '/url=["\']([^"\']+)["\']/', $content, $matches ) ) { - return $matches[1]; - } - - return ''; - } - /** * Extract categories from a SimplePie item. * @@ -736,127 +428,6 @@ class Peptide_News_Fetcher { return ! empty( $final_url ) ? $final_url : false; } - - /** - * Backfill thumbnails for existing articles that have none. - * - * Attempts OG scraping for non-aggregator URLs, then uses AI image - * generation for any remaining articles without thumbnails. - * - * @return array Summary with counts and diagnostic samples. - */ - public function backfill_article_thumbnails() { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); - - // Reset sentinels so we retry everything. - $wpdb->query( - "UPDATE {$table} - SET thumbnail_url = '' - WHERE thumbnail_url = '_no_image' - AND is_active = 1" - ); - - $articles = $wpdb->get_results( - "SELECT id, source, source_url, title, excerpt, ai_summary, tags, categories - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL ) - ORDER BY fetched_at DESC - LIMIT 20" - ); - - $result = array( - 'total' => count( $articles ), - 'scraped' => 0, - 'generated' => 0, - 'failed' => 0, - 'samples' => array(), - ); - - if ( empty( $articles ) ) { - return $result; - } - - // Phase 1: OG scrape non-aggregator URLs. - foreach ( $articles as $article ) { - $domain = $this->extract_domain( $article->source_url ); - if ( in_array( $domain, $aggregators, true ) ) { - continue; // Skip — will be handled by AI generation. - } - - $image_url = $this->scrape_og_image( $article->source_url ); - if ( ! empty( $image_url ) ) { - $local_path = $this->download_thumbnail( $image_url, $article->id, $article->title ); - $update_data = array( 'thumbnail_url' => $image_url ); - if ( $local_path ) { - $update_data['thumbnail_local'] = $local_path; - } - $wpdb->update( $table, $update_data, array( 'id' => $article->id ) ); - $result['scraped']++; - - if ( count( $result['samples'] ) < 5 ) { - $result['samples'][] = array( - 'id' => $article->id, - 'title' => mb_substr( $article->title, 0, 60 ), - 'method' => 'og_scrape', - 'status' => 'ok', - ); - } - } - - usleep( 300000 ); - } - - // Phase 2: AI generation for articles still missing thumbnails. - if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { - $ai_generated = Peptide_News_LLM::generate_missing_thumbnails( 20 ); - $result['generated'] = $ai_generated; - } - - // Count remaining failures. - $still_missing = $wpdb->get_var( - "SELECT COUNT(*) - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL )" - ); - $result['failed'] = (int) $still_missing; - - // Mark remaining as '_no_image' so cron doesn't retry every cycle. - $wpdb->query( - "UPDATE {$table} - SET thumbnail_url = '_no_image' - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL )" - ); - - if ( $result['scraped'] > 0 || $result['generated'] > 0 ) { - $this->clear_article_cache(); - } - - return $result; - } - - /** - * AJAX handler for the thumbnail backfill action. - */ - public function ajax_backfill_thumbnails() { - check_ajax_referer( 'peptide_news_admin', 'nonce' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( 'Unauthorized', 403 ); - } - - $result = $this->backfill_article_thumbnails(); - wp_send_json_success( $result ); - } - /** * Backfill source names for existing articles that show an aggregator domain. * diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index 3d1e4ff..c8bfa89 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -233,7 +233,7 @@ class Peptide_News_LLM { * @param string $prompt * @return string|WP_Error The response text or an error. */ - private static function call_openrouter( $api_key, $model, $prompt ) { + public static function call_openrouter( $api_key, $model, $prompt ) { $body = array( 'model' => $model, 'messages' => array( @@ -370,258 +370,6 @@ class Peptide_News_LLM { } } - /** - * Generate AI thumbnails for articles that still have no image - * after OG scraping. Uses an image generation model via OpenRouter. - * - * @param int $batch_size Max articles to process per run. - * @return int Number of thumbnails generated. - */ - public static function generate_missing_thumbnails( $batch_size = 10 ) { - if ( ! self::is_enabled() ) { - return 0; - } - - $image_model = get_option( 'peptide_news_llm_image_model', '' ); - if ( empty( $image_model ) ) { - return 0; - } - - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Find active articles with no usable thumbnail (including _no_image sentinel). - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, title, excerpt, ai_summary, tags, categories - FROM {$table} - WHERE is_active = 1 - AND ( thumbnail_url = '' OR thumbnail_url IS NULL OR thumbnail_url = '_no_image' ) - AND ( thumbnail_local = '' OR thumbnail_local IS NULL ) - ORDER BY fetched_at DESC - LIMIT %d", - $batch_size - ) ); - - if ( empty( $articles ) ) { - return 0; - } - - $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); - $generated = 0; - - foreach ( $articles as $article ) { - $image_data = self::generate_article_thumbnail( $api_key, $image_model, $article ); - - if ( $image_data ) { - // Save the image locally. - $local_path = self::save_generated_image( $image_data, $article->id ); - - if ( $local_path ) { - $wpdb->update( - $table, - array( 'thumbnail_local' => $local_path ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - $generated++; - } - } - - // Respect rate limits. - usleep( 1000000 ); // 1 second between generations. - } - - if ( $generated > 0 ) { - self::clear_article_cache(); - } - - return $generated; - } - - /** - * Generate a thumbnail image for a single article via OpenRouter. - * - * @param string $api_key - * @param string $model Image generation model ID. - * @param object $article Database row. - * @return string|false Base64-encoded image data, or false. - */ - private static function generate_article_thumbnail( $api_key, $model, $article ) { - $prompt = self::build_image_prompt( $article ); - - $body = array( - 'model' => $model, - 'messages' => array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ), - ); - - $response = wp_remote_post( self::API_URL, array( - 'timeout' => 60, - 'headers' => array( - 'Authorization' => 'Bearer ' . $api_key, - 'Content-Type' => 'application/json', - 'HTTP-Referer' => home_url(), - 'X-Title' => 'Peptide News Aggregator', - ), - 'body' => wp_json_encode( $body ), - ) ); - - if ( is_wp_error( $response ) ) { - self::log_error( 'Image generation failed for article ' . $article->id . ': ' . $response->get_error_message() ); - return false; - } - - $status = wp_remote_retrieve_response_code( $response ); - $data = json_decode( wp_remote_retrieve_body( $response ), true ); - - if ( $status < 200 || $status >= 300 ) { - $error = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $status; - self::log_error( 'Image generation HTTP error for article ' . $article->id . ': ' . $error ); - return false; - } - - // Extract the image data from the response. - // OpenRouter image models may return base64 data in various formats. - if ( ! empty( $data['choices'][0]['message']['content'] ) ) { - $content = $data['choices'][0]['message']['content']; - - // If content is an array (multimodal), look for image parts. - if ( is_array( $content ) ) { - foreach ( $content as $part ) { - if ( isset( $part['type'] ) && 'image_url' === $part['type'] - && isset( $part['image_url'] ) && is_array( $part['image_url'] ) - && isset( $part['image_url']['url'] ) && is_string( $part['image_url']['url'] ) ) { - $url = $part['image_url']['url']; - if ( strpos( $url, 'data:image' ) === 0 ) { - // Extract base64 from data URL. - $base64 = preg_replace( '/^data:image\/\w+;base64,/', '', $url ); - $decoded = base64_decode( $base64, true ); - if ( false === $decoded ) { - self::log_error( 'Invalid base64 image data for article.' ); - return false; - } - return $decoded; - } - } - } - } - - // If it's a URL string, download it (with SSRF protection). - if ( is_string( $content ) && preg_match( '/^https?:\/\//', $content ) ) { - $content_url = trim( $content ); - if ( ! wp_http_validate_url( $content_url ) ) { - self::log_error( 'Invalid image URL (SSRF protection) for article ' . $article->id ); - return false; - } - $img_response = wp_remote_get( $content_url, array( - 'timeout' => 30, - 'limit_response_size' => 5 * 1024 * 1024, - ) ); - if ( ! is_wp_error( $img_response ) ) { - return wp_remote_retrieve_body( $img_response ); - } - } - } - - // Check for data array format (DALL-E style). - if ( ! empty( $data['data'][0]['b64_json'] ) ) { - $decoded = base64_decode( $data['data'][0]['b64_json'], true ); - if ( false === $decoded ) { - self::log_error( 'Invalid base64 in DALL-E response for article ' . $article->id ); - return false; - } - return $decoded; - } - - if ( ! empty( $data['data'][0]['url'] ) ) { - $dalle_url = $data['data'][0]['url']; - if ( wp_http_validate_url( $dalle_url ) ) { - $img_response = wp_remote_get( $dalle_url, array( - 'timeout' => 30, - 'limit_response_size' => 5 * 1024 * 1024, - ) ); - if ( ! is_wp_error( $img_response ) ) { - return wp_remote_retrieve_body( $img_response ); - } - } else { - self::log_error( 'Invalid DALL-E image URL (SSRF protection) for article ' . $article->id ); - } - } - - self::log_error( 'Could not extract image data from response for article ' . $article->id ); - return false; - } - - /** - * Build the prompt for AI image generation. - * - * @param object $article - * @return string - */ - private static function build_image_prompt( $article ) { - $summary = ! empty( $article->ai_summary ) ? $article->ai_summary : $article->excerpt; - $tags = ! empty( $article->tags ) ? $article->tags : ( $article->categories ?? '' ); - - return "Generate a professional, science-themed thumbnail image for a peptide research news article. " - . "The image should be modern, clean, and suitable for a news website card layout (16:9 aspect ratio). " - . "Use a scientific aesthetic: molecular structures, laboratory elements, or abstract biotech visuals. " - . "Do NOT include any text or words in the image.\n\n" - . "Article title: " . $article->title . "\n" - . ( ! empty( $summary ) ? "Summary: " . mb_substr( $summary, 0, 200 ) . "\n" : '' ) - . ( ! empty( $tags ) ? "Keywords: " . $tags . "\n" : '' ); - } - - /** - * Save a generated image to the WP uploads directory. - * - * @param string $image_data Raw image binary data. - * @param int $article_id - * @return string|false Relative path from uploads base, or false. - */ - private static function save_generated_image( $image_data, $article_id ) { - if ( empty( $image_data ) || strlen( $image_data ) < 1000 ) { - return false; - } - - $upload_dir = wp_upload_dir(); - $target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs'; - - if ( ! file_exists( $target_dir ) ) { - wp_mkdir_p( $target_dir ); - } - - // Detect image type from data. - $finfo = new finfo( FILEINFO_MIME_TYPE ); - $mime = $finfo->buffer( $image_data ); - $ext_map = array( - 'image/png' => 'png', - 'image/jpeg' => 'jpg', - 'image/webp' => 'webp', - 'image/gif' => 'gif', - ); - $extension = isset( $ext_map[ $mime ] ) ? $ext_map[ $mime ] : 'png'; - - $filename = 'pn-ai-' . $article_id . '.' . $extension; - $filepath = $target_dir . '/' . $filename; - - $result = file_put_contents( $filepath, $image_data ); - - if ( false === $result ) { - self::log_error( 'Failed to save generated thumbnail: ' . $filepath ); - return false; - } - - // Set secure file permissions. - chmod( $filepath, 0644 ); - - return 'peptide-news-thumbs/' . $filename; - } - /** * Clear all article transient caches. */ diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php index cc895f9..d907a2b 100644 --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -133,8 +133,8 @@ class Peptide_News_Rest_API { $table = $wpdb->prefix . 'peptide_news_articles'; $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, source, source_url, title, excerpt, author, thumbnail_url, - thumbnail_local, published_at, categories, tags + "SELECT id, source, source_url, title, excerpt, ai_summary, author, + published_at, categories, tags FROM {$table} WHERE is_active = 1 ORDER BY published_at DESC diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 7b92530..7dee51b 100644 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -81,7 +81,6 @@ class Peptide_News { $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); $this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' ); $this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' ); - $this->loader->add_action( 'wp_ajax_peptide_news_backfill_thumbnails', $fetcher, 'ajax_backfill_thumbnails' ); } /** diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php index 39f7912..b8b100d 100644 --- a/peptide-news-plugin.php +++ b/peptide-news-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide News Aggregator * Plugin URI: https://github.com/peptiderepo/peptide-news-plugin * Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting. - * Version: 1.2.2 + * Version: 1.3.0 * Author: Peptide News Team * Author URI: https://github.com/peptiderepo * License: GPL-2.0+ @@ -22,7 +22,7 @@ if ( ! defined( 'WPINC' ) ) { /** * Current plugin version — follows SemVer. */ -define( 'PEPTIDE_NEWS_VERSION', '1.2.2' ); +define( 'PEPTIDE_NEWS_VERSION', '1.3.0' ); define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); diff --git a/peptide-news-plugin/admin/class-peptide-news-admin.php b/peptide-news-plugin/admin/class-peptide-news-admin.php deleted file mode 100644 index 5fb863e..0000000 --- a/peptide-news-plugin/admin/class-peptide-news-admin.php +++ /dev/null @@ -1,90 +0,0 @@ -plugin_name = $plugin_name; - $this->version = $version; - } - - /** - * Enqueue admin CSS. - */ - public function enqueue_styles( $hook ) { - if ( strpos( $hook, 'peptide-news' ) === false ) { - return; - } - wp_enqueue_style( - $this->plugin_name . '-admin', - PEPTIDE_NEWS_PLUGIN_URL . 'admin/css/admin-style.css', - array(), - $this->version, - 'all' - ); - } - - /** - * Enqueue admin JS. - */ - public function enqueue_scripts( $hook ) { - if ( strpos( $hook, 'peptide-news' ) === false ) { - return; - } - - // Chart.js for analytics dashboard. - wp_enqueue_script( - 'chartjs', - 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js', - array(), - '4.4.0', - true - ); - - wp_enqueue_script( - $this->plugin_name . '-admin', - PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js', - array( 'jquery', 'dashboard' ), - $this->version, - true - ); - } - - /** - * Register the settings page. - */ - public function register_settings_page() { - add_management_page( - 'manage', - 'Peptide News', - 'Peptide News', - 'manage_options', - array( $this, 'render_settings_page' ) - ); - } - - /** - * Render the settings page. - */ - public function render_settings_page() { - require PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/settings-page.php'; - } - - /** - * Render the analytics dashboard. - */ - public function render_dashboard() { - require PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php'; - } -} diff --git a/peptide-news-plugin/admin/css/admin-style.css b/peptide-news-plugin/admin/css/admin-style.css deleted file mode 100644 index b39bcaa..0000000 --- a/peptide-news-plugin/admin/css/admin-style.css +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Peptide News — Admin Styles - * - * @since 1.0.0 - */ - -/* Dashboard layout */ -.peptide-news-dashboard { - max-width: 1400px; -} - -/* Date filter bar */ -.pn-date-filter { - display: flex; - align-items: center; - gap: 10px; - margin: 20px 0; - padding: 15px 20px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 8px; -} - -.pn-date-filter input[type="date"] { - padding: 6px 10px; - border: 1px solid #cbd5e1; - border-radius: 6px; -} - -/* Summary cards */ -.pn-summary-cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 16px; - margin: 20px 0; -} - -.pn-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 24px; - text-align: center; - transition: box-shadow 0.2s ease; -} - -.pn-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); -} - -.pn-card h3 { - font-size: 32px; - font-weight: 700; - color: #1e293b; - margin: 0 0 4px; -} - -.pn-card p { - font-size: 13px; - color: #64748b; - margin: 0; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Charts */ -.pn-charts-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; - margin: 20px 0; -} - -.pn-chart-wide { - grid-column: 1 / -1; -} - -.pn-chart-box { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 20px; - position: relative; -} - -.pn-chart-box canvas { - max-height: 300px; - width: 100% !important; -} - -.pn-chart-box h2 { - font-size: 16px; - font-weight: 600; - color: #334155; - margin: 0 0 16px; - padding: 0; -} - -/* Tables */ -.pn-table-section { - margin: 30px 0; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 20px; -} - -.pn-table-section h2 { - font-size: 16px; - font-weight: 600; - color: #334155; - margin: 0 0 16px; -} - -.pn-table-section .wp-list-table { - border: none; -} - -/* Articles page title count */ -.title-count { - font-weight: 400; - color: #64748b; - font-size: 16px; -} - -/* Responsive */ -@media (max-width: 782px) { - .pn-charts-row { - grid-template-columns: 1fr; - } - - .pn-summary-cards { - grid-template-columns: repeat(2, 1fr); - } - - .pn-date-filter { - flex-wrap: wrap; - } -} diff --git a/peptide-news-plugin/admin/js/admin-script.js b/peptide-news-plugin/admin/js/admin-script.js deleted file mode 100644 index 594f2e4..0000000 --- a/peptide-news-plugin/admin/js/admin-script.js +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Admin JS for Peptide News Analytics Dashboard - * - * Initialises Chart.js charts using the data exported by dashboard.php - * via the global `peptideNewsDashboardData` object. - * - * @since 1.1.0 - */ - -(function () { - 'use strict'; - - /* ── Guard: only run when dashboard data is available ── */ - if (typeof peptideNewsDashboardData === 'undefined') { - return; - } - - var data = peptideNewsDashboardData; - - /* ── Shared colour palette ── */ - var colours = { - blue: 'rgba(59, 130, 246, 1)', - blueFill: 'rgba(59, 130, 246, 0.08)', - green: 'rgba(16, 185, 129, 1)', - greenFill: 'rgba(16, 185, 129, 0.08)', - palette: [ - 'rgba(59, 130, 246, 0.85)', - 'rgba(16, 185, 129, 0.85)', - 'rgba(245, 158, 11, 0.85)', - 'rgba(239, 68, 68, 0.85)', - 'rgba(139, 92, 246, 0.85)', - 'rgba(236, 72, 153, 0.85)', - 'rgba(20, 184, 166, 0.85)', - 'rgba(249, 115, 22, 0.85)' - ] - }; - - /* ── 1. Click Trends — Line Chart ── */ - var trendsCanvas = document.getElementById('pn-trends-chart'); - if (trendsCanvas && data.trends) { - var trendsLabels = data.trends.map(function (row) { return row.click_date; }); - var trendsClicks = data.trends.map(function (row) { return parseInt(row.total_clicks, 10) || 0; }); - var trendsUnique = data.trends.map(function (row) { return parseInt(row.total_unique, 10) || 0; }); - - new Chart(trendsCanvas, { - type: 'line', - data: { - labels: trendsLabels, - datasets: [ - { - label: 'Total Clicks', - data: trendsClicks, - borderColor: colours.blue, - backgroundColor: colours.blueFill, - borderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5, - fill: true, - tension: 0.3 - }, - { - label: 'Unique Visitors', - data: trendsUnique, - borderColor: colours.green, - backgroundColor: colours.greenFill, - borderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5, - fill: true, - tension: 0.3 - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false - }, - plugins: { - legend: { - position: 'top', - labels: { usePointStyle: true, padding: 16 } - } - }, - scales: { - x: { - grid: { display: false }, - ticks: { maxTicksLimit: 12 } - }, - y: { - beginAtZero: true, - ticks: { precision: 0 } - } - } - } - }); - } - - /* ── 2. Device Breakdown — Doughnut Chart ── */ - var devicesCanvas = document.getElementById('pn-devices-chart'); - if (devicesCanvas && data.devices) { - var deviceLabels = data.devices.map(function (row) { return row.device_type || 'Unknown'; }); - var deviceCounts = data.devices.map(function (row) { return parseInt(row.click_count, 10) || 0; }); - - new Chart(devicesCanvas, { - type: 'doughnut', - data: { - labels: deviceLabels, - datasets: [{ - data: deviceCounts, - backgroundColor: colours.palette.slice(0, deviceLabels.length), - borderWidth: 2, - borderColor: '#fff' - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom', - labels: { usePointStyle: true, padding: 12 } - } - }, - cutout: '55%' - } - }); - } - - /* ── 3. Source Performance — Bar Chart ── */ - var sourcesCanvas = document.getElementById('pn-sources-chart'); - if (sourcesCanvas && data.sources) { - var sourceLabels = data.sources.map(function (row) { return row.source || 'Unknown'; }); - var sourceCounts = data.sources.map(function (row) { return parseInt(row.click_count, 10) || 0; }); - - new Chart(sourcesCanvas, { - type: 'bar', - data: { - labels: sourceLabels, - datasets: [{ - label: 'Clicks', - data: sourceCounts, - backgroundColor: colours.palette.slice(0, sourceLabels.length), - borderRadius: 4, - maxBarThickness: 48 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: false } - }, - scales: { - x: { - grid: { display: false } - }, - y: { - beginAtZero: true, - ticks: { precision: 0 } - } - } - } - }); - } -})(); diff --git a/peptide-news-plugin/admin/partials/articles-list.php b/peptide-news-plugin/admin/partials/articles-list.php deleted file mode 100644 index ef49cea..0000000 --- a/peptide-news-plugin/admin/partials/articles-list.php +++ /dev/null @@ -1,89 +0,0 @@ -prefix . 'peptide_news_articles'; -$page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; -$per_page = 25; -$offset = ( $page - 1 ) * $per_page; - -$total = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); -$articles = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM {$table} ORDER BY published_at DESC LIMIT %d OFFSET %d", - $per_page, - $offset -) ); - -$total_pages = ceil( $total / $per_page ); -?> - -
-

()

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID
id ); ?> - - title, 12 ) ); ?> - - source ); ?>categories ); ?>published_at ) ) ); ?>fetched_at ) ) ); ?> - thumbnail_url ) ) : ?> - - - - - is_active ? '✓' : '✗'; ?>
- - 1 ) : ?> -
-
- add_query_arg( 'paged', '%#%' ), - 'format' => '', - 'current' => $page, - 'total' => $total_pages, - ) ); - ?> -
-
- -
diff --git a/peptide-news-plugin/admin/partials/dashboard.php b/peptide-news-plugin/admin/partials/dashboard.php deleted file mode 100644 index e2b301a..0000000 --- a/peptide-news-plugin/admin/partials/dashboard.php +++ /dev/null @@ -1,25 +0,0 @@ - -
-
-
- -
-
-
-
-

Top Articles

-
-
-
    -
  • Loading...
  • -
-
-
-
-get_charset_collate(); - - // Create articles table. - $wpdb->query( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}peptide_news_articles ( - ID bigint(19) UNSIGNED AUTO_INCREMENT, - title varchar(255), - excerpt LONGTEXT, - content LONGTEXT, - source varchar(255), - author varchar(255), - thumbnail varchar(500), - source_url text, - category_id bigint(20), - published_at datetime, - fetched_at datetime, - PRIMARY KEY (ID) - ){ $upload_file_name}{" WITH PARTITION (TITLE, STTUS; - * INTEGER ", DROP TIE VABLOCKED IF EXISTS"} WITH (TEMP=da2) "); - } -} diff --git a/peptide-news-plugin/includes/class-peptide-news-analytics.php b/peptide-news-plugin/includes/class-peptide-news-analytics.php deleted file mode 100644 index 826d90a..0000000 --- a/peptide-news-plugin/includes/class-peptide-news-analytics.php +++ /dev/null @@ -1,346 +0,0 @@ -prefix . 'peptide_news_clicks'; - - $ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip(); - - // Optionally anonymize IP (GDPR-friendly). - if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) { - $ip = self::anonymize_ip( $ip ); - } - - $data = array( - 'article_id' => absint( $article_id ), - 'clicked_at' => current_time( 'mysql' ), - 'user_ip' => sanitize_text_field( $ip ), - 'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ), - 'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ), - 'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ), - 'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ), - 'user_id' => is_user_logged_in() ? get_current_user_id() : null, - 'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ), - ); - - $result = $wpdb->insert( $table, $data ); - - // Update daily aggregate. - if ( false !== $result ) { - self::update_daily_stats( $article_id, $ip ); - } - - return false !== $result; - } - - /** - * Update daily aggregated stats. - * - * @param int $article_id - * @param string $ip Anonymized IP for unique visitor counting. - */ - private static function update_daily_stats( $article_id, $ip ) { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_daily_stats'; - $today = current_time( 'Y-m-d' ); - $clicks_tbl = $wpdb->prefix . 'peptide_news_clicks'; - - // Upsert daily click count. - $existing = $wpdb->get_row( $wpdb->prepare( - "SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s", - $article_id, - $today - ) ); - - // Count unique IPs for today. - $unique = $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s", - $article_id, - $today - ) ); - - if ( $existing ) { - $wpdb->update( - $table, - array( - 'click_count' => $existing->click_count + 1, - 'unique_visitors' => (int) $unique, - ), - array( 'id' => $existing->id ), - array( '%d', '%d' ), - array( '%d' ) - ); - } else { - $wpdb->insert( - $table, - array( - 'article_id' => $article_id, - 'stat_date' => $today, - 'click_count' => 1, - 'unique_visitors' => 1, - ) - ); - } - } - - /** - * Get top articles by click count within a date range. - * - * @param string $start_date Y-m-d - * @param string $end_date Y-m-d - * @param int $limit - * @return array - */ - public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { - global $wpdb; - - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at, - SUM(s.click_count) AS total_clicks, - SUM(s.unique_visitors) AS total_unique - FROM {$stats_table} s - INNER JOIN {$articles_table} a ON a.id = s.article_id - WHERE s.stat_date BETWEEN %s AND %s - GROUP BY a.id - ORDER BY total_clicks DESC - LIMIT %d", - $start_date, - $end_date, - $limit - ) ); - } - - /** - * Get click trends over time (daily totals). - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_click_trends( $start_date, $end_date ) { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_daily_stats'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique - FROM {$table} - WHERE stat_date BETWEEN %s AND %s - GROUP BY stat_date - ORDER BY stat_date ASC", - $start_date, - $end_date - ) ); - } - - /** - * Get popular topics/categories by click volume. - * - * @param string $start_date - * @param string $end_date - * @param int $limit - * @return array - */ - public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) { - global $wpdb; - - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - // Get articles with their click counts. - $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT a.categories, a.title, SUM(s.click_count) AS clicks - FROM {$stats_table} s - INNER JOIN {$articles_table} a ON a.id = s.article_id - WHERE s.stat_date BETWEEN %s AND %s AND a.categories != '' - GROUP BY a.id - ORDER BY clicks DESC", - $start_date, - $end_date - ) ); - - // Aggregate by category. - $topics = array(); - foreach ( $rows as $row ) { - $cats = array_map( 'trim', explode( ',', $row->categories ) ); - foreach ( $cats as $cat ) { - if ( empty( $cat ) ) { - continue; - } - $cat_lower = strtolower( $cat ); - if ( ! isset( $topics[ $cat_lower ] ) ) { - $topics[ $cat_lower ] = array( - 'topic' => $cat, - 'total_clicks' => 0, - 'article_count' => 0, - ); - } - $topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks; - $topics[ $cat_lower ]['article_count'] += 1; - } - } - - // Sort by clicks. - usort( $topics, function ( $a, $b ) { - return $b['total_clicks'] - $a['total_clicks']; - } ); - - return array_slice( $topics, 0, $limit ); - } - - /** - * Get device breakdown stats. - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_device_breakdown( $start_date, $end_date ) { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_clicks'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT device_type, COUNT(*) AS click_count - FROM {$table} - WHERE DATE(clicked_at) BETWEEN %s AND %s - GROUP BY device_type - ORDER BY click_count DESC", - $start_date, - $end_date - ) ); - } - - /** - * Get source performance stats. - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_source_performance( $start_date, $end_date ) { - global $wpdb; - - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT a.source, - COUNT(DISTINCT a.id) AS article_count, - SUM(s.click_count) AS total_clicks, - ROUND(SUM(s.click_count) / COUNT(DISTINCT a.id), 1) AS avg_clicks_per_article - FROM {$stats_table} s - INNER JOIN {$articles_table} a ON a.id = s.article_id - WHERE s.stat_date BETWEEN %s AND %s - GROUP BY a.source - ORDER BY total_clicks DESC", - $start_date, - $end_date - ) ); - } - - /** - * Export click data as CSV-ready array. - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function export_clicks_csv( $start_date, $end_date ) { - global $wpdb; - - $clicks_table = $wpdb->prefix . 'peptide_news_clicks'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, - c.referrer_url, c.page_url, c.device_type, c.session_id - FROM {$clicks_table} c - INNER JOIN {$articles_table} a ON a.id = c.article_id - WHERE DATE(c.clicked_at) BETWEEN %s AND %s - ORDER BY c.clicked_at DESC", - $start_date, - $end_date - ), ARRAY_A ); - } - - /** - * Get the client IP address. - * - * @return string - */ - private static function get_client_ip() { - $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); - - foreach ( $ip_keys as $key ) { - if ( ! empty( $_SERVER[ $key ] ) ) { - $ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ); - $ip = trim( $ip[0] ); - if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { - return $ip; - } - } - } - - return '0.0.0.0'; - } - - /** - * Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6). - * - * @param string $ip - * @return string - */ - private static function anonymize_ip( $ip ) { - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { - return preg_replace( '/\.\d+$/', '.0', $ip ); - } - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { - return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000'; - } - return '0.0.0.0'; - } - - /** - * Simple device detection from user agent. - * - * @param string $ua - * @return string - */ - private static function detect_device( $ua ) { - $ua = strtolower( $ua ); - - if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { - return 'tablet'; - } - if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) { - return 'mobile'; - } - if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) { - return 'bot'; - } - - return 'desktop'; - } -} diff --git a/peptide-news-plugin/includes/class-peptide-news-deactivator.php b/peptide-news-plugin/includes/class-peptide-news-deactivator.php deleted file mode 100644 index 6e5f66c..0000000 --- a/peptide-news-plugin/includes/class-peptide-news-deactivator.php +++ /dev/null @@ -1,18 +0,0 @@ -actions = array(); - $this->filters = array(); - } - - public function run() { - $admin_actions = array( - 'enqueue_styles', - 'enqueue_scripts', - 'register_settings_page', - ); - - foreach ( $actions as $action ) { - add_action( 'admin_enoqueue_scripts', array( $this-$action ) ); - } -    \ No newline at end of file diff --git a/peptide-news-plugin/includes/class-peptide-news-rest-api.php b/peptide-news-plugin/includes/class-peptide-news-rest-api.php deleted file mode 100644 index a7913c5..0000000 --- a/peptide-news-plugin/includes/class-peptide-news-rest-api.php +++ /dev/null @@ -1,22 +0,0 @@ - 'GET', - 'callback' => array( $this, 'get_articles' ), - 'permission_callback' => array( $this, 'rublic_permission' ), - ) - ); - } -} diff --git a/peptide-news-plugin/includes/class-peptide-news.php b/peptide-news-plugin/includes/class-peptide-news.php deleted file mode 100644 index 3b94c29..0000000 --- a/peptide-news-plugin/includes/class-peptide-news.php +++ /dev/null @@ -1,53 +0,0 @@ - loader = new Peptide_News_Loader(); - $this -> admin = new Peptide_News_Admin('peptide-news', PEPTIDE_NEWS_VERSION); - $this -> public = new Peptide_News_Public('peptide-news', PEPTIDE_NEWS_VERSION); - } - - public function run() { - $this->loader->run(); - $this->admin->run(); - $this->public->run(); - } -} diff --git a/peptide-news-plugin/peptide-news-plugin.php b/peptide-news-plugin/peptide-news-plugin.php deleted file mode 100644 index bd68137..0000000 --- a/peptide-news-plugin/peptide-news-plugin.php +++ /dev/null @@ -1,62 +0,0 @@ -run(); -} - -peptide_news_run(); diff --git a/peptide-news-plugin/public/class-peptide-news-public.php b/peptide-news-plugin/public/class-peptide-news-public.php deleted file mode 100644 index a6f4d0f..0000000 --- a/peptide-news-plugin/public/class-peptide-news-public.php +++ /dev/null @@ -1,67 +0,0 @@ -plugin_name = $plugin_name; - $this->version = $version; - } - - public function enqueue_styles() { - wp_enqueue_style( - $this->plugin_name, - PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', - array(), - $this->version, - 'all' - ); - } - - public function enqueue_scripts() { - wp_enqueue_script( - $this->plugin_name, - PEPTIDE_NEWS_PLUGIN_URL . 'public/js/public-script.js', - array(), - $this->version, - true - ); - } - - public function register_widget() { - register_widget( 'Peptide News Widget', array( this, 'render_widget' ) ); - } - - public function render_widget( $instance, $args ) { - require PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; - } - - public function register_shortcodes() { - add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); - } - - public function render_shortcode( $attr ) { - global $wpdb; - - $count = intval( $issuet( $attr['count'] ) ? $attr['count'] : 10 ); - $layout = $isset( $attr['layout'] ) ? $attr['layout'] : 'card'; - - $articles = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}peptide_news_articles LIMIT $count" ); - - ob %start(); - include PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; - $output = ob_get_clean(); - return $output; - } -} diff --git a/peptide-news-plugin/public/css/public-style.css b/peptide-news-plugin/public/css/public-style.css deleted file mode 100644 index 841ed5b..0000000 --- a/peptide-news-plugin/public/css/public-style.css +++ /dev/null @@ -1,17 +0,0 @@ -/* Peptide News – Public Styles */ - -.article-card { - background: white; - border: 1px solid #ddd; - padding: 15px; - margin-bottom: 20px; - border-radius: 4px; -} - - -.article-title { - font-size: 18px; - font-weight: bold; - color: #333; - margin-bottom: 10px; -} diff --git a/peptide-news-plugin/public/js/public-script.js b/peptide-news-plugin/public/js/public-script.js deleted file mode 100644 index b084361..0000000 --- a/peptide-news-plugin/public/js/public-script.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Peptide News — Public Scripts - * - * Event triggers for all \"peptide_news_clicked\" through Navigator.SendBeacon * - */ - -(function() { - const clickHandler = function() { - if (!window.peptide_news_data) return; - - var that = this.closest('[data-article-id]'); - var article_id = that.dataset.articleId; - var article_url = that.dataset.articleUrl; - - // Send beacon - var data = { - action: 'peptide_news_clicked', - article_id: article_id, - url: article_url, - _nonce: window.peptide_news_data.nonce - }; - - navigator.sendBeacon('blob:' + new Blob([JSON.stringify(data)]), window.peptide_news_data.ajax_url); - }; - - document.querySelectorAll('[data-article-id] .pn-article-link').forEach(el => el.addEventListener('click', clickHandler)); -})(); diff --git a/peptide-news-plugin/uninstall.php b/peptide-news-plugin/uninstall.php deleted file mode 100644 index 1f36e5d..0000000 --- a/peptide-news-plugin/uninstall.php +++ /dev/null @@ -1,28 +0,0 @@ -query( "DROP TABLE IF EXISTS {$wpdb->prefix}peptide_news_daily_stats" ); -$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}peptide_news_clicks" ); -$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}peptide_news_articles" ); - -// Remove all options. -$options = $wpdb->get_col( - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'peptide_news_%'" -); - -foreach ( $options as $option ) { - delete_option( $option ); -} diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 56d13db..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - ./tests/unit - - - - - - ./includes - ./admin - ./public - - - ./admin/partials - - - diff --git a/public/class-peptide-news-public.php b/public/class-peptide-news-public.php index caec701..b99a11a 100644 --- a/public/class-peptide-news-public.php +++ b/public/class-peptide-news-public.php @@ -2,10 +2,11 @@ /** * Public-facing functionality. * - * Registers the [peptide_news] shortcode, the sidebar widget, - * and handles AJAX click tracking. + * Registers the [peptide_news] shortcode which renders a React-powered + * news feed, the sidebar widget, and handles AJAX click tracking. * * @since 1.0.0 + * @since 2.0.0 Rewritten to use React frontend. */ class Peptide_News_Public { @@ -35,21 +36,19 @@ class Peptide_News_Public { /** * Register front-end JS (enqueued on demand by the shortcode/widget). + * + * Loads React (from WordPress core), the feed component, and + * passes configuration via wp_localize_script. */ public function enqueue_scripts() { + // React feed component (depends on WP-bundled React). wp_register_script( - $this->plugin_name, - PEPTIDE_NEWS_PLUGIN_URL . 'public/js/public-script.js', - array( 'jquery' ), + $this->plugin_name . '-feed', + PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js', + array( 'react', 'react-dom' ), $this->version, true ); - - wp_localize_script( $this->plugin_name, 'peptideNewsPublic', array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'peptide_news_track' ), - 'session_id' => $this->get_or_create_session_id(), - ) ); } /** @@ -69,111 +68,35 @@ class Peptide_News_Public { /** * Render the [peptide_news] shortcode. * + * Outputs a mount-point div for the React feed component and + * enqueues all required assets. Configuration is passed to JS + * via a localized script object. + * * @param array $atts Shortcode attributes. * @return string HTML output. */ public function render_shortcode( $atts ) { $atts = shortcode_atts( array( - 'count' => get_option( 'peptide_news_articles_count', 10 ), - 'layout' => 'list', // card | compact | list + 'count' => get_option( 'peptide_news_articles_count', 10 ), ), $atts, 'peptide_news' ); - $articles = $this->get_articles( absint( $atts['count'] ) ); - - if ( empty( $articles ) ) { - return '

' . esc_html__( 'No peptide news articles available yet. Check back soon!', 'peptide-news' ) . '

'; - } + $count = absint( $atts['count'] ); // Enqueue assets only when the shortcode is actually rendered. wp_enqueue_style( $this->plugin_name ); - wp_enqueue_script( $this->plugin_name ); + wp_enqueue_script( $this->plugin_name . '-feed' ); - $fallback_thumb = get_option( 'peptide_news_thumbnail_fallback', '' ); - $layout = sanitize_key( $atts['layout'] ); + // Pass configuration to the React component. + wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array( + 'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'peptide_news_track' ), + 'sessionId' => $this->get_or_create_session_id(), + 'count' => $count, + ) ); - ob_start(); - ?> -
- thumbnail_url ) && '_no_image' !== $article->thumbnail_url ) { - $thumb = $article->thumbnail_url; - } elseif ( ! empty( $article->thumbnail_local ) ) { - $upload_dir = wp_upload_dir(); - $thumb = $upload_dir['baseurl'] . '/' . $article->thumbnail_local; - } - if ( empty( $thumb ) ) { - $thumb = $fallback_thumb; - } - $date = wp_date( 'M j, Y', strtotime( $article->published_at ) ); - ?> -
- -
- - <?php echo esc_attr( $article->title ); ?> - -
- - -
- - -

- - title ); ?> - -

- - ai_summary ) ? $article->ai_summary : ( $article->excerpt ?? '' ); - if ( ! empty( $display_text ) ) : ?> -

- -

- - - author ) ) : ?> - - author ); ?> - - - - tags ) ? $article->tags : ( $article->categories ?? '' ); - if ( ! empty( $tag_source ) ) : ?> -
- - - -
- -
-
- -
- '; } /** @@ -214,48 +137,9 @@ class Peptide_News_Public { } } - /** - * Get active articles from the database. - * - * @param int $count Number of articles. - * @return array - */ - private function get_articles( $count ) { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Use transient cache to avoid DB hits on every page load. - $cache_key = 'peptide_news_articles_' . $count; - $cached = get_transient( $cache_key ); - - if ( false !== $cached ) { - return $cached; - } - - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, source, source_url, title, excerpt, ai_summary, author, thumbnail_url, - thumbnail_local, published_at, categories, tags - FROM {$table} - WHERE is_active = 1 - ORDER BY published_at DESC - LIMIT %d", - $count - ) ); - - // Cache for 5 minutes. - set_transient( $cache_key, $articles, 5 * MINUTE_IN_SECONDS ); - - return $articles; - } - /** * Generate or retrieve a session ID for visitor tracking. * - * Sets the cookie in PHP if it doesn't exist so the value - * is consistent between the server-side localized script data - * and the client-side cookie. - * * @return string */ private function get_or_create_session_id() { @@ -265,7 +149,6 @@ class Peptide_News_Public { $session_id = wp_generate_uuid4(); - // Set a 30-minute session cookie (matches JS-side behaviour). if ( ! headers_sent() ) { setcookie( 'pn_session_id', @@ -274,7 +157,7 @@ class Peptide_News_Public { 'expires' => time() + 1800, 'path' => '/', 'secure' => is_ssl(), - 'httponly' => false, // JS needs read access for sendBeacon. + 'httponly' => false, 'samesite' => 'Lax', ) ); @@ -313,7 +196,7 @@ class Peptide_News_Widget extends WP_Widget { } $public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION ); - echo $public->render_shortcode( array( 'count' => $count, 'layout' => 'compact' ) ); + echo $public->render_shortcode( array( 'count' => $count ) ); echo $args['after_widget']; } diff --git a/public/css/public-style.css b/public/css/public-style.css index 11fb011..29a4a5e 100644 --- a/public/css/public-style.css +++ b/public/css/public-style.css @@ -1,114 +1,79 @@ /** - * Peptide News Aggregator – Public Styles + * Peptide News Feed — Public Styles * - * Styles for the front-end display of peptide news articles. + * Clean, modern news feed layout without thumbnails. + * Focuses on readability and content hierarchy. * - * @since 1.0.0 - * @since 1.2.2 Rewritten for list layout with thumbnail left / text right. + * @since 2.0.0 */ /* ── Feed container ────────────────────────────────────────────── */ .pn-news-feed { + max-width: 760px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + color: #1a1a2e; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* ── Articles list ─────────────────────────────────────────────── */ +.pn-articles-list { display: flex; flex-direction: column; gap: 0; - max-width: 900px; - margin: 0 auto; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } -.pn-no-articles p { - text-align: center; - color: #666; - padding: 2rem 1rem; - font-size: 0.9375rem; -} - -/* ── Individual article row ────────────────────────────────────── */ +/* ── Individual article ────────────────────────────────────────── */ .pn-article { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 1.25rem; - padding: 1.25rem 0; - border-bottom: 1px solid #e8e8e8; + padding: 1.5rem 0; + border-bottom: 1px solid #e8eaed; transition: background-color 0.15s ease; } +.pn-article:first-child { + padding-top: 0; +} + .pn-article:last-child { border-bottom: none; } -.pn-article:hover { - background-color: #fafbfc; -} - -/* ── Thumbnail (left column, fixed width) ──────────────────────── */ -.pn-article-thumb { - flex: 0 0 160px; - width: 160px; - min-height: 100px; - overflow: hidden; - border-radius: 6px; -} - -.pn-article-thumb a { - display: block; - line-height: 0; -} - -.pn-article-thumb img { - width: 100%; - height: 110px; - object-fit: cover; - border-radius: 6px; - transition: transform 0.2s ease; -} - -.pn-article:hover .pn-article-thumb img { - transform: scale(1.03); -} - -/* ── Content (right column, flexible) ──────────────────────────── */ -.pn-article-content { - flex: 1; - min-width: 0; -} - /* ── Meta line (source · date) ─────────────────────────────────── */ .pn-article-meta { display: flex; align-items: center; gap: 0.4rem; - font-size: 0.8rem; - color: #777; - margin-bottom: 0.35rem; + margin-bottom: 0.375rem; + font-size: 0.8125rem; + color: #6b7280; line-height: 1.4; } .pn-source { font-weight: 600; - color: #555; + color: #4b5563; text-transform: uppercase; - letter-spacing: 0.025em; - font-size: 0.7rem; + letter-spacing: 0.03em; + font-size: 0.75rem; } .pn-separator { - color: #ccc; + color: #d1d5db; } .pn-date { - color: #888; + color: #9ca3af; } /* ── Title ─────────────────────────────────────────────────────── */ .pn-article-title { - font-size: 1.05rem; + font-size: 1.125rem; font-weight: 600; line-height: 1.4; - margin: 0 0 0.4rem; - color: #1a1a2e; + margin: 0 0 0.5rem; + color: #111827; } .pn-article-title a { @@ -118,23 +83,23 @@ } .pn-article-title a:hover { - color: #0073aa; + color: #2563eb; } -.pn-article-title a:focus, -.pn-article-thumb a:focus { - outline: 2px solid #0073aa; +.pn-article-title a:focus-visible { + outline: 2px solid #2563eb; outline-offset: 2px; + border-radius: 2px; } /* ── Excerpt / AI summary ──────────────────────────────────────── */ .pn-article-excerpt { - font-size: 0.875rem; - line-height: 1.6; - color: #555; - margin: 0 0 0.5rem; + font-size: 0.9375rem; + line-height: 1.65; + color: #4b5563; + margin: 0 0 0.625rem; display: -webkit-box; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } @@ -142,9 +107,9 @@ /* ── Author ────────────────────────────────────────────────────── */ .pn-author { display: inline-block; - font-size: 0.775rem; - color: #888; - margin-bottom: 0.35rem; + font-size: 0.8125rem; + color: #9ca3af; + margin-bottom: 0.375rem; } .pn-author::before { @@ -156,60 +121,266 @@ .pn-tags { display: flex; flex-wrap: wrap; - gap: 0.35rem; - margin-top: 0.25rem; + gap: 0.375rem; + margin-top: 0.375rem; } .pn-tag { display: inline-block; - font-size: 0.7rem; - background: #f0f4f8; - color: #4a6fa5; - padding: 0.15rem 0.55rem; - border-radius: 12px; + font-size: 0.6875rem; + background: #eff6ff; + color: #1d4ed8; + padding: 0.1875rem 0.625rem; + border-radius: 100px; line-height: 1.5; font-weight: 500; + letter-spacing: 0.01em; } -/* ── Compact layout override (for widgets) ─────────────────────── */ -.pn-layout-compact .pn-article { - gap: 0.85rem; - padding: 0.75rem 0; +/* ── Loading skeleton ──────────────────────────────────────────── */ +.pn-loading { + display: flex; + flex-direction: column; + gap: 0; } -.pn-layout-compact .pn-article-thumb { - flex: 0 0 80px; - width: 80px; - min-height: 56px; +.pn-skeleton-card { + padding: 1.5rem 0; + border-bottom: 1px solid #e8eaed; } -.pn-layout-compact .pn-article-thumb img { - height: 56px; +.pn-skeleton-card:first-child { + padding-top: 0; } -.pn-layout-compact .pn-article-title { - font-size: 0.9rem; +.pn-skeleton-line { + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: pn-shimmer 1.5s ease-in-out infinite; + border-radius: 4px; + margin-bottom: 0.5rem; } -.pn-layout-compact .pn-article-excerpt, -.pn-layout-compact .pn-author, -.pn-layout-compact .pn-tags { - display: none; +.pn-skeleton-meta { + width: 140px; + height: 14px; } -/* ── Responsive: stack on small screens ────────────────────────── */ +.pn-skeleton-title { + width: 85%; + height: 20px; +} + +.pn-skeleton-excerpt { + width: 100%; + height: 14px; +} + +.pn-skeleton-excerpt-short { + width: 60%; + height: 14px; +} + +.pn-skeleton-tags { + display: flex; + gap: 0.375rem; + margin-top: 0.5rem; +} + +.pn-skeleton-tag { + width: 64px; + height: 22px; + border-radius: 100px; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: pn-shimmer 1.5s ease-in-out infinite; +} + +@keyframes pn-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Error state ───────────────────────────────────────────────── */ +.pn-error { + text-align: center; + padding: 2.5rem 1rem; + color: #6b7280; +} + +.pn-error p { + font-size: 0.9375rem; + margin: 0 0 1rem; +} + +.pn-retry-btn { + display: inline-block; + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + color: #2563eb; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.pn-retry-btn:hover { + background: #dbeafe; + border-color: #93c5fd; +} + +.pn-retry-btn:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* ── Empty state ───────────────────────────────────────────────── */ +.pn-empty { + text-align: center; + padding: 2.5rem 1rem; +} + +.pn-empty p { + font-size: 0.9375rem; + color: #6b7280; + margin: 0; +} + +/* ── Pagination ────────────────────────────────────────────────── */ +.pn-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1.5rem 0 0.5rem; + margin-top: 0.5rem; + border-top: 1px solid #e8eaed; +} + +.pn-page-btn { + display: inline-flex; + align-items: center; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1; +} + +.pn-page-btn:hover:not(:disabled) { + background: #f9fafb; + border-color: #9ca3af; +} + +.pn-page-btn:disabled { + color: #d1d5db; + border-color: #e5e7eb; + cursor: not-allowed; +} + +.pn-page-btn:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.pn-page-info { + font-size: 0.8125rem; + color: #6b7280; + min-width: 100px; + text-align: center; +} + +/* ── Responsive ────────────────────────────────────────────────── */ @media (max-width: 600px) { - .pn-article { - flex-direction: column; - gap: 0.75rem; + .pn-article-title { + font-size: 1rem; } - .pn-article-thumb { - flex: none; - width: 100%; + .pn-article-excerpt { + font-size: 0.875rem; + -webkit-line-clamp: 2; } - .pn-article-thumb img { - height: 180px; + .pn-pagination { + gap: 0.5rem; + } + + .pn-page-btn { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + } +} + +/* ── Dark mode support via prefers-color-scheme ────────────────── */ +@media (prefers-color-scheme: dark) { + .pn-news-feed { + color: #e5e7eb; + } + + .pn-article { + border-bottom-color: #374151; + } + + .pn-article-title { + color: #f3f4f6; + } + + .pn-article-title a:hover { + color: #60a5fa; + } + + .pn-source { + color: #9ca3af; + } + + .pn-article-excerpt { + color: #9ca3af; + } + + .pn-tag { + background: #1e3a5f; + color: #93c5fd; + } + + .pn-skeleton-line, + .pn-skeleton-tag { + background: linear-gradient(90deg, #1f2937 25%, #374151 50%, #1f2937 75%); + background-size: 200% 100%; + } + + .pn-page-btn { + color: #e5e7eb; + background: #1f2937; + border-color: #4b5563; + } + + .pn-page-btn:hover:not(:disabled) { + background: #374151; + } + + .pn-page-btn:disabled { + color: #4b5563; + border-color: #374151; + } + + .pn-retry-btn { + color: #60a5fa; + background: #1e3a5f; + border-color: #1d4ed8; + } + + .pn-pagination { + border-top-color: #374151; + } + + .pn-skeleton-card { + border-bottom-color: #374151; } } diff --git a/public/js/peptide-news-feed.js b/public/js/peptide-news-feed.js new file mode 100644 index 0000000..c3c2ee3 --- /dev/null +++ b/public/js/peptide-news-feed.js @@ -0,0 +1,367 @@ +/** + * Peptide News Feed — React Frontend + * + * A clean, accessible news feed component that displays peptide research + * articles with title, summary, source, date, and keyword tags. + * + * @since 2.0.0 + */ +const { + useState, + useEffect, + useCallback, + useRef, + memo +} = React; + +/* ── Constants ──────────────────────────────────────────────────────── */ + +const ARTICLES_PER_PAGE = 10; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/* ── Utilities ──────────────────────────────────────────────────────── */ + +/** + * Format an ISO date string to a human-readable form. + * + * @param {string} dateStr ISO date string. + * @returns {string} Formatted date (e.g., "Apr 5, 2026"). + */ +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); +} + +/** + * Calculate a human-readable relative time string. + * + * @param {string} dateStr ISO date string. + * @returns {string} Relative time (e.g., "2 hours ago"). + */ +function timeAgo(dateStr) { + if (!dateStr) return ''; + const now = Date.now(); + const then = new Date(dateStr).getTime(); + if (isNaN(then)) return ''; + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return formatDate(dateStr); +} + +/* ── Simple in-memory cache ─────────────────────────────────────────── */ + +const apiCache = new Map(); +function getCached(key) { + const entry = apiCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > CACHE_TTL_MS) { + apiCache.delete(key); + return null; + } + return entry.data; +} +function setCache(key, data) { + apiCache.set(key, { + data, + ts: Date.now() + }); +} + +/* ── API Layer ──────────────────────────────────────────────────────── */ + +/** + * Fetch articles from the WP REST API with caching. + * + * @param {string} restUrl Base REST URL. + * @param {number} page Page number. + * @param {number} count Articles per page. + * @param {AbortSignal} signal Abort signal for cleanup. + * @returns {Promise} Response data. + */ +async function fetchArticles(restUrl, page, count, signal) { + const cacheKey = `${restUrl}:${page}:${count}`; + const cached = getCached(cacheKey); + if (cached) return cached; + const url = `${restUrl}articles?count=${count}&page=${page}`; + const resp = await fetch(url, { + signal + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + const data = await resp.json(); + setCache(cacheKey, data); + return data; +} + +/* ── Click Tracking ─────────────────────────────────────────────────── */ + +/** + * Track an outbound click via sendBeacon or fallback XHR. + * + * @param {number} articleId + */ +function trackClick(articleId) { + const config = window.peptideNewsFeed || {}; + if (!config.ajaxUrl || !config.nonce) return; + const formData = new FormData(); + formData.append('action', 'peptide_news_track_click'); + formData.append('nonce', config.nonce); + formData.append('article_id', articleId); + formData.append('referrer', document.referrer || ''); + formData.append('page_url', window.location.href); + formData.append('session_id', config.sessionId || ''); + if (navigator.sendBeacon) { + navigator.sendBeacon(config.ajaxUrl, formData); + } else { + fetch(config.ajaxUrl, { + method: 'POST', + body: formData + }).catch(() => {}); + } +} + +/* ── Components ─────────────────────────────────────────────────────── */ + +/** + * Single keyword tag pill. + */ +const Tag = memo(function Tag({ + label +}) { + return /*#__PURE__*/React.createElement("span", { + className: "pn-tag" + }, label); +}); + +/** + * Single article card. + */ +const ArticleCard = memo(function ArticleCard({ + article +}) { + const summary = article.ai_summary || article.excerpt || ''; + const tagSource = article.tags || article.categories || ''; + const tags = tagSource ? tagSource.split(',').map(t => t.trim()).filter(Boolean).slice(0, 5) : []; + const handleClick = useCallback(() => { + trackClick(article.id); + }, [article.id]); + return /*#__PURE__*/React.createElement("article", { + className: "pn-article", + role: "article" + }, /*#__PURE__*/React.createElement("div", { + className: "pn-article-meta" + }, /*#__PURE__*/React.createElement("span", { + className: "pn-source" + }, article.source), /*#__PURE__*/React.createElement("span", { + className: "pn-separator", + "aria-hidden": "true" + }, "\xB7"), /*#__PURE__*/React.createElement("time", { + className: "pn-date", + dateTime: article.published_at, + title: formatDate(article.published_at) + }, timeAgo(article.published_at))), /*#__PURE__*/React.createElement("h3", { + className: "pn-article-title" + }, /*#__PURE__*/React.createElement("a", { + href: article.source_url, + onClick: handleClick, + target: "_blank", + rel: "noopener noreferrer" + }, article.title)), summary && /*#__PURE__*/React.createElement("p", { + className: "pn-article-excerpt" + }, summary), article.author && /*#__PURE__*/React.createElement("span", { + className: "pn-author" + }, article.author), tags.length > 0 && /*#__PURE__*/React.createElement("div", { + className: "pn-tags", + role: "list", + "aria-label": "Keywords" + }, tags.map(tag => /*#__PURE__*/React.createElement(Tag, { + key: tag, + label: tag + })))); +}); + +/** + * Loading skeleton placeholder. + */ +function LoadingSkeleton({ + count = 3 +}) { + return /*#__PURE__*/React.createElement("div", { + className: "pn-loading", + "aria-busy": "true", + "aria-label": "Loading articles" + }, Array.from({ + length: count + }, (_, i) => /*#__PURE__*/React.createElement("div", { + key: i, + className: "pn-skeleton-card" + }, /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-meta" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-title" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-excerpt" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-line pn-skeleton-excerpt-short" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tags" + }, /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tag" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tag" + }), /*#__PURE__*/React.createElement("div", { + className: "pn-skeleton-tag" + }))))); +} + +/** + * Error state with retry. + */ +function ErrorMessage({ + message, + onRetry +}) { + return /*#__PURE__*/React.createElement("div", { + className: "pn-error", + role: "alert" + }, /*#__PURE__*/React.createElement("p", null, "Unable to load articles. ", message), /*#__PURE__*/React.createElement("button", { + className: "pn-retry-btn", + onClick: onRetry, + type: "button" + }, "Try Again")); +} + +/** + * Empty state. + */ +function EmptyState() { + return /*#__PURE__*/React.createElement("div", { + className: "pn-empty" + }, /*#__PURE__*/React.createElement("p", null, "No peptide news articles available yet. Check back soon!")); +} + +/** + * Pagination controls. + */ +const Pagination = memo(function Pagination({ + page, + totalPages, + onPageChange +}) { + if (totalPages <= 1) return null; + return /*#__PURE__*/React.createElement("nav", { + className: "pn-pagination", + "aria-label": "Article pages" + }, /*#__PURE__*/React.createElement("button", { + className: "pn-page-btn", + onClick: () => onPageChange(page - 1), + disabled: page <= 1, + "aria-label": "Previous page", + type: "button" + }, "\u2190 Newer"), /*#__PURE__*/React.createElement("span", { + className: "pn-page-info", + "aria-current": "page" + }, "Page ", page, " of ", totalPages), /*#__PURE__*/React.createElement("button", { + className: "pn-page-btn", + onClick: () => onPageChange(page + 1), + disabled: page >= totalPages, + "aria-label": "Next page", + type: "button" + }, "Older \u2192")); +}); + +/* ── Main Feed Component ────────────────────────────────────────────── */ + +/** + * PeptideNewsFeed — root component. + * + * Reads configuration from window.peptideNewsFeed which is + * localized by the PHP shortcode renderer. + */ +function PeptideNewsFeed() { + const config = window.peptideNewsFeed || {}; + const restUrl = config.restUrl || '/wp-json/peptide-news/v1/'; + const perPage = config.count || ARTICLES_PER_PAGE; + const [articles, setArticles] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const feedRef = useRef(null); + const loadArticles = useCallback(async (pageNum, signal) => { + setLoading(true); + setError(null); + try { + const data = await fetchArticles(restUrl, pageNum, perPage, signal); + setArticles(data.articles || []); + setTotalPages(data.total_pages || 1); + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message || 'Something went wrong.'); + } + } finally { + setLoading(false); + } + }, [restUrl, perPage]); + useEffect(() => { + const controller = new AbortController(); + loadArticles(page, controller.signal); + return () => controller.abort(); + }, [page, loadArticles]); + const handlePageChange = useCallback(newPage => { + setPage(newPage); + // Scroll feed container into view on pagination. + if (feedRef.current) { + feedRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }, []); + const handleRetry = useCallback(() => { + loadArticles(page); + }, [page, loadArticles]); + return /*#__PURE__*/React.createElement("div", { + className: "pn-news-feed", + ref: feedRef + }, loading && /*#__PURE__*/React.createElement(LoadingSkeleton, { + count: Math.min(perPage, 5) + }), !loading && error && /*#__PURE__*/React.createElement(ErrorMessage, { + message: error, + onRetry: handleRetry + }), !loading && !error && articles.length === 0 && /*#__PURE__*/React.createElement(EmptyState, null), !loading && !error && articles.length > 0 && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + className: "pn-articles-list", + role: "feed", + "aria-label": "Peptide news articles" + }, articles.map(article => /*#__PURE__*/React.createElement(ArticleCard, { + key: article.id, + article: article + }))), /*#__PURE__*/React.createElement(Pagination, { + page: page, + totalPages: totalPages, + onPageChange: handlePageChange + }))); +} + +/* ── Mount ──────────────────────────────────────────────────────────── */ + +document.addEventListener('DOMContentLoaded', function () { + const containers = document.querySelectorAll('[data-peptide-news-feed]'); + containers.forEach(function (el) { + const root = ReactDOM.createRoot(el); + root.render(React.createElement(PeptideNewsFeed)); + }); +}); diff --git a/public/js/public-script.js b/public/js/public-script.js index ff350da..ef97f53 100644 --- a/public/js/public-script.js +++ b/public/js/public-script.js @@ -1,83 +1,9 @@ /** - * Peptide News — Public Click Tracker + * Peptide News — Legacy Click Tracker (deprecated) * - * Tracks outbound clicks on article links via AJAX and - * manages visitor session cookies. + * Click tracking is now handled by the React feed component. + * This file is kept as an empty placeholder for backward compatibility. * * @since 1.0.0 + * @deprecated 2.0.0 Use peptide-news-feed.js instead. */ -(function ($) { - 'use strict'; - - // Set session cookie if it doesn't exist. - function initSession() { - if (!getCookie('pn_session_id')) { - var sessionId = peptideNewsPublic.session_id || generateUUID(); - setCookie('pn_session_id', sessionId, 30); // 30-minute cookie - } - } - - function getCookie(name) { - var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); - return match ? decodeURIComponent(match[2]) : null; - } - - function setCookie(name, value, minutes) { - var d = new Date(); - d.setTime(d.getTime() + minutes * 60 * 1000); - var cookie = name + '=' + encodeURIComponent(value) + - ';expires=' + d.toUTCString() + - ';path=/;SameSite=Lax'; - if (window.location.protocol === 'https:') { - cookie += ';Secure'; - } - document.cookie = cookie; - } - - function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - var r = (Math.random() * 16) | 0; - var v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - } - - // Track click via AJAX (fire-and-forget with beacon fallback). - function trackClick(articleId) { - var data = { - action: 'peptide_news_track_click', - nonce: peptideNewsPublic.nonce, - article_id: articleId, - referrer: document.referrer || '', - page_url: window.location.href, - session_id: getCookie('pn_session_id') || '', - }; - - // Try sendBeacon first (non-blocking, survives page navigation). - if (navigator.sendBeacon) { - var formData = new FormData(); - Object.keys(data).forEach(function (key) { - formData.append(key, data[key]); - }); - navigator.sendBeacon(peptideNewsPublic.ajax_url, formData); - return; - } - - // Fallback to AJAX. - $.post(peptideNewsPublic.ajax_url, data); - } - - $(document).ready(function () { - initSession(); - - // Delegate click events on tracked links. - $(document).on('click', '.pn-track-click', function () { - var articleId = $(this).data('article-id'); - if (articleId) { - trackClick(articleId); - } - // Don't prevent default — let the link open normally. - }); - }); - -})(jQuery); diff --git a/public/react-app/PeptideNewsFeed.jsx b/public/react-app/PeptideNewsFeed.jsx new file mode 100644 index 0000000..7d38654 --- /dev/null +++ b/public/react-app/PeptideNewsFeed.jsx @@ -0,0 +1,374 @@ +/** + * Peptide News Feed — React Frontend + * + * A clean, accessible news feed component that displays peptide research + * articles with title, summary, source, date, and keyword tags. + * + * @since 2.0.0 + */ +const { useState, useEffect, useCallback, useRef, memo } = React; + +/* ── Constants ──────────────────────────────────────────────────────── */ + +const ARTICLES_PER_PAGE = 10; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/* ── Utilities ──────────────────────────────────────────────────────── */ + +/** + * Format an ISO date string to a human-readable form. + * + * @param {string} dateStr ISO date string. + * @returns {string} Formatted date (e.g., "Apr 5, 2026"). + */ +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +/** + * Calculate a human-readable relative time string. + * + * @param {string} dateStr ISO date string. + * @returns {string} Relative time (e.g., "2 hours ago"). + */ +function timeAgo(dateStr) { + if (!dateStr) return ''; + const now = Date.now(); + const then = new Date(dateStr).getTime(); + if (isNaN(then)) return ''; + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return formatDate(dateStr); +} + +/* ── Simple in-memory cache ─────────────────────────────────────────── */ + +const apiCache = new Map(); + +function getCached(key) { + const entry = apiCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > CACHE_TTL_MS) { + apiCache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key, data) { + apiCache.set(key, { data, ts: Date.now() }); +} + +/* ── API Layer ──────────────────────────────────────────────────────── */ + +/** + * Fetch articles from the WP REST API with caching. + * + * @param {string} restUrl Base REST URL. + * @param {number} page Page number. + * @param {number} count Articles per page. + * @param {AbortSignal} signal Abort signal for cleanup. + * @returns {Promise} Response data. + */ +async function fetchArticles(restUrl, page, count, signal) { + const cacheKey = `${restUrl}:${page}:${count}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `${restUrl}articles?count=${count}&page=${page}`; + const resp = await fetch(url, { signal }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + setCache(cacheKey, data); + return data; +} + +/* ── Click Tracking ─────────────────────────────────────────────────── */ + +/** + * Track an outbound click via sendBeacon or fallback XHR. + * + * @param {number} articleId + */ +function trackClick(articleId) { + const config = window.peptideNewsFeed || {}; + if (!config.ajaxUrl || !config.nonce) return; + + const formData = new FormData(); + formData.append('action', 'peptide_news_track_click'); + formData.append('nonce', config.nonce); + formData.append('article_id', articleId); + formData.append('referrer', document.referrer || ''); + formData.append('page_url', window.location.href); + formData.append('session_id', config.sessionId || ''); + + if (navigator.sendBeacon) { + navigator.sendBeacon(config.ajaxUrl, formData); + } else { + fetch(config.ajaxUrl, { method: 'POST', body: formData }).catch(() => {}); + } +} + +/* ── Components ─────────────────────────────────────────────────────── */ + +/** + * Single keyword tag pill. + */ +const Tag = memo(function Tag({ label }) { + return {label}; +}); + +/** + * Single article card. + */ +const ArticleCard = memo(function ArticleCard({ article }) { + const summary = article.ai_summary || article.excerpt || ''; + const tagSource = article.tags || article.categories || ''; + const tags = tagSource + ? tagSource.split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5) + : []; + + const handleClick = useCallback(() => { + trackClick(article.id); + }, [article.id]); + + return ( +
+
+ {article.source} + + +
+ +

+ + {article.title} + +

+ + {summary && ( +

{summary}

+ )} + + {article.author && ( + {article.author} + )} + + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + ))} +
+ )} +
+ ); +}); + +/** + * Loading skeleton placeholder. + */ +function LoadingSkeleton({ count = 3 }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +/** + * Error state with retry. + */ +function ErrorMessage({ message, onRetry }) { + return ( +
+

Unable to load articles. {message}

+ +
+ ); +} + +/** + * Empty state. + */ +function EmptyState() { + return ( +
+

No peptide news articles available yet. Check back soon!

+
+ ); +} + +/** + * Pagination controls. + */ +const Pagination = memo(function Pagination({ page, totalPages, onPageChange }) { + if (totalPages <= 1) return null; + + return ( + + ); +}); + +/* ── Main Feed Component ────────────────────────────────────────────── */ + +/** + * PeptideNewsFeed — root component. + * + * Reads configuration from window.peptideNewsFeed which is + * localized by the PHP shortcode renderer. + */ +function PeptideNewsFeed() { + const config = window.peptideNewsFeed || {}; + const restUrl = config.restUrl || '/wp-json/peptide-news/v1/'; + const perPage = config.count || ARTICLES_PER_PAGE; + + const [articles, setArticles] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const feedRef = useRef(null); + + const loadArticles = useCallback( + async (pageNum, signal) => { + setLoading(true); + setError(null); + try { + const data = await fetchArticles(restUrl, pageNum, perPage, signal); + setArticles(data.articles || []); + setTotalPages(data.total_pages || 1); + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message || 'Something went wrong.'); + } + } finally { + setLoading(false); + } + }, + [restUrl, perPage] + ); + + useEffect(() => { + const controller = new AbortController(); + loadArticles(page, controller.signal); + return () => controller.abort(); + }, [page, loadArticles]); + + const handlePageChange = useCallback((newPage) => { + setPage(newPage); + // Scroll feed container into view on pagination. + if (feedRef.current) { + feedRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, []); + + const handleRetry = useCallback(() => { + loadArticles(page); + }, [page, loadArticles]); + + return ( +
+ {loading && } + + {!loading && error && ( + + )} + + {!loading && !error && articles.length === 0 && } + + {!loading && !error && articles.length > 0 && ( + <> +
+ {articles.map((article) => ( + + ))} +
+ + + + )} +
+ ); +} + +/* ── Mount ──────────────────────────────────────────────────────────── */ + +document.addEventListener('DOMContentLoaded', function () { + const containers = document.querySelectorAll('[data-peptide-news-feed]'); + containers.forEach(function (el) { + const root = ReactDOM.createRoot(el); + root.render(React.createElement(PeptideNewsFeed)); + }); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index ced6b39..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,237 +0,0 @@ -errors[ $code ][] = $message; - if ( $data ) { - $this->error_data[ $code ] = $data; - } - } - } - - public function get_error_code() { - $codes = array_keys( $this->errors ); - return $codes ? $codes[0] : ''; - } - - public function get_error_message( $code = '' ) { - if ( ! $code ) { - $code = $this->get_error_code(); - } - return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : ''; - } - } -} - -// ── Load plugin classes for testing ─────────────────────────────────── -// Content filter can be loaded standalone (no WordPress runtime needed). -require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php'; - -// LLM class provides is_valid_model() used by Admin sanitize callbacks. -require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; - -// Stub WP_REST_Request for REST API tests. -if ( ! class_exists( 'WP_REST_Request' ) ) { - class WP_REST_Request { - private $params = array(); - private $method = 'GET'; - - public function __construct( $method = 'GET', $route = '' ) { - $this->method = $method; - } - - public function set_param( $key, $value ) { - $this->params[ $key ] = $value; - } - - public function get_param( $key ) { - return isset( $this->params[ $key ] ) ? $this->params[ $key ] : null; - } - - public function get_params() { - return $this->params; - } - } -} diff --git a/tests/unit/AdminTest.php b/tests/unit/AdminTest.php deleted file mode 100644 index ea35c3c..0000000 --- a/tests/unit/AdminTest.php +++ /dev/null @@ -1,109 +0,0 @@ -admin = new \Peptide_News_Admin( 'peptide-news', '1.2.1' ); - - // Reset global test state. - global $_test_settings_errors; - $_test_settings_errors = array(); - } - - /** - * Constructor should set plugin_name property. - */ - public function test_constructor_sets_plugin_name(): void { - $reflection = new \ReflectionClass( $this->admin ); - $prop = $reflection->getProperty( 'plugin_name' ); - $prop->setAccessible( true ); - - $this->assertEquals( 'peptide-news', $prop->getValue( $this->admin ) ); - } - - /** - * Constructor should set version property. - */ - public function test_constructor_sets_version(): void { - $reflection = new \ReflectionClass( $this->admin ); - $prop = $reflection->getProperty( 'version' ); - $prop->setAccessible( true ); - - $this->assertEquals( '1.2.1', $prop->getValue( $this->admin ) ); - } - - /** - * sanitize_model_id() should accept a valid model ID. - */ - public function test_sanitize_model_id_accepts_valid_id(): void { - $valid = 'openai/gpt-4o-mini'; - $this->assertEquals( $valid, $this->admin->sanitize_model_id( $valid ) ); - } - - /** - * sanitize_model_id() should accept model IDs with colons and dots. - */ - public function test_sanitize_model_id_accepts_complex_ids(): void { - $valid = 'google/gemini-2.0-flash:free'; - $this->assertEquals( $valid, $this->admin->sanitize_model_id( $valid ) ); - } - - /** - * sanitize_model_id() should reject empty strings. - */ - public function test_sanitize_model_id_allows_empty_to_clear(): void { - $result = $this->admin->sanitize_model_id( '' ); - $this->assertEquals( '', $result ); - } - - /** - * sanitize_model_id() should reject strings with spaces. - */ - public function test_sanitize_model_id_rejects_spaces(): void { - global $_test_settings_errors; - - $result = $this->admin->sanitize_model_id( 'invalid model id' ); - - $this->assertNotEmpty( $_test_settings_errors, 'Expected a settings error for invalid model ID.' ); - } - - /** - * sanitize_model_id() should reject strings with special characters. - */ - public function test_sanitize_model_id_rejects_special_chars(): void { - global $_test_settings_errors; - - $result = $this->admin->sanitize_model_id( '' ); - - $this->assertNotEmpty( $_test_settings_errors, 'Expected a settings error for XSS attempt.' ); - } - - /** - * enqueue_styles() should not enqueue on non-plugin pages. - * - * We verify it returns early by checking no errors are thrown - * when called with a non-matching hook. - */ - public function test_enqueue_styles_skips_non_plugin_pages(): void { - // Should return without error when hook doesn't match. - $this->admin->enqueue_styles( 'edit.php' ); - $this->assertTrue( true ); // No exception = pass. - } -} diff --git a/tests/unit/ContentFilterTest.php b/tests/unit/ContentFilterTest.php deleted file mode 100644 index 29aaefa..0000000 --- a/tests/unit/ContentFilterTest.php +++ /dev/null @@ -1,555 +0,0 @@ - 'example.com', - 'source_url' => 'https://example.com/article-123', - 'title' => 'New Peptide Research Shows Promise for Cancer Treatment', - 'excerpt' => 'Researchers at MIT have discovered a novel peptide compound.', - 'content' => '

A team of researchers at MIT has identified a new class of peptide compounds that show significant anti-tumor activity in preclinical trials.

', - 'author' => 'Jane Doe', - 'thumbnail_url' => 'https://example.com/thumb.jpg', - 'published_at' => '2026-04-01 10:00:00', - 'categories' => 'Research, Oncology', - 'tags' => '', - 'language' => 'en', - ), $overrides ); - } - - // ============================================= - // evaluate_article() — Domain blocking tests - // ============================================= - - public function test_blocks_article_from_blocked_domain() { - $article = $this->make_article( array( - 'source' => 'prnewswire.com', - 'source_url' => 'https://www.prnewswire.com/news/peptide-launch', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - Peptide_News_Content_Filter::get_title_keywords(), - Peptide_News_Content_Filter::get_body_keywords(), - Peptide_News_Content_Filter::get_blocked_domains(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - $this->assertStringContainsString( 'blocked_domain', $result['rule'] ); - } - - public function test_blocks_article_with_blocked_domain_in_url() { - $article = $this->make_article( array( - 'source' => 'news.globenewswire.com', - 'source_url' => 'https://news.globenewswire.com/release/2026/peptide', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array( 'globenewswire.com' ), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - } - - public function test_allows_article_from_clean_domain() { - $article = $this->make_article( array( - 'source' => 'nature.com', - 'source_url' => 'https://nature.com/articles/peptide-study', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array( 'prnewswire.com' ), - 2 - ); - - $this->assertSame( 'clean', $result['verdict'] ); - } - - // ============================================= - // evaluate_article() — Title keyword tests - // ============================================= - - public function test_blocks_article_with_press_release_in_title() { - $article = $this->make_article( array( - 'title' => 'Press Release: BioTech Corp Announces New Peptide Drug', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'press release' ), - array(), - array(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - $this->assertStringContainsString( 'title_keyword', $result['rule'] ); - } - - public function test_blocks_article_with_sponsored_in_title() { - $article = $this->make_article( array( - 'title' => 'Sponsored: The Future of Peptide Therapeutics', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'sponsored' ), - array(), - array(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - } - - public function test_title_keyword_match_is_case_insensitive() { - $article = $this->make_article( array( - 'title' => 'PRESS RELEASE: Major Discovery in Peptide Science', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'press release' ), - array(), - array(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - } - - public function test_allows_article_with_clean_title() { - $article = $this->make_article( array( - 'title' => 'Scientists Discover Novel Peptide for Treating Alzheimer\'s', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'press release', 'sponsored', 'advertisement' ), - array(), - array(), - 2 - ); - - $this->assertNotSame( 'promotional', $result['verdict'] ); - } - - // ============================================= - // evaluate_article() — Body keyword tests - // ============================================= - - public function test_blocks_article_exceeding_body_keyword_threshold() { - $article = $this->make_article( array( - 'content' => '

For immediate release. Media contact: PR Team. About the company: We are a leader in peptide research. Forward-looking statements apply.

', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array( 'for immediate release', 'media contact', 'about the company', 'forward-looking statements' ), - array(), - 2 // threshold = 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - $this->assertStringContainsString( 'body_keywords', $result['rule'] ); - } - - public function test_borderline_when_body_matches_below_threshold() { - $article = $this->make_article( array( - 'content' => '

This is a real article with some media contact info at the end.

', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array( 'media contact', 'for immediate release', 'about the company' ), - array(), - 2 - ); - - $this->assertSame( 'borderline', $result['verdict'] ); - } - - public function test_clean_when_no_body_keywords_match() { - $article = $this->make_article( array( - 'content' => '

A rigorous study published in Nature Chemistry demonstrates that cyclic peptides can target previously undruggable protein-protein interactions.

', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array( 'for immediate release', 'media contact', 'forward-looking statements' ), - array(), - 2 - ); - - $this->assertSame( 'clean', $result['verdict'] ); - } - - public function test_strict_sensitivity_blocks_with_single_body_match() { - $article = $this->make_article( array( - 'content' => '

Great peptide research. Forward-looking statements may apply.

', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array( 'forward-looking statements' ), - array(), - 1 // strict threshold - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - } - - public function test_lenient_sensitivity_requires_more_body_matches() { - $article = $this->make_article( array( - 'content' => '

For immediate release. Media contact details below.

', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array( 'for immediate release', 'media contact', 'forward-looking statements' ), - array(), - 3 // lenient threshold - ); - - // Only 2 matches but threshold is 3, so borderline not promotional. - $this->assertSame( 'borderline', $result['verdict'] ); - } - - // ============================================= - // evaluate_article() — URL path tests - // ============================================= - - public function test_blocks_article_with_press_release_url_path() { - $article = $this->make_article( array( - 'source_url' => 'https://example.com/press-releases/peptide-announcement', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - $this->assertStringContainsString( 'url_path', $result['rule'] ); - } - - public function test_blocks_article_with_sponsored_url_path() { - $article = $this->make_article( array( - 'source_url' => 'https://example.com/sponsored/peptide-overview', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - } - - // ============================================= - // evaluate_article() — Priority / rule ordering - // ============================================= - - public function test_domain_block_takes_priority_over_clean_title() { - $article = $this->make_article( array( - 'title' => 'Legitimate Sounding Peptide Research Title', - 'source' => 'businesswire.com', - 'source_url' => 'https://www.businesswire.com/news/peptide-research', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array( 'businesswire.com' ), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - $this->assertStringContainsString( 'blocked_domain', $result['rule'] ); - } - - // ============================================= - // filter_articles() — Batch filtering - // ============================================= - - public function test_filter_articles_removes_promotional_keeps_clean() { - $articles = array( - $this->make_article( array( - 'title' => 'Genuine Peptide Research Breakthrough', - ) ), - $this->make_article( array( - 'title' => 'Press Release: BioTech Announces Product', - 'source_url' => 'https://prnewswire.com/release/123', - 'source' => 'prnewswire.com', - ) ), - $this->make_article( array( - 'title' => 'Another Real Study on Peptide Folding', - ) ), - ); - - $filtered = Peptide_News_Content_Filter::filter_articles( $articles ); - - $this->assertCount( 2, $filtered ); - $this->assertSame( 'Genuine Peptide Research Breakthrough', $filtered[0]['title'] ); - $this->assertSame( 'Another Real Study on Peptide Folding', $filtered[1]['title'] ); - } - - public function test_filter_articles_returns_all_when_disabled() { - // Temporarily set filter to disabled. - global $_test_options; - $_test_options['peptide_news_filter_enabled'] = 0; - - $articles = array( - $this->make_article( array( 'source' => 'prnewswire.com' ) ), - $this->make_article( array( 'title' => 'Press Release: Something' ) ), - ); - - $filtered = Peptide_News_Content_Filter::filter_articles( $articles ); - - $this->assertCount( 2, $filtered ); - - // Restore. - $_test_options['peptide_news_filter_enabled'] = 1; - } - - public function test_filter_articles_skips_malformed_entries() { - $articles = array( - $this->make_article( array( 'title' => 'Valid Article' ) ), - 'not_an_array', - array( 'title' => 'Missing source_url' ), - $this->make_article( array( 'title' => 'Another Valid Article' ) ), - ); - - $filtered = Peptide_News_Content_Filter::filter_articles( $articles ); - - // Only the two valid articles should survive. - $this->assertCount( 2, $filtered ); - $this->assertSame( 'Valid Article', $filtered[0]['title'] ); - $this->assertSame( 'Another Valid Article', $filtered[1]['title'] ); - } - - public function test_filter_articles_handles_empty_array() { - $filtered = Peptide_News_Content_Filter::filter_articles( array() ); - - $this->assertIsArray( $filtered ); - $this->assertEmpty( $filtered ); - } - - // ============================================= - // Keyword/domain getter tests - // ============================================= - - public function test_get_title_keywords_returns_defaults_when_empty() { - global $_test_options; - $_test_options['peptide_news_filter_title_keywords'] = ''; - - $keywords = Peptide_News_Content_Filter::get_title_keywords(); - - $this->assertNotEmpty( $keywords ); - $this->assertContains( 'press release', $keywords ); - $this->assertContains( 'sponsored', $keywords ); - } - - public function test_get_title_keywords_returns_custom_when_set() { - global $_test_options; - $_test_options['peptide_news_filter_title_keywords'] = "custom keyword\nanother phrase"; - - $keywords = Peptide_News_Content_Filter::get_title_keywords(); - - $this->assertCount( 2, $keywords ); - $this->assertContains( 'custom keyword', $keywords ); - $this->assertContains( 'another phrase', $keywords ); - - // Restore. - $_test_options['peptide_news_filter_title_keywords'] = ''; - } - - public function test_get_blocked_domains_returns_defaults_when_empty() { - global $_test_options; - $_test_options['peptide_news_filter_blocked_domains'] = ''; - - $domains = Peptide_News_Content_Filter::get_blocked_domains(); - - $this->assertNotEmpty( $domains ); - $this->assertContains( 'prnewswire.com', $domains ); - $this->assertContains( 'businesswire.com', $domains ); - } - - public function test_get_body_keywords_returns_defaults_when_empty() { - global $_test_options; - $_test_options['peptide_news_filter_body_keywords'] = ''; - - $keywords = Peptide_News_Content_Filter::get_body_keywords(); - - $this->assertNotEmpty( $keywords ); - $this->assertContains( 'for immediate release', $keywords ); - } - - // ============================================= - // Classification prompt test - // ============================================= - - public function test_build_classification_prompt_contains_article_details() { - // Use reflection to test private method. - $method = new ReflectionMethod( 'Peptide_News_Content_Filter', 'build_classification_prompt' ); - $method->setAccessible( true ); - - $article = $this->make_article( array( - 'title' => 'Test Peptide Article', - 'excerpt' => 'This is a test excerpt.', - 'content' => '

This is the content body of the article.

', - 'source' => 'example.com', - ) ); - - $prompt = $method->invoke( null, $article ); - - $this->assertStringContainsString( 'EDITORIAL', $prompt ); - $this->assertStringContainsString( 'PROMOTIONAL', $prompt ); - $this->assertStringContainsString( 'Test Peptide Article', $prompt ); - $this->assertStringContainsString( 'example.com', $prompt ); - // Verify structured delimiters are present for injection mitigation. - $this->assertStringContainsString( '', $prompt ); - $this->assertStringContainsString( '', $prompt ); - $this->assertStringContainsString( '', $prompt ); - } - - // ============================================= - // Edge cases - // ============================================= - - public function test_handles_article_with_empty_fields() { - $article = $this->make_article( array( - 'title' => '', - 'excerpt' => '', - 'content' => '', - 'source' => '', - 'source_url' => '', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'press release' ), - array( 'media contact' ), - array( 'prnewswire.com' ), - 2 - ); - - $this->assertSame( 'clean', $result['verdict'] ); - } - - public function test_handles_article_with_null_fields() { - $article = array( - 'title' => null, - 'excerpt' => null, - 'content' => null, - 'source' => null, - 'source_url' => null, - ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'press release' ), - array( 'media contact' ), - array( 'prnewswire.com' ), - 2 - ); - - $this->assertSame( 'clean', $result['verdict'] ); - } - - public function test_empty_keyword_lists_do_not_cause_errors() { - $article = $this->make_article(); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array(), - 2 - ); - - $this->assertSame( 'clean', $result['verdict'] ); - } - - public function test_keyword_list_with_blank_entries_is_handled() { - $article = $this->make_article( array( - 'title' => 'Press Release: New Peptide', - ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( '', ' ', 'press release', '' ), - array(), - array(), - 2 - ); - - $this->assertSame( 'promotional', $result['verdict'] ); - } - - // ============================================= - // Score tracking - // ============================================= - - public function test_domain_block_returns_high_score() { - $article = $this->make_article( array( 'source' => 'prnewswire.com' ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array(), - array(), - array( 'prnewswire.com' ), - 2 - ); - - $this->assertSame( 100, $result['score'] ); - } - - public function test_title_keyword_returns_significant_score() { - $article = $this->make_article( array( 'title' => 'Sponsored content about peptides' ) ); - - $result = Peptide_News_Content_Filter::evaluate_article( - $article, - array( 'sponsored' ), - array(), - array(), - 2 - ); - - $this->assertGreaterThanOrEqual( 50, $result['score'] ); - } -} diff --git a/tests/unit/FetcherTest.php b/tests/unit/FetcherTest.php deleted file mode 100644 index 2bd8e11..0000000 --- a/tests/unit/FetcherTest.php +++ /dev/null @@ -1,81 +0,0 @@ -<?php -/** - * Unit tests for Peptide_News_Fetcher. - * - * @package PeptideNews\Tests - */ - -namespace PeptideNews\Tests\Unit; - -use PHPUnit\Framework\TestCase; - -// Load the class under test. -require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; - -class FetcherTest extends TestCase { - - /** - * @var \Peptide_News_Fetcher - */ - private $fetcher; - - protected function setUp(): void { - $this->fetcher = new \Peptide_News_Fetcher(); - } - - /** - * add_custom_cron_schedules() should add the expected intervals. - */ - public function test_add_custom_cron_schedules_adds_intervals(): void { - $existing = array( - 'hourly' => array( - 'interval' => 3600, - 'display' => 'Once Hourly', - ), - ); - - $result = $this->fetcher->add_custom_cron_schedules( $existing ); - - // Original schedule must be preserved. - $this->assertArrayHasKey( 'hourly', $result ); - - // Custom schedules must be added. - $this->assertArrayHasKey( 'every_fifteen_minutes', $result ); - $this->assertArrayHasKey( 'every_thirty_minutes', $result ); - $this->assertArrayHasKey( 'every_four_hours', $result ); - $this->assertArrayHasKey( 'every_six_hours', $result ); - } - - /** - * Custom intervals should have correct durations. - */ - public function test_cron_schedule_intervals_are_correct(): void { - $result = $this->fetcher->add_custom_cron_schedules( array() ); - - $this->assertEquals( 900, $result['every_fifteen_minutes']['interval'] ); - $this->assertEquals( 1800, $result['every_thirty_minutes']['interval'] ); - $this->assertEquals( 14400, $result['every_four_hours']['interval'] ); - $this->assertEquals( 21600, $result['every_six_hours']['interval'] ); - } - - /** - * Custom schedules should have display names. - */ - public function test_cron_schedules_have_display_names(): void { - $result = $this->fetcher->add_custom_cron_schedules( array() ); - - foreach ( $result as $key => $schedule ) { - $this->assertArrayHasKey( 'display', $schedule, "Schedule '{$key}' missing 'display' key." ); - $this->assertNotEmpty( $schedule['display'], "Schedule '{$key}' has empty display name." ); - } - } - - /** - * Passing an empty array should still return all custom schedules. - */ - public function test_cron_schedules_work_with_empty_input(): void { - $result = $this->fetcher->add_custom_cron_schedules( array() ); - - $this->assertCount( 4, $result ); - } -} diff --git a/tests/unit/PluginBootstrapTest.php b/tests/unit/PluginBootstrapTest.php deleted file mode 100644 index f642846..0000000 --- a/tests/unit/PluginBootstrapTest.php +++ /dev/null @@ -1,84 +0,0 @@ -<?php -/** - * Unit tests for the main plugin bootstrap file. - * - * Validates constants, version strings, and file structure. - * - * @package PeptideNews\Tests - */ - -namespace PeptideNews\Tests\Unit; - -use PHPUnit\Framework\TestCase; - -class PluginBootstrapTest extends TestCase { - - /** - * Plugin version constant should be defined and non-empty. - */ - public function test_version_constant_is_defined(): void { - $this->assertTrue( defined( 'PEPTIDE_NEWS_VERSION' ) ); - $this->assertNotEmpty( PEPTIDE_NEWS_VERSION ); - } - - /** - * Plugin directory constant should point to a real directory. - */ - public function test_plugin_dir_constant_is_valid(): void { - $this->assertTrue( defined( 'PEPTIDE_NEWS_PLUGIN_DIR' ) ); - $this->assertDirectoryExists( PEPTIDE_NEWS_PLUGIN_DIR ); - } - - /** - * Main plugin file should exist. - */ - public function test_main_plugin_file_exists(): void { - $this->assertFileExists( PEPTIDE_NEWS_PLUGIN_DIR . 'peptide-news-plugin.php' ); - } - - /** - * All required class files should exist. - */ - public function test_required_class_files_exist(): void { - $files = array( - 'includes/class-peptide-news.php', - 'includes/class-peptide-news-activator.php', - 'includes/class-peptide-news-deactivator.php', - 'includes/class-peptide-news-fetcher.php', - 'includes/class-peptide-news-analytics.php', - 'includes/class-peptide-news-rest-api.php', - 'admin/class-peptide-news-admin.php', - ); - - foreach ( $files as $file ) { - $path = PEPTIDE_NEWS_PLUGIN_DIR . $file; - $this->assertFileExists( $path, "Required file missing: {$file}" ); - } - } - - /** - * Admin JavaScript file should exist. - */ - public function test_admin_script_exists(): void { - $this->assertFileExists( PEPTIDE_NEWS_PLUGIN_DIR . 'admin/js/admin-script.js' ); - } - - /** - * Admin stylesheet should exist. - */ - public function test_admin_style_exists(): void { - $this->assertFileExists( PEPTIDE_NEWS_PLUGIN_DIR . 'admin/css/admin-style.css' ); - } - - /** - * Version string should follow SemVer pattern. - */ - public function test_version_follows_semver(): void { - // Allow semver with optional pre-release suffix (e.g. 1.2.1-test). - $this->assertMatchesRegularExpression( - '/^\d+\.\d+\.\d+(-[\w.]+)?$/', - PEPTIDE_NEWS_VERSION, - 'Version does not follow SemVer pattern.' - ); - } -} diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php deleted file mode 100644 index 07d9ac1..0000000 --- a/tests/unit/RestApiTest.php +++ /dev/null @@ -1,102 +0,0 @@ -<?php -/** - * Unit tests for Peptide_News_Rest_API. - * - * @package PeptideNews\Tests - */ - -namespace PeptideNews\Tests\Unit; - -use PHPUnit\Framework\TestCase; - -// Load the class under test. -require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; - -class RestApiTest extends TestCase { - - /** - * @var \Peptide_News_Rest_API - */ - private $api; - - protected function setUp(): void { - $this->api = new \Peptide_News_Rest_API(); - } - - /** - * validate_date_format() should accept valid Y-m-d dates. - */ - public function test_validate_date_accepts_valid_format(): void { - $request = new \WP_REST_Request(); - $result = $this->api->validate_date_format( '2026-01-15', $request, 'start_date' ); - - $this->assertTrue( $result ); - } - - /** - * validate_date_format() should accept today's date. - */ - public function test_validate_date_accepts_today(): void { - $request = new \WP_REST_Request(); - $result = $this->api->validate_date_format( date( 'Y-m-d' ), $request, 'end_date' ); - - $this->assertTrue( $result ); - } - - /** - * validate_date_format() should reject invalid formats. - */ - public function test_validate_date_rejects_wrong_format(): void { - $request = new \WP_REST_Request(); - $result = $this->api->validate_date_format( '01-15-2026', $request, 'start_date' ); - - $this->assertInstanceOf( \WP_Error::class, $result ); - } - - /** - * validate_date_format() should reject empty strings. - */ - public function test_validate_date_rejects_empty(): void { - $request = new \WP_REST_Request(); - $result = $this->api->validate_date_format( '', $request, 'start_date' ); - - $this->assertInstanceOf( \WP_Error::class, $result ); - } - - /** - * validate_date_format() should reject non-date strings. - */ - public function test_validate_date_rejects_garbage(): void { - $request = new \WP_REST_Request(); - $result = $this->api->validate_date_format( 'not-a-date', $request, 'start_date' ); - - $this->assertInstanceOf( \WP_Error::class, $result ); - } - - /** - * validate_date_format() should reject dates with slashes. - */ - public function test_validate_date_rejects_slashes(): void { - $request = new \WP_REST_Request(); - $result = $this->api->validate_date_format( '2026/01/15', $request, 'start_date' ); - - $this->assertInstanceOf( \WP_Error::class, $result ); - } - - /** - * check_admin_permissions() should return WP_Error when user lacks capability. - */ - public function test_check_admin_permissions_denies_non_admin(): void { - // Stub current_user_can to return false. - if ( ! function_exists( 'current_user_can' ) ) { - function current_user_can() { - return false; - } - } - - $request = new \WP_REST_Request(); - $result = $this->api->check_admin_permissions( $request ); - - $this->assertInstanceOf( \WP_Error::class, $result ); - } -}