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 . '
+
+
+
+ ' . $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;
}