From 3a0acc49a22e1fa109d51f8267a361c9a9fca7ba Mon Sep 17 00:00:00 2001 From: hbrain Date: Sun, 7 Jun 2026 13:06:43 +0200 Subject: [PATCH] Added listmonk API call for published article --- admin.php | 4 + lib/listmonk.php | 129 ++++++++++++++++++++++++++ lib/send_push.php | 229 +++++++++++++++++++++++++++++++--------------- 3 files changed, 289 insertions(+), 73 deletions(-) create mode 100644 lib/listmonk.php diff --git a/admin.php b/admin.php index 7ebc3b3..abf9c97 100644 --- a/admin.php +++ b/admin.php @@ -1916,7 +1916,10 @@ async function handleSwitchToggle(form, checkbox){ }else{ data.delete(checkbox.name); } + const useBusyOverlay = form.dataset.switch === 'show'; + const busyLabel = checkbox.checked ? 'Showing article…' : 'Hiding article…'; checkbox.disabled = true; + if(useBusyOverlay) showThumbBusy(busyLabel); try{ const res = await fetch('admin.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }}); const json = await res.json().catch(() => ({})); @@ -1929,6 +1932,7 @@ async function handleSwitchToggle(form, checkbox){ showToast(e.message || 'Switch update failed', false); }finally{ checkbox.disabled = false; + if(useBusyOverlay) setTimeout(hideThumbBusy, 120); } } diff --git a/lib/listmonk.php b/lib/listmonk.php new file mode 100644 index 0000000..85713f5 --- /dev/null +++ b/lib/listmonk.php @@ -0,0 +1,129 @@ + + ' . $safeTitle . ' + + +
+

+ ' . $safeTitle . ' +

+ +

+ ' . $safeExcerpt . ' +

+
+ +

+ + Read the story + +

+'; + + $payload = [ + 'name' => 'New article: ' . $title, + 'subject' => $newsletterTitle, + 'lists' => [$listId], + 'from_email' => 'MVLog ', + 'type' => 'regular', + 'content_type' => 'html', + 'body' => $body, + 'template_id' => $templateId, + 'messenger' => 'email', + 'status' => 'draft', + 'data' => [ + 'newsletter_title' => $newsletterTitle, + ], + 'headers' => [ + ['Reply-To' => 'mvlog@bubulescu.org'] + ], + ]; + + $json = json_encode($payload); + + if (function_exists('curl_init')) { + $ch = curl_init($base . '/api/campaigns'); + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERPWD => $user . ':' . $pass, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $json, + ]); + + $response = curl_exec($ch); + + if ($response === false) { + $error = curl_error($ch); + curl_close($ch); + throw new Exception('Listmonk API request failed: ' . $error); + } + + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + } else { + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json +Authorization: Basic " . base64_encode($user . ':' . $pass) . " +", + 'content' => $json, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + $response = @file_get_contents($base . '/api/campaigns', false, $context); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int)$m[1]; + } + if ($response === false) { + throw new Exception('Listmonk API request failed.'); + } + } + + $decoded = json_decode($response, true); + + if ($code < 200 || $code >= 300) { + throw new Exception('Listmonk campaign create failed. HTTP ' . $code . ': ' . $response); + } + + return $decoded; +} \ No newline at end of file diff --git a/lib/send_push.php b/lib/send_push.php index 7c87366..243deb7 100644 --- a/lib/send_push.php +++ b/lib/send_push.php @@ -86,6 +86,80 @@ function mvlog_read_article_id(string $jobdir): string return mvlog_article_id_is_valid($value) ? $value : ''; } +function mvlog_read_job_data_fields(string $jobdir): array +{ + $data = ['title' => '', 'teaser' => '', 'description' => '']; + $file = rtrim($jobdir, '/') . '/data.txt'; + if (!is_file($file) || !is_readable($file)) return $data; + + $lines = @file($file, FILE_IGNORE_NEW_LINES); + if (!is_array($lines)) return $data; + + $current = ''; + foreach ($lines as $line) { + $trim = trim((string)$line); + if ($trim === '') { + if ($current === 'description') $data['description'] .= "\n"; + continue; + } + + if (preg_match('/^([A-Za-z_ -]+):\s*(.*)$/', $trim, $m)) { + $key = strtolower(trim((string)$m[1])); + $value = trim((string)$m[2]); + if ($key === 'title') { $data['title'] = $value; $current = 'title'; } + elseif (in_array($key, ['teaser', 'tagline', 'subtitle'], true)) { $data['teaser'] = $value; $current = 'teaser'; } + elseif (in_array($key, ['description', 'desc', 'text'], true)) { $data['description'] = $value; $current = 'description'; } + else { $current = ''; } + } elseif ($current === 'description') { + $data['description'] .= ($data['description'] !== '' ? "\n" : '') . $trim; + } + } + + foreach ($data as $k => $v) $data[$k] = trim((string)$v); + return $data; +} + +function mvlog_random_newsletter_title(string $mvlog_root, string $logfile): string +{ + $titles_file = rtrim($mvlog_root, '/') . '/header_titles.php'; + $titles = is_file($titles_file) && is_readable($titles_file) ? @require $titles_file : []; + if (!is_array($titles)) $titles = []; + + $clean = []; + foreach ($titles as $title) { + $title = trim((string)$title); + if ($title !== '') $clean[] = $title; + } + + if (!$clean) { + error_log("[mvlog] header_titles.php missing/empty, using default newsletter title\n", 3, $logfile); + return 'Mistakes, Views & Luck'; + } + + return $clean[array_rand($clean)]; +} + +function mvlog_try_create_journal_campaign(string $articleId, string $articleTitle, string $articleUrl, string $articleExcerpt, string $newsletterTitle, string $mvlog_root, string $logfile): void +{ + $helper = $mvlog_root . '/lib/listmonk.php'; + if (!is_file($helper) || !is_readable($helper)) { + error_log("[mvlog] listmonk helper missing at $helper, skipping journal campaign\n", 3, $logfile); + return; + } + + try { + include_once $helper; + if (!function_exists('listmonkCreateCampaign')) { + error_log("[mvlog] listmonkCreateCampaign missing in $helper, skipping journal campaign\n", 3, $logfile); + return; + } + listmonkCreateCampaign($articleId, $articleUrl, $articleTitle, $articleExcerpt, $newsletterTitle); + error_log("[mvlog] journal campaign created for $articleUrl\n", 3, $logfile); + } catch (Throwable $e) { + error_log("[mvlog] journal campaign failed for $articleUrl: " . $e->getMessage() . "\n", 3, $logfile); + } +} + function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = null, $logfile = null) { if ($mvlog_root === null) $mvlog_root = dirname(__DIR__); @@ -98,34 +172,18 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul $articleRef = strtolower(trim((string)$articleRefOrJob)); $articleId = mvlog_article_id_is_valid($articleRef) ? $articleRef : mvlog_read_article_id((string)$jobdir); if ($articleId === '') { - error_log("[mvlog] article id missing, skipping push for $job\n", 3, $logfile); + error_log("[mvlog] article id missing, skipping announcement for $job\n", 3, $logfile); return false; } $identity = $articleId; - // Don't notify for hidden jobs + // Don't announce hidden jobs. if (is_file($jobdir . '/.mvlog-hidden')) { - error_log("[mvlog] job $job hidden, skipping push\n", 3, $logfile); + error_log("[mvlog] job $job hidden, skipping announcement\n", 3, $logfile); return false; } - // Gather subscriptions - if (!is_file($subs_file)) { - error_log("[mvlog] subscriptions file missing, skipping push for $identity\n", 3, $logfile); - return false; - } - $subs_raw = @file_get_contents($subs_file); - if (!is_string($subs_raw) || trim($subs_raw) === '') { - error_log("[mvlog] subscriptions empty, skipping push for $identity\n", 3, $logfile); - return false; - } - $subs = json_decode($subs_raw, true); - if (!is_array($subs) || count($subs) === 0) { - error_log("[mvlog] subscriptions invalid/empty, skipping push for $identity\n", 3, $logfile); - return false; - } - - // Read cache and check if already shown + // Read cache and check if already announced. $cache = []; if (is_file($cache_file)) { $cache = json_decode((string)@file_get_contents($cache_file), true) ?: []; @@ -133,24 +191,11 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul $shown = isset($cache['shown']) && is_array($cache['shown']) ? $cache['shown'] : []; // Transition support: honor both new (ID) and legacy (job name) keys. if (isset($shown[$identity]) || ($identity !== $job && isset($shown[$job]))) { - error_log("[mvlog] push already sent for $identity (job=$job), skipping\n", 3, $logfile); + error_log("[mvlog] announcement already sent for $identity (job=$job), skipping\n", 3, $logfile); return false; } - // Resolve push config consistently (same source model as push_config.php) - $cfg = mvlog_load_push_config($mvlog_root); - if (!$cfg) { - error_log("[mvlog] push config not found/invalid, skipping push for $identity\n", 3, $logfile); - return false; - } - - $tmp_push_config = mvlog_write_temp_push_json($cfg); - if (!$tmp_push_config) { - error_log("[mvlog] failed to create temp push config for $identity\n", 3, $logfile); - return false; - } - - // Determine article title (used both in notification text and filtered URL). + // Determine article title (used in push text and journal campaign). $articleTitle = mvlog_nice_title((string)$job); if (preg_match('/^\d{8}[_-](.+)$/', (string)$job, $m)) { $articleTitle = mvlog_nice_title((string)$m[1]); @@ -173,59 +218,97 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul if ($articleTitle === '') $articleTitle = mvlog_nice_title((string)$job); + $jobData = mvlog_read_job_data_fields((string)$jobdir); + $journalTitle = (string)($jobData['title'] !== '' ? $jobData['title'] : $articleTitle); + $newsletterTitle = mvlog_random_newsletter_title($mvlog_root, $logfile); + $articleExcerpt = (string)($jobData['teaser'] !== '' ? $jobData['teaser'] : $jobData['description']); + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + if (mb_strlen($articleExcerpt) > 500) $articleExcerpt = rtrim(mb_substr($articleExcerpt, 0, 497)) . '…'; + } elseif (strlen($articleExcerpt) > 500) { + $articleExcerpt = rtrim(substr($articleExcerpt, 0, 497)) . '...'; + } + $articleUrl = is_file($jobdir . '/.mvlog-permalink') ? './?id=' . rawurlencode($articleId) : './'; + $journalUrl = 'https://bubulescu.org/' . (is_file($jobdir . '/.mvlog-permalink') ? '?id=' . rawurlencode($articleId) : ''); - $payload = json_encode([ - 'title' => $articleTitle, - 'body' => 'New unreliable article @ MVLog!', - 'url' => $articleUrl, - 'tag' => "mvlog-show-{$identity}", - ], JSON_UNESCAPED_UNICODE); + // Attempt web-push, but never let push failure prevent the journal/Listmonk campaign. + $pushOk = false; + $pushStatus = 'skipped'; + $cfgSource = 'none'; - // Call node send_push.js with subscriptions on stdin - $send_script = escapeshellarg($mvlog_root . '/send_push.js'); - $push_config_esc = escapeshellarg($tmp_push_config); - $payload_esc = escapeshellarg((string)$payload); - $cmd = "node $send_script $push_config_esc $payload_esc"; + $subs_raw = is_file($subs_file) ? @file_get_contents($subs_file) : false; + $subs = is_string($subs_raw) && trim($subs_raw) !== '' ? json_decode($subs_raw, true) : null; + if (!is_array($subs) || count($subs) === 0) { + error_log("[mvlog] subscriptions missing/empty/invalid, skipping web-push for $identity\n", 3, $logfile); + } else { + $cfg = mvlog_load_push_config($mvlog_root); + if (!$cfg) { + error_log("[mvlog] push config not found/invalid, skipping web-push for $identity\n", 3, $logfile); + } else { + $cfgSource = (string)($cfg['_source'] ?? 'unknown'); + $tmp_push_config = mvlog_write_temp_push_json($cfg); + if (!$tmp_push_config) { + error_log("[mvlog] failed to create temp push config for $identity\n", 3, $logfile); + } else { + $payload = json_encode([ + 'title' => $articleTitle, + 'body' => 'New unreliable article @ MVLog!', + 'url' => $articleUrl, + 'tag' => "mvlog-show-{$identity}", + ], JSON_UNESCAPED_UNICODE); - $descriptorspec = [ - 0 => ['pipe', 'r'], // stdin - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr - ]; + $send_script = escapeshellarg($mvlog_root . '/send_push.js'); + $push_config_esc = escapeshellarg($tmp_push_config); + $payload_esc = escapeshellarg((string)$payload); + $cmd = "node $send_script $push_config_esc $payload_esc"; - $proc = proc_open($cmd, $descriptorspec, $pipes); - if (!is_resource($proc)) { - @unlink($tmp_push_config); - error_log("[mvlog] failed to start node send_push process for $identity\n", 3, $logfile); - return false; + $descriptorspec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = proc_open($cmd, $descriptorspec, $pipes); + if (!is_resource($proc)) { + error_log("[mvlog] failed to start node send_push process for $identity\n", 3, $logfile); + $pushStatus = 'failed-start'; + } else { + fwrite($pipes[0], (string)$subs_raw); + fclose($pipes[0]); + + $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]); + + $rc = proc_close($proc); + if ($rc === 0) { + $pushOk = true; + $pushStatus = 'sent'; + } else { + $pushStatus = 'failed'; + error_log("[mvlog] send_push failed for $identity rc=$rc stdout=" . trim((string)$stdout) . " stderr=" . trim((string)$stderr) . "\n", 3, $logfile); + } + } + @unlink($tmp_push_config); + } + } } - // Write subscriptions to stdin - fwrite($pipes[0], $subs_raw); - fclose($pipes[0]); + // Always attempt the journal/Listmonk campaign for the same eligible trigger. + mvlog_try_create_journal_campaign($articleId, $journalTitle, $journalUrl, $articleExcerpt, $newsletterTitle, $mvlog_root, $logfile); - $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]); - $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]); - - $rc = proc_close($proc); - @unlink($tmp_push_config); - - if ($rc !== 0) { - error_log("[mvlog] send_push failed for $identity rc=$rc stdout=" . trim((string)$stdout) . " stderr=" . trim((string)$stderr) . "\n", 3, $logfile); - return false; - } - - // On success, record in cache['shown'] + // Record the announcement so the same article is not announced repeatedly. if (!is_array($cache)) $cache = []; + unset($cache['sent']); if (!isset($cache['shown']) || !is_array($cache['shown'])) $cache['shown'] = []; $cache['shown'][$identity] = [ 'sent_at' => date(DATE_ATOM), 'job' => $job, 'article_id' => $articleId, + 'title' => $journalTitle, + 'newsletter_title' => $newsletterTitle, + 'push' => ($pushOk ? 'successful' : 'failed'), ]; - // Atomic write: write to tmp then rename $tmp = $cache_file . '.tmp.' . bin2hex(random_bytes(6)); $json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; if (@file_put_contents($tmp, $json, LOCK_EX) === false) { @@ -239,6 +322,6 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul return false; } - error_log("[mvlog] show-notification sent: identity=$identity job=$job (cfg=" . ($cfg['_source'] ?? 'unknown') . ")\n", 3, $logfile); + error_log("[mvlog] announcement processed: identity=$identity job=$job push=$pushStatus (cfg=$cfgSource)\n", 3, $logfile); return true; }