Removed webui files

This commit is contained in:
hbrain 2026-06-04 05:35:07 +00:00
parent f46217f37b
commit 4f81e406f3
2 changed files with 0 additions and 2309 deletions

500
index.php
View file

@ -1,500 +0,0 @@
<?php
date_default_timezone_set('Europe/Copenhagen');
$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);
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function nice_title($s){ return trim(ucwords(str_replace(['_','-'], ' ', (string)$s))); }
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 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';
}
function hidden_video_outputs($config){
$hidden = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (!is_file($dir . '/.mvlog-hidden')) continue;
$state = is_file($dir . '/.movmaker-state.json') ? json_decode((string)file_get_contents($dir . '/.movmaker-state.json'), true) : [];
if (is_array($state) && !empty($state['output'])) $hidden[basename((string)$state['output'])] = true;
}
return $hidden;
}
function map_hidden_outputs($config){
$hidden = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (!is_file($dir . '/.mvlog-hide-map')) continue;
$state = is_file($dir . '/.movmaker-state.json') ? json_decode((string)file_get_contents($dir . '/.movmaker-state.json'), true) : [];
if (is_array($state) && !empty($state['output'])) $hidden[basename((string)$state['output'])] = true;
}
return $hidden;
}
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 = function_exists('video_thumb_source_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 video_article_id($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) continue;
$state = json_decode((string)file_get_contents($stateFile), true);
if (!is_array($state)) continue;
$output = basename((string)($state['output'] ?? ''));
$previewOutput = basename((string)($state['preview_output'] ?? ''));
if ($videoName === $output || $videoName === $previewOutput) return (string)($state['article_id'] ?? '');
}
return '';
}
function article_og_image_url($articleId){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return '';
$path = __DIR__ . '/out-dir/' . $articleId . '_og.jpg';
if (!is_file($path)) return '';
return 'https://bubulescu.org/out-dir/' . rawurlencode($articleId . '_og.jpg');
}
function video_metadata($path){
$base = pathinfo($path, PATHINFO_FILENAME);
$meta = [
'title' => nice_title($base),
'teaser' => '',
'date' => date('Y-m-d', filemtime($path)),
'sort_date' => date('Y-m-d', filemtime($path)),
'location' => '',
'quote_da' => '',
'description' => ''
];
if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) {
$meta['date'] = "$m[1]-$m[2]-$m[3]";
$meta['sort_date'] = "$m[1]-$m[2]-$m[3]";
$meta['title'] = nice_title($m[4]);
}
foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) {
if (!is_file($sidecar)) continue;
foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (!str_contains($line, ':')) continue;
[$k, $v] = array_map('trim', explode(':', $line, 2));
$k = strtolower($k);
if (in_array($k, ['title','teaser','date','location','place','description','quote_da'], true)) {
$target = ($k === 'place') ? 'location' : $k;
$meta[$target] = $target === 'quote_da' ? trim((string)$v, "\"“”") : $v;
}
}
}
$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 '.escapeshellarg($path).' 2>/dev/null');
$data = json_decode((string)$json, true);
$tags = $data['format']['tags'] ?? [];
$lower = [];
foreach ($tags as $k => $v) $lower[strtolower((string)$k)] = $v;
if (!empty($lower['title'])) $meta['title'] = $lower['title'];
if (!empty($lower['teaser'])) $meta['teaser'] = $lower['teaser'];
if (!empty($lower['date'])) $meta['date'] = $lower['date'];
if (!empty($lower['creation_time'])) {
$meta['sort_date'] = substr((string)$lower['creation_time'], 0, 10);
if (empty($lower['date'])) $meta['date'] = $meta['sort_date'];
}
foreach (['location','place','location-eng','com.apple.quicktime.location.name','com.apple.quicktime.location.iso6709'] as $k) {
if (!empty($lower[$k])) { $meta['location'] = $lower[$k]; break; }
}
foreach (['description','synopsis'] as $k) {
if (!empty($lower[$k])) { $meta['description'] = $lower[$k]; break; }
}
if (!empty($lower['quote_da'])) {
$meta['quote_da'] = trim((string)$lower['quote_da'], "\"“”");
}
}
return $meta;
}
function load_video_cache($videos){
$cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache) || ($cache['version'] ?? 0) !== 4) $cache = [];
$items = $cache['items'] ?? [];
$newItems = [];
$changed = false;
foreach ($videos as $path) {
$key = basename($path);
$mtime = filemtime($path);
$size = filesize($path);
$cached = $items[$key] ?? null;
if (!is_array($cached) || ($cached['mtime'] ?? null) !== $mtime || ($cached['size'] ?? null) !== $size) {
$cached = ['mtime' => $mtime, 'size' => $size, 'metadata' => video_metadata($path)];
$changed = true;
}
$newItems[$key] = $cached;
}
if (array_diff(array_keys($items), array_keys($newItems))) $changed = true;
if ($changed) {
file_put_contents($cacheFile, json_encode([
'version' => 4,
'generated_at' => date('c'),
'items' => $newItems
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
}
return $newItems;
}
$hiddenOutputs = hidden_video_outputs($config);
$hiddenMapOutputs = map_hidden_outputs($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))
));
$videoCache = load_video_cache($videos);
usort($videos, function($a, $b) use ($videoCache) {
$am = $videoCache[basename($a)] ?? [];
$bm = $videoCache[basename($b)] ?? [];
$ad = strtotime((string)($am['metadata']['sort_date'] ?? $am['metadata']['date'] ?? '')) ?: filemtime($a);
$bd = strtotime((string)($bm['metadata']['sort_date'] ?? $bm['metadata']['date'] ?? '')) ?: filemtime($b);
return $bd <=> $ad;
});
$articleId = trim((string)($_GET['id'] ?? ''));
if ($articleId !== '') {
$videos = array_values(array_filter($videos, function($v) use ($articleId, $config) {
return video_article_id($config, basename($v)) === $articleId;
}));
$rawKeyword = '';
$keywordType = 'general';
$keywordValue = '';
} else {
$rawKeyword = trim((string)($_GET['q'] ?? ''));
// Backward compatibility with older filter/query formats.
if ($rawKeyword === '') {
$legacyTitle = trim((string)($_GET['f_title'] ?? ''));
$legacyDate = trim((string)($_GET['f_date'] ?? ''));
$legacyLocation = trim((string)($_GET['f_location'] ?? ''));
$legacyJob = trim((string)($_GET['job'] ?? ''));
if ($legacyTitle !== '') $rawKeyword = $legacyTitle;
elseif ($legacyDate !== '') $rawKeyword = 'date=' . $legacyDate;
elseif ($legacyLocation !== '') $rawKeyword = 'location=' . $legacyLocation;
elseif ($legacyJob !== '') {
$legacy = preg_replace('/^\d{8}[_-]/', '', $legacyJob);
$rawKeyword = nice_title($legacy ?: $legacyJob);
}
}
$keywordType = 'general';
$keywordValue = $rawKeyword;
if ($rawKeyword !== '' && preg_match('/^\s*(date|location|place)\s*=\s*(.+)\s*$/i', $rawKeyword, $m)) {
$keywordType = lower_text((string)$m[1]);
if ($keywordType === 'place') $keywordType = 'location';
$keywordValue = trim((string)$m[2]);
}
$videos = array_values(array_filter($videos, function($v) use ($videoCache, $keywordType, $keywordValue) {
if ($keywordValue === '') return true;
$name = basename($v);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$title = (string)($m['title'] ?? '');
$teaser = (string)($m['teaser'] ?? '');
$description = (string)($m['description'] ?? '');
$quoteDa = (string)($m['quote_da'] ?? '');
$date = (string)($m['date'] ?? '');
$sortDate = (string)($m['sort_date'] ?? '');
$location = (string)($m['location'] ?? '');
if ($keywordType === 'date') {
return ci_contains($date, $keywordValue) || ci_contains($sortDate, $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));
$per = $config['items_per_page'];
$total = count($videos);
$pages = max(1, (int)ceil($total / $per));
$page = min($page, $pages);
$slice = array_slice($videos, ($page - 1) * $per, $per);
$activeFilterCount = ($keywordValue !== '') ? 1 : 0;
$baseParams = ($rawKeyword !== '') ? ['q' => $rawKeyword] : [];
$defaultOgImageUrl = 'https://bubulescu.org/assets/img/bubulescuorg.jpg';
$ogImageUrl = $defaultOgImageUrl;
$ogImageAlt = 'MVLog: journal of an unreliable narrator.';
if ($articleId !== '') {
$customOg = article_og_image_url($articleId);
if ($customOg !== '') {
$ogImageUrl = $customOg;
if (!empty($slice)) {
$firstVideo = basename((string)$slice[0]);
$firstMeta = $videoCache[$firstVideo]['metadata'] ?? video_metadata($slice[0]);
$firstTitle = trim((string)($firstMeta['title'] ?? ''));
if ($firstTitle !== '') $ogImageAlt = 'MVLog: ' . $firstTitle;
}
}
}
$headerTitles = [
'Multiple Voices Log',
'MultiVerse Log',
'Miles & Views Log',
'Moving Vistas Log',
'Motorcycle Voyage Log',
'Mapped Vagabond Log',
'Mountain Valley Log',
'Miles Versus Logic',
'Motorcycle Voyage Log',
'Mapped Vagabond Log',
'Multiple Viewpoints Log',
'Multiple Versions of Life',
'Motion, Velocity & Luck',
'Miles, Views & Life',
'Many Voices of Life',
'Many Views of Life',
'Miles Beyond Reality',
'Motion, Velocity & Lunacy',
'Maps, Villages & Legends',
'Multi-Vision Log',
'Mutable View Log',
'Mistakes, Victories & Lessons',
'Mostly Verified Legends',
'Mostly Vague Logistics',
'Marginally Viable Leadership',
'Mileage Versus Luck',
'Mechanical Violence Log',
'Moderately Violent Leisure',
'Mistakes Validated Later',
'Maybe Very Lost',
'Miles, Views & Lunch',
'Maps, Villages & Lager',
'Missing Various Lefts',
'Mistakes, Views & Luck',
'Many Very Longroads',
'Mileage Varies Largely',
'Motorcycling Via Luck',
'Motorcycle Victim Lifestyle',
'Mildly Violating Limits',
'My Vices Last',
];
$siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVL</title>
<!-- Basic Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="MVLog: journal of an unreliable narrator.">
<meta property="og:description" content="Motorcycle road stories, videos and occasional bad decisions.">
<meta property="og:url" content="https://bubulescu.org/">
<meta property="og:site_name" content="Bubulescu.Org">
<!-- Image -->
<meta property="og:image" content="<?=h($ogImageUrl)?>">
<meta property="og:image:secure_url" content="<?=h($ogImageUrl)?>">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="<?=h($ogImageAlt)?>">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="<?=h($ogImageUrl)?>">
<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}
.filter-input-wrap input{margin:0;padding-right:2.15rem}
.filter-clear-inside{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}
.filter-clear-inside:hover{background:#d97b48;color:#111315;transform:translateY(-50%) scale(1.05)}
.filter-clear-inside.is-empty{opacity:.38;pointer-events:none}
.filter-submit{display:inline-grid;place-items:center;width:2.35rem;height:2.35rem;padding:0;border-radius:.5rem;background:#C46A3A;color:#111315}
.filter-submit .icon{font-size:1.02rem;line-height:1;transform:translateY(.01em)}
.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}
.filter-summary{color:#A0A4AB;font-size:.9rem;margin:0 0 .8rem}
.video-row-title{font-size:1.35rem;line-height:1.2;color:#F3F4F6}
.video-row-title a{color:inherit;text-decoration:none}
.video-row-title a:hover{text-decoration:underline}
.video-row-head-mobile{display:none}
.video-info .video-row-title{margin:0 0 .35rem}
.video-info .meta{margin:0 0 .45rem}
.description-teaser{margin:.15rem 0 .45rem;color:#EADFC9}
.description-quote{margin:.15rem 0 .45rem;font-style:italic;font-size:.82rem;color:#D6D9DE}
#videos .video-wrapper{align-self:start}
#videos .video-info{align-self:start}
#videos .video-info .description{margin-top:0}
.meta a{color:inherit;text-decoration:none}
.meta a:hover{text-decoration:underline}
.site-header.admin-header{padding-top:.9rem;padding-bottom:.7rem}
@media (max-width:900px){
.video-row-head-mobile{display:block;grid-column:1 / -1;margin:0 0 .05rem}
.video-row-head-mobile .description-teaser{margin:.25rem 0 .3rem}
#videos .video-wrapper{grid-column:1 / -1}
#videos .video-info{grid-column:1 / -1}
.video-info .video-row-title.video-row-title-desktop,
.video-info .meta.meta-desktop,
.video-info .description-teaser.description-teaser-desktop{display:none}
#videos .video-info .description{margin-top:.8rem}
}
</style>
</head>
<body>
<?php include __DIR__ . '/_header.php'; ?>
<main>
<section id="videos">
<?php if ($articleId === ''): ?>
<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>
</div>
<button type="submit" class="filter-submit" aria-label="Filter">
<span class="icon" aria-hidden="true">🔍</span>
<span class="sr-only">Filter</span>
</button>
</form>
<?php endif; ?>
<p class="filter-summary">
Showing <?=h((string)$total)?> article(s)
<?php if($activeFilterCount > 0): ?> with <?=h((string)$activeFilterCount)?> active filter(s)<?php endif; ?>
</p>
<?php if(!$slice): ?><p>No videos found for current filter.</p><?php endif; ?>
<div class="video-list">
<?php foreach($slice as $v):
$name = basename($v);
$base = pathinfo($name, PATHINFO_FILENAME);
$articleIdForVideo = video_article_id($config, $name);
$permalinkUrl = $articleIdForVideo !== '' ? query_url(['id' => $articleIdForVideo]) : '';
$url = $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'] ?? '')]);
$locFilterUrl = query_url(['q' => 'location=' . (string)($m['location'] ?? '')]);
?>
<article class="video-row">
<div class="video-row-head video-row-head-mobile">
<h2 class="video-row-title"><?php if($permalinkUrl !== ''): ?><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a><?php else: ?><?=h($m['title'])?><?php endif; ?></h2>
<p class="meta">
<span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span>
<?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?>
</p>
<?php if(!empty($m['teaser'])): ?><p class="description-teaser description-teaser-mobile"><?=h((string)$m['teaser'])?></p><?php endif; ?>
</div>
<div class="video-wrapper">
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></video>
<?php
$map = pathinfo($name, PATHINFO_FILENAME)."_map.png";
$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>
<?php endif; ?>
</div>
<div class="video-info">
<h2 class="video-row-title video-row-title-desktop"><?php if($permalinkUrl !== ''): ?><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a><?php else: ?><?=h($m['title'])?><?php endif; ?></h2>
<p class="meta meta-desktop">
<span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span>
<?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?>
</p>
<?php if(!empty($m['teaser'])): ?><p class="description-teaser description-teaser-desktop"><?=h((string)$m['teaser'])?></p><?php endif; ?>
<?php if(!empty($m['description'])): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?>
<?php if(!empty($m['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$m['quote_da'], "\"“”"))?>”</p><?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
<div class="pages">
<?php for($i=1;$i<=$pages;$i++):
$linkParams = $baseParams;
if ($i > 1) $linkParams['page'] = $i;
?>
<a class="<?=$i===$page?'active':''?>" href="<?=h(query_url($linkParams))?>"><?=$i?></a>
<?php endfor; ?>
</div>
</section>
</main>
<?php include __DIR__ . '/_footer.php'; ?>
</body>
</html>

1809
new.php

File diff suppressed because it is too large Load diff