movmaker-webui/admin.php

2287 lines
122 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
require __DIR__ . '/auth.php';
mvlog_require_login();
$config = require __DIR__ . "/config.php";
foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true);
if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true);
const ARTICLE_ID_FILE = '.mvlog-id';
const MAP_HIDDEN_FILE = '.mvlog-hide-map';
const PERMALINK_FILE = '.mvlog-permalink';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function ajax_json($data){ header('Content-Type: application/json; charset=UTF-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; }
function lower_text($s){ return function_exists('mb_strtolower') ? mb_strtolower((string)$s, 'UTF-8') : strtolower((string)$s); }
function fold_text($s){
$s = strtr((string)$s, [
'æ'=>'ae','Æ'=>'ae','ø'=>'o','Ø'=>'o','å'=>'aa','Å'=>'aa',
'ä'=>'ae','Ä'=>'ae','ö'=>'oe','Ö'=>'oe','ü'=>'ue','Ü'=>'ue','ß'=>'ss','ẞ'=>'ss',
'č'=>'c','Č'=>'c','ć'=>'c','Ć'=>'c','ž'=>'z','Ž'=>'z','š'=>'s','Š'=>'s','đ'=>'d','Đ'=>'d',
]);
if (function_exists('iconv')) {
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($x !== false) $s = $x;
}
$s = lower_text($s);
return preg_replace('/[^a-z0-9]+/', ' ', $s) ?? '';
}
function ci_contains($haystack, $needle){
$haystack = (string)$haystack;
$needle = (string)$needle;
if ($needle === '') return true;
if (function_exists('mb_stripos') && mb_stripos($haystack, $needle, 0, 'UTF-8') !== false) return true;
if (strpos(lower_text($haystack), lower_text($needle)) !== false) return true;
$foldHay = fold_text($haystack);
$foldNeedle = trim(fold_text($needle));
if ($foldNeedle === '') return false;
return strpos($foldHay, $foldNeedle) !== false;
}
function ascii_safe($s){
$s = strtr((string)$s, [
'æ'=>'ae','Æ'=>'Ae','ø'=>'o','Ø'=>'O','å'=>'aa','Å'=>'Aa',
'ä'=>'ae','Ä'=>'Ae','ö'=>'oe','Ö'=>'Oe','ü'=>'ue','Ü'=>'Ue','ß'=>'ss','ẞ'=>'SS',
'č'=>'c','Č'=>'C','ć'=>'c','Ć'=>'C','ž'=>'z','Ž'=>'Z','š'=>'s','Š'=>'S','đ'=>'d','Đ'=>'D',
]);
if (function_exists('iconv')) {
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($x !== false) $s = $x;
}
return preg_replace('/[^\x20-\x7E]/', '', $s) ?? '';
}
function slugify($s){ $s = ascii_safe($s); $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '-', $s); return trim($s, '-') ?: 'movie'; }
function rrmdir($dir){ if(!is_dir($dir)) return; foreach(scandir($dir) as $f){ if($f==='.'||$f==='..') continue; $p="$dir/$f"; is_dir($p)?rrmdir($p):unlink($p);} rmdir($dir); }
function apply_thumbnail_overlay($imagePath, $title, $teaser) {
$logFile = __DIR__ . '/logs/image_magick.log';
file_put_contents($logFile, "--- New Thumbnail Job ---
", FILE_APPEND);
$fontPath = __DIR__ . '/assets/fonts/IBMPlexSans-SemiBold.ttf';
$titleFontPath = is_file(__DIR__ . '/assets/fonts/BebasNeue-Regular.ttf')
? __DIR__ . '/assets/fonts/BebasNeue-Regular.ttf'
: $fontPath;
$teaserFontPath = is_file(__DIR__ . '/assets/fonts/NotoSans-Bold.ttf')
? __DIR__ . '/assets/fonts/NotoSans-Bold.ttf'
: $fontPath;
if (!is_file($fontPath)) {
throw new RuntimeException('Thumbnail font file is missing.');
}
$scale = 3;
$baseW = 1200;
$baseH = 630;
$imgW = $baseW * $scale;
$imgH = $baseH * $scale;
$targetW = $baseW;
$targetH = $baseH;
$normalize = static function ($text): string {
$text = trim((string)$text);
if ($text === '') return '';
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
};
$wrapText = static function ($text, int $width) use ($normalize): string {
$text = $normalize($text);
return $text === '' ? '' : wordwrap($text, $width, "
", false);
};
$clampWrappedText = static function ($text, int $width, int $maxLines, bool $cutLongWords = false) use ($normalize): string {
$text = $normalize($text);
if ($text === '') return '';
$words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if (!$words) return '';
$candidate = implode(' ', $words);
while (true) {
$wrapped = wordwrap($candidate, $width, "
", $cutLongWords);
$lineCount = substr_count($wrapped, "
") + 1;
if ($lineCount <= $maxLines || count($words) <= 1) return $wrapped;
array_pop($words);
$candidate = implode(' ', $words) . '…';
}
};
$fitTitleText = static function ($text) use ($wrapText): array {
$bestText = '';
$bestSize = 95;
$bestWidth = 28;
$bestLines = 1;
for ($size = 84; $size >= 54; $size -= 4) {
$width = max(16, min(46, (int)round(23 + (84 - $size) / 2.7)));
$candidate = $wrapText($text, $width);
$lines = substr_count($candidate, "
") + 1;
$bestText = $candidate;
$bestSize = $size;
$bestWidth = $width;
$bestLines = $lines;
if ($lines <= 2) break;
}
return [$bestText, $bestSize, $bestWidth, $bestLines];
};
[$titleText, $titleFontSizeBase, $titleWrapWidth, $titleLines] = $fitTitleText($title);
$teaserText = $clampWrappedText($teaser, 34, 6, true);
$teaserLines = max(1, substr_count($teaserText, "
") + 1);
$baseLeft = max(14, (int)round($baseW * 0.046));
$baseTitleY = max(20, (int)round($baseH * 0.062));
$baseTitleLineStep = max(68, (int)round($baseH * 0.043));
$baseTeaserY = max((int)round($baseH * 0.235), $baseTitleY + ($titleLines * $baseTitleLineStep) + max(54, (int)round($baseH * 0.03)));
$left = $baseLeft * $scale;
$titleY = $baseTitleY * $scale;
$titleLineStep = $baseTitleLineStep * $scale;
$teaserY = $baseTeaserY * $scale;
$shadowOffset = 3 * $scale;
$titleLineSpacing = 2 * $scale;
$teaserLineSpacing = -18 * $scale;
$titlePointSize = $titleFontSizeBase * $scale;
$teaserPointSize = 58 * $scale;
$shadowFill = 'rgba(0,0,0,0.35)';
$titleFill = '#F3F4F6';
$teaserFill = '#EADFC9';
$overlayBase = tempnam(sys_get_temp_dir(), 'thumb_overlay_');
$outputPath = dirname($imagePath) . '/' . basename($imagePath) . '.tmp.' . uniqid('', true) . '.jpg';
if ($overlayBase === false) {
throw new RuntimeException('Failed to create temporary thumbnail overlay file.');
}
$overlayPath = $overlayBase . '.png';
@unlink($overlayBase);
$cmd1 = sprintf(
'/usr/bin/convert -size %dx%d xc:none -font %s -pointsize %d -fill %s -interline-spacing %d -gravity northwest -annotate +%d+%d %s -fill %s -annotate +%d+%d %s -font %s -pointsize %d -fill %s -interline-spacing %d -gravity northwest -annotate +%d+%d %s -fill %s -annotate +%d+%d %s %s 2>&1',
$imgW,
$imgH,
escapeshellarg($titleFontPath),
$titlePointSize,
escapeshellarg($shadowFill),
$titleLineSpacing,
$left + $shadowOffset,
$titleY + $shadowOffset,
escapeshellarg($titleText),
escapeshellarg($titleFill),
$left,
$titleY,
escapeshellarg($titleText),
escapeshellarg($teaserFontPath),
$teaserPointSize,
escapeshellarg($shadowFill),
$teaserLineSpacing,
$left + $shadowOffset,
$teaserY + $shadowOffset,
escapeshellarg($teaserText),
escapeshellarg($teaserFill),
$left,
$teaserY,
escapeshellarg($teaserText),
escapeshellarg($overlayPath)
);
file_put_contents($logFile, "Overlay CMD: " . $cmd1 . "
", FILE_APPEND);
$out1 = shell_exec($cmd1);
file_put_contents($logFile, "Overlay Output: " . $out1 . "
", FILE_APPEND);
if (!is_file($overlayPath) || filesize($overlayPath) < 100) {
@unlink($overlayPath);
@unlink($outputPath);
throw new RuntimeException('Failed to render thumbnail overlay: ' . trim((string)$out1));
}
$maxBytes = 600 * 1024;
$qualities = [96, 92, 88, 84, 80, 76, 72, 68, 64];
$finalPath = '';
$finalSize = 0;
$finalOut = '';
foreach ($qualities as $quality) {
$attemptPath = dirname($imagePath) . '/' . basename($imagePath) . '.tmp.' . uniqid('', true) . '.jpg';
$cmd2 = sprintf(
'/usr/bin/convert -filter Lanczos -define filter:blur=0.85 %s -resize %dx%d^ -gravity center -extent %dx%d -modulate 70,82 %s -composite -resize %dx%d -gravity center -extent %dx%d -unsharp 0x0.75+0.75+0.008 -strip -interlace Plane -sampling-factor 4:2:0 -quality %d %s 2>&1',
escapeshellarg($imagePath),
$imgW,
$imgH,
$imgW,
$imgH,
escapeshellarg($overlayPath),
$targetW,
$targetH,
$targetW,
$targetH,
$quality,
escapeshellarg($attemptPath)
);
file_put_contents($logFile, "Composite CMD (q=$quality): " . $cmd2 . "
", FILE_APPEND);
$out2 = shell_exec($cmd2);
file_put_contents($logFile, "Composite Output (q=$quality): " . $out2 . "
", FILE_APPEND);
if (!is_file($attemptPath) || filesize($attemptPath) < 100) {
@unlink($attemptPath);
$finalOut = trim((string)$out2);
continue;
}
$finalPath = $attemptPath;
$finalSize = filesize($attemptPath);
$finalOut = trim((string)$out2);
if ($finalSize <= $maxBytes) break;
}
if ($finalPath === '') {
@unlink($overlayPath);
@unlink($outputPath);
throw new RuntimeException('Failed to compose thumbnail overlay: ' . $finalOut);
}
if ($finalSize > $maxBytes) {
file_put_contents($logFile, "Thumbnail still above size limit: " . $finalSize . " bytes
", FILE_APPEND);
}
if (!@rename($finalPath, $imagePath)) {
@unlink($overlayPath);
@unlink($finalPath);
throw new RuntimeException('Failed to replace thumbnail image.');
}
@unlink($overlayPath);
@unlink($outputPath);
}
function ensure_input_dir_permissions($dir){
if (!is_dir($dir) || is_link($dir)) return;
@chmod($dir, 02775);
$items = @scandir($dir);
if (!is_array($items)) return;
foreach ($items as $name) {
if ($name === '.' || $name === '..') continue;
$path = $dir . '/' . $name;
if (is_link($path)) continue;
if (is_dir($path)) @chmod($path, 02775);
elseif (is_file($path)) @chmod($path, 0664);
}
}
function input_dirs($base){ $dirs = glob($base.'/*', GLOB_ONLYDIR) ?: []; foreach ($dirs as $dir) ensure_input_dir_permissions($dir); return $dirs; }
function safe_input_dir($base, $name){ $name = basename((string)$name); $path = realpath($base . '/' . $name); $root = realpath($base); if (!$path || !$root || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) || !is_dir($path)) throw new RuntimeException('Invalid input directory.'); ensure_input_dir_permissions($path); return $path; }
function article_id_is_valid($id){ return is_string($id) && preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id); }
function generate_article_id(){ return gmdate('YmdHis') . bin2hex(random_bytes(8)); }
function read_article_id($dir){
$idFile = rtrim((string)$dir, '/').'/'.ARTICLE_ID_FILE;
if (!is_file($idFile)) return '';
$id = trim((string)file_get_contents($idFile));
return article_id_is_valid($id) ? $id : '';
}
function permalink_slug_from_title($title, $fallback = 'post'){
$slug = strtr((string)$title, [
'æ'=>'ae','Æ'=>'ae','ø'=>'o','Ø'=>'o','å'=>'aa','Å'=>'aa',
'ä'=>'ae','Ä'=>'ae','ö'=>'oe','Ö'=>'oe','ü'=>'ue','Ü'=>'ue','ß'=>'ss','ẞ'=>'ss',
'č'=>'c','Č'=>'c','ć'=>'c','Ć'=>'c','ž'=>'z','Ž'=>'z','š'=>'s','Š'=>'s','đ'=>'d','Đ'=>'d',
]);
if (function_exists('iconv')) {
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $slug);
if ($ascii !== false) $slug = $ascii;
}
$slug = strtolower($slug);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : $fallback;
}
function article_permalink_url($config, $articleId){
$articleId = strtolower(trim((string)$articleId));
if (!article_id_is_valid($articleId)) return '';
$map = [];
$used = [];
$dirs = input_dirs($config['uploads_dir']);
sort($dirs, SORT_STRING);
foreach ($dirs as $dir) {
$id = read_article_id($dir);
if ($id === '') continue;
$data = read_data($dir);
$title = trim((string)($data['title'] ?? ''));
if ($title === '') $title = basename((string)$dir);
$base = permalink_slug_from_title($title, $id);
$slug = $base;
if (isset($used[$slug])) $slug = $base . '-' . substr($id, -6);
$n = 2;
while (isset($used[$slug])) {
$slug = $base . '-' . substr($id, -6) . '-' . $n;
$n++;
}
$used[$slug] = true;
$map[$id] = '/post/' . rawurlencode($slug);
}
return $map[$articleId] ?? '';
}
function mvlog_announcement_sent(string $mvlogRoot, string $articleId, string $job): bool {
$cacheFile = rtrim($mvlogRoot, '/') . '/cache/push_notifications.json';
if (!is_file($cacheFile)) return false;
$cache = json_decode((string)@file_get_contents($cacheFile), true);
if (!is_array($cache) || !is_array($cache['shown'] ?? null)) return false;
$shown = $cache['shown'];
$job = basename(trim($job));
if (article_id_is_valid($articleId) && isset($shown[$articleId])) return true;
return $job !== '' && isset($shown[$job]);
}
function write_article_id($dir, $id){
if (!article_id_is_valid($id)) throw new RuntimeException('Invalid article id.');
$idFile = rtrim((string)$dir, '/').'/'.ARTICLE_ID_FILE;
$tmp = $idFile . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $id . "\n", LOCK_EX) === false) throw new RuntimeException('Cannot write article id.');
@chmod($tmp, 0664);
if (!rename($tmp, $idFile)) { @unlink($tmp); throw new RuntimeException('Cannot save article id.'); }
}
function ensure_article_id($dir, &$seenIds = null){
$existing = read_article_id($dir);
if ($existing !== '' && (!is_array($seenIds) || empty($seenIds[$existing]))) {
if (is_array($seenIds)) $seenIds[$existing] = basename((string)$dir);
return $existing;
}
do { $id = generate_article_id(); } while (is_array($seenIds) && !empty($seenIds[$id]));
write_article_id($dir, $id);
if (is_array($seenIds)) $seenIds[$id] = basename((string)$dir);
return $id;
}
function article_index_signature($dirs){
$names = array_map('basename', $dirs);
sort($names, SORT_STRING);
$parts = [];
foreach ($names as $name) $parts[] = $name;
return hash('sha256', implode("\n", $parts));
}
function load_articles_index($base, $dirs = null){
if ($dirs === null) $dirs = input_dirs($base);
$cacheFile = __DIR__ . '/cache/articles-index.json';
$signature = article_index_signature($dirs);
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (is_array($cache)
&& ($cache['version'] ?? 0) === 1
&& ($cache['signature'] ?? '') === $signature
&& is_array($cache['by_id'] ?? null)
&& is_array($cache['by_dir'] ?? null)
&& !in_array('', $cache['by_dir'], true)) {
return $cache;
}
$byId = [];
$byDir = [];
foreach ($dirs as $dir) {
$name = basename((string)$dir);
$id = read_article_id($dir);
if ($id === '' || !empty($byId[$id])) {
$id = ensure_article_id($dir, $byId);
}
if ($id !== '' && empty($byId[$id])) $byId[$id] = $name;
$byDir[$name] = $id;
}
ksort($byId, SORT_STRING);
ksort($byDir, SORT_STRING);
$out = [
'version' => 1,
'generated_at' => date('c'),
'signature' => $signature,
'by_id' => $byId,
'by_dir' => $byDir,
];
file_put_contents($cacheFile, json_encode($out, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
return $out;
}
function resolve_input_dir($base, $idOrDir, $articleIndex = null){
$token = trim((string)$idOrDir);
if ($token === '') throw new RuntimeException('Invalid input directory.');
if ($articleIndex === null) $articleIndex = load_articles_index($base);
if (article_id_is_valid($token)) {
$name = (string)($articleIndex['by_id'][$token] ?? '');
if ($name !== '') return safe_input_dir($base, $name);
}
return safe_input_dir($base, $token);
}
function resolve_input_dir_from_request($base, $id, $dir, $articleIndex = null){
$id = trim((string)$id);
$dir = trim((string)$dir);
if ($id !== '') {
try { return resolve_input_dir($base, $id, $articleIndex); }
catch (Throwable $e) { if ($dir !== '') return resolve_input_dir($base, $dir, $articleIndex); throw $e; }
}
return resolve_input_dir($base, $dir, $articleIndex);
}
function read_describe_prompt($dir){
$file = rtrim((string)$dir, '/') . '/.mvlog-describe-prompt.txt';
return is_file($file) ? trim((string)@file_get_contents($file)) : '';
}
function write_describe_prompt($dir, $text){
$file = rtrim((string)$dir, '/') . '/.mvlog-describe-prompt.txt';
$text = trim((string)$text);
if ($text === '') {
if (is_file($file)) @unlink($file);
return;
}
file_put_contents($file, $text . PHP_EOL);
@chmod($file, 0664);
}
function read_data($dir){
$data = ['title'=>'','teaser'=>'','quote_da'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $file = $dir . '/data.txt';
if (!is_file($file)) return $data;
$lines = file($file, FILE_IGNORE_NEW_LINES);
for ($i = 0; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
$low = strtolower($key);
if ($value === '|' && in_array($low, ['description','desc','synopsis'], true)) {
$block = [];
while ($i + 1 < count($lines)) {
$next = $lines[$i + 1];
if (trim($next) !== '' && $next[0] !== ' ' && $next[0] !== "\t") break;
$i++;
if (str_starts_with($next, ' ')) $next = substr($next, 2);
elseif (str_starts_with($next, ' ') || str_starts_with($next, "\t")) $next = substr($next, 1);
$block[] = rtrim($next);
}
$data['description'] = trim(implode("\n", $block));
} elseif (in_array($low, ['title','name'], true)) $data['title'] = $value;
elseif (in_array($low, ['teaser','tagline','subtitle'], true)) $data['teaser'] = $value;
elseif ($low === 'quote_da') $data['quote_da'] = trim($value, "\"“”");
elseif (in_array($low, ['date','dates','when'], true)) $data['date'] = $value;
elseif (in_array($low, ['place','location','where'], true)) $data['place'] = $value;
elseif (in_array($low, ['description','desc','synopsis'], true)) $data['description'] = $value;
elseif (str_ends_with($low, ' audio')) { $fileKey = trim(substr($key, 0, -6)); if ($fileKey !== '' && in_array(strtolower($value), ['1','yes','true','on'], true)) $data['video_audio'][$fileKey] = true; }
elseif ($key !== '') $data['captions'][$key] = $value;
}
return $data;
}
function location_language_for_country($countryCode){
$map = ['dk'=>'da','de'=>'de','at'=>'de','ch'=>'de','hr'=>'hr','se'=>'sv','no'=>'no','fi'=>'fi','pl'=>'pl','cz'=>'cs','sk'=>'sk','fr'=>'fr','it'=>'it','es'=>'es','pt'=>'pt','nl'=>'nl','be'=>'nl','ba'=>'bs','rs'=>'sr','si'=>'sl','hu'=>'hu','gb'=>'en','uk'=>'en','ie'=>'en'];
$countryCode = strtolower(trim((string)$countryCode));
return $map[$countryCode] ?? 'en';
}
function nominatim_reverse_json($lat, $lon, $language){
$url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query(['format'=>'jsonv2','lat'=>sprintf('%.8F', $lat),'lon'=>sprintf('%.8F', $lon),'addressdetails'=>'1','accept-language'=>$language]);
$context = stream_context_create(['http'=>['method'=>'GET','header'=>'User-Agent: mvlog-admin/1.0 (reverse geocoding media metadata)\r\n','timeout'=>12]]);
$json = @file_get_contents($url, false, $context);
$data = $json !== false ? json_decode((string)$json, true) : null;
return is_array($data) ? $data : [];
}
function reverse_geocode_place($lat, $lon){
$cacheFile = __DIR__ . '/cache/reverse-geocode.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache)) $cache = [];
$key = sprintf('%.5F,%.5F', $lat, $lon);
if (isset($cache[$key]) && is_string($cache[$key])) return $cache[$key];
$first = nominatim_reverse_json($lat, $lon, 'en');
$countryCode = (string)($first['address']['country_code'] ?? '');
$language = location_language_for_country($countryCode);
$data = ($language === 'en') ? $first : nominatim_reverse_json($lat, $lon, $language);
$address = is_array($data['address'] ?? null) ? $data['address'] : [];
$city = (string)($address['city'] ?? $address['town'] ?? $address['village'] ?? $address['island'] ?? $address['suburb'] ?? $address['hamlet'] ?? $address['municipality'] ?? '');
$country = (string)($address['country'] ?? '');
$place = trim(implode(', ', array_filter([$city, $country], fn($v)=>trim((string)$v) !== '')));
if ($place === '') $place = trim($country);
if ($place !== '') {
$cache[$key] = $place;
@file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX);
@chmod($cacheFile, 0664);
}
return $place;
}
function media_gps_coordinates($path){
$exiftool = trim((string)shell_exec('command -v exiftool 2>/dev/null'));
if ($exiftool === '' || !is_file($path)) return null;
$cmd = escapeshellarg($exiftool) . ' -json -n -GPSLatitude -GPSLongitude -GPSCoordinates ' . escapeshellarg($path) . ' 2>/dev/null';
$rows = json_decode((string)shell_exec($cmd), true);
$row = is_array($rows) && isset($rows[0]) && is_array($rows[0]) ? $rows[0] : [];
$lat = $row['GPSLatitude'] ?? null;
$lon = $row['GPSLongitude'] ?? null;
if ((!is_numeric($lat) || !is_numeric($lon)) && !empty($row['GPSCoordinates']) && preg_match('/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/', (string)$row['GPSCoordinates'], $m)) {
$lat = $m[1];
$lon = $m[2];
}
if (!is_numeric($lat) || !is_numeric($lon)) return null;
$lat = (float)$lat;
$lon = (float)$lon;
if (abs($lat) < 0.000001 && abs($lon) < 0.000001) return null;
return [$lat, $lon];
}
function infer_location_from_first_media($dir){
foreach (visual_order_files($dir) as $name) {
$gps = media_gps_coordinates($dir . '/' . $name);
if (!$gps) continue;
$place = reverse_geocode_place($gps[0], $gps[1]);
if ($place !== '') return $place;
}
return '';
}
function display_month_year_from_media_datetime($value){
$value = preg_replace('/[^0-9]/', '', (string)$value);
if (strlen($value) < 8) return '';
$year = (int)substr($value, 0, 4);
$month = (int)substr($value, 4, 2);
$day = (int)substr($value, 6, 2);
if (!checkdate($month, $day, $year)) return '';
return date('F Y', mktime(0, 0, 0, $month, $day, $year));
}
function infer_date_from_first_media($dir){
foreach (visual_order_files($dir) as $name) {
$date = media_file_date($dir . '/' . $name);
if ($date === null) continue;
$display = display_month_year_from_media_datetime($date);
if ($display !== '') return $display;
}
return '';
}
function write_data($dir, $post){
if (array_key_exists('describe_prompt', $post)) write_describe_prompt($dir, $post['describe_prompt']);
$title = trim($post['title'] ?? '');
$teaser = trim($post['teaser'] ?? '');
$quote_da = trim($post['quote_da'] ?? '');
$place = trim($post['place'] ?? '');
if ($place === '') $place = infer_location_from_first_media($dir);
$date = trim($post['date'] ?? '');
if ($date === '') $date = infer_date_from_first_media($dir);
$description = trim($post['description'] ?? '');
if (function_exists('mb_substr')) $description = mb_substr($description, 0, 5000, 'UTF-8'); else $description = substr($description, 0, 5000);
$lines = [];
if ($title !== '') $lines[] = 'Title: ' . $title;
if ($teaser !== '') $lines[] = 'Teaser: ' . $teaser;
if ($quote_da !== '') $lines[] = 'Quote_da: ' . trim($quote_da, "\"“”");
if ($place !== '') $lines[] = 'Location: ' . $place;
if ($date !== '') $lines[] = 'Date: ' . $date;
if ($description !== '') {
$lines[] = 'Description: |';
$description = str_replace(["\r\n", "\r"], "\n", $description);
foreach (explode("\n", $description) as $descLine) $lines[] = ' ' . rtrim($descLine);
}
$useAudioFiles = $post['use_audio_files'] ?? [];
$useAudio = is_array($useAudioFiles) ? array_flip(array_map('basename', array_map('strval', $useAudioFiles))) : [];
foreach (array_keys($useAudio) as $fileName) if ($fileName !== '') $lines[] = $fileName . ' audio: yes';
$captionFiles = $post['caption_files'] ?? [];
$captions = $post['captions'] ?? [];
if (is_array($captionFiles) && is_array($captions)) {
foreach ($captionFiles as $i => $fileName) {
$fileName = basename((string)$fileName);
$caption = trim((string)($captions[$i] ?? ''));
if ($fileName !== '' && $caption !== '') $lines[] = $fileName . ': ' . $caption;
}
}
file_put_contents($dir . '/data.txt', implode("\n", $lines) . "\n");
}
function editable_file($f){ $ext = strtolower(pathinfo((string)$f, PATHINFO_EXTENSION)); return $f !== '' && $f[0] !== '.' && $f !== 'data.txt' && in_array($ext, ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm','mp3','wav','m4a','aac','ogg','flac'], true); }
function natural_file_compare($a, $b){ return strnatcasecmp((string)$a, (string)$b); }
function is_visual_media_file($name){
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm'], true);
}
function parse_date_from_filename($path) {
$filename = basename($path);
if (!preg_match('/(?:^|[^0-9])(\\d{8})[_-]?(\\d{6})(?:[^0-9]|$)/', $filename, $m)) {
return null;
}
$y = (int)substr($m[1], 0, 4);
$mo = (int)substr($m[1], 4, 2);
$d = (int)substr($m[1], 6, 2);
$hh = (int)substr($m[2], 0, 2);
$mi = (int)substr($m[2], 2, 2);
$ss = (int)substr($m[2], 4, 2);
if (!checkdate($mo, $d, $y) || $hh > 23 || $mi > 59 || $ss > 59) {
return null;
}
return $m[1] . $m[2];
}
function mvlog_local_timezone(){
static $tz = null;
if ($tz instanceof DateTimeZone) return $tz;
$name = trim((string)@file_get_contents('/etc/timezone'));
if ($name === '') $name = (string)date_default_timezone_get();
if ($name === '') $name = 'UTC';
try { $tz = new DateTimeZone($name); }
catch (Throwable $e) { $tz = new DateTimeZone('UTC'); }
return $tz;
}
function parse_embedded_datetime_to_local($value){
$value = trim((string)$value);
if ($value === '') return null;
try {
$dt = new DateTimeImmutable($value);
return $dt->setTimezone(mvlog_local_timezone())->format('YmdHis');
} catch (Throwable $e) {
return null;
}
}
function media_file_date($path){
$ext = strtolower(pathinfo((string)$path, PATHINFO_EXTENSION));
// Priority 2: Exif/embedded metadata
if (in_array($ext, ['jpg','jpeg','tif','tiff'], true) && function_exists('exif_read_data')) {
$exif = @exif_read_data($path);
foreach (['DateTimeOriginal','DateTimeDigitized','DateTime'] as $k) {
$value = (string)($exif[$k] ?? '');
if ($value !== '' && preg_match('/^(\\d{4}):(\\d{2}):(\\d{2})(?:\\s+(\\d{2}):(\\d{2}):(\\d{2}))?/', $value, $m)) {
$date = $m[1] . $m[2] . $m[3];
$time = isset($m[4]) ? ($m[4] . $m[5] . $m[6]) : '000000';
return $date . $time;
}
}
}
if (in_array($ext, ['mp4','mov','m4v','avi','mkv','webm'], true)) {
$ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null'));
if ($ffprobe !== '') {
$json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags=creation_time:stream_tags=creation_time '.escapeshellarg($path).' 2>/dev/null');
$data = json_decode((string)$json, true);
$candidates = [];
if (is_array($data['format']['tags'] ?? null)) $candidates[] = (string)($data['format']['tags']['creation_time'] ?? '');
foreach (($data['streams'] ?? []) as $stream) if (is_array($stream['tags'] ?? null)) $candidates[] = (string)($stream['tags']['creation_time'] ?? '');
foreach ($candidates as $value) {
$normalized = parse_embedded_datetime_to_local($value);
if ($normalized !== null) {
return $normalized;
}
if (preg_match('/^(\\d{4})-(\\d{2})-(\\d{2})/', $value, $m)) {
return $m[1].$m[2].$m[3].'000000';
}
}
}
}
// Priority 3: filename parsing
$from_filename = parse_date_from_filename($path);
if ($from_filename) {
return $from_filename;
}
return null;
}
function media_sort_datetime($path){
return media_file_date($path) ?? date('YmdHis', filemtime($path));
}
function list_files($dir){
ensure_input_dir_permissions($dir);
$files = array_values(array_filter(scandir($dir), fn($f)=>editable_file($f) && is_file($dir.'/'.$f)));
$orderFile = $dir . '/order.json';
$media = [];
$other = [];
foreach ($files as $name) {
if (is_visual_media_file($name)) $media[] = $name;
else $other[] = $name;
}
// Priority 1: explicit order file, but regenerate if file set changed.
if (is_file($orderFile)) {
$order = json_decode((string)file_get_contents($orderFile), true);
if (is_array($order)) {
$mediaSet = array_fill_keys($media, true);
$orderSet = [];
$sortedMedia = [];
foreach ($order as $name) {
if (!is_string($name) || !is_visual_media_file($name)) continue;
if (isset($mediaSet[$name]) && !isset($orderSet[$name])) {
$orderSet[$name] = true;
$sortedMedia[] = $name;
}
}
$sameSet = (count($orderSet) === count($mediaSet));
if ($sameSet) {
foreach ($mediaSet as $name => $_) {
if (!isset($orderSet[$name])) {
$sameSet = false;
break;
}
}
}
if ($sameSet) {
return array_merge($sortedMedia, $other);
}
}
}
// Missing/invalid/stale order.json: sort by datetime and recreate order.json
usort($media, function($a, $b) use ($dir) {
$da = media_sort_datetime($dir . '/' . $a);
$db = media_sort_datetime($dir . '/' . $b);
if ($da === $db) return 0; // never fallback to filename ordering
return strcmp($da, $db);
});
file_put_contents($orderFile, json_encode(array_values($media), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
@chmod($orderFile, 0664);
return array_merge($media, $other);
}
function media_sort_files($dir, $files){
return list_files($dir);
}
function visual_order_files($dir){
$all = list_files($dir);
return array_values(array_filter($all, fn($name)=>is_visual_media_file($name)));
}
function write_visual_order($dir, $order){
$clean = [];
$seen = [];
foreach ((array)$order as $name) {
if (!is_string($name)) continue;
$name = basename($name);
if (!is_visual_media_file($name) || isset($seen[$name])) continue;
if (!is_file($dir . '/' . $name)) continue;
$seen[$name] = true;
$clean[] = $name;
}
file_put_contents($dir . '/order.json', json_encode(array_values($clean), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
@chmod($dir . '/order.json', 0664);
return $clean;
}
function move_visual_file_order($dir, $file, $direction){
$file = basename((string)$file);
if (!is_visual_media_file($file) || !is_file($dir . '/' . $file)) throw new RuntimeException('Invalid media file for reordering.');
$direction = strtolower(trim((string)$direction));
if (!in_array($direction, ['up','down'], true)) throw new RuntimeException('Invalid move direction.');
$order = visual_order_files($dir);
$idx = array_search($file, $order, true);
if ($idx === false) throw new RuntimeException('File not found in visual order.');
$newIdx = $idx;
if ($direction === 'up' && $idx > 0) {
[$order[$idx - 1], $order[$idx]] = [$order[$idx], $order[$idx - 1]];
$newIdx = $idx - 1;
} elseif ($direction === 'down' && $idx < count($order) - 1) {
[$order[$idx], $order[$idx + 1]] = [$order[$idx + 1], $order[$idx]];
$newIdx = $idx + 1;
}
write_visual_order($dir, $order);
return [
'changed' => $newIdx !== $idx,
'index' => $newIdx,
'count' => count($order),
];
}
function input_dir_date_from_media($dir){
$dates = [];
foreach (list_files($dir) as $file) {
$date = media_file_date($dir . '/' . $file);
if ($date !== null) $dates[] = $date;
}
if ($dates) {
sort($dates, SORT_STRING);
return $dates[0];
}
return date('Ymd');
}
function input_dir_date_from_text($text){
$text = trim((string)$text);
if ($text === '') return null;
if (preg_match('/^(\d{4})[.-]?(\d{2})[.-]?(\d{2})$/', $text, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) return "$m[1]$m[2]$m[3]";
if (preg_match('/^(\d{1,2})[\/. -](\d{1,2})[\/. -](\d{4})$/', $text, $m) && checkdate((int)$m[2], (int)$m[1], (int)$m[3])) return sprintf('%04d%02d%02d', $m[3], $m[2], $m[1]);
$ts = strtotime($text);
if ($ts !== false) return date('Ymd', $ts);
if (preg_match('/^(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+(\d{4})$/i', $text, $m)) {
$months = ['jan'=>1,'january'=>1,'feb'=>2,'february'=>2,'mar'=>3,'march'=>3,'apr'=>4,'april'=>4,'may'=>5,'jun'=>6,'june'=>6,'jul'=>7,'july'=>7,'aug'=>8,'august'=>8,'sep'=>9,'sept'=>9,'september'=>9,'oct'=>10,'october'=>10,'nov'=>11,'november'=>11,'dec'=>12,'december'=>12];
return sprintf('%04d%02d01', (int)$m[2], $months[strtolower($m[1])]);
}
return null;
}
function unique_input_dir($base, $slug){
$dir = $base . '/' . $slug;
if (!file_exists($dir)) return $dir;
for ($i = 2; ; $i++) {
$candidate = $base . '/' . $slug . '-' . $i;
if (!file_exists($candidate)) return $candidate;
}
}
function input_dir_signature($dir){
$script = <<<'PY'
import hashlib, json, os, sys
root = sys.argv[1]
entries = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d != '.movmaker-lock']
for name in filenames:
if name in {'.movmaker-state.json', '.movmaker-enabled', '.movmaker-preview', '.mvlog-hidden', '.mvlog-permalink', '.mvlog-id', '.mvlog-hide-map', '.mvlog-describe-prompt.txt', '.mvlog-last-openrouter-response.json', '.mvlog-last-openrouter-text.txt'}:
continue
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
st = os.stat(path)
mtime_ns = getattr(st, 'st_mtime_ns', int(st.st_mtime * 1_000_000_000))
entries.append([rel, st.st_size, mtime_ns])
entries.sort()
print(hashlib.sha256(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest())
PY;
$cmd = 'python3 -c ' . escapeshellarg($script) . ' ' . escapeshellarg($dir);
$output = trim(shell_exec($cmd) ?? '');
return $output !== '' ? $output : '';
}
function input_dir_info($dir){
$data = read_data($dir);
$files = list_files($dir);
return [
'name' => basename($dir),
'article_id' => ensure_article_id($dir),
'title' => $data['title'] ?: basename($dir),
'sort_date' => input_dir_date_from_text($data['date'] ?? '') ?? input_dir_date_from_media($dir),
'file_count' => count($files),
'signature' => input_dir_signature($dir),
];
}
function load_input_dir_cache($dirs){
$cacheFile = __DIR__ . '/cache/input-dirs.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache) || ($cache['version'] ?? 0) !== 1) $cache = ['version'=>1,'items'=>[]];
$oldItems = is_array($cache['items'] ?? null) ? $cache['items'] : [];
$items = []; $changed = false;
foreach ($dirs as $dir) {
$name = basename($dir);
$sig = input_dir_signature($dir);
$cached = $oldItems[$name] ?? null;
if (is_array($cached) && ($cached['signature'] ?? '') === $sig && !empty($cached['article_id'])) {
$items[$name] = $cached;
} else {
$items[$name] = input_dir_info($dir);
$changed = true;
}
}
if (array_diff(array_keys($oldItems), array_keys($items))) $changed = true;
if ($changed) file_put_contents($cacheFile, json_encode(['version'=>1,'generated_at'=>date('c'),'items'=>$items], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
return $items;
}
function sort_input_dirs_by_metadata_date(&$dirs, $dirInfo){
usort($dirs, function($a, $b) use ($dirInfo) {
$ad = $dirInfo[basename($a)]['sort_date'] ?? '00000000';
$bd = $dirInfo[basename($b)]['sort_date'] ?? '00000000';
$cmp = strcmp($bd, $ad);
return $cmp !== 0 ? $cmp : strnatcasecmp(basename($b), basename($a));
});
}
function orphan_videos($config){
$referenced = [];
foreach (input_dirs($config['uploads_dir']) as $dir) {
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (is_array($state) && !empty($state['output'])) $referenced[basename((string)$state['output'])] = true;
}
$videos = glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [];
return array_values(array_filter($videos, fn($v)=>empty($referenced[basename($v)])));
}
function input_dir_status($dir){
$stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : '';
if (is_dir($lockDir)) return $status !== '' ? $status : 'processing';
return in_array($status, ['processing', 'stale', 'error'], true) ? $status : '';
}
function input_dir_running($dir){
$stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : '';
return is_dir($lockDir) || $status === 'processing';
}
function input_dir_enabled($dir){ return is_file($dir . '/.movmaker-enabled'); }
function set_input_dir_enabled($dir, $enabled){
$path = $dir . '/.movmaker-enabled';
if ($enabled) { file_put_contents($path, "enabled\n"); @chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
}
function input_dir_preview($dir){
if (is_file($dir . '/.movmaker-preview')) return true;
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) return false;
$state = json_decode((string)file_get_contents($stateFile), true);
return is_array($state) && !empty($state['preview']);
}
function set_input_dir_preview($dir, $preview){
$path = $dir . '/.movmaker-preview';
if ($preview) { file_put_contents($path, "preview\n"); @chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
$state['article_id'] = ensure_article_id($dir);
$state['preview'] = (bool)$preview;
$state['preview_updated_at'] = gmdate('c');
file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n");
@chmod($stateFile, 0664);
}
function input_dir_visible($dir){ return !is_file($dir . '/.mvlog-hidden'); }
function set_input_dir_visible($dir, $visible){
$path = $dir . '/.mvlog-hidden';
if ($visible) { if (is_file($path)) unlink($path); }
else { file_put_contents($path, "hidden\n"); @chmod($path, 0664); }
}
function input_dir_has_full_output($dir, $config){
$state = input_dir_state($dir);
$output = basename((string)($state['output'] ?? ''));
return $output !== '' && is_file($config['videos_dir'] . '/' . $output) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', $output);
}
function input_dir_can_show($dir, $config){ return input_dir_has_full_output($dir, $config) && !input_dir_preview($dir); }
function input_dir_effective_visible($dir, $config){ return input_dir_can_show($dir, $config) && input_dir_visible($dir); }
function input_dir_permalink($dir){ return is_file($dir . '/' . PERMALINK_FILE); }
function input_dir_can_permalink($dir, $config){ return input_dir_can_show($dir, $config); }
function input_dir_ui_state($dir, $config){
$preview = input_dir_preview($dir);
$enabled = input_dir_enabled($dir);
$canShow = input_dir_can_show($dir, $config);
$visible = $canShow && input_dir_visible($dir);
$permalink = input_dir_permalink($dir);
$canPermalink = input_dir_can_permalink($dir, $config);
return [
'enabled' => $enabled,
'preview' => $preview,
'visible' => $visible,
'permalink' => $permalink,
'can_show' => $canShow,
'can_permalink' => $canPermalink,
'public_locked' => $visible || $permalink,
];
}
function input_dir_public_locked($dir, $config){ $s = input_dir_ui_state($dir, $config); return !empty($s['public_locked']); }
function input_dir_switch_payload($dir, $config, $articleId = ''){
$s = input_dir_ui_state($dir, $config);
$s['dir'] = basename((string)$dir);
$s['id'] = $articleId !== '' ? $articleId : read_article_id($dir);
return $s;
}
function initialize_input_dir_defaults($dir, $config){
// Directly uploaded directories do not have admin marker files yet.
// Keep them private by default so Show is off, Permalink is off, and Preview/Render stay available.
if (!is_file($dir . '/.mvlog-hidden') && !input_dir_permalink($dir) && !input_dir_has_full_output($dir, $config)) {
set_input_dir_visible($dir, false);
}
}
function initialize_input_dirs_defaults($dirs, $config){ foreach ($dirs as $dir) initialize_input_dir_defaults($dir, $config); }
function set_input_dir_permalink($dir, $permalink){
$path = $dir . '/' . PERMALINK_FILE;
if ($permalink) { file_put_contents($path, "permalink\n"); @chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
}
function input_dir_map_hidden($dir){ return is_file($dir . '/' . MAP_HIDDEN_FILE); }
function set_input_dir_map_hidden($dir, $hidden){
$path = $dir . '/' . MAP_HIDDEN_FILE;
if ($hidden) { file_put_contents($path, "hide-map\n"); @chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
}
function is_image_media_file($name){
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg','jpeg','png','webp','gif'], true);
}
function public_url_path($path){
$path = str_replace('\\', '/', trim((string)$path));
if ($path === '') return '';
$parts = array_values(array_filter(explode('/', $path), static fn($p) => $p !== ''));
return implode('/', array_map('rawurlencode', $parts));
}
function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
$info = video_thumb_source_info($config, $videoName);
$sourcePath = (string)($info['source_path'] ?? '');
if ($sourcePath !== '') {
$abs = str_starts_with($sourcePath, '/') ? $sourcePath : __DIR__ . '/' . ltrim($sourcePath, '/');
if (is_file($abs)) return public_url_path($sourcePath) . '?v=' . filemtime($abs);
}
$sourceFile = basename((string)($info['source_file'] ?? ''));
$inputDir = basename((string)($info['input_dir'] ?? ''));
if ($sourceFile !== '' && $inputDir !== '') {
$rel = 'in-dir/' . $inputDir . '/' . $sourceFile;
$abs = __DIR__ . '/' . $rel;
if (is_file($abs)) return public_url_path($rel) . '?v=' . filemtime($abs);
}
return '';
}
function video_thumb_source_info($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return [];
$base = pathinfo($videoName, PATHINFO_FILENAME);
$file = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (!is_file($file)) return [];
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data : [];
}
function ensure_state_article_id($dir){
$articleId = ensure_article_id($dir);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
if (($state['article_id'] ?? '') !== $articleId) {
$state['article_id'] = $articleId;
file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n");
@chmod($stateFile, 0664);
}
return $articleId;
}
function input_dir_state($dir){
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
$state['article_id'] = ($state['article_id'] ?? '') ?: read_article_id($dir);
return $state;
}
function active_worker_jobs($config){
$jobs = [];
foreach (input_dirs($config['uploads_dir']) as $dir) {
$name = basename($dir);
$state = input_dir_state($dir);
$articleId = (string)($state['article_id'] ?? '') ?: ensure_article_id($dir);
$status = (string)($state['status'] ?? '');
$locked = is_dir($dir . '/.movmaker-lock');
if ($locked || $status === 'processing') {
$started = '';
if (is_file($dir . '/.movmaker-lock/started_at')) $started = trim((string)file_get_contents($dir . '/.movmaker-lock/started_at'));
if ($started === '' && !empty($state)) $started = (string)($state['started_at'] ?? $state['updated_at'] ?? '');
$jobs[] = ['name'=>$name,'id'=>$articleId,'status'=>$status ?: ($locked ? 'processing' : 'unknown'),'started_at'=>$started];
}
}
return $jobs;
}
function format_job_age($startedAt){
$ts = strtotime((string)$startedAt);
if (!$ts) return '';
$seconds = max(0, time() - $ts);
$h = intdiv($seconds, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($h > 0) return $h . 'h ' . $m . 'm';
if ($m > 0) return $m . 'm ' . $s . 's';
return $s . 's';
}
function input_dir_output($dir){
$state = input_dir_state($dir);
return basename((string)($state['output'] ?? ''));
}
function regenerate_input_dir_maps($dir, $config){
$logFile = '/var/log/mvlog_map.log';
$python = trim((string)shell_exec('command -v python3 2>/dev/null'));
$script = __DIR__ . '/bin/gpsmap.py';
if ($python === '' || !is_file($script)) {
file_put_contents($logFile, '[mvlog] map regenerate skipped for ' . basename((string)$dir) . ' (python=' . ($python !== '' ? 'yes' : 'no') . ', script=' . (is_file($script) ? 'yes' : 'no') . ")\n", FILE_APPEND);
return;
}
$state = input_dir_state($dir);
$targets = [];
foreach (['output', 'preview_output'] as $key) {
$name = basename((string)($state[$key] ?? ''));
if ($name === '') continue;
$targets[] = $config['videos_dir'] . '/' . pathinfo($name, PATHINFO_FILENAME) . '_map.png';
}
// Fallback target if no output filename is known yet.
if (!$targets) {
$targets[] = $config['videos_dir'] . '/' . basename((string)$dir) . '_map.png';
}
$targets = array_values(array_unique($targets));
file_put_contents($logFile, '[mvlog] map regenerate requested for ' . basename((string)$dir) . ' targets=' . implode(', ', array_map('basename', $targets)) . "\n", FILE_APPEND);
foreach ($targets as $outPng) {
file_put_contents($logFile, '[mvlog] map regenerate start for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . "\n", FILE_APPEND);
$cmd = escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($dir) . ' ' . escapeshellarg($outPng) . ' 2>&1';
$output = (string)shell_exec($cmd);
if (is_file($outPng)) {
@chmod($outPng, 0664);
clearstatcache(true, $outPng);
$size = @filesize($outPng);
file_put_contents($logFile, '[mvlog] map regenerate done for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . ' bytes=' . ($size !== false ? $size : 'unknown') . "\n", FILE_APPEND);
} else {
file_put_contents($logFile, '[mvlog] map regenerate failed for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . ' output=' . trim($output) . "\n", FILE_APPEND);
}
}
}
function cached_video_metadata($output, $fallback){
$meta = [
'title'=>$fallback['title'] ?? '',
'teaser'=>$fallback['teaser'] ?? '',
'date'=>$fallback['date'] ?? '',
'sort_date'=>$fallback['date'] ?? '',
'location'=>$fallback['place'] ?? '',
'quote_da'=>$fallback['quote_da'] ?? '',
'description'=>$fallback['description'] ?? ''
];
$cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
$cached = $cache['items'][$output]['metadata'] ?? null;
if (is_array($cached)) {
// Admin content fields must stay editable/source-of-truth from data.txt.
// Only use rendered/index metadata for the displayed date/location under the title.
foreach (['date','sort_date','location'] as $k) if (!empty($cached[$k])) $meta[$k] = $cached[$k];
}
return $meta;
}
function paginate($items, $page, $perPage = 8){
$total = count($items);
$pages = max(1, (int)ceil($total / $perPage));
$page = max(1, min((int)$page, $pages));
return [$page, $pages, array_slice($items, ($page - 1) * $perPage, $perPage), $total];
}
function page_links($tab, $page, $pages){
if ($pages <= 1) return '';
$extra = '';
if ($tab === 'edit') {
$q = trim((string)($_GET['q'] ?? ''));
if ($q !== '') $extra .= '&q=' . rawurlencode($q);
if (!empty($_GET['f_preview'])) $extra .= '&f_preview=1';
if (!empty($_GET['f_render'])) $extra .= '&f_render=1';
if (!empty($_GET['f_shown'])) $extra .= '&f_shown=1';
}
$html = '<div class="pages">';
for ($i = 1; $i <= $pages; $i++) {
$class = $i === $page ? 'active' : '';
$html .= '<a class="'.$class.'" href="admin.php?tab='.rawurlencode($tab).'&page='.$i.$extra.'">'.$i.'</a>';
}
return $html . '</div>';
}
function save_uploads($field, $dest, $allowed){
if (empty($_FILES[$field])) return [];
$files = $_FILES[$field]; $saved = [];
$count = is_array($files['name']) ? count($files['name']) : 0;
for ($i=0; $i<$count; $i++) {
if ($files['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
if ($files['error'][$i] !== UPLOAD_ERR_OK) throw new RuntimeException("Upload failed: ".$files['name'][$i]);
$ext = strtolower(pathinfo($files['name'][$i], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) throw new RuntimeException("File type not allowed: ".$files['name'][$i]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($files['name'][$i]));
$target = $dest . '/' . sprintf('%03d_', count(glob($dest.'/*') ?: [])+count($saved)+1) . $base;
if (!move_uploaded_file($files['tmp_name'][$i], $target)) throw new RuntimeException("Cannot save upload");
@chmod($target, 0664);
$saved[] = basename($target);
}
return $saved;
}
$msg = $_GET['msg'] ?? null; $err = null;
$allowedMedia = ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm'];
$allowedAudio = ['mp3','wav','m4a','aac','ogg','flac'];
$initialDirs = input_dirs($config['uploads_dir']);
initialize_input_dirs_defaults($initialDirs, $config);
$articleIndex = load_articles_index($config['uploads_dir'], $initialDirs);
try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'create';
if ($action === 'create') {
$title = trim($_POST['title'] ?? '');
if ($title === '') throw new RuntimeException('Title is required.');
$tmpSlug = '.upload-' . date('YmdHis') . '-' . bin2hex(random_bytes(4));
$tmpDir = $config['uploads_dir'] . '/' . $tmpSlug;
if (!mkdir($tmpDir, 0775, true)) throw new RuntimeException('Cannot create temporary input directory.');
@chmod($tmpDir, 02775);
$media = save_uploads('media', $tmpDir, $allowedMedia);
if (!$media) { rrmdir($tmpDir); throw new RuntimeException('Upload at least one image or video.'); }
save_uploads('audio', $tmpDir, $allowedAudio);
$mediaDate = input_dir_date_from_text($_POST['date'] ?? '') ?? input_dir_date_from_media($tmpDir);
$slug = $mediaDate . '_' . slugify($title);
$dir = unique_input_dir($config['uploads_dir'], $slug);
$slug = basename($dir);
if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); }
@chmod($dir, 02775);
$articleId = ensure_article_id($dir);
write_data($dir, $_POST);
set_input_dir_enabled($dir, false);
set_input_dir_preview($dir, false);
set_input_dir_visible($dir, false);
regenerate_input_dir_maps($dir, $config);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>"Created movmaker input directory: in-dir/$slug", 'dir'=>$slug, 'id'=>$articleId, 'tab'=>'edit']);
}
header('Location: admin.php?tab=edit');
exit;
} elseif ($action === 'set_enabled') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
initialize_input_dir_defaults($dir, $config);
if (input_dir_public_locked($dir, $config)) throw new RuntimeException('Disable Show and Permalink before changing render.');
$enabledNow = !empty($_POST['enabled']);
set_input_dir_enabled($dir, $enabledNow);
if ($enabledNow) {
set_input_dir_preview($dir, false);
}
if (!input_dir_can_show($dir, $config)) set_input_dir_visible($dir, false);
$message = ($enabledNow ? 'Enabled rendering for: in-dir/' : 'Disabled rendering for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
$payload = input_dir_switch_payload($dir, $config, $articleId);
$payload['ok'] = true;
$payload['message'] = $message;
ajax_json($payload);
}
header('Location: admin.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_preview') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
initialize_input_dir_defaults($dir, $config);
if (input_dir_public_locked($dir, $config)) throw new RuntimeException('Disable Show and Permalink before changing preview.');
$previewNow = !empty($_POST['preview']);
set_input_dir_preview($dir, $previewNow);
if ($previewNow) { set_input_dir_enabled($dir, false); set_input_dir_visible($dir, false); }
$message = ($previewNow ? 'Enabled preview for: in-dir/' : 'Disabled preview for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
$payload = input_dir_switch_payload($dir, $config, $articleId);
$payload['ok'] = true;
$payload['message'] = $message;
ajax_json($payload);
}
header('Location: admin.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_visible') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
$visibleNow = !empty($_POST['visible']);
$sendImmediately = !empty($_POST['send_immediately']);
$articleAlreadyAnnounced = mvlog_announcement_sent(__DIR__, $articleId, basename($dir));
if ($articleAlreadyAnnounced) $sendImmediately = false;
$canShow = input_dir_can_show($dir, $config);
$canPermalink = input_dir_can_permalink($dir, $config);
if ($visibleNow && !$canShow) throw new RuntimeException('Only finished full-quality videos can be shown.');
if ($visibleNow && !$canPermalink) throw new RuntimeException('Only finished full-quality videos can use permalinks.');
set_input_dir_visible($dir, $visibleNow && $canShow);
if ($visibleNow && $canShow && $canPermalink) set_input_dir_permalink($dir, true);
// Attempt to send "Show enabled" notification (server-side) on enable. Non-fatal.
if ($visibleNow && $canShow) {
if (is_file(__DIR__ . '/lib/send_push.php')) {
include_once __DIR__ . '/lib/send_push.php';
try_send_show_notification($articleId ?: basename($dir), $dir, __DIR__, '/var/log/mvlog_notify.log', $sendImmediately);
} else {
error_log("[mvlog] send_push helper missing, cannot send notification for " . basename($dir) . "
", 3, '/var/log/mvlog_notify.log');
}
}
$message = ($visibleNow ? 'Shown: in-dir/' : 'Hidden: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
$payload = input_dir_switch_payload($dir, $config, $articleId);
$payload['ok'] = true;
$payload['announced'] = mvlog_announcement_sent(__DIR__, $articleId, basename($dir));
$payload['message'] = $message;
ajax_json($payload);
}
header('Location: admin.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_permalink') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
$permalinkNow = !empty($_POST['permalink']);
$canPermalink = input_dir_can_permalink($dir, $config);
if ($permalinkNow && !$canPermalink) throw new RuntimeException('Only finished full-quality videos can use permalinks.');
set_input_dir_permalink($dir, $permalinkNow && $canPermalink);
if (!$permalinkNow) set_input_dir_visible($dir, false);
$message = ($permalinkNow ? 'Permalink enabled for: in-dir/' : 'Permalink disabled for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
$payload = input_dir_switch_payload($dir, $config, $articleId);
$payload['ok'] = true;
$payload['message'] = $message;
ajax_json($payload);
}
header('Location: admin.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_map_hidden') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
initialize_input_dir_defaults($dir, $config);
if (input_dir_public_locked($dir, $config)) throw new RuntimeException('Disable Show and Permalink before changing map visibility.');
$mapHiddenNow = !empty($_POST['map_hidden']);
set_input_dir_map_hidden($dir, $mapHiddenNow);
$message = ($mapHiddenNow ? 'Map hidden on index for: in-dir/' : 'Map visible on index for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
$payload = input_dir_switch_payload($dir, $config, $articleId);
$payload['ok'] = true;
$payload['map_hidden'] = $mapHiddenNow;
$payload['message'] = $message;
ajax_json($payload);
}
header('Location: admin.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_video_thumb') {
$logFile = __DIR__ . '/logs/image_magick.log';
file_put_contents($logFile, "Entering set_video_thumb action...\n", FILE_APPEND); // LOG 1
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
if ($file === '' || !is_image_media_file($file) || !is_file($dir . '/' . $file)) throw new RuntimeException('Invalid image file.');
$state = input_dir_state($dir);
$meta = read_data($dir); // Need this for title and teaser
$outputs = [];
foreach (['output', 'preview_output'] as $key) {
$name = basename((string)($state[$key] ?? ''));
if ($name !== '' && is_file($config['videos_dir'] . '/' . $name)) $outputs[] = $name;
}
$outputs = array_values(array_unique($outputs));
if (!$outputs) throw new RuntimeException('No rendered video found for this input directory yet.');
$src = $dir . '/' . $file;
$thumbName = $articleId . '_og.jpg';
$target = $config['videos_dir'] . '/' . $thumbName;
if (is_file($target)) @unlink($target);
if (!@copy($src, $target)) {
file_put_contents($logFile, "Failed to copy thumbnail.\n", FILE_APPEND); // LOG 2
throw new RuntimeException('Failed to write thumbnail.');
}
@chmod($target, 0664);
file_put_contents($logFile, "Thumbnail copied, now applying overlay...\n", FILE_APPEND); // LOG 3
// Apply text overlay after smart resizing/cropping to 1200x630
apply_thumbnail_overlay($target, (string)($meta['title'] ?? ''), (string)($meta['teaser'] ?? ''));
$written = [];
foreach ($outputs as $out) {
$base = pathinfo($out, PATHINFO_FILENAME);
// Remove any old thumbnail files in thumbs_dir
foreach (['jpg','jpeg','png','webp','gif'] as $oldExt) {
$old = $config['thumbs_dir'] . '/' . $base . '.' . $oldExt;
if (is_file($old)) @unlink($old);
}
$thumbMeta = [
'article_id' => $articleId,
'input_dir' => basename($dir),
'source_file' => $file,
'source_path' => 'in-dir/' . basename($dir) . '/' . $file,
'thumb_file' => $thumbName,
'thumb_path' => 'out-dir/' . $thumbName,
'created_at' => gmdate('c'),
];
$thumbMetaPath = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (file_put_contents($thumbMetaPath, json_encode($thumbMeta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX) === false) {
throw new RuntimeException('Failed to write thumbnail metadata.');
}
@chmod($thumbMetaPath, 0664);
$written[] = basename($target);
}
$message = 'Thumbnail set from ' . $file . ' for ' . count($written) . ' video(s).';
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'message' => $message,
'dir' => basename($dir),
'id' => $articleId,
'written' => $written,
]);
}
header('Location: admin.php?tab=edit&id=' . rawurlencode((string)$articleId) . '&edit=' . rawurlencode(basename($dir)) . '&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'update') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
file_put_contents('/var/log/mvlog_map.log', '[mvlog] save changes pressed for ' . basename($dir) . ' id=' . $articleId . "\n", FILE_APPEND);
try {
write_data($dir, $_POST);
save_uploads('media', $dir, $allowedMedia);
save_uploads('audio', $dir, $allowedAudio);
} finally {
// Always regenerate map image(s) whenever Save changes is pressed.
regenerate_input_dir_maps($dir, $config);
}
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir), 'id'=>$articleId]);
header('Location: admin.php?tab=edit');
exit;
} elseif ($action === 'delete_file') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
if (!editable_file($file) || !is_file($dir.'/'.$file)) throw new RuntimeException('Invalid file.');
unlink($dir.'/'.$file);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>'Removed file: ' . $file, 'tab'=>'edit']);
}
header('Location: admin.php?tab=edit');
exit;
} elseif ($action === 'reorder_file') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
$direction = strtolower(trim((string)($_POST['direction'] ?? '')));
$result = move_visual_file_order($dir, $file, $direction);
$message = $result['changed'] ? ('Moved file ' . ($direction === 'up' ? 'up' : 'down') . ': ' . $file) : 'No move possible for file: ' . $file;
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'message' => $message,
'file' => $file,
'direction' => $direction,
'changed' => !empty($result['changed']),
'index' => (int)($result['index'] ?? -1),
'count' => (int)($result['count'] ?? 0),
'dir' => basename($dir),
'id' => $articleId,
]);
}
header('Location: admin.php?tab=edit&id=' . rawurlencode((string)$articleId) . '&edit=' . rawurlencode(basename($dir)) . '&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'delete_video') {
$file = basename((string)($_POST['video'] ?? ''));
$path = $config['videos_dir'] . '/' . $file;
if ($file === '' || !is_file($path)) throw new RuntimeException('Invalid video.');
unlink($path);
$cache = __DIR__ . '/cache/videos.json';
if (is_file($cache)) unlink($cache);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>'Deleted video: ' . $file, 'tab'=>'videos']);
}
header('Location: admin.php?tab=videos');
exit;
} elseif ($action === 'delete_dir') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$name = basename($dir);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$output = is_array($state) ? basename((string)($state['output'] ?? '')) : '';
if (!empty($_POST['delete_output']) && $output !== '') {
$outPath = $config['videos_dir'] . '/' . $output;
if (is_file($outPath)) unlink($outPath);
$cache = __DIR__ . '/cache/videos.json';
if (is_file($cache)) unlink($cache);
}
rrmdir($dir);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>'Deleted input directory: in-dir/' . $name, 'tab'=>'edit']);
}
header('Location: admin.php?tab=edit');
exit;
}
}
} catch (Throwable $e) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['ajax'])) {
http_response_code(400);
ajax_json(['ok'=>false, 'error'=>$e->getMessage()]);
}
$err = $e->getMessage();
}
$dirs = input_dirs($config['uploads_dir']);
initialize_input_dirs_defaults($dirs, $config);
$articleIndex = load_articles_index($config['uploads_dir'], $dirs);
$dirInfo = load_input_dir_cache($dirs);
sort_input_dirs_by_metadata_date($dirs, $dirInfo);
$orphanVideos = orphan_videos($config);
$tab = $_GET['tab'] ?? 'edit';
$rawKeyword = trim((string)($_GET['q'] ?? ''));
$keywordType = 'general';
$keywordValue = $rawKeyword;
$filterPreview = !empty($_GET['f_preview']);
$filterRender = !empty($_GET['f_render']);
$filterShown = !empty($_GET['f_shown']);
$filterPermalink = !empty($_GET['f_permalink']);
$statusFilterActive = $filterPreview || $filterRender || $filterShown || $filterPermalink;
$searchEmpty = ($rawKeyword === '' && !$statusFilterActive);
if ($rawKeyword !== '' && preg_match('/^\s*(date|location|place)\s*=\s*(.+)\s*$/i', $rawKeyword, $m)) {
$keywordType = strtolower((string)$m[1]);
if ($keywordType === 'place') $keywordType = 'location';
$keywordValue = trim((string)$m[2]);
}
if ($keywordValue !== '' || $statusFilterActive) {
$dirs = array_values(array_filter($dirs, function($dir) use ($keywordType, $keywordValue, $config, $statusFilterActive, $filterPreview, $filterRender, $filterShown, $filterPermalink) {
$data = read_data($dir);
$state = input_dir_state($dir);
$fullOutput = basename((string)($state['output'] ?? ''));
$previewOutput = basename((string)($state['preview_output'] ?? ''));
$hasFullVideo = $fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput);
$hasPreviewVideo = $previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput);
$preview = input_dir_preview($dir);
$displayOutput = ($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : ''));
$hasVideo = $displayOutput !== '';
$enabled = input_dir_enabled($dir);
$canShow = input_dir_can_show($dir, $config);
$visible = input_dir_effective_visible($dir, $config);
if ($statusFilterActive) {
$statusMatch = false;
if ($filterPreview && $preview) $statusMatch = true;
if ($filterRender && $enabled) $statusMatch = true;
if ($filterShown && $visible) $statusMatch = true;
if ($filterPermalink && input_dir_permalink($dir)) $statusMatch = true;
if (!$statusMatch) return false;
}
if ($keywordValue === '') return true;
$meta = $hasVideo ? cached_video_metadata($displayOutput, $data) : ['title'=>(string)($data['title'] ?? ''),'teaser'=>(string)($data['teaser'] ?? ''),'date'=>(string)($data['date'] ?? ''),'location'=>(string)($data['place'] ?? ''),'quote_da'=>(string)($data['quote_da'] ?? ''),'description'=>(string)($data['description'] ?? '')];
$title = (string)(($meta['title'] ?? '') !== '' ? $meta['title'] : basename($dir));
$teaser = (string)($meta['teaser'] ?? '');
$description = (string)($meta['description'] ?? '');
$quoteDa = (string)($meta['quote_da'] ?? '');
$date = (string)($meta['date'] ?? '');
$location = (string)($meta['location'] ?? '');
if ($keywordType === 'date') return ci_contains($date, $keywordValue);
if ($keywordType === 'location') return ci_contains($location, $keywordValue);
return ci_contains($title, $keywordValue) || ci_contains($teaser, $keywordValue) || ci_contains($quoteDa, $keywordValue) || ci_contains($description, $keywordValue);
}));
}
$page = max(1, (int)($_GET['page'] ?? 1));
[$editPage, $editPages, $dirsPage, $dirsTotal] = paginate($dirs, $tab === 'edit' ? $page : 1);
[$videoPage, $videoPages, $orphanVideosPage, $orphanVideosTotal] = paginate($orphanVideos, $tab === 'videos' ? $page : 1);
$editName = $_GET['edit'] ?? '';
$editId = $_GET['id'] ?? '';
$editDir = null; $editStatus = ''; $editRunning = false; $editData = ['title'=>'','teaser'=>'','quote_da'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $editFiles = [];
if ($editName !== '' || $editId !== '') { try { $editDir = resolve_input_dir_from_request($config['uploads_dir'], $editId, $editName, $articleIndex); $editStatus = input_dir_status($editDir); $editRunning = input_dir_running($editDir); if (!$editRunning) { $editData = read_data($editDir); $editFiles = media_sort_files($editDir, list_files($editDir)); } } catch (Throwable $e) { $err = $e->getMessage(); } }
$runningJobs = active_worker_jobs($config);
?>
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>MVLog Admin</title><link rel="icon" type="image/png" href="assets/img/moto_travel.png"><link rel="stylesheet" href="style.css?v=20260621c"><style>.admin-search-form{display:flex;flex-wrap:nowrap;align-items:center;gap:.55rem;margin:0 0 .6rem;overflow-x:auto;padding-bottom:2px}.admin-search-wrap{position:relative;min-width:180px;flex:1 1 auto}.admin-search-wrap input{margin:0;padding-right:2.15rem}.admin-search-clear{position:absolute;right:.45rem;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;border-radius:50%;background:#C46A3A;color:#111315;text-decoration:none;font-size:1rem;line-height:1;font-weight:700;box-shadow:0 1px 4px #0007;transition:opacity .15s ease,transform .15s ease}.admin-search-clear:hover{background:#d97b48;color:#111315;transform:translateY(-50%) scale(1.05)}.admin-search-clear.is-empty{opacity:.38;pointer-events:none}.admin-search-submit{display:inline-grid;place-items:center;width:2.35rem;height:2.35rem;padding:0;border-radius:.5rem;background:#C46A3A;color:#111315;flex:0 0 auto}.admin-search-submit .icon{font-size:1.02rem;line-height:1;transform:translateY(.01em)}.admin-search-filters{display:flex;flex-wrap:nowrap;gap:.7rem;align-items:center;flex:0 0 auto;white-space:nowrap}.admin-filter-item{display:inline-flex;align-items:center;gap:.35rem;margin:0;white-space:nowrap;font-size:.92rem}.admin-filter-item input[type=checkbox]{width:.9rem;height:.9rem;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.description-teaser{margin:.2rem 0 .45rem;color:#eadfca}.description-quote{margin:.2rem 0 .45rem;font-style:italic;font-size:.82rem;color:#d4d7dd}.meta a{color:inherit;text-decoration:none}.meta a:hover{text-decoration:underline}.meta-filter{display:inline-flex;align-items:center;gap:.25rem;padding:.12rem .5rem;border-radius:999px;background:rgba(255,255,255,.06);cursor:pointer;user-select:none;transition:background-color .15s ease,transform .15s ease}.meta-filter:hover,.meta-filter:focus-visible{background:rgba(255,255,255,.12);transform:translateY(-1px);outline:none}.meta-filter:focus-visible{box-shadow:0 0 0 2px rgba(202,162,109,.35)}</style></head><body>
<header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="/" aria-label="MVLog home" target="_blank"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1 style="font-size:1.15rem">MVLog <span class="admin-word">Admin</span></h1><p>journal of an<br>unreliable narrator.</p></a></div></header>
<main>
<?php if($err): ?><div class="err"><?=h($err)?></div><?php endif; ?>
<?php if($tab==='videos'): ?><section><h2>Videos without input dir</h2><?php if(!$orphanVideos): ?><p>No orphan videos.</p><?php endif; ?><div class="admin-list"><?php foreach($orphanVideosPage as $v): $vn=basename($v); ?><form method="post" class="admin-item" onsubmit="return confirm('Delete this generated video?') js-ajax-form"><div><strong><?=h($vn)?></strong><br><span><?=h(round(filesize($v)/1048576,1))?> MB</span></div><input type="hidden" name="action" value="delete_video"><input type="hidden" name="video" value="<?=h($vn)?>"><button type="submit">Delete video</button></form><?php endforeach; ?></div><?=page_links('videos',$videoPage,$videoPages)?></section><?php endif; ?>
<?php if($tab==='edit' && !$editDir): ?><section><form method="get" class="admin-search-form"><input type="hidden" name="tab" value="edit"><div class="admin-search-wrap"><input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/teaser/quote_da/description or use date=... / location=..." aria-label="Search input dirs"><a class="admin-search-clear <?=$searchEmpty ? 'is-empty' : ''?>" href="admin.php?tab=edit" aria-label="Clear filter" title="Clear filter">×</a></div><div class="admin-search-filters"><label class="admin-filter-item" title="Preview"><input type="checkbox" name="f_preview" value="1" <?=$filterPreview?'checked':''?>><span>P</span></label><label class="admin-filter-item" title="Render"><input type="checkbox" name="f_render" value="1" <?=$filterRender?'checked':''?>><span>R</span></label><label class="admin-filter-item" title="Show"><input type="checkbox" name="f_shown" value="1" <?=$filterShown?'checked':''?>><span>S</span></label><label class="admin-filter-item" title="Permalink"><input type="checkbox" name="f_permalink" value="1" <?=$filterPermalink?'checked':''?>><span>PL</span></label></div><button type="submit" class="admin-search-submit" aria-label="Filter"><span class="icon" aria-hidden="true">🔍</span><span class="sr-only">Filter</span></button></form><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list video-list"><?php foreach($dirsPage as $d): $runStatus=input_dir_status($d); $running=input_dir_running($d); $enabled=input_dir_enabled($d); $preview=input_dir_preview($d); $state=input_dir_state($d); $fullOutput=basename((string)($state['output'] ?? '')); $previewOutput=basename((string)($state['preview_output'] ?? '')); $hasFullVideo=$fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput); $hasPreviewVideo=$previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput); $displayOutput=($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo=$displayOutput !== ''; $displayVideoPath=$hasVideo ? $config['videos_dir'].'/'.$displayOutput : ''; $posterUrl=$hasVideo ? video_thumb_public_url($config, $displayOutput) : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=input_dir_can_show($d, $config); $visible=input_dir_effective_visible($d, $config); $articleIdCurrent=read_article_id($d); $announced=mvlog_announcement_sent(__DIR__, $articleIdCurrent, basename($d)); $mapHidden=input_dir_map_hidden($d); $permalink=input_dir_permalink($d); $canPermalink=input_dir_can_permalink($d, $config); $publicLocked=input_dir_public_locked($d, $config); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=$hasVideo ? cached_video_metadata($displayOutput, $data) : ['title'=>(string)($data['title'] ?? ''),'teaser'=>(string)($data['teaser'] ?? ''),'date'=>(string)($data['date'] ?? ''),'location'=>(string)($data['place'] ?? ''),'quote_da'=>(string)($data['quote_da'] ?? ''),'description'=>(string)($data['description'] ?? '')]; $currentSignature=$info['signature'] ?? ''; $fullFingerprint=is_array($state)?(string)($state['fingerprint'] ?? ''):''; $previewFingerprint=is_array($state)?(string)($state['preview_fingerprint'] ?? ''):''; $editedSinceRender=$hasFullVideo && $currentSignature !== '' && $fullFingerprint !== '' && $currentSignature !== $fullFingerprint; $editedSincePreview=!$hasFullVideo && $previewVideo && $currentSignature !== '' && $previewFingerprint !== '' && $currentSignature !== $previewFingerprint; $staleLabel=$editedSinceRender ? 'Edited since render' : ($editedSincePreview ? 'Edited since preview' : ''); ?><article class="video-row admin-video-row<?= $visible ? ' shown' : '' ?>" data-job="<?=h(basename($d))?>" data-id="<?=h($articleIdCurrent)?>" data-announced="<?= $announced ? '1' : '0' ?>" data-permalink="<?= $permalink ? '1' : '0' ?>" data-visible="<?= $visible ? '1' : '0' ?>" data-can-permalink="<?= $canPermalink ? '1' : '0' ?>" data-public-locked="<?= $publicLocked ? '1' : '0' ?>"><div><?php $map_n = ($displayOutput !== '' ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . '_map.png'; $hasMap = is_file($config['videos_dir'].'/'.$map_n); ?><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></video><?php if($hasMap): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?php endif; ?></div><?php else: ?><div class="video-placeholder"><img src="assets/img/moto_travel.png" alt=""><span>No video yet</span></div><?php if($hasMap): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?php endif; ?><?php endif; ?></div><div class="video-info"><?php $thumbMeta = $hasVideo ? video_thumb_source_info($config, $displayOutput) : []; if(!empty($thumbMeta['source_file'])): ?><p style="margin:.2rem 0 .45rem;color:#A0A4AB;font-size:.82rem">Thumb source: <?=h((string)$thumbMeta['source_file'])?></p><?php endif; ?><div class="admin-row-top"><div class="admin-switches">
<form method="post" class="inline-form switch-form" data-switch="preview">
<input type="hidden" name="action" value="set_preview">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="preview" value="1" <?=$preview?'checked':''?> <?=$publicLocked?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Preview</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="render">
<input type="hidden" name="action" value="set_enabled">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="enabled" value="1" <?=$enabled?'checked':''?> <?=$publicLocked?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Render</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="show">
<input type="hidden" name="action" value="set_visible">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="visible" value="1" <?=$visible?'checked':''?> <?=!$canShow?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Show</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="permalink">
<input type="hidden" name="action" value="set_permalink">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="permalink" value="1" <?=$permalink?'checked':''?> <?=!$canPermalink?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Permalink</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="hide-map">
<input type="hidden" name="action" value="set_map_hidden">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="map_hidden" value="1" <?=$mapHidden?'checked':''?> <?=$publicLocked?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Hide map</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<?php if($hasVideo): ?><time class="admin-time<?= $staleLabel ? ' admin-time-stale' : '' ?>" title="<?=h($staleLabel ?: 'Video created at')?>"><?=h(date('d.m.Y H:i', filemtime($displayVideoPath)))?></time><?php endif; ?>
</div><div class="admin-actions"><?php if($running): ?><span class="button disabled" title="Rendering now" data-edit-action="1">Rendering</span><?php elseif($publicLocked): ?><a class="button disabled" title="Disable Show and Permalink before editing" aria-disabled="true" data-edit-action="1" data-edit-href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>">Edit</a><?php else: ?><a class="button" href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>" data-edit-action="1" data-edit-href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>">Edit</a><?php endif; ?></div></div><?php $articleIdForLink = read_article_id($d); $permalinkHref = article_permalink_url($config, $articleIdForLink); $titlePlain = $meta['title'] ?: ($info['title'] ?? basename($d)); $titleText = h($titlePlain); $dateFilterUrl = $meta['date'] ? ('admin.php?tab=edit&q=' . rawurlencode('date=' . $meta['date'])) : ''; $locFilterUrl = $meta['location'] ? ('admin.php?tab=edit&q=' . rawurlencode('location=' . $meta['location'])) : ''; ?><h2 class="video-row-title" data-title-text="<?=h($titlePlain)?>" data-permalink-url="<?=h($permalinkHref)?>"><?php if($permalink && $permalinkHref !== ''): ?><a href="<?=h($permalinkHref)?>" target="_blank"><?= $titleText ?></a><?php else: ?><?= $titleText ?><?php endif; ?></h2><p class="meta"><?php if($meta['date']): ?><a class="meta-filter" href="<?=h($dateFilterUrl)?>" title="Filter by date"><?=h($meta['date'])?></a><?php endif; ?><?php if($meta['location']): ?><a class="meta-filter" href="<?=h($locFilterUrl)?>" title="Filter by location"><?=h($meta['location'])?></a><?php endif; ?></p><?php if(!empty($meta['teaser'])): ?><p class="description-teaser"><?=h((string)$meta['teaser'])?></p><?php endif; ?><?php if($meta['description']): ?><p class="description"><?=nl2br(h($meta['description']), false)?></p><?php endif; ?><?php if(!empty($meta['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$meta['quote_da'], "\"“”"))?>”</p><?php endif; ?><?php $detailParts = []; if(!$hasVideo) $detailParts[] = 'No video'; if($previewVideo) $detailParts[] = 'Preview video'; $detailParts[] = basename($d); $detailParts[] = (string)($info['file_count'] ?? count(list_files($d))) . ' files'; ?><p class="details"><?=h(implode(' · ', $detailParts))?></p></div></article><?php endforeach; ?></div><?=page_links('edit',$editPage,$editPages)?></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && $editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><div class="err">This input directory is <?=h($editStatus)?>. Editing is disabled until the job completes.</div></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && !$editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><form method="post" enctype="multipart/form-data" id="edit-form"><input type="hidden" name="action" value="update"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label>Title<input name="title" value="<?=h($editData['title'])?>"></label><label>Teaser<input name="teaser" value="<?=h($editData['teaser'])?>" maxlength="240"></label><label>Danish quote<input name="quote_da" value="<?=h($editData['quote_da'])?>" maxlength="280"></label><label>Date<input name="date" value="<?=h($editData['date'])?>"></label><label>Place<input name="place" value="<?=h($editData['place'])?>"></label><label>Describe prompt<textarea name="describe_prompt" rows="4" placeholder="Optional extra guidance used by the Describe button"><?=h(read_describe_prompt($editDir))?></textarea></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-edit"><?=h($editData['description'])?></textarea><small id="description-counter-edit" class="counter"></small></label><button type="submit">Save changes</button><label>Add images / videos<input type="file" name="media[]" multiple accept="image/*,video/*"></label><label>Add audio<input type="file" name="audio[]" multiple accept="audio/*"></label><h3>Files and captions</h3><div class="file-list"><?php foreach($editFiles as $f): $ext=strtolower(pathinfo($f, PATHINFO_EXTENSION)); $fileUrl='in-dir/'.rawurlencode(basename($editDir)).'/'.rawurlencode($f); ?><div class="caption-row"><div class="preview"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><img src="<?=h($fileUrl)?>" alt=""><?php elseif(in_array($ext,['mp4','mov','m4v','webm'])): ?><video src="<?=h($fileUrl)?>" controls preload="metadata"></video><a class="download-link" href="<?=h($fileUrl)?>" download>Download</a><?php else: ?><span><?=h(strtoupper($ext ?: 'FILE'))?></span><?php endif; ?></div><div class="caption-fields"><strong><?=h($f)?></strong><?php if(in_array($ext,['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm'])): ?><input type="hidden" name="caption_files[]" value="<?=h($f)?>"><textarea name="captions[]" placeholder="Optional caption for this file"><?=h($editData['captions'][$f] ?? '')?></textarea><?php if(in_array($ext,['mp4','mov','m4v','webm'])): ?><label class="checkbox-label"><input type="checkbox" name="use_audio_files[]" value="<?=h($f)?>" <?=!empty($editData['video_audio'][$f])?'checked':''?>> <span>Use video file audio</span></label><?php endif; ?><?php else: ?><small>No caption for audio files</small><?php endif; ?></div><div class="file-actions" style="display:flex;gap:.45rem;flex-wrap:wrap;align-self:start"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><button type="button" class="set-thumb-btn" onclick="setThumb(<?=h(json_encode($f))?>)">Set as thumb</button><?php endif; ?><?php if(in_array($ext,['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm'])): ?><button type="button" class="button order-btn-up" title="Move up" onclick="moveFileOrder(<?=h(json_encode($f))?>,'up',this.closest('.caption-row'),this)">↑</button><button type="button" class="button order-btn-down" title="Move down" onclick="moveFileOrder(<?=h(json_encode($f))?>,'down',this.closest('.caption-row'),this)">↓</button><?php endif; ?><button type="button" onclick="deleteFile(<?=h(json_encode($f))?>)">Remove</button></div></div><?php endforeach; ?></div><button type="submit">Save changes</button></form><form method="post" id="delete-file-form" style="display:none"><input type="hidden" name="action" value="delete_file"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><input type="hidden" name="file" id="delete-file-name" value=""></form><form method="post" onsubmit="return confirm(\'Delete this input directory\?\')" id="delete-dir-form""><input type="hidden" name="action" value="delete_dir"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label class="checkbox-label"><input type="checkbox" name="delete_output" value="1"> <span>Also delete generated video, if any</span></label><button class="danger" type="submit">Delete Article</button></form></section><?php endif; ?>
<?php if($tab==='new'): ?><section><h2>Add new movie input</h2><form method="post" enctype="multipart/form-data" id="new-form"><input type="hidden" name="action" value="create"><label>Title*<input name="title" required></label><label>Teaser<input name="teaser" maxlength="240"></label><label>Danish quote<input name="quote_da" maxlength="280"></label><label>Date<input name="date" placeholder="2026-05-25"></label><label>Place<input name="place"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-new"></textarea><small id="description-counter-new" class="counter"></small></label><label>Images / videos*<input type="file" name="media[]" multiple required accept="image/*,video/*"></label><label>Audio (optional)<input type="file" name="audio[]" multiple accept="audio/*"></label><button type="submit">Create</button></form></section><?php endif; ?>
<script>
function deleteFile(name){
if(!confirm('Remove this file?')) return;
var form = document.getElementById('delete-file-form');
if(!form){ showToast('Internal error: delete form not found', false); return; }
var dirInput = form.querySelector('input[name=dir]');
var dir = dirInput ? dirInput.value : '';
var idInput = form.querySelector('input[name=id]');
var articleId = idInput ? idInput.value : '';
// find the caption-row for this file name
var rows = document.querySelectorAll('.caption-row');
var target = null;
for(var i=0;i<rows.length;i++){
var strong = rows[i].querySelector('.caption-fields strong') || rows[i].querySelector('strong');
if(!strong) continue;
if(strong.textContent.trim() === name){ target = rows[i]; break; }
}
var body = 'action=delete_file&dir=' + encodeURIComponent(dir) + '&id=' + encodeURIComponent(articleId) + '&file=' + encodeURIComponent(name) + '&ajax=1';
fetch('admin.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok: false, message: 'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error(json && (json.error || json.message) ? (json.error || json.message) : 'Delete failed');
if(target) target.remove();
refreshFileOrderButtons();
showToast(json.message || ('Removed ' + name), true);
}).catch(function(err){
showToast(err.message || String(err), false);
});
}
var thumbBusyOverlay = null;
function ensureThumbBusyStyles(){
if(document.getElementById('thumb-busy-styles')) return;
var style = document.createElement('style');
style.id = 'thumb-busy-styles';
style.textContent = '@keyframes mvlog-spin{to{transform:rotate(360deg)}}' +
'#thumb-busy-overlay{position:fixed;inset:0;background:rgba(17,24,39,.42);backdrop-filter:blur(1px);display:none;align-items:center;justify-content:center;z-index:2990;}' +
'#thumb-busy-overlay.show{display:flex;}' +
'#thumb-busy-overlay .panel{background:rgba(15,23,42,.92);color:#F3F4F6;border:1px solid rgba(255,255,255,.14);box-shadow:0 20px 60px rgba(0,0,0,.38);border-radius:16px;padding:22px 28px;min-width:240px;text-align:center;}' +
'#thumb-busy-overlay .spinner{width:52px;height:52px;border:5px solid rgba(255,255,255,.18);border-top-color:#F3F4F6;border-radius:50%;margin:0 auto 14px;animation:mvlog-spin .8s linear infinite;}' +
'#thumb-busy-overlay .label{font-size:1rem;font-weight:600;}' +
'button.set-thumb-btn.is-busy-target{opacity:.55;cursor:not-allowed;}';
document.head.appendChild(style);
}
function ensureThumbBusyOverlay(){
if(thumbBusyOverlay) return thumbBusyOverlay;
thumbBusyOverlay = document.getElementById('thumb-busy-overlay');
if(thumbBusyOverlay) return thumbBusyOverlay;
ensureThumbBusyStyles();
thumbBusyOverlay = document.createElement('div');
thumbBusyOverlay.id = 'thumb-busy-overlay';
thumbBusyOverlay.innerHTML = '<div class="panel"><div class="spinner"></div><div class="label">Creating thumbnail…</div></div>';
document.body.appendChild(thumbBusyOverlay);
return thumbBusyOverlay;
}
function setThumbButtonsDisabled(disabled){
var buttons = document.querySelectorAll('button.set-thumb-btn');
for(var i=0;i<buttons.length;i++){
buttons[i].disabled = disabled;
buttons[i].classList.toggle('is-busy-target', disabled);
}
}
function showThumbBusy(label){
var overlay = ensureThumbBusyOverlay();
setThumbButtonsDisabled(true);
var text = overlay.querySelector('.label');
if(text && label) text.textContent = label;
overlay.classList.add('show');
}
function hideThumbBusy(){
if(thumbBusyOverlay) thumbBusyOverlay.classList.remove('show');
setThumbButtonsDisabled(false);
}
function setThumb(name){
var ctx = getEditDirContext();
if(!ctx.dir){ showToast('Internal error: missing input dir', false); return; }
showThumbBusy('Creating thumbnail…');
var body = 'action=set_video_thumb&dir=' + encodeURIComponent(ctx.dir) + '&id=' + encodeURIComponent(ctx.id) + '&file=' + encodeURIComponent(name) + '&ajax=1';
fetch('admin.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok:false, message:'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error((json && (json.error || json.message)) ? (json.error || json.message) : 'Set thumb failed');
showToast(json.message || ('Thumbnail set from ' + name), true);
setTimeout(hideThumbBusy, 120);
}).catch(function(err){
showToast(err.message || String(err), false);
setTimeout(hideThumbBusy, 120);
});
}
function getEditDirContext(){
var form = document.getElementById('delete-file-form');
if(!form) return {dir:'', id:''};
var dirInput = form.querySelector('input[name=dir]');
var idInput = form.querySelector('input[name=id]');
return {
dir: dirInput ? dirInput.value : '',
id: idInput ? idInput.value : ''
};
}
function captionRowFileName(row){
if(!row) return '';
var strong = row.querySelector('.caption-fields strong') || row.querySelector('strong');
return strong ? strong.textContent.trim() : '';
}
function isVisualMediaName(name){
var lower = String(name || '').toLowerCase();
return /\.(jpg|jpeg|png|webp|gif|mp4|mov|m4v|avi|mkv|webm)$/.test(lower);
}
function getVisualCaptionRows(){
return Array.prototype.slice.call(document.querySelectorAll('.caption-row')).filter(function(row){
return isVisualMediaName(captionRowFileName(row));
});
}
function moveFileOrder(name, direction, row, triggerBtn){
var ctx = getEditDirContext();
if(!ctx.dir){ showToast('Internal error: missing input dir', false); return; }
if(triggerBtn) triggerBtn.disabled = true;
var body = 'action=reorder_file'
+ '&dir=' + encodeURIComponent(ctx.dir)
+ '&id=' + encodeURIComponent(ctx.id)
+ '&file=' + encodeURIComponent(name)
+ '&direction=' + encodeURIComponent(direction)
+ '&ajax=1';
fetch('admin.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok:false, error:'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error((json && (json.error || json.message)) ? (json.error || json.message) : 'Reorder failed');
if(row && json.changed){
var rows = getVisualCaptionRows();
var idx = rows.indexOf(row);
if(direction === 'up' && idx > 0){
row.parentNode.insertBefore(row, rows[idx - 1]);
} else if(direction === 'down' && idx >= 0 && idx < rows.length - 1){
row.parentNode.insertBefore(rows[idx + 1], row);
}
}
refreshFileOrderButtons();
showToast(json.message || 'Order updated', true);
}).catch(function(err){
showToast(err.message || String(err), false);
}).finally(function(){
if(triggerBtn) triggerBtn.disabled = false;
});
}
function ensureFileOrderButtons(){
var rows = document.querySelectorAll('.caption-row');
for(var i=0;i<rows.length;i++){
var row = rows[i];
if(row.dataset.orderButtons === '1') continue;
var fileName = captionRowFileName(row);
if(!isVisualMediaName(fileName)) { row.dataset.orderButtons = '1'; continue; }
var existingUp = row.querySelector('.order-btn-up');
var existingDown = row.querySelector('.order-btn-down');
if(existingUp || existingDown) { row.dataset.orderButtons = '1'; continue; }
var removeBtn = row.querySelector('button[onclick*="deleteFile"]');
if(!removeBtn) { row.dataset.orderButtons = '1'; continue; }
var wrap = document.createElement('span');
wrap.className = 'file-order-controls';
wrap.style.display = 'inline-flex';
wrap.style.gap = '6px';
wrap.style.marginRight = '8px';
var upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'button order-btn-up';
upBtn.textContent = '↑';
upBtn.title = 'Move up';
upBtn.style.padding = '0.2rem 0.45rem';
var downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'button order-btn-down';
downBtn.textContent = '↓';
downBtn.title = 'Move down';
downBtn.style.padding = '0.2rem 0.45rem';
(function(r, file, up, down){
up.addEventListener('click', function(){ moveFileOrder(file, 'up', r, up); });
down.addEventListener('click', function(){ moveFileOrder(file, 'down', r, down); });
})(row, fileName, upBtn, downBtn);
wrap.appendChild(upBtn);
wrap.appendChild(downBtn);
row.insertBefore(wrap, removeBtn);
row.dataset.orderButtons = '1';
}
}
function refreshFileOrderButtons(){
ensureFileOrderButtons();
var rows = getVisualCaptionRows();
for(var i=0;i<rows.length;i++){
var row = rows[i];
var up = row.querySelector('.order-btn-up');
var down = row.querySelector('.order-btn-down');
if(up) up.disabled = (i === 0);
if(down) down.disabled = (i === rows.length - 1);
}
}
document.addEventListener('DOMContentLoaded', refreshFileOrderButtons);
</script>
<script>
function showToast(message, ok, details){
let wrap=document.getElementById('toast-wrap');
if(!wrap){
wrap=document.createElement('div');
wrap.id='toast-wrap';
wrap.className='toast-wrap';
document.body.appendChild(wrap);
}
const toast=document.createElement('div');
toast.className='toast '+(ok? 'ok-toast' : 'err-toast');
toast.style.pointerEvents = 'auto';
const text=document.createElement('div');
text.style.whiteSpace='normal';
text.textContent=message;
toast.appendChild(text);
if(details){
const actions=document.createElement('div');
actions.style.marginTop='8px';
actions.style.textAlign='right';
const dbtn=document.createElement('button');
dbtn.className='button';
dbtn.textContent='Details';
dbtn.style.padding='0.35rem 0.6rem';
dbtn.style.fontSize='0.85rem';
dbtn.addEventListener('click', function(e){ e.stopPropagation(); e.preventDefault(); showLogModal('Generator log', details); });
actions.appendChild(dbtn);
toast.appendChild(actions);
}
wrap.appendChild(toast);
requestAnimationFrame(function(){toast.classList.add('show');});
const timeout = details ? 15000 : 4000;
setTimeout(function(){
toast.classList.remove('show');
setTimeout(function(){toast.remove();},250);
}, timeout);
}
function showLogModal(title, text){
var existing=document.getElementById('mvlog-log-modal');
if(existing) existing.remove();
var overlay=document.createElement('div');
overlay.id='mvlog-log-modal';
overlay.style.cssText='position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10001;';
var box=document.createElement('div');
box.style.cssText='background:white;color:#111;padding:16px;max-width:90%;max-height:80vh;overflow:auto;border-radius:6px;font-family:inherit;';
var h=document.createElement('h3');
h.textContent=title||'Details';
var pre=document.createElement('pre');
pre.style.cssText='white-space:pre-wrap;font-family:monospace;margin-top:10px;border:1px solid #eee;padding:12px;background:#f8f8f8;border-radius:4px; max-height:60vh; overflow:auto;';
pre.textContent=text||'';
var btnBar=document.createElement('div');
btnBar.style.cssText='margin-top:12px;text-align:right;';
var copyBtn=document.createElement('button');
copyBtn.className='button'; copyBtn.textContent='Copy';
var closeBtn=document.createElement('button'); closeBtn.className='button'; closeBtn.textContent='Close'; closeBtn.style.marginLeft='8px';
btnBar.appendChild(copyBtn); btnBar.appendChild(closeBtn);
box.appendChild(h); box.appendChild(pre); box.appendChild(btnBar); overlay.appendChild(box); document.body.appendChild(overlay);
closeBtn.addEventListener('click', function(){ overlay.remove(); });
copyBtn.addEventListener('click', function(){ try{ navigator.clipboard.writeText(text).then(function(){ showToast('Copied to clipboard', true); }, function(){ showToast('Copy failed', false); }); }catch(e){ showToast('Copy failed', false); } });
}
const editForm=document.getElementById('edit-form');
if(editForm) handleAjaxFormSubmit(editForm, 'edit');
const newForm=document.getElementById('new-form');
if(newForm) handleAjaxFormSubmit(newForm, 'edit');
const deleteFileForm=document.getElementById('delete-file-form');
if(deleteFileForm) handleAjaxFormSubmit(deleteFileForm, 'edit');
const deleteDirForm=document.getElementById('delete-dir-form');
if(deleteDirForm) handleAjaxFormSubmit(deleteDirForm, 'edit');
// For old delete video forms
// Redirect tab defaults to videos
for(const form of document.querySelectorAll('.js-ajax-form')) {
handleAjaxFormSubmit(form, form.dataset.redirectTab || 'videos');
}
function handleAjaxFormSubmit(form, redirectTab) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submit = form.querySelector('button[type="submit"]');
const data = new FormData(form);
data.set('ajax', '1');
const action = String(data.get('action') || '');
const verb = action === 'update' ? 'Saving changes…'
: action === 'delete_video' ? 'Deleting'
: action === 'delete_dir' ? 'Deleting'
: action === 'delete_file' ? 'Deleting'
: action === 'create' ? 'Creating'
: 'Working';
let timer = 0;
let success = false;
const useBusyOverlay = action === 'update' || action === 'create';
const busyLabel = action === 'create' ? 'Creating input directory…' : 'Saving changes…';
if (submit) {
submit.dataset.label = submit.dataset.label || submit.textContent.trim() || 'Submit';
submit.disabled = true;
submit.style.opacity = '0.78';
submit.style.cursor = 'wait';
if (!useBusyOverlay) {
let dots = 0;
submit.textContent = verb;
timer = window.setInterval(function(){
dots = (dots + 1) % 4;
submit.textContent = verb + '.'.repeat(dots);
}, 320);
}
}
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(() => ({}));
if(!res.ok || !json.ok) throw new Error(json.error || 'Operation failed');
success = true;
showToast(json.message || 'Operation successful', true);
setTimeout(() => { window.location.href = 'admin.php?tab=' + (json.tab || redirectTab); }, 450);
} catch(e) {
showToast(e.message || 'Operation failed', false);
if (useBusyOverlay) setTimeout(hideThumbBusy, 120);
} finally {
if (timer) window.clearInterval(timer);
if(submit && !success) {
submit.disabled = false;
submit.style.opacity = '';
submit.style.cursor = '';
submit.textContent = submit.dataset.label || 'Submit';
}
if (useBusyOverlay && !success) hideThumbBusy();
}
});
}
const initialMessage = <?= json_encode($msg ?? '') ?>;
if (initialMessage) showToast(initialMessage, true);
initSwitchForms();
function initSwitchForms(){
document.querySelectorAll('.switch-form').forEach(function(form){
const checkbox=form.querySelector('input[type="checkbox"]');
if(!checkbox) return;
checkbox.dataset.state = checkbox.checked ? '1' : '0';
form.addEventListener('submit', function(event){ event.preventDefault(); });
checkbox.addEventListener('change', function(event){
event.preventDefault();
handleSwitchToggle(form, checkbox);
});
});
}
function ensureShowPromptStyles(){
if(document.getElementById('show-prompt-styles')) return;
const style = document.createElement('style');
style.id = 'show-prompt-styles';
style.textContent = '#show-prompt-overlay{position:fixed;inset:0;background:rgba(17,24,39,.48);backdrop-filter:blur(1px);display:flex;align-items:center;justify-content:center;z-index:2995;}' +
'#show-prompt-overlay .panel{background:rgba(15,23,42,.96);color:#F3F4F6;border:1px solid rgba(255,255,255,.14);box-shadow:0 20px 60px rgba(0,0,0,.38);border-radius:16px;padding:22px 28px;min-width:min(360px,calc(100vw - 2rem));max-width:460px;}' +
'#show-prompt-overlay h3{margin:.1rem 0 .65rem;font-size:1.1rem;}' +
'#show-prompt-overlay p{margin:.45rem 0 .9rem;color:#A0A4AB;line-height:1.45;}' +
'#show-prompt-overlay .prompt-check{display:flex;align-items:center;gap:.55rem;margin:.8rem 0 1rem;font-weight:600;}' +
'#show-prompt-overlay .prompt-check input{display:inline-block;width:auto;margin:0;}' +
'#show-prompt-overlay .actions{display:flex;justify-content:flex-end;gap:.55rem;margin-top:.8rem;}';
document.head.appendChild(style);
}
function promptSendEmailsImmediately(){
ensureShowPromptStyles();
return new Promise(function(resolve){
const overlay = document.createElement('div');
overlay.id = 'show-prompt-overlay';
overlay.innerHTML = '<div class="panel" role="dialog" aria-modal="true" aria-labelledby="show-prompt-title">' +
'<h3 id="show-prompt-title">Show article</h3>' +
'<p>This will announce the article and create the journal campaign.</p>' +
'<label class="prompt-check"><input type="checkbox" id="show-send-immediately"> <span>Send emails immediately</span></label>' +
'<div class="actions"><button type="button" id="show-prompt-ok">OK</button></div>' +
'</div>';
document.body.appendChild(overlay);
const checkbox = overlay.querySelector('#show-send-immediately');
const ok = overlay.querySelector('#show-prompt-ok');
ok.addEventListener('click', function(){
const value = !!(checkbox && checkbox.checked);
overlay.remove();
resolve(value);
});
ok.focus();
});
}
async function handleSwitchToggle(form, checkbox){
if(checkbox.disabled) return;
const previousState = checkbox.dataset.state === '1';
const data = new FormData(form);
data.set('ajax', '1');
const useBusyOverlay = form.dataset.switch === 'show';
const isShowingNow = useBusyOverlay && checkbox.checked && !previousState;
const row = form.closest('.admin-video-row');
const alreadyAnnounced = !!(row && row.dataset.announced === '1');
let sendImmediately = false;
if(isShowingNow && !alreadyAnnounced){
sendImmediately = await promptSendEmailsImmediately();
}
if(checkbox.checked){
data.set(checkbox.name, '1');
}else{
data.delete(checkbox.name);
}
if(sendImmediately){
data.set('send_immediately', '1');
}
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(() => ({}));
if(!res.ok || !json.ok) throw new Error(json.error || 'Switch update failed');
applySwitchPayload(json);
showToast(json.message || 'Updated input directory', true);
}catch(e){
checkbox.checked = previousState;
checkbox.dataset.state = previousState ? '1' : '0';
showToast(e.message || 'Switch update failed', false);
}finally{
checkbox.disabled = false;
if(useBusyOverlay) setTimeout(hideThumbBusy, 120);
}
}
function updateAdminRowTitleLink(row){
if(!row) return;
const titleEl = row.querySelector('h2');
if(!titleEl) return;
const title = titleEl.dataset.titleText || titleEl.textContent.trim();
const url = titleEl.dataset.permalinkUrl || '';
const permalinkOn = row.dataset.permalink === '1';
titleEl.textContent = '';
if(url && permalinkOn){
const link = document.createElement('a');
link.href = url;
link.textContent = title;
titleEl.appendChild(link);
} else {
titleEl.textContent = title;
}
}
function rowPublicLocked(row){
return !!row && row.dataset.publicLocked === '1';
}
function updatePublicLockedControls(row){
if(!row) return;
const locked = rowPublicLocked(row);
['preview','render','hide-map'].forEach(function(type){
const input = row.querySelector('form[data-switch="'+type+'"] input[type="checkbox"]');
if(input) input.disabled = locked;
});
const describe = row.querySelector('.describe-button');
if(describe){
describe.classList.toggle('disabled', locked);
describe.setAttribute('aria-disabled', locked ? 'true' : 'false');
describe.title = locked ? 'Disable Show and Permalink before describing' : '';
}
const edit = row.querySelector('[data-edit-action="1"]');
if(edit && edit.tagName === 'A'){
if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';
if(locked){
edit.removeAttribute('href');
edit.classList.add('disabled');
edit.setAttribute('aria-disabled', 'true');
edit.title = 'Disable Show and Permalink before editing';
}else{
const href = edit.dataset.editHref || '';
if(href) edit.setAttribute('href', href);
edit.classList.remove('disabled');
edit.removeAttribute('aria-disabled');
edit.title = 'Edit';
}
}
}
window.updatePublicLockedControls = updatePublicLockedControls;
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('.admin-video-row').forEach(updatePublicLockedControls);
});
function applySwitchPayload(payload){
if(!payload) return;
let row = null;
if(payload.id) row = document.querySelector('.admin-video-row[data-id="'+payload.id+'"]');
if(!row && payload.dir) row = document.querySelector('.admin-video-row[data-job="'+payload.dir+'"]');
if(!row) return;
function setSwitch(type, value){
const target = row.querySelector('form[data-switch="'+type+'"] input[type="checkbox"]');
if(!target) return;
target.checked = !!value;
target.dataset.state = target.checked ? '1' : '0';
}
if('preview' in payload) setSwitch('preview', payload.preview);
if('enabled' in payload) setSwitch('render', payload.enabled);
if('map_hidden' in payload) setSwitch('hide-map', payload.map_hidden);
if('permalink' in payload){
setSwitch('permalink', payload.permalink);
row.dataset.permalink = payload.permalink ? '1' : '0';
}
if('visible' in payload){
setSwitch('show', payload.visible);
row.classList.toggle('shown', !!payload.visible);
row.dataset.visible = payload.visible ? '1' : '0';
}
if('public_locked' in payload){
row.dataset.publicLocked = payload.public_locked ? '1' : '0';
}else{
row.dataset.publicLocked = (row.dataset.permalink === '1' || row.dataset.visible === '1') ? '1' : '0';
}
if('can_show' in payload){
const showInput = row.querySelector('form[data-switch="show"] input[type="checkbox"]');
if(showInput){
showInput.disabled = !payload.can_show;
if(!payload.can_show){
showInput.checked = false;
showInput.dataset.state = '0';
row.classList.remove('shown');
row.dataset.visible = '0';
if(!('public_locked' in payload)) row.dataset.publicLocked = (row.dataset.permalink === '1') ? '1' : '0';
}
}
}
if('announced' in payload){
row.dataset.announced = payload.announced ? '1' : '0';
}
if('can_permalink' in payload){
row.dataset.canPermalink = payload.can_permalink ? '1' : '0';
const permalinkInput = row.querySelector('form[data-switch="permalink"] input[type="checkbox"]');
if(permalinkInput){
permalinkInput.disabled = !payload.can_permalink;
}
}
updateAdminRowTitleLink(row);
updatePublicLockedControls(row);
}
</script>
<script>
function initMetaFilters(){
const form = document.querySelector('.admin-search-form');
if(!form) return;
const input = form.querySelector('input[name="q"]');
if(!input) return;
document.querySelectorAll('.meta-filter[data-search]').forEach(function(el){
if(el.dataset.metaFilterBound === '1') return;
el.dataset.metaFilterBound = '1';
function applyFilter(){
input.value = el.dataset.search || '';
if (typeof form.requestSubmit === 'function') form.requestSubmit();
else form.submit();
}
el.addEventListener('click', function(e){
e.preventDefault();
e.stopPropagation();
applyFilter();
});
el.addEventListener('keydown', function(e){
if(e.key === 'Enter' || e.key === ' '){
e.preventDefault();
applyFilter();
}
});
});
}
document.addEventListener('DOMContentLoaded', initMetaFilters);
document.addEventListener('DOMContentLoaded', function(){
const box=document.getElementById('job-status');
if(!box) return;
function fmt(job){
const name=(job.name||'job').replace(/_/g,' ');
const started=job.started_at ? new Date(job.started_at) : null;
const ageText = started && !Number.isNaN(started.getTime()) ? ' <span class="job-age">(' + formatAge(started) + ')</span>' : '';
return '<span class="job-dot"></span><span>'+name+'</span>'+ageText;
}
function formatAge(started){
const seconds = Math.max(0, Math.floor((Date.now() - started.getTime()) / 1000));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function syncEditButtons(runningIds, runningJobs){
document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){
const job=row.dataset.job || '';
const articleId=row.dataset.id || '';
const edit=row.querySelector('[data-edit-action="1"]');
if(!edit) return;
const running=(articleId && runningIds.has(articleId)) || runningJobs.has(job);
if(edit.tagName === 'A'){
if(running){
if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';
edit.removeAttribute('href');
edit.classList.add('disabled');
edit.setAttribute('aria-disabled', 'true');
edit.title='Rendering now';
}else{
const href=edit.dataset.editHref || ('?edit=' + encodeURIComponent(job));
edit.setAttribute('href', href);
edit.classList.remove('disabled');
edit.removeAttribute('aria-disabled');
edit.title='Edit';
}
}else{
edit.classList.toggle('disabled', running);
edit.title=running ? 'Rendering now' : '';
}
if (typeof updatePublicLockedControls === 'function') updatePublicLockedControls(row);
});
}
async function updateJobs(){
try{
const r=await fetch('job_status.php',{cache:'no-store'});
if(!r.ok) throw new Error('status failed');
const data=await r.json();
const jobs=(data.jobs||[]).filter(function(job){ return job && (job.name || job.id); });
if (Array.isArray(data.switches) && typeof applySwitchPayload === 'function') {
data.switches.forEach(function(payload){ applySwitchPayload(payload); });
}
const runningIds=new Set(jobs.map(function(job){ return String(job.id || ''); }).filter(function(v){ return v !== ''; }));
const runningJobs=new Set(jobs.map(function(job){ return String(job.name || ''); }).filter(function(v){ return v !== ''; }));
syncEditButtons(runningIds, runningJobs);
if(!jobs.length){
box.hidden=true;
box.innerHTML='';
return;
}
box.hidden=false;
box.innerHTML=jobs.map(fmt).join('');
}catch(e){
if(box.innerHTML.trim() !== '') box.hidden=false;
}
}
updateJobs();
setInterval(updateJobs,32000);
});
</script></main>
<footer>Input dirs are saved under <code>in-dir/</code>.</footer>
<?php include __DIR__ . '/_footer_admin.php'; ?>
</body></html>