From 2b162e581fae5ff3cc9b7286235a49cc19598aa7 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sun, 23 Aug 2026 13:35:10 +0000 Subject: [PATCH] Upload via GUI (30 Dateien) --- includes/analytics.php | 165 ++- includes/guest-authors.php | 1952 +++++++++++++++++++++++++++++++++--- 2 files changed, 1973 insertions(+), 144 deletions(-) diff --git a/includes/analytics.php b/includes/analytics.php index 1842a02..bbf77e2 100644 --- a/includes/analytics.php +++ b/includes/analytics.php @@ -1,13 +1,13 @@ -get_results($wpdb->prepare($sql, $params)); } +/** + * Ruft die jüngsten Einzelereignisse ab – Grundlage für die Detailtabelle. + * + * @return array Rohe Analytics-Daten (begrenzt). + */ +function wp_multi_fetch_raw_analytics($start_datetime = null, $end_datetime = null) { + global $wpdb; + + list($where, $params) = wp_multi_analytics_date_where($start_datetime, $end_datetime); + + // Ohne Limit würde die Detailtabelle bei anonymem Tracking die Seite sprengen + $params[] = (int) apply_filters('wp_multi_analytics_detail_limit', 200); + + $sql = "SELECT DATE(timestamp) AS date, action, post_id, 1 AS count, user_id, timestamp + FROM " . WP_MULTI_ANALYTICS_TABLE . " + $where + ORDER BY timestamp DESC + LIMIT %d"; + + return $wpdb->get_results($wpdb->prepare($sql, $params)); +} + /** * Verarbeitet rohe Analytics-Daten für Diagramm und Tabelle. * * @param array $results Rohe Analytics-Daten. * @return array Verarbeitete Daten. */ -function wp_multi_process_analytics_data($results) { +function wp_multi_process_analytics_data($results, $recent = null) { $dates = []; $comment_counts = []; $view_counts = []; @@ -184,7 +290,7 @@ function wp_multi_process_analytics_data($results) { 'fill' => false, ] ], - 'data' => $results + 'data' => ($recent === null) ? $results : $recent ]; } @@ -202,8 +308,9 @@ function wp_multi_get_analytics_data($start_datetime = null, $end_datetime = nul return $cached_data; } - $results = wp_multi_fetch_raw_analytics($start_datetime, $end_datetime); - $data = wp_multi_process_analytics_data($results); + $totals = wp_multi_fetch_analytics_totals($start_datetime, $end_datetime); + $recent = wp_multi_fetch_raw_analytics($start_datetime, $end_datetime); + $data = wp_multi_process_analytics_data($totals, $recent); set_transient($cache_key, $data, HOUR_IN_SECONDS); return $data; @@ -308,7 +415,7 @@ function wp_multi_display_user_analytics() { - user_id); ?> + user_id ? esc_html($row->user_id) : esc_html__('Gast', 'wp-multi'); ?> action); ?> 'active', 'emoji' => '🟢', 'label' => __('Aktiv', 'wp-multi'), 'class' => 'wpm-badge--success', 'rank' => 1]; + } elseif ($days <= 180) { + $status = ['key' => 'inactive', 'emoji' => '🟡', 'label' => __('Inaktiv', 'wp-multi'), 'class' => 'wpm-badge--warning', 'rank' => 2]; + } elseif ((int) $post_count >= 10) { + $status = ['key' => 'former_prolific', 'emoji' => '⭐', 'label' => __('Ehemaliger Vielschreiber', 'wp-multi'), 'class' => 'wpm-badge--star', 'rank' => 4]; + } else { + $status = ['key' => 'dormant', 'emoji' => '🔴', 'label' => __('Stark inaktiv', 'wp-multi'), 'class' => 'wpm-badge--danger', 'rank' => 3]; + } + + $status['days'] = $days; + + return $status; +} + +/** + * Liefert die Beschriftungen aller Aktivitätsstatus für Filter und Legende. + */ +function wp_multi_get_guest_author_status_labels() { + return [ + 'former_prolific' => '⭐ ' . __('Ehemaliger Vielschreiber', 'wp-multi'), + 'dormant' => '🔴 ' . __('Stark inaktiv', 'wp-multi'), + 'inactive' => '🟡 ' . __('Inaktiv', 'wp-multi'), + 'active' => '🟢 ' . __('Aktiv', 'wp-multi'), + ]; +} + +/** + * Liefert die Rangleiter der Autoren, absteigend nach Schwelle. + * + * 🎉 Newcomer ab 5 – ⭐ Vielschreiber ab 10 – 🏆 Etablierter Autor ab 25 – + * 👑 Star-Autor ab 50 – 💎 Legende ab 100 Geschichten. + * + * @return array Zuordnung "Schwelle => [emoji, Bezeichnung]". + */ +function wp_multi_get_guest_author_ranks() { + return [ + 100 => ['💎', __('Legende', 'wp-multi')], + 50 => ['👑', __('Star-Autor', 'wp-multi')], + 25 => ['🏆', __('Etablierter Autor', 'wp-multi')], + 10 => ['⭐', __('Vielschreiber', 'wp-multi')], + 5 => ['🎉', __('Newcomer', 'wp-multi')], + ]; +} + +/** + * Ermittelt den höchsten erreichten Rang eines Autors. + * + * Es wird immer nur der höchste erreichte Rang zurückgegeben, nie mehrere. + * + * @param int $post_count Anzahl veröffentlichter Geschichten. + * @return array|null Array mit threshold, emoji, label und label_full – oder null ohne Rang. + */ +function wp_multi_get_guest_author_milestone($post_count) { + $post_count = (int) $post_count; + + foreach (wp_multi_get_guest_author_ranks() as $threshold => $rank) { + if ($post_count >= $threshold) { + return [ + 'threshold' => $threshold, + 'emoji' => $rank[0], + 'label' => $rank[1], + /* translators: 1: Rangbezeichnung, 2: Schwelle des Rangs */ + 'label_full' => sprintf(__('%1$s (ab %2$d Geschichten)', 'wp-multi'), $rank[1], $threshold), + ]; + } + } + + return null; +} + +/** + * Ermittelt den nächsten noch nicht erreichten Rang. + * + * @param int $post_count Anzahl veröffentlichter Geschichten. + * @return array|null Array mit threshold, emoji, label und missing – oder null nach dem letzten Rang. + */ +function wp_multi_get_guest_author_next_milestone($post_count) { + $post_count = (int) $post_count; + + // Aufsteigend, damit der nächste offene Rang zuerst greift + $ranks = array_reverse(wp_multi_get_guest_author_ranks(), true); + + foreach ($ranks as $threshold => $rank) { + if ($post_count < $threshold) { + return [ + 'threshold' => $threshold, + 'emoji' => $rank[0], + 'label' => $rank[1], + 'missing' => $threshold - $post_count, + ]; + } + } + + return null; +} + +/** + * Zählt die veröffentlichten Beiträge eines Gast-Autors. + * + * Nutzt dieselbe Regel wie die Autoren-Übersicht, damit Übersicht und + * Autorenprofil nie unterschiedliche Zahlen anzeigen. + * + * @param string $guest_author Name des Gast-Autors. + * @return int + */ +function wp_multi_get_guest_author_post_count($guest_author) { + if (empty($guest_author)) { + return 0; + } + + global $wpdb; + + return (int) $wpdb->get_var($wpdb->prepare(" + SELECT COUNT(*) + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id + WHERE pm.meta_key = '_guest_author' + AND pm.meta_value = %s + AND p.post_status = 'publish' + ", $guest_author)); +} + +/** + * Liefert die Aufrufe je Gast-Autor aus der Analytics-Tabelle. + * + * Gezählt werden alle Besucher (siehe wp_multi_post_view_activity()); Bots sind + * ausgenommen und ein Besucher zählt je Beitrag höchstens einmal pro halbe Stunde. + * + * @return array|null Zuordnung "Autorenname => Aufrufe" oder null ohne Analytics-Tabelle. + */ +function wp_multi_get_guest_author_views() { + if (!defined('WP_MULTI_ANALYTICS_TABLE')) { + return null; + } + + global $wpdb; + $table = WP_MULTI_ANALYTICS_TABLE; + + if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) { + return null; + } + + $results = $wpdb->get_results(" + SELECT pm.meta_value AS guest_author, COUNT(*) AS views + FROM {$table} a + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = a.post_id AND pm.meta_key = '_guest_author' + WHERE a.action = 'view' + AND pm.meta_value != '' + GROUP BY pm.meta_value + "); + + $views = []; + foreach ((array) $results as $row) { + $views[$row->guest_author] = (int) $row->views; + } + + return $views; +} + +/** + * Liefert die Reiter der Gast-Autoren-Seite. + */ +function wp_multi_get_guest_author_tabs() { + return [ + 'authors' => __('Gast-Autoren', 'wp-multi'), + 'series' => __('Liegengebliebene Serien', 'wp-multi'), + 'views' => __('Aufrufe je Geschichte', 'wp-multi'), + ]; +} + // Callback-Funktion für die Gast-Autor-Übersicht function wp_multi_guest_author_overview_page() { + $tabs = wp_multi_get_guest_author_tabs(); + $current_tab = isset($_GET['tab']) ? sanitize_key($_GET['tab']) : 'authors'; + if (!isset($tabs[$current_tab])) { + $current_tab = 'authors'; + } + + $base_url = add_query_arg(['page' => 'guest_author_overview'], admin_url('admin.php')); + + wpmt_admin_page_open( + __('Gast-Autoren', 'wp-multi'), + __('Alle Gast-Autoren mit Anzahl ihrer veröffentlichten Beiträge und ihrem Aktivitätsstatus', 'wp-multi') + ); + ?> + + ' . esc_html__('Die Serien-Erkennung konnte nicht ausgeführt werden.', 'wp-multi') . '

'; + wpmt_admin_card_close(); + } + } elseif ($current_tab === 'views') { + wp_multi_render_post_views_table($base_url); + } else { + wp_multi_render_guest_author_table($base_url); + } + + wpmt_admin_page_close(); +} + +/** + * Rendert die Aufrufe einzelner Geschichten mit dem Datum des letzten Aufrufs. + * + * @param string $base_url Basis-URL der Übersichtsseite. + */ +function wp_multi_render_post_views_table($base_url) { + wpmt_admin_card_open(__('Aufrufe je Geschichte', 'wp-multi')); + + if (!defined('WP_MULTI_ANALYTICS_TABLE')) { + echo '

' . esc_html__('Das Analytics-Modul ist nicht aktiv, deshalb liegen keine Aufrufe vor.', 'wp-multi') . '

'; + wpmt_admin_card_close(); + return; + } + + global $wpdb; + $table = WP_MULTI_ANALYTICS_TABLE; + + if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) { + echo '

' . esc_html__('Die Analytics-Tabelle existiert noch nicht. Sie wird bei der Aktivierung des Plugins angelegt.', 'wp-multi') . '

'; + wpmt_admin_card_close(); + return; + } + + // Sortierparameter aus URL lesen (Whitelist gegen SQL-Injection) + $valid_orderby = ['views' => 'views', 'last_view' => 'last_view', 'title' => 'p.post_title']; + // Standard: zuletzt gelesene Geschichten oben + $orderby = isset($_GET['orderby']) ? sanitize_key($_GET['orderby']) : 'last_view'; + if (!isset($valid_orderby[$orderby])) { + $orderby = 'last_view'; + } + $order = isset($_GET['order']) && in_array(strtoupper($_GET['order']), ['ASC', 'DESC']) ? strtoupper($_GET['order']) : 'DESC'; + $sql_orderby = $valid_orderby[$orderby]; + + // Lange Listen bremsen die Seite aus – Anzahl per Filter anpassbar + $limit = (int) apply_filters('wp_multi_post_views_limit', 100); + $sort_url = add_query_arg('tab', 'views', $base_url); + + $posts = $wpdb->get_results($wpdb->prepare(" + SELECT a.post_id, p.post_title, p.post_status, + COUNT(*) AS views, + MAX(a.timestamp) AS last_view, + pm.meta_value AS guest_author + FROM {$table} a + INNER JOIN {$wpdb->posts} p ON p.ID = a.post_id + LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' + WHERE a.action = 'view' + GROUP BY a.post_id, p.post_title, p.post_status, pm.meta_value + ORDER BY {$sql_orderby} {$order}, p.post_title ASC + LIMIT %d + ", $limit)); + + $total_views = 0; + foreach ((array) $posts as $row) { + $total_views += (int) $row->views; + } + + $date_format = get_option('date_format') . ' ' . get_option('time_format'); + ?> +

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + post_title !== '' ? $row->post_title : __('(ohne Titel)', 'wp-multi')); ?> + + post_status !== 'publish') : ?> + (post_status); ?>) + + guest_author, $base_url, 'views'); ?>views)); ?> + last_view))); + ?> + (last_view)))); ?>) +
+
+ = $limit) : ?> +

+ +

+ + ['series_count' => int, 'max_parts' => int]" oder null ohne Serien. + */ +function wp_multi_get_guest_author_series_stats() { + $source = wp_multi_detect_series_source(); + + if (!$source) { + return null; + } + + global $wpdb; + $parent_sql = $source['parent'] ? $wpdb->prepare('AND tt.parent = %d', $source['parent']) : ''; + + $results = $wpdb->get_results($wpdb->prepare(" + SELECT x.guest_author, + SUM(CASE WHEN x.parts >= 2 THEN 1 ELSE 0 END) AS series_count, + MAX(x.parts) AS max_parts + FROM ( + SELECT pm.meta_value AS guest_author, tt.term_id, COUNT(DISTINCT p.ID) AS parts + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' AND pm.meta_value != '' + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s {$parent_sql} + WHERE p.post_status = 'publish' + GROUP BY pm.meta_value, tt.term_id + ) x + GROUP BY x.guest_author + ", $source['taxonomy'])); + + $stats = []; + foreach ((array) $results as $row) { + $stats[$row->guest_author] = [ + 'series_count' => (int) $row->series_count, + 'max_parts' => (int) $row->max_parts, + ]; + } + + return $stats; +} + +/** + * Durchschnittliche Aufrufe je Geschichte über alle Gast-Autoren hinweg. + * + * Maßstab für "gute Aufrufe" in der Reaktivierungsbewertung. Wird einmal je + * Seitenaufruf berechnet, damit Übersicht und Detailansicht identisch bewerten. + * + * @return float + */ +function wp_multi_get_average_guest_author_views() { + static $average = null; + + if ($average !== null) { + return $average; + } + + $table = wp_multi_get_analytics_table(); + + if (!$table) { + $average = 0.0; + return $average; + } + + global $wpdb; + + $views = (int) $wpdb->get_var(" + SELECT COUNT(*) + FROM {$table} a + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = a.post_id AND pm.meta_key = '_guest_author' AND pm.meta_value != '' + WHERE a.action = 'view' + "); + + $posts = (int) $wpdb->get_var(" + SELECT COUNT(*) + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' AND pm.meta_value != '' + WHERE p.post_status = 'publish' + "); + + $average = $posts ? ($views / $posts) : 0.0; + + return $average; +} + +/** + * Bewertet, wie lohnend es ist, einen Autor zurückzuholen. + * + * 🔥 Sehr interessant – ab 10 Geschichten, mindestens ein halbes Jahr still, + * mit einer eigenen Serie von mindestens 3 Teilen + * ⭐ Interessant – ab 5 Geschichten, mindestens ein halbes Jahr still, + * und überdurchschnittlich gelesen + * 🟡 Beobachten – ab 3 Geschichten und mindestens ein halbes Jahr still + * ⚪ Keine Priorität – 1–2 Geschichten, lange still + * – Kein Bedarf – schreibt noch + * + * @param array $data Kennzahlen des Autors (post_count, days, views, series_count, max_parts). + * @param float $avg_views Durchschnittliche Aufrufe je Geschichte auf der ganzen Seite. + * @return array Array mit key, emoji, label, class und rank. + */ +function wp_multi_get_guest_author_reactivation($data, $avg_views = 0) { + $post_count = isset($data['post_count']) ? (int) $data['post_count'] : 0; + $days = isset($data['days']) ? (int) $data['days'] : 0; + $views = isset($data['views']) ? (int) $data['views'] : 0; + $series_count = isset($data['series_count']) ? (int) $data['series_count'] : 0; + $max_parts = isset($data['max_parts']) ? (int) $data['max_parts'] : 0; + + $min_days = (int) apply_filters('wp_multi_reactivation_min_days', 180); + + // Wer noch schreibt, muss nicht zurückgeholt werden + if ($days < $min_days) { + return ['key' => 'current', 'emoji' => '–', 'label' => __('Kein Bedarf', 'wp-multi'), 'class' => '', 'rank' => 0]; + } + + if ($post_count >= 10 && $series_count > 0 && $max_parts >= 3) { + return ['key' => 'hot', 'emoji' => '🔥', 'label' => __('Sehr interessant', 'wp-multi'), 'class' => 'wpm-badge--danger', 'rank' => 4]; + } + + // "Mehrere Geschichten mit guten Aufrufen": im Schnitt deutlich über dem + // Seitendurchschnitt gelesen. Ohne Analytics-Daten greift die Regel nicht. + $good_views_factor = (float) apply_filters('wp_multi_reactivation_views_factor', 1.5); + $has_good_views = ($avg_views > 0 && $post_count > 0 && ($views / $post_count) >= ($avg_views * $good_views_factor)); + + if ($post_count >= 5 && $has_good_views) { + return ['key' => 'interesting', 'emoji' => '⭐', 'label' => __('Interessant', 'wp-multi'), 'class' => 'wpm-badge--warning', 'rank' => 3]; + } + + if ($post_count >= 3) { + return ['key' => 'watch', 'emoji' => '🟡', 'label' => __('Beobachten', 'wp-multi'), 'class' => 'wpm-badge--warning', 'rank' => 2]; + } + + return ['key' => 'low', 'emoji' => '⚪', 'label' => __('Keine Priorität', 'wp-multi'), 'class' => '', 'rank' => 1]; +} + +/** + * Liefert die Beschriftungen aller Reaktivierungsstufen für den Filter. + */ +function wp_multi_get_reactivation_labels() { + return [ + 'hot' => '🔥 ' . __('Sehr interessant', 'wp-multi'), + 'interesting' => '⭐ ' . __('Interessant', 'wp-multi'), + 'watch' => '🟡 ' . __('Beobachten', 'wp-multi'), + 'low' => '⚪ ' . __('Keine Priorität', 'wp-multi'), + 'current' => '– ' . __('Kein Bedarf', 'wp-multi'), + ]; +} + +/** + * Gibt eine Filterzeile mit Zählern aus. + * + * @param string $title Beschriftung der Zeile. + * @param string $param Name des URL-Parameters. + * @param array $labels Zuordnung "Wert => Beschriftung". + * @param array $counts Zuordnung "Wert => Anzahl". + * @param string $active Aktuell aktiver Wert ('' = alle). + * @param int $total Gesamtzahl für "Alle". + * @param string $link_url Basis-URL für die Links. + */ +function wp_multi_render_filter_row($title, $param, $labels, $counts, $active, $total, $link_url) { + ?> +

+ + + + + + + $label) : ?> + | + + + () + + + +

+ 'pm.meta_value', 'posts' => 'post_count']; + // Statusfilter und Reaktivierungsfilter aus URL lesen ('' = alle anzeigen) + $status_labels = wp_multi_get_guest_author_status_labels(); + $filter = isset($_GET['activity']) ? sanitize_key($_GET['activity']) : ''; + if (!isset($status_labels[$filter])) { + $filter = ''; + } + + $reactivation_labels = wp_multi_get_reactivation_labels(); + $potential = isset($_GET['potential']) ? sanitize_key($_GET['potential']) : ''; + if (!isset($reactivation_labels[$potential])) { + $potential = ''; + } + + // Aufrufe und Serien je Autor – jeweils null, wenn die Quelle fehlt + $views = wp_multi_get_guest_author_views(); + $show_views = is_array($views); + $series_stats = wp_multi_get_guest_author_series_stats(); + $show_series = is_array($series_stats); + + // Gültige Sortierfelder. 'status', 'views', 'series' und 'potential' werden in + // PHP sortiert, da sie nicht aus der Hauptabfrage stammen. + $valid_orderby = ['guest_author' => 'pm.meta_value', 'posts' => 'post_count', 'date' => 'last_post_date']; + $php_orderby = ['status', 'views', 'series', 'potential']; + if (!isset($valid_orderby[$orderby]) && !in_array($orderby, $php_orderby, true)) { + $orderby = 'guest_author'; + } $sql_orderby = isset($valid_orderby[$orderby]) ? $valid_orderby[$orderby] : 'pm.meta_value'; $sql_order = $order; - // Basis-URL für Sortier-Links - $base_url = add_query_arg(['page' => 'guest_author_overview'], admin_url('admin.php')); + // URLs für Sortier- und Filter-Links (Reiter, Filter und Sortierung bleiben erhalten) + $base_url = add_query_arg('tab', 'authors', $base_url); + $sort_url = $base_url; + if ($filter) { + $sort_url = add_query_arg('activity', $filter, $sort_url); + } + if ($potential) { + $sort_url = add_query_arg('potential', $potential, $sort_url); + } + + $filter_url = add_query_arg(['orderby' => $orderby, 'order' => $order], $base_url); + $status_url = $potential ? add_query_arg('potential', $potential, $filter_url) : $filter_url; + $potential_url = $filter ? add_query_arg('activity', $filter, $filter_url) : $filter_url; global $wpdb; $query = " - SELECT pm.meta_value AS guest_author, COUNT(*) AS post_count + SELECT pm.meta_value AS guest_author, COUNT(*) AS post_count, MAX(p.post_date) AS last_post_date FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id WHERE pm.meta_key = '_guest_author' AND pm.meta_value != '' AND p.post_status = 'publish' GROUP BY pm.meta_value - ORDER BY {$sql_orderby} {$sql_order} + ORDER BY {$sql_orderby} {$sql_order}, pm.meta_value ASC "; $guest_authors = $wpdb->get_results($query); - wpmt_admin_page_open( - __('Gast-Autoren', 'wp-multi'), - __('Alle Gast-Autoren mit Anzahl ihrer veröffentlichten Beiträge', 'wp-multi') - ); + // Durchschnittliche Aufrufe je Geschichte – Maßstab für "gute Aufrufe" + $avg_views = wp_multi_get_average_guest_author_views(); + + // Kennzahlen ergänzen, Autoren je Filterwert zählen und einschränken + $status_counts = array_fill_keys(array_keys($status_labels), 0); + $reactivation_counts = array_fill_keys(array_keys($reactivation_labels), 0); + $rows = []; + $position = 0; + + foreach ((array) $guest_authors as $author) { + $author->activity = wp_multi_get_guest_author_activity($author->last_post_date, $author->post_count); + $author->views = $show_views && isset($views[$author->guest_author]) ? $views[$author->guest_author] : 0; + $author->series_count = $show_series && isset($series_stats[$author->guest_author]) ? $series_stats[$author->guest_author]['series_count'] : 0; + $author->max_parts = $show_series && isset($series_stats[$author->guest_author]) ? $series_stats[$author->guest_author]['max_parts'] : 0; + $author->position = $position++; // Reihenfolge aus der DB (korrekte Sortierung inkl. Umlauten) + + $author->reactivation = wp_multi_get_guest_author_reactivation([ + 'post_count' => (int) $author->post_count, + 'days' => $author->activity ? $author->activity['days'] : 0, + 'views' => $author->views, + 'series_count' => $author->series_count, + 'max_parts' => $author->max_parts, + ], $avg_views); + + if ($author->activity) { + $status_counts[$author->activity['key']]++; + } + $reactivation_counts[$author->reactivation['key']]++; + + if ($filter !== '' && (!$author->activity || $author->activity['key'] !== $filter)) { + continue; + } + if ($potential !== '' && $author->reactivation['key'] !== $potential) { + continue; + } + + $rows[] = $author; + } + + // Sortierung nach berechneten Spalten: bei Gleichstand die DB-Reihenfolge behalten + if (in_array($orderby, $php_orderby, true)) { + usort($rows, function ($a, $b) use ($orderby, $order) { + switch ($orderby) { + case 'views': + $value_a = (int) $a->views; + $value_b = (int) $b->views; + break; + case 'series': + $value_a = (int) $a->series_count; + $value_b = (int) $b->series_count; + break; + case 'potential': + $value_a = (int) $a->reactivation['rank']; + $value_b = (int) $b->reactivation['rank']; + break; + default: + $value_a = $a->activity ? $a->activity['rank'] : 0; + $value_b = $b->activity ? $b->activity['rank'] : 0; + } + + if ($value_a === $value_b) { + return $a->position - $b->position; + } + + $diff = $value_a - $value_b; + + return ($order === 'DESC') ? -$diff : $diff; + }); + } + + $colspan = 4 + ($show_views ? 1 : 0) + ($show_series ? 1 : 0); wpmt_admin_card_open(); + + wp_multi_render_filter_row( + __('Status:', 'wp-multi'), + 'activity', + $status_labels, + $status_counts, + $filter, + count((array) $guest_authors), + $status_url + ); + + wp_multi_render_filter_row( + __('Reaktivierung:', 'wp-multi'), + 'potential', + $reactivation_labels, + $reactivation_counts, + $potential, + count((array) $guest_authors), + $potential_url + ); ?>
- - + + + + + + + + + + - - + + + activity; ?> - - + + + + + + + + + + - + + + +
- - - - - - - - - - - - - -
guest_author); ?>post_count); ?>guest_author, $base_url, 'authors'); ?>post_count)); ?> + series_count) : ?> + series_count)); ?> + + + + views)); ?> + + + last_post_date)); ?> + (last_post_date))))); ?>) + + + + + reactivation; ?> + + + + + + + + + +
+
+ +

+ + $tab, 'guest_author' => $name], $base_url); +} + +/** + * Gibt einen oder mehrere Autorennamen als Links zur Detailansicht aus. + * + * @param string $names Ein Name oder mehrere, mit ", " verbunden. + * @param string $base_url Basis-URL der Übersichtsseite. + * @param string $tab Aktueller Reiter. + * @param bool $split Bei true wird an ", " getrennt (GROUP_CONCAT-Listen). + */ +function wp_multi_render_guest_author_link($names, $base_url, $tab = 'authors', $split = false) { + if ($names === null || $names === '') { + echo ''; + return; + } + + $list = $split ? array_filter(array_map('trim', explode(',', $names))) : [$names]; + $links = []; + + foreach ($list as $name) { + $links[] = '' . esc_html($name) . ''; + } + + echo implode(', ', $links); +} + +/** + * Zeigt alles zu einem Gast-Autor auf einer Seite: Kennzahlen, Serien und + * Einzelgeschichten. Grundlage für die Entscheidung, wen man kontaktiert. + * + * @param string $base_url Basis-URL der Übersichtsseite. + * @param string $guest_author Name des Gast-Autors. + * @param string $tab Reiter, aus dem der Aufruf kam. + */ +function wp_multi_render_guest_author_detail($base_url, $guest_author, $tab = 'authors') { + global $wpdb; + + $back_url = add_query_arg('tab', $tab, $base_url); + + // Kennzahlen des Autors + $summary = $wpdb->get_row($wpdb->prepare(" + SELECT COUNT(*) AS post_count, MAX(p.post_date) AS last_post_date + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID + WHERE pm.meta_key = '_guest_author' + AND pm.meta_value = %s + AND p.post_status = 'publish' + ", $guest_author)); + + // Der Name stammt aus einem Meta-Feld und wird deshalb selbst escaped + // ausgegeben, statt ihn als Kartentitel durchzureichen. + wpmt_admin_card_open(__('Autoren-Details', 'wp-multi')); + ?> +

+

+ post_count) { + echo '

' . esc_html__('Zu diesem Namen wurden keine veröffentlichten Geschichten gefunden.', 'wp-multi') . '

'; + wpmt_admin_card_close(); + return; + } + + $post_count = (int) $summary->post_count; + $activity = wp_multi_get_guest_author_activity($summary->last_post_date, $post_count); + $milestone = wp_multi_get_guest_author_milestone($post_count); + + // Gesamtaufrufe aller Geschichten dieses Autors + $analytics_table = wp_multi_get_analytics_table(); + $total_views = null; + + if ($analytics_table) { + $total_views = (int) $wpdb->get_var($wpdb->prepare(" + SELECT COUNT(*) + FROM {$analytics_table} a + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = a.post_id AND pm.meta_key = '_guest_author' + WHERE a.action = 'view' + AND pm.meta_value = %s + ", $guest_author)); + } + ?> + + prepare('AND tt.parent = %d', $source['parent']) : ''; + + $series = $wpdb->get_results($wpdb->prepare(" + SELECT t.term_id, t.name AS series_name, + COUNT(DISTINCT p.ID) AS author_parts, + MAX(p.post_date) AS last_part_date + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' AND pm.meta_value = %s + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s {$parent_sql} + INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id + WHERE p.post_status = 'publish' + GROUP BY t.term_id, t.name + ORDER BY last_part_date DESC + ", $guest_author, $source['taxonomy'])); + + if ($series) { + // Gesamtteile der Serie (auch von anderen Autoren) und Aufrufe nachladen + $term_ids = []; + foreach ($series as $row) { + $term_ids[] = (int) $row->term_id; + } + $term_id_list = implode(',', $term_ids); + + $totals = $wpdb->get_results($wpdb->prepare(" + SELECT tt.term_id, COUNT(DISTINCT p.ID) AS total_parts + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s + WHERE p.post_status = 'publish' + AND tt.term_id IN ({$term_id_list}) + GROUP BY tt.term_id + ", $source['taxonomy'])); + + $total_parts = []; + foreach ((array) $totals as $total) { + $total_parts[(int) $total->term_id] = (int) $total->total_parts; + } + + wp_multi_add_series_details($series, $source['taxonomy']); + + // Beiträge in Serien merken, damit sie unten nicht doppelt auftauchen + $ids = $wpdb->get_col($wpdb->prepare(" + SELECT DISTINCT p.ID + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s + WHERE tt.term_id IN ({$term_id_list}) + ", $source['taxonomy'])); + + foreach ((array) $ids as $id) { + $series_post_ids[] = (int) $id; + } + ?> +

+
+ + + + + + + + + + + + + + term_id, $source['taxonomy']); + $parts = isset($total_parts[(int) $row->term_id]) ? $total_parts[(int) $row->term_id] : (int) $row->author_parts; + ?> + + + + + + + + + + +
+ + series_name); ?> + + series_name); ?> + + + + author_parts !== (int) $parts) : ?> + + author_parts); + ?> + + + + last_part_id) : ?> + + last_part_title !== '' ? $row->last_part_title : __('(ohne Titel)', 'wp-multi')); ?> +
+ + + last_part_date)); ?> + (last_part_date))))); ?>) + +
series_views)); ?>
+
+ get_results($wpdb->prepare(" + SELECT p.ID, p.post_title, p.post_date + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' + WHERE pm.meta_value = %s + AND p.post_status = 'publish' + {$exclude_sql} + ORDER BY p.post_date DESC + LIMIT %d + ", $guest_author, $limit)); + + // Aufrufe der angezeigten Geschichten + $post_views = []; + if ($analytics_table && $posts) { + $post_ids = []; + foreach ($posts as $row) { + $post_ids[] = (int) $row->ID; + } + $post_id_list = implode(',', $post_ids); + + $view_rows = $wpdb->get_results(" + SELECT post_id, COUNT(*) AS views + FROM {$analytics_table} + WHERE action = 'view' + AND post_id IN ({$post_id_list}) + GROUP BY post_id + "); + + foreach ((array) $view_rows as $view_row) { + $post_views[(int) $view_row->post_id] = (int) $view_row->views; + } + } + ?> +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + post_title !== '' ? $row->post_title : __('(ohne Titel)', 'wp-multi')); ?> + + post_date)); ?>ID]) ? $post_views[(int) $row->ID] : 0)); ?>
string, 'parent' => int, 'label' => string] oder null. + */ +function wp_multi_detect_series_source() { + // Feste Vorgabe per Filter, z. B. add_filter('wp_multi_series_taxonomy', fn() => 'meine-serien'); + $forced = apply_filters('wp_multi_series_taxonomy', ''); + if ($forced && taxonomy_exists($forced)) { + return ['taxonomy' => $forced, 'parent' => 0, 'label' => $forced]; + } + + // Mögliche Namen einer eigenen Serien-Taxonomie + $candidates = apply_filters('wp_multi_series_taxonomy_candidates', ['series', 'serie', 'serien', 'story-series']); + + foreach ($candidates as $candidate) { + if (!taxonomy_exists($candidate)) { + continue; + } + + $count = wp_count_terms(['taxonomy' => $candidate, 'hide_empty' => false]); + if (!is_wp_error($count) && (int) $count > 0) { + return ['taxonomy' => $candidate, 'parent' => 0, 'label' => $candidate]; + } + } + + // Zweites Modell: Oberkategorie mit einer Unterkategorie je Serie + foreach ($candidates as $candidate) { + $term = get_term_by('slug', $candidate, 'category'); + if (!$term || is_wp_error($term)) { + continue; + } + + $children = get_term_children($term->term_id, 'category'); + if (!is_wp_error($children) && !empty($children)) { + return [ + 'taxonomy' => 'category', + 'parent' => (int) $term->term_id, + /* translators: %s: Name der Oberkategorie */ + 'label' => sprintf(__('Unterkategorien von "%s"', 'wp-multi'), $term->name), + ]; + } + } + + return null; +} + +/** + * Gibt einen Spaltenkopf mit Sortier-Link aus. + * + * @param string $label Beschriftung der Spalte. + * @param string $key Sortierschlüssel dieser Spalte. + * @param string $orderby Aktuell aktive Sortierung. + * @param string $order Aktuelle Richtung (ASC/DESC). + * @param string $sort_url Basis-URL für den Link. + * @param string $default_order Richtung beim ersten Klick auf diese Spalte. + */ +function wp_multi_render_sort_link($label, $key, $orderby, $order, $sort_url, $default_order = 'ASC') { + $is_active = ($orderby === $key); + // Aktive Spalte kehrt die Richtung um, inaktive startet mit ihrem Standard + $next_order = $is_active ? (($order === 'ASC') ? 'DESC' : 'ASC') : $default_order; + $url = add_query_arg(['orderby' => $key, 'order' => $next_order], $sort_url); + + echo '' . esc_html($label); + if ($is_active) { + echo ' ' . (($order === 'ASC') ? '↑' : '↓') . ''; + } + echo ''; +} + +/** + * Liefert den Namen der Analytics-Tabelle, sofern sie existiert. + * + * @return string Tabellenname oder '' ohne Analytics-Modul. + */ +function wp_multi_get_analytics_table() { + static $table = null; + + if ($table !== null) { + return $table; + } + + if (!defined('WP_MULTI_ANALYTICS_TABLE')) { + $table = ''; + return $table; + } + + global $wpdb; + $name = WP_MULTI_ANALYTICS_TABLE; + $table = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $name)) ? $name : ''; + + return $table; +} + +/** + * Ergänzt Serien um letzten Teil und Aufrufzahlen. + * + * Läuft in eigenen Abfragen statt als weiterer JOIN in der Hauptabfrage: Ein + * zusätzlicher JOIN würde die Zeilen vervielfachen und die Teile-Zählung + * verfälschen. + * + * @param array $rows Serienzeilen mit term_id. + * @param string $taxonomy Taxonomie der Serien. + */ +function wp_multi_add_series_details(&$rows, $taxonomy) { + if (empty($rows)) { + return; + } + + global $wpdb; + + $term_ids = []; + foreach ($rows as $row) { + $term_ids[] = (int) $row->term_id; + } + $term_id_list = implode(',', $term_ids); + + // Letzter Teil je Serie: absteigend sortiert, der erste Treffer je Serie gewinnt + $parts = $wpdb->get_results($wpdb->prepare(" + SELECT tt.term_id, p.ID AS post_id, p.post_title + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s + WHERE p.post_status = 'publish' + AND tt.term_id IN ({$term_id_list}) + ORDER BY p.post_date DESC + ", $taxonomy)); + + $last_parts = []; + foreach ((array) $parts as $part) { + if (!isset($last_parts[$part->term_id])) { + $last_parts[$part->term_id] = $part; + } + } + + // Aufrufe der ganzen Serie und des letzten Teils + $series_views = []; + $post_views = []; + $analytics_table = wp_multi_get_analytics_table(); + + if ($analytics_table) { + $totals = $wpdb->get_results($wpdb->prepare(" + SELECT tt.term_id, COUNT(*) AS views + FROM {$analytics_table} a + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = a.post_id + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s + WHERE a.action = 'view' + AND tt.term_id IN ({$term_id_list}) + GROUP BY tt.term_id + ", $taxonomy)); + + foreach ((array) $totals as $total) { + $series_views[(int) $total->term_id] = (int) $total->views; + } + + $post_ids = []; + foreach ($last_parts as $part) { + $post_ids[] = (int) $part->post_id; + } + + if ($post_ids) { + $post_id_list = implode(',', $post_ids); + $single = $wpdb->get_results(" + SELECT post_id, COUNT(*) AS views + FROM {$analytics_table} + WHERE action = 'view' + AND post_id IN ({$post_id_list}) + GROUP BY post_id + "); + + foreach ((array) $single as $entry) { + $post_views[(int) $entry->post_id] = (int) $entry->views; + } + } + } + + foreach ($rows as $row) { + $term_id = (int) $row->term_id; + $last_part = isset($last_parts[$term_id]) ? $last_parts[$term_id] : null; + + $row->last_part_id = $last_part ? (int) $last_part->post_id : 0; + $row->last_part_title = $last_part ? $last_part->post_title : ''; + $row->series_views = isset($series_views[$term_id]) ? $series_views[$term_id] : 0; + $row->last_part_views = ($row->last_part_id && isset($post_views[$row->last_part_id])) ? $post_views[$row->last_part_id] : 0; + } +} + +/** + * Zeigt Serien, die seit längerem keinen neuen Teil bekommen haben. + * + * Beispiel: "Serie mit 7 Teilen – letzter Teil vor 11 Monaten". Damit sieht man + * im Backend sofort, wo sich Nachfragen beim Autor lohnt. + */ +function wp_multi_render_stalled_series($base_url = '') { + // Mindestens so viele Teile und so viele Tage ohne Fortsetzung + $min_parts = (int) apply_filters('wp_multi_stalled_series_min_parts', 2); + $min_days = (int) apply_filters('wp_multi_stalled_series_min_days', 180); + + if ($base_url === '') { + $base_url = add_query_arg(['page' => 'guest_author_overview'], admin_url('admin.php')); + } + $sort_url = add_query_arg('tab', 'series', $base_url); + + // Sortierparameter aus URL lesen. 'series_views' und 'last_part_views' stammen + // aus den Nachfragen und werden deshalb in PHP sortiert. + $valid_orderby = ['name' => 't.name', 'parts' => 'part_count', 'date' => 'last_part_date']; + $php_orderby = ['series_views', 'last_part_views']; + $orderby = isset($_GET['orderby']) ? sanitize_key($_GET['orderby']) : 'date'; + if (!isset($valid_orderby[$orderby]) && !in_array($orderby, $php_orderby, true)) { + $orderby = 'date'; + } + // Standard: neueste Fortsetzung oben + $order = isset($_GET['order']) && in_array(strtoupper($_GET['order']), ['ASC', 'DESC']) ? strtoupper($_GET['order']) : 'DESC'; + $sql_orderby = isset($valid_orderby[$orderby]) ? $valid_orderby[$orderby] : 'last_part_date'; + + wpmt_admin_card_open(__('Liegengebliebene Serien', 'wp-multi')); + + $source = wp_multi_detect_series_source(); + + if (!$source) { + wp_multi_render_series_source_hint(); + wpmt_admin_card_close(); + return; + } + + global $wpdb; + + // Bei Unterkategorien nur die Kinder der Oberkategorie berücksichtigen + $parent_sql = $source['parent'] ? $wpdb->prepare('AND tt.parent = %d', $source['parent']) : ''; + + $series = $wpdb->get_results($wpdb->prepare(" + SELECT t.term_id, t.name AS series_name, + COUNT(DISTINCT p.ID) AS part_count, + MAX(p.post_date) AS last_part_date, + GROUP_CONCAT(DISTINCT pm.meta_value ORDER BY pm.meta_value SEPARATOR ', ') AS authors + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id + INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = %s {$parent_sql} + INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id + LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' AND pm.meta_value != '' + WHERE p.post_status = 'publish' + GROUP BY t.term_id, t.name + HAVING part_count >= %d + ORDER BY {$sql_orderby} {$order}, last_part_date DESC + ", $source['taxonomy'], $min_parts)); + + // Nur Serien behalten, deren letzter Teil lange genug zurückliegt + $stalled = []; + foreach ((array) $series as $row) { + $last_ts = strtotime(get_gmt_from_date($row->last_part_date)); + if (!$last_ts) { + continue; + } + + $row->days = (int) floor((time() - $last_ts) / DAY_IN_SECONDS); + if ($row->days > $min_days) { + $stalled[] = $row; + } + } + + // Letzten Teil und Aufrufe nur für die tatsächlich angezeigten Serien nachladen + wp_multi_add_series_details($stalled, $source['taxonomy']); + + // Sortierung nach Aufrufen: bei Gleichstand die DB-Reihenfolge behalten + if (in_array($orderby, $php_orderby, true)) { + $position = 0; + foreach ($stalled as $row) { + $row->position = $position++; + } + + usort($stalled, function ($a, $b) use ($orderby, $order) { + $value_a = (int) $a->$orderby; + $value_b = (int) $b->$orderby; + + if ($value_a === $value_b) { + return $a->position - $b->position; + } + + $diff = $value_a - $value_b; + + return ($order === 'DESC') ? -$diff : $diff; + }); + } + + $show_views = (bool) wp_multi_get_analytics_table(); + $colspan = $show_views ? 6 : 4; + ?> +

+ +

+
+ + + + + + + + + + + + + + + + + term_id, $source['taxonomy']); ?> + + + + + + + + + + + + + + + +
+ + series_name); ?> + + series_name); ?> + + part_count; ?> + last_part_id) : ?> + + last_part_title !== '' ? $row->last_part_title : __('(ohne Titel)', 'wp-multi')); ?> +
+ + + last_part_date)); ?> + (last_part_date))))); ?>) + +
series_views)); ?>last_part_views)); ?>authors, $base_url, 'series', true); ?>
+
+ +

+ + true], 'objects'); + ?> +

+

+
+ + + + + + + + + + + $taxonomy->name, 'hide_empty' => false]); ?> + + + + + + + +
name); ?>labels->name); ?>
+
+

+ + add_filter('wp_multi_series_taxonomy', function () { return 'taxonomie-name'; }); +

+ Zeilen, 'post_ids' => IDs aller Teile dieser Serien] + */ +function wp_multi_get_guest_author_series($guest_author) { + $empty = ['series' => [], 'post_ids' => []]; + $source = wp_multi_detect_series_source(); + + if (!$source || $guest_author === '') { + return $empty; + } + + global $wpdb; + $parent_sql = $source['parent'] ? $wpdb->prepare('AND tt.parent = %d', $source['parent']) : ''; + + $series = $wpdb->get_results($wpdb->prepare(" + SELECT t.term_id, t.name AS series_name, + COUNT(DISTINCT p.ID) AS author_parts, + MAX(p.post_date) AS last_part_date + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' AND pm.meta_value = %s + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s {$parent_sql} + INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id + WHERE p.post_status = 'publish' + GROUP BY t.term_id, t.name + ORDER BY last_part_date DESC + ", $guest_author, $source['taxonomy'])); + + if (!$series) { + return $empty; + } + + $term_ids = []; + foreach ($series as $row) { + $term_ids[] = (int) $row->term_id; + } + $term_id_list = implode(',', $term_ids); + + // Gesamtlänge der Serie – auch Teile anderer Autoren zählen dazu + $totals = $wpdb->get_results($wpdb->prepare(" + SELECT tt.term_id, COUNT(DISTINCT p.ID) AS total_parts + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s + WHERE p.post_status = 'publish' + AND tt.term_id IN ({$term_id_list}) + GROUP BY tt.term_id + ", $source['taxonomy'])); + + $total_parts = []; + foreach ((array) $totals as $total) { + $total_parts[(int) $total->term_id] = (int) $total->total_parts; + } + + foreach ($series as $row) { + $row->total_parts = isset($total_parts[(int) $row->term_id]) ? $total_parts[(int) $row->term_id] : (int) $row->author_parts; + $row->taxonomy = $source['taxonomy']; + } + + // Alle Beiträge dieser Serien – damit sie in der Einzelliste nicht doppelt stehen + $post_ids = $wpdb->get_col($wpdb->prepare(" + SELECT DISTINCT p.ID + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s + WHERE tt.term_id IN ({$term_id_list}) + ", $source['taxonomy'])); + + return [ + 'series' => $series, + 'post_ids' => array_map('intval', (array) $post_ids), + ]; +} + +/** + * Findet die Seite, auf der die Autorenübersicht steht. + * + * Gesucht wird die Seite mit dem Shortcode [guest_authors]; das Ergebnis wird + * zwischengespeichert, damit die Suche nicht bei jedem Profilaufruf läuft. + * + * @return string URL oder '' wenn keine Übersichtsseite existiert. + */ +function wp_multi_get_guest_author_overview_url() { + $url = apply_filters('wp_multi_author_overview_url', ''); + + if ($url) { + return $url; + } + + $cached = get_transient('wp_multi_author_overview_page'); + + if ($cached === false) { + global $wpdb; + + $cached = (int) $wpdb->get_var(" + SELECT ID + FROM {$wpdb->posts} + WHERE post_type = 'page' + AND post_status = 'publish' + AND post_content LIKE '%[guest_authors%' + ORDER BY ID ASC + LIMIT 1 + "); + + set_transient('wp_multi_author_overview_page', $cached, 12 * HOUR_IN_SECONDS); + } + + return $cached ? get_permalink($cached) : ''; +} + // Shortcode für Gastautoren-Liste -function wp_multi_guest_author_shortcode() { +function wp_multi_guest_author_shortcode($atts = []) { ob_start(); global $wpdb; - // Alle Gastautoren mit Beitragsanzahl abrufen + // Erlaubte Sortierfelder (Whitelist – verhindert SQL-Injection) + $valid_orderby = [ + 'name' => 'pm.meta_value', + 'posts' => 'post_count', + 'date' => 'last_post_date', + 'new' => 'first_post_date', + ]; + // Sinnvolle Standardrichtung je Spalte + $default_order = ['name' => 'ASC', 'posts' => 'DESC', 'date' => 'DESC', 'new' => 'DESC']; + + // Voreinstellung über Shortcode-Attribute, z. B. [guest_authors orderby="date" order="desc"] + $atts = shortcode_atts(['orderby' => 'name', 'order' => ''], $atts, 'guest_authors'); + + // URL-Parameter haben Vorrang vor den Shortcode-Attributen + $orderby = isset($_GET['ga_orderby']) ? sanitize_key(wp_unslash($_GET['ga_orderby'])) : sanitize_key($atts['orderby']); + if (!isset($valid_orderby[$orderby])) { + $orderby = 'name'; + } + + $order_raw = isset($_GET['ga_order']) ? sanitize_key(wp_unslash($_GET['ga_order'])) : sanitize_key($atts['order']); + $order = in_array(strtoupper($order_raw), ['ASC', 'DESC'], true) ? strtoupper($order_raw) : $default_order[$orderby]; + + $sql_orderby = $valid_orderby[$orderby]; + + // Alle Gastautoren mit Beitragsanzahl, erster und letzter Veröffentlichung $query = " - SELECT pm.meta_value AS guest_author, COUNT(*) AS post_count + SELECT pm.meta_value AS guest_author, COUNT(*) AS post_count, + MAX(p.post_date) AS last_post_date, MIN(p.post_date) AS first_post_date FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id - WHERE pm.meta_key = '_guest_author' - AND pm.meta_value != '' + WHERE pm.meta_key = '_guest_author' + AND pm.meta_value != '' AND p.post_status = 'publish' GROUP BY pm.meta_value - ORDER BY pm.meta_value ASC + ORDER BY {$sql_orderby} {$order}, pm.meta_value ASC "; $guest_authors = $wpdb->get_results($query); + // Serien je Autor (leer, wenn es keine Serien-Taxonomie gibt) + $series_stats = wp_multi_get_guest_author_series_stats(); + // URL der Gastautoren-Beitragsseite $guest_author_page_id = get_option('wp_multi_guest_author_page_id'); $guest_author_page_url = $guest_author_page_id ? get_permalink($guest_author_page_id) : '#'; + // Beschriftungen der Sortier-Schaltflächen + $sort_labels = [ + 'name' => __('Name', 'wp-multi'), + 'posts' => __('Meiste Geschichten', 'wp-multi'), + 'date' => __('Zuletzt veröffentlicht', 'wp-multi'), + 'new' => __('Neueste Autoren', 'wp-multi'), + ]; + ?>
+ +
+ + $label) : ?> + $key, 'ga_order' => strtolower($next_order)]) . '#guest-author-section'; + ?> + + + + + + + +
+
- -

guest_author); ?>

-

post_count); ?>

+ post_count); + $series_count = (is_array($series_stats) && isset($series_stats[$author->guest_author])) ? $series_stats[$author->guest_author]['series_count'] : 0; + ?> +
+

+ guest_author); ?> + + + +

+

+ post_count, 'wp-multi')), esc_html(number_format_i18n($author->post_count))); + ?> + + · + + +

+ last_post_date)) : ?> +

last_post_date))); ?>

+
@@ -231,80 +1855,178 @@ function wp_multi_guest_author_shortcode() { } add_shortcode('guest_authors', 'wp_multi_guest_author_shortcode'); -// Shortcode für Gastautoren-Beiträge +// Shortcode für das Autorenprofil function wp_multi_guest_author_posts_shortcode() { ob_start(); + global $wpdb; $guest_author = isset($_GET['guest_author']) ? sanitize_text_field(wp_unslash($_GET['guest_author'])) : ''; + $post_count = $guest_author ? wp_multi_get_guest_author_post_count($guest_author) : 0; + $overview_url = wp_multi_get_guest_author_overview_url(); + + if (!$guest_author || !$post_count) { + ?> +
+

+ +

+
+ get_results($wpdb->prepare(" + SELECT p.ID, p.post_title, p.post_date + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' + WHERE pm.meta_value = %s + AND p.post_status = 'publish' + {$exclude_sql} + ORDER BY p.post_date DESC + LIMIT %d + ", $guest_author, $latest_limit)); + + // Beliebteste Geschichten – nur wenn Aufrufe vorliegen + $popular = []; + $analytics_table = wp_multi_get_analytics_table(); + + if ($analytics_table) { + $popular_limit = (int) apply_filters('wp_multi_author_profile_popular_limit', 5); + + $popular = $wpdb->get_results($wpdb->prepare(" + SELECT p.ID, p.post_title, COUNT(*) AS views + FROM {$analytics_table} a + INNER JOIN {$wpdb->posts} p ON p.ID = a.post_id + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_guest_author' + WHERE a.action = 'view' + AND pm.meta_value = %s + AND p.post_status = 'publish' + GROUP BY p.ID, p.post_title + ORDER BY views DESC + LIMIT %d + ", $guest_author, $popular_limit)); + } ?>
-

- + +

+ + +

+ + + +

-
+ +

['post', 'page', 'dein_custom_post_type'], - 'post_status' => 'publish', - 'meta_query' => array( - array( - 'key' => '_guest_author', - 'value' => $guest_author, - 'compare' => '=' - ) - ), - 'tax_query' => array( - array( - 'taxonomy' => 'category', - 'field' => 'slug', - 'terms' => 'series', - 'operator' => 'NOT IN' - ) - ), - 'posts_per_page' => -1 - ); - - $query = new WP_Query($args); - - - - if ($query->have_posts()) { - while ($query->have_posts()) { - $query->the_post(); - // Beitragsinhalt abrufen und kürzen - $content = get_the_content(); - $content = wp_strip_all_tags($content); // HTML-Tags entfernen - $trimmed_content = wp_trim_words($content, 30, '...'); - ?> -

-

- -

-

-
- -

- -

- -
+ + · + +

+ + +

+ + + + +

+ + +

+ +

+ + + +

+
+ + term_id, $row->taxonomy); ?> +
+

+ + series_name); ?> + + series_name); ?> + +

+

+ total_parts, 'wp-multi')), esc_html(number_format_i18n($row->total_parts))); + ?> + · + last_part_date))); ?> +

+
+ +
+ + + +

+ + + + +

+
+ +
+

+ ID)); ?> +

+

post_date)); ?>

+
+ +
+