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

131
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,9 +395,23 @@ usort($allVideos, function($a, $b) use ($videoCache) {
return $bd <=> $ad;
});
$articleId = trim((string)($_GET['id'] ?? ''));
if ($articleId !== '' && !preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) {
render_404_page();
$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)) {
@ -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">