v1.9.1: fix world map rendering and resolve eight small TODO items

World map:
- Fix zero-padded ISO numeric ids leaving 31 countries unrendered
  (Brazil, Australia, Belgium, Austria, Algeria and more)
- Fix Russia and Fiji smearing across the full map width at the antimeridian
  by unwrapping ring longitudes and drawing them at both edges
- Crop to 84N-60S, add evenodd fill rule, 174 countries rendered

Improvements:
- Logger::tail() reads backwards in chunks instead of loading the whole file
- External links get rel=noopener noreferrer in footer and Markdown content
- formatDisplayName() cleaned up and guarded against empty input
- Export statistics as CSV (Excel BOM) or JSON
- GeoIP database auto-updates when older than 35 days
- Editor shortcuts Ctrl/Cmd+S to save and Ctrl/Cmd+N for a new page
- Live search filter in the admin content browser
- Content versioning with timestamped .bak copies in content/-backups/

Also removes eight stale TODO entries that were already implemented
This commit is contained in:
2026-07-29 15:53:35 +02:00
parent 5357bc8915
commit 0fe5c75eae
12 changed files with 772 additions and 281 deletions
+107
View File
@@ -390,6 +390,7 @@ function handleContentEdit(AdminAuth $auth, array $config): void
? implode(', ', array_map('trim', $_POST['plugins']))
: '';
$content = updateContentFrontmatter($content, 'plugins', $plugins);
backupContentFile($filePath);
file_put_contents($filePath, $content);
if (!$wasRenamed) {
adminLog($config, 'info', $user['username'] . ' sloeg ' . basename($filePath) . ' op');
@@ -890,6 +891,45 @@ function handleStatistics(AdminAuth $auth, array $config): void
$analytics = new Analytics($ana);
$stats = $analytics->getStats($period);
// Export (CSV / JSON)
if (isset($_GET['export'])) {
$format = $_GET['export'] === 'json' ? 'json' : 'csv';
$periodSlug = $period === 0 ? 'alles' : $period . 'dagen';
$filename = 'codepress-statistieken-' . $periodSlug . '-' . date('Y-m-d');
if ($format === 'json') {
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.json"');
echo json_encode($stats, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // BOM so Excel reads UTF-8 correctly
fputcsv($out, ['Sectie', 'Sleutel', 'Waarde']);
foreach ($stats['totals'] as $k => $v) {
fputcsv($out, ['Totalen', $k, $v]);
}
foreach ($stats['countries'] as $k => $v) {
fputcsv($out, ['Landen', $k . ' (' . GeoIP::getCountryName($k === 'UNKNOWN' ? null : $k) . ')', $v]);
}
foreach ($stats['pages'] as $k => $v) {
fputcsv($out, ["Pagina's", $k, $v]);
}
foreach ($stats['referrers'] as $k => $v) {
fputcsv($out, ['Verwijzers', $k, $v]);
}
foreach ($stats['daily_chart'] as $day) {
fputcsv($out, ['Per dag', $day['date'], $day['views'] . ' weergaven, ' . $day['uniques'] . ' uniek']);
}
fclose($out);
}
adminLog($config, 'info', $user['username'] . ' exporteerde statistieken (' . strtoupper($format) . ')');
exit;
}
// GeoIP database status
$geoDir = $config['codepress_root'] . '/admin/storage/geoip';
$geoMeta = null;
@@ -897,6 +937,34 @@ function handleStatistics(AdminAuth $auth, array $config): void
$geoMeta = json_decode(file_get_contents($geoDir . '/meta.json'), true);
}
// Auto-update the GeoIP database when it is missing or older than 35 days
$geoAutoUpdated = false;
if (($ana['geoip_provider'] ?? 'local') === 'local' && !empty($ana['enabled'])) {
$lastUpdate = !empty($geoMeta['updated']) ? strtotime($geoMeta['updated']) : 0;
$isStale = $lastUpdate === 0 || $lastUpdate < strtotime('-35 days');
$lockFile = $geoDir . '/.autoupdate';
// Only retry once a day if the download keeps failing
$recentlyTried = file_exists($lockFile) && filemtime($lockFile) > strtotime('-1 day');
if ($isStale && !$recentlyTried) {
if (!is_dir($geoDir)) {
@mkdir($geoDir, 0755, true);
}
@touch($lockFile);
require_once __DIR__ . '/../cli/geoip-update.php';
$autoResult = updateGeoIPDatabase();
if ($autoResult['success']) {
$geoMeta = $autoResult['meta'];
$geoAutoUpdated = true;
adminLog($config, 'info', 'GeoIP database automatisch bijgewerkt (was verouderd)');
if ($message === '') {
$message = 'GeoIP database was verouderd en is automatisch bijgewerkt.';
$messageType = 'info';
}
}
}
}
// World map SVG
$worldMapPath = $config['codepress_root'] . '/public/assets/img/world-map.svg';
$worldMapSvg = file_exists($worldMapPath) ? file_get_contents($worldMapPath) : '';
@@ -1745,6 +1813,45 @@ function handleUpdate(AdminAuth $auth, array $config): void
// --- Frontmatter helpers ---
/**
* Keep a timestamped copy of a content file before it is overwritten.
*
* Backups live in a hidden -backups directory inside the content folder so the
* CMS itself skips them, and only the newest few versions per file are kept.
*/
function backupContentFile(string $filePath, int $keep = 5): void
{
if (!is_file($filePath) || filesize($filePath) === 0) {
return;
}
$contentRoot = realpath(dirname(__DIR__) . '/content');
$realFile = realpath($filePath);
if (!$contentRoot || !$realFile || strpos($realFile, $contentRoot) !== 0) {
return;
}
$backupDir = $contentRoot . '/-backups';
if (!is_dir($backupDir) && !@mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
return;
}
// Mirror the relative path so files with the same name never collide
$relative = ltrim(substr($realFile, strlen($contentRoot)), '/');
$flat = str_replace('/', '__', $relative);
@copy($realFile, $backupDir . '/' . $flat . '.' . date('Ymd-His') . '.bak');
// Prune older versions of this specific file
$existing = glob($backupDir . '/' . $flat . '.*.bak') ?: [];
if (count($existing) > $keep) {
sort($existing); // filenames sort chronologically
foreach (array_slice($existing, 0, count($existing) - $keep) as $old) {
@unlink($old);
}
}
}
function parseFrontmatterField(string $content, string $key, string $default = ''): string
{
if (preg_match('/^---\s*\n(.*?)\n---\s*\n/s', $content, $matches)) {
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 164 KiB

+33
View File
@@ -230,4 +230,37 @@
document.getElementById('editor-form').addEventListener('submit', function () {
editor.save();
});
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
function handleShortcut(e) {
var mod = e.ctrlKey || e.metaKey;
if (!mod || e.altKey) return;
var key = (e.key || '').toLowerCase();
if (key === 's') {
e.preventDefault();
editor.save();
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
return;
}
if (key === 'n') {
e.preventDefault();
window.location.href = '/admin/content-new';
}
}
document.addEventListener('keydown', handleShortcut);
// CodeMirror swallows keystrokes inside the editor, so bind there too
editor.setOption('extraKeys', Object.assign({}, editor.getOption('extraKeys') || {}, {
'Ctrl-S': function () { handleShortcut({ ctrlKey: true, key: 's', preventDefault: function () {} }); },
'Cmd-S': function () { handleShortcut({ metaKey: true, key: 's', preventDefault: function () {} }); }
}));
})();