Permalinks changed from id to title slug...

This commit is contained in:
hbrain 2026-06-20 17:28:27 +02:00
parent 07aa53689e
commit db4a48d08d
7 changed files with 267 additions and 35 deletions

View file

@ -40,7 +40,7 @@ Important dirs:
- `location=...` / `place=...`
- Case-insensitive + accent-insensitive matching
- Shows title/date/location/teaser/description/quote_da
- Article permalinks use `?id=<article_id>`; filename fallback is no longer used
- Article permalinks use `/post/<title-slug>`; legacy `?id=<article_id>` URLs redirect to the canonical post URL.
- Permalink pages hide the bottom page numbers
- Optional map image per video (hidden when source article has `.mvlog-hide-map`)
- Video posters are resolved from the source image recorded in the thumbnail sidecar JSON; if the source image is missing, the `<video>` gets no poster
@ -101,7 +101,7 @@ Files:
- `sw.js` — service worker fallback notification text
Current text behavior:
- Show notifications open the article permalink (`./?id=<article_id>`)
- Show notifications open the canonical article permalink (`/post/<title-slug>`)
- payload body: `New aticle @ MVL!`
- SW fallback title/body: `MVL` / `New article @ MVL!`

View file

@ -2,4 +2,4 @@
// Shared header for public pages (index.php, contact.php, etc.)
$brandUrl = $brandUrl ?? '/';
if ($brandUrl === '') $brandUrl = '/';
?><header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="<?=h($brandUrl)?>" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="<?=h($brandUrl)?>"><h1><?=h($siteHeaderTitle)?></h1><p>journal of an<br>unreliable narrator.</p></a></div><div class="header-actions"><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a><a class="contact-link" href="contact.php" aria-label="Contact" title="Contact"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg></a></div></header>
?><header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="<?=h($brandUrl)?>" aria-label="MVLog home"><img src="/assets/img/moto_travel.png" alt=""></a><a class="brand" href="<?=h($brandUrl)?>"><h1><?=h($siteHeaderTitle)?></h1><p>journal of an<br>unreliable narrator.</p></a></div><div class="header-actions"><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="/feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a><a class="contact-link" href="/contact.php" aria-label="Contact" title="Contact"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg></a></div></header>

View file

@ -273,6 +273,47 @@ function read_article_id($dir){
$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;
@ -1556,7 +1597,7 @@ $runningJobs = active_worker_jobs($config);
<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); $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="/?id=<?=rawurlencode($articleIdForLink)?>"><?php if(article_id_is_valid($articleIdForLink)): ?><a href="/?id=<?=rawurlencode($articleIdForLink)?>" 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; ?>
</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; ?>
@ -2032,8 +2073,9 @@ function updateAdminRowTitleLink(row){
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){
if(url && permalinkOn){
const link = document.createElement('a');
link.href = url;
link.textContent = title;

View file

@ -28,10 +28,21 @@ function video_metadata($path){
}
return $meta;
}
function article_id_is_valid($id){ return is_string($id) && preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id); }
function read_article_id($dir){ $file=rtrim((string)$dir,'/').'/.mvlog-id'; if(!is_file($file)) return ''; $id=strtolower(trim((string)file_get_contents($file))); return article_id_is_valid($id)?$id:''; }
function 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 title_from_data($dir){ $file=rtrim((string)$dir,'/').'/data.txt'; if(is_file($file)){ foreach(file($file, FILE_IGNORE_NEW_LINES)?:[] as $line){ if(!str_contains($line,':')) continue; [$k,$v]=array_map('trim',explode(':',$line,2)); if(in_array(strtolower($k),['title','name'],true)&&$v!=='') return $v; }} return nice_title(basename((string)$dir)); }
function article_permalink_map($config){ $map=[]; $used=[]; $dirs=glob($config['uploads_dir'].'/*', GLOB_ONLYDIR)?:[]; sort($dirs,SORT_STRING); foreach($dirs as $dir){ if(!is_file($dir.'/.mvlog-permalink')) continue; $id=read_article_id($dir); if($id==='') continue; $base=slug_from_title(title_from_data($dir),$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; }
function article_permalink_for_video($config,$videoName,$map){ $videoName=basename((string)$videoName); foreach(glob($config['uploads_dir'].'/*', GLOB_ONLYDIR)?:[] as $dir){ if(!is_file($dir.'/.mvlog-permalink')) continue; $stateFile=$dir.'/.movmaker-state.json'; if(!is_file($stateFile)) continue; $state=json_decode((string)file_get_contents($stateFile),true); if(!is_array($state)) continue; if($videoName===basename((string)($state['output']??''))){ $id=read_article_id($dir); return $map[$id]??''; }} return ''; }
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'https';
$host = $_SERVER['HTTP_HOST'] ?? 'bubulescu.org';
$baseUrl = $scheme . '://' . $host . rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
$hiddenOutputs = hidden_video_outputs($config);
$permalinkMap = article_permalink_map($config);
$videos = array_values(array_filter(glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [], fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v))));
usort($videos, fn($a,$b)=>filemtime($b)<=>filemtime($a));
$videos = array_slice($videos,0,20);
@ -45,14 +56,14 @@ echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
<description>Latest videos from MVLog Bubulescu.Org</description>
<language>en</language>
<lastBuildDate><?=date(DATE_RSS)?></lastBuildDate>
<?php foreach($videos as $v): $name=basename($v); $m=video_metadata($v); $url=$baseUrl.'/out-dir/'.rawurlencode($name); $desc=trim(($m['location'] ? $m['location'].'. ' : '').$m['description']); ?>
<?php foreach($videos as $v): $name=basename($v); $m=video_metadata($v); $videoUrl=$baseUrl.'/out-dir/'.rawurlencode($name); $articlePath=article_permalink_for_video($config,$name,$permalinkMap); $url=$articlePath!=='' ? ('https://bubulescu.org'.$articlePath) : $videoUrl; $desc=trim(($m['location'] ? $m['location'].'. ' : '').$m['description']); ?>
<item>
<title><?=x($m['title'])?></title>
<link><?=x($url)?></link>
<guid isPermaLink="true"><?=x($url)?></guid>
<pubDate><?=date(DATE_RSS, filemtime($v))?></pubDate>
<description><?=x($desc)?></description>
<enclosure url="<?=x($url)?>" length="<?=filesize($v)?>" type="video/mp4" />
<enclosure url="<?=x($videoUrl)?>" length="<?=filesize($v)?>" type="video/mp4" />
</item>
<?php endforeach; ?>
</channel>

125
index.php
View file

@ -4,16 +4,11 @@ $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);
if (preg_match('~/index\.php(?:\?|$)~', (string)($_SERVER['REQUEST_URI'] ?? '')) && preg_match('/^[0-9]{14}[a-f0-9]{16}$/', (string)($_GET['id'] ?? ''))) {
header('Location: /?id=' . rawurlencode((string)$_GET['id']), true, 301);
exit;
}
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function render_description_html($s){
$html = h($s);
$linked = preg_replace_callback('/(?<![A-Za-z0-9_])(subscribe|contact|journal|postcard)(?![A-Za-z0-9_])/i', static function($m){
return '<a class="description-contact-link" href="contact.php" target="_blank" rel="noopener noreferrer">' . $m[0] . '<span class="external-link-icon" aria-hidden="true">↗</span><span class="sr-only"> (opens in new tab)</span></a>';
return '<a class="description-contact-link" href="/contact.php" target="_blank" rel="noopener noreferrer">' . $m[0] . '<span class="external-link-icon" aria-hidden="true">↗</span><span class="sr-only"> (opens in new tab)</span></a>';
}, $html);
return nl2br($linked ?? $html, false);
}
@ -46,7 +41,7 @@ function ci_contains($haystack, $needle){
function keep_non_empty(array $params): array { return array_filter($params, fn($v)=>$v !== '' && $v !== null); }
function query_url(array $params): string {
$params = keep_non_empty($params);
return $params ? ('?' . http_build_query($params)) : 'index.php';
return $params ? ('/?' . http_build_query($params)) : '/';
}
function hidden_video_outputs($config){
@ -111,7 +106,7 @@ 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));
return '/' . implode('/', array_map('rawurlencode', $parts));
}
function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName);
@ -175,6 +170,82 @@ function article_permalink_enabled($config, $articleId){
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return false;
return !empty($cache[$articleId]);
}
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 permalink_title_for_dir($config, $dir, $videoCache = []){
$dataFile = rtrim((string)$dir, '/') . '/data.txt';
if (is_file($dataFile)) {
foreach (file($dataFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (!str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
if (in_array(strtolower($key), ['title','name'], true) && $value !== '') return $value;
}
}
$stateFile = rtrim((string)$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 ($output !== '') {
$path = $config['videos_dir'] . '/' . $output;
if (is_file($path)) {
$meta = $videoCache[$output]['metadata'] ?? video_metadata($path);
$title = trim((string)($meta['title'] ?? ''));
if ($title !== '') return $title;
}
}
return nice_title(basename((string)$dir));
}
function permalink_slug_map($config, $videoCache = []){
$map = [];
$used = [];
$dirs = input_dirs($config['uploads_dir']);
sort($dirs, SORT_STRING);
foreach ($dirs as $dir) {
if (!input_dir_permalink($dir)) continue;
$id = read_article_id($dir);
if ($id === '') continue;
$base = permalink_slug_from_title(permalink_title_for_dir($config, $dir, $videoCache), $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[$slug] = $id;
}
return $map;
}
function article_permalink_url($config, $articleId, $videoCache = [], $absolute = false){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return '';
foreach (permalink_slug_map($config, $videoCache) as $slug => $id) {
if ($id === $articleId) {
$path = '/post/' . rawurlencode($slug);
return $absolute ? ('https://bubulescu.org' . $path) : $path;
}
}
return '';
}
function article_id_for_permalink_slug($config, $slug, $videoCache = []){
$slug = strtolower(trim((string)$slug, "/ \t\n\r\0\x0B"));
if (!preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug)) return '';
$map = permalink_slug_map($config, $videoCache);
return $map[$slug] ?? '';
}
function article_og_image_url($articleId){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return '';
@ -203,8 +274,8 @@ function render_blank_page(){
<title>MVLog</title>
<?php include __DIR__ . '/_pwa_head.php'; ?>
<link rel="icon" type="image/png" href="assets/img/moto_travel.png">
<link rel="stylesheet" href="style.css?v=20260531u">
<link rel="icon" type="image/png" href="/assets/img/moto_travel.png">
<link rel="stylesheet" href="/style.css?v=20260531u">
<style>html,body{height:100%;overflow:hidden}body{height:100dvh}main{flex:1;min-height:0}</style>
</head>
<body>
@ -324,10 +395,24 @@ usort($allVideos, function($a, $b) use ($videoCache) {
return $bd <=> $ad;
});
$postSlug = trim((string)($_GET['post'] ?? ''));
$articleId = '';
if ($postSlug !== '') {
$articleId = article_id_for_permalink_slug($config, $postSlug, $videoCache);
if ($articleId === '') render_404_page();
} else {
$articleId = trim((string)($_GET['id'] ?? ''));
if ($articleId !== '' && !preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) {
render_404_page();
}
if ($articleId !== '' && article_permalink_enabled($config, $articleId)) {
$canonicalPath = article_permalink_url($config, $articleId, $videoCache, false);
if ($canonicalPath !== '') {
header('Location: ' . $canonicalPath, true, 301);
exit;
}
}
}
if ($articleId !== '') {
if (!article_permalink_enabled($config, $articleId)) {
render_404_page();
@ -409,7 +494,8 @@ $defaultOgImageUrl = 'https://bubulescu.org/assets/img/bubulescuorg.jpg';
$ogImageUrl = $defaultOgImageUrl;
$ogImageAlt = 'journal of an unreliable narrator.';
$ogType = ($articleId !== '') ? 'article' : 'website';
$ogUrl = ($articleId !== '') ? ('https://bubulescu.org/?id=' . rawurlencode($articleId)) : 'https://bubulescu.org/';
$canonicalArticleUrl = ($articleId !== '') ? article_permalink_url($config, $articleId, $videoCache, true) : '';
$ogUrl = ($canonicalArticleUrl !== '') ? $canonicalArticleUrl : 'https://bubulescu.org/';
$pageTitle = 'MVLog';
if ($articleId !== '') {
$customOg = article_og_image_url($articleId);
@ -435,6 +521,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title><?=h($pageTitle)?></title>
<link rel="canonical" href="<?=h($ogUrl)?>">
<?php include __DIR__ . '/_pwa_head.php'; ?>
<!-- Basic Open Graph -->
@ -457,9 +544,9 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<meta name="twitter:title" content="<?=h($ogImageAlt)?>">
<meta name="twitter:description" content="Motorcycle road stories, hockey passion, pizza nerdism and facts remembered by an unreliable narrator.">
<link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="feed.php">
<link rel="icon" type="image/png" href="assets/img/moto_travel.png">
<link rel="stylesheet" href="style.css?v=20260531u">
<link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="/feed.php">
<link rel="icon" type="image/png" href="/assets/img/moto_travel.png">
<link rel="stylesheet" href="/style.css?v=20260531u">
<style>
.filter-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:0 0 .4rem}
.filter-input-wrap{position:relative;min-width:0}
@ -524,7 +611,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<form method="get" class="filter-form">
<div class="filter-input-wrap">
<input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/teaser/quote_da/description or use date=... / location=..." aria-label="Search articles">
<a class="filter-clear-inside <?=$rawKeyword === '' ? 'is-empty' : ''?>" href="index.php" aria-label="Clear filter" title="Clear filter">×</a>
<a class="filter-clear-inside <?=$rawKeyword === '' ? 'is-empty' : ''?>" href="/" aria-label="Clear filter" title="Clear filter">×</a>
</div>
<button type="submit" class="filter-submit" aria-label="Filter">
<span class="icon" aria-hidden="true">🔍</span>
@ -541,9 +628,9 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
$base = pathinfo($name, PATHINFO_FILENAME);
$articleIdForVideo = video_article_id($config, $name);
$permalinkEnabled = $articleIdForVideo !== '' && article_permalink_enabled($config, $articleIdForVideo);
$permalinkUrl = $permalinkEnabled ? ('/?id=' . rawurlencode($articleIdForVideo)) : '';
$shareUrl = $permalinkEnabled ? ('https://bubulescu.org/?id=' . rawurlencode($articleIdForVideo)) : '';
$url = $config['public_videos'].'/'.rawurlencode($name);
$permalinkUrl = $permalinkEnabled ? article_permalink_url($config, $articleIdForVideo, $videoCache, false) : '';
$shareUrl = $permalinkEnabled ? article_permalink_url($config, $articleIdForVideo, $videoCache, true) : '';
$url = '/' . ltrim((string)$config['public_videos'], '/') . '/'.rawurlencode($name);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$posterUrl = video_thumb_public_url($config, $name);
$dateFilterUrl = query_url(['q' => 'date=' . (string)($m['date'] ?? '')]);
@ -565,7 +652,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
$hideMap = !empty($hiddenMapOutputs[$name]);
if(!$hideMap && is_file($config['videos_dir']."/".$map)):
?>
<a class="map-image-link" href="<?=h($config['public_videos']."/".rawurlencode($map))?>"><img class="map-image" src="<?=h($config['public_videos']."/".rawurlencode($map))?>" alt="Map"></a>
<a class="map-image-link" href="<?=h("/" . ltrim((string)$config['public_videos'], "/") . "/" . rawurlencode($map))?>"><img class="map-image" src="<?=h("/" . ltrim((string)$config['public_videos'], "/") . "/" . rawurlencode($map))?>" alt="Map"></a>
<?php endif; ?>
</div>
<div class="video-info">

View file

@ -86,6 +86,50 @@ function mvlog_read_article_id(string $jobdir): string
return mvlog_article_id_is_valid($value) ? $value : '';
}
function mvlog_permalink_slug_from_title(string $title, string $fallback = 'post'): string
{
$slug = strtr($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 mvlog_permalink_path_for_article(string $mvlog_root, string $articleId): string
{
$articleId = strtolower(trim($articleId));
if (!mvlog_article_id_is_valid($articleId)) return '/';
$dirs = glob(rtrim($mvlog_root, '/') . '/in-dir/*', GLOB_ONLYDIR) ?: [];
sort($dirs, SORT_STRING);
$used = [];
foreach ($dirs as $dir) {
if (!is_file($dir . '/.mvlog-permalink')) continue;
$id = mvlog_read_article_id($dir);
if ($id === '') continue;
$fields = mvlog_read_job_data_fields($dir);
$title = trim((string)($fields['title'] !== '' ? $fields['title'] : basename($dir)));
$base = mvlog_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;
if ($id === $articleId) return '/post/' . rawurlencode($slug);
}
return '/';
}
function mvlog_read_job_data_fields(string $jobdir): array
{
$data = ['title' => '', 'teaser' => '', 'description' => ''];
@ -228,8 +272,9 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul
$articleExcerpt = rtrim(substr($articleExcerpt, 0, 497)) . '...';
}
$articleUrl = is_file($jobdir . '/.mvlog-permalink') ? './?id=' . rawurlencode($articleId) : './';
$journalUrl = 'https://bubulescu.org/' . (is_file($jobdir . '/.mvlog-permalink') ? '?id=' . rawurlencode($articleId) : '');
$articlePath = is_file($jobdir . '/.mvlog-permalink') ? mvlog_permalink_path_for_article($mvlog_root, $articleId) : '/';
$articleUrl = '.' . $articlePath;
$journalUrl = 'https://bubulescu.org' . $articlePath;
// Attempt web-push, but never let push failure prevent the journal/Listmonk campaign.
$pushOk = false;

View file

@ -21,6 +21,34 @@ function sitemap_article_id_is_valid(string $id): bool {
return (bool)preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id);
}
function sitemap_slug_from_title(string $title, string $fallback = 'post'): string {
$slug = strtr($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 sitemap_title_from_data_file(string $dir): string {
$file = rtrim($dir, '/') . '/data.txt';
if (is_file($file)) {
foreach (file($file, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (!str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
if (in_array(strtolower($key), ['title','name'], true) && $value !== '') return $value;
}
}
return basename($dir);
}
function sitemap_add_url(array &$urls, string $loc, string $lastmod, string $changefreq, string $priority): void {
$urls[$loc] = [
'loc' => $loc,
@ -43,7 +71,10 @@ foreach ($staticPages as [$loc, $path, $changefreq, $priority]) {
}
}
foreach (glob(rtrim($uploadsDir, '/') . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
$articleEntries = [];
$articleDirs = glob(rtrim($uploadsDir, '/') . '/*', GLOB_ONLYDIR) ?: [];
sort($articleDirs, SORT_STRING);
foreach ($articleDirs as $dir) {
if (!is_file($dir . '/.mvlog-permalink')) continue;
$idFile = $dir . '/.mvlog-id';
@ -68,11 +99,27 @@ foreach (glob(rtrim($uploadsDir, '/') . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (is_file($videoPath)) $lastmodCandidates[] = (int)filemtime($videoPath);
}
$lastmodTs = $lastmodCandidates ? max($lastmodCandidates) : (int)filemtime($dir);
$articleEntries[] = [
'id' => $articleId,
'base_slug' => sitemap_slug_from_title(sitemap_title_from_data_file($dir), $articleId),
'lastmod' => sitemap_lastmod_from_ts($lastmodCandidates ? max($lastmodCandidates) : (int)filemtime($dir)),
];
}
$usedSlugs = [];
foreach ($articleEntries as $entry) {
$slug = $entry['base_slug'];
if (isset($usedSlugs[$slug])) $slug = $entry['base_slug'] . '-' . substr($entry['id'], -6);
$n = 2;
while (isset($usedSlugs[$slug])) {
$slug = $entry['base_slug'] . '-' . substr($entry['id'], -6) . '-' . $n;
$n++;
}
$usedSlugs[$slug] = true;
sitemap_add_url(
$urls,
'https://bubulescu.org/?id=' . rawurlencode($articleId),
sitemap_lastmod_from_ts($lastmodTs),
'https://bubulescu.org/post/' . rawurlencode($slug),
$entry['lastmod'],
'monthly',
'0.8'
);