'ae', 'ø' => 'o', 'å' => 'a', 'Æ' => 'ae', 'Ø' => 'o', 'Å' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ü' => 'u', 'ö' => 'o', 'ä' => 'a', ]; $text = strtr($text, $map); $text = mb_strtolower($text, 'UTF-8'); $text = preg_replace('/[^a-z0-9]+/u', '-', $text) ?? ''; $text = trim($text, '-'); $text = preg_replace('/-+/', '-', $text) ?? ''; return $text === '' ? 'uten-tittel' : $text; } function publiserSlugTaken(string $section, string $slug, ?string $excludeLiveFolder = null): bool { if (is_dir(publiserDraftDir($section, $slug))) return true; $liveRoot = publiserContentRoot() . "/$section"; foreach ((glob("$liveRoot/*", GLOB_ONLYDIR) ?: []) as $d) { $name = basename($d); if ($excludeLiveFolder !== null && $name === $excludeLiveFolder) continue; if ($name === $slug) return true; if (preg_match('/^\d{4}-\d{2}-\d{2}-' . preg_quote($slug, '/') . '$/', $name)) return true; } return false; } /** $excludeLiveFolder: skip this live folder in the collision check - used * when re-deriving a slug for an item that is itself being moved (e.g. * unpublish), so it doesn't collide with its own current live folder. */ function publiserUniqueSlug(string $section, string $baseSlug, ?string $excludeLiveFolder = null): string { $slug = $baseSlug; $i = 2; while (publiserSlugTaken($section, $slug, $excludeLiveFolder)) { $slug = "{$baseSlug}-{$i}"; $i++; } return $slug; } // --- metadata.ini read/write ------------------------------------------------ function publiserReadMetadata(string $dir): array { $path = "$dir/metadata.ini"; if (!file_exists($path)) return []; // TYPED (unlike the plain parse_ini_file() the CMS itself uses to read // metadata) keeps bare `true`/`false` as real booleans instead of // PHP's "1"/"" normal-mode quirk, so publiserIniValue() can round-trip // them back to bare true/false instead of a quoted "1" string. return parse_ini_file($path, true, INI_SCANNER_TYPED) ?: []; } function publiserIniValue(mixed $value): string { if (is_bool($value)) return $value ? 'true' : 'false'; if (is_int($value) || is_float($value)) return (string)$value; $str = (string)$value; return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $str) . '"'; } /** Writes a full metadata array back to disk. Array-valued keys become [section] blocks (e.g. "en"). */ function publiserWriteMetadataFile(string $dir, array $meta): void { $lines = []; $sectionBlocks = []; foreach ($meta as $key => $value) { if (is_array($value)) { $sectionBlocks[$key] = $value; continue; } if ($value === null || $value === '') continue; $lines[] = "$key = " . publiserIniValue($value); } foreach ($sectionBlocks as $name => $fields) { $sectionLines = []; foreach ($fields as $k => $v) { if ($v === null || $v === '' || is_array($v)) continue; $sectionLines[] = "$k = " . publiserIniValue($v); } if (!$sectionLines) continue; $lines[] = ''; $lines[] = "[$name]"; array_push($lines, ...$sectionLines); } publiserAtomicWrite("$dir/metadata.ini", implode("\n", $lines) . "\n"); } function publiserSaveMetadata(string $dir, array $fields): void { $existing = publiserReadMetadata($dir); foreach ($fields as $k => $v) { if ($v === null) { unset($existing[$k]); continue; } $existing[$k] = $v; } publiserWriteMetadataFile($dir, $existing); } function publiserAtomicWrite(string $path, string $content): void { $fp = fopen($path, 'c'); if (!$fp) throw new RuntimeException("Cannot open for writing: $path"); if (!flock($fp, LOCK_EX)) { fclose($fp); throw new RuntimeException("Cannot lock: $path"); } ftruncate($fp, 0); rewind($fp); fwrite($fp, $content); fflush($fp); flock($fp, LOCK_UN); fclose($fp); } // --- Locked move (publish / unpublish / trash / restore all go through this) --- function publiserLockedRename(string $src, string $dest): void { if (!is_dir($src)) throw new RuntimeException("Not found: $src"); if (file_exists($dest)) throw new RuntimeException("Already exists: $dest"); $lockPath = $src . '/.publiser-lock'; $fp = fopen($lockPath, 'c'); if (!$fp) throw new RuntimeException('Could not create lock file'); if (!flock($fp, LOCK_EX | LOCK_NB)) { fclose($fp); throw new RuntimeException('Item is being modified by another request, try again'); } clearstatcache(true, $src); if (!is_dir($src)) { flock($fp, LOCK_UN); fclose($fp); throw new RuntimeException('Item was already moved'); } @unlink($lockPath); $ok = publiserMoveDir($src, $dest); flock($fp, LOCK_UN); fclose($fp); if (!$ok) throw new RuntimeException("Failed to move $src to $dest"); } /** * Moves a directory, falling back to recursive copy+delete if rename() * fails (EXDEV - crossing a filesystem/mount boundary). content/ and * custom/ are separate bind mounts in the dev container even though * they're the same host filesystem, so this isn't just a theoretical case. */ function publiserMoveDir(string $src, string $dest): bool { if (@rename($src, $dest)) { return true; } try { publiserCopyRecursive($src, $dest); publiserRemoveRecursive($src); return true; } catch (Throwable $e) { if (is_dir($dest)) { publiserRemoveRecursive($dest); } error_log('publiser: move fallback failed: ' . $e->getMessage()); return false; } } function publiserCopyRecursive(string $src, string $dest): void { publiserEnsureDir($dest); foreach ((scandir($src) ?: []) as $item) { if ($item === '.' || $item === '..') continue; $srcPath = "$src/$item"; $destPath = "$dest/$item"; if (is_dir($srcPath)) { publiserCopyRecursive($srcPath, $destPath); } elseif (!copy($srcPath, $destPath)) { throw new RuntimeException("Failed to copy $srcPath to $destPath"); } } } function publiserRemoveRecursive(string $dir): void { foreach ((scandir($dir) ?: []) as $item) { if ($item === '.' || $item === '..') continue; $path = "$dir/$item"; if (is_dir($path)) { publiserRemoveRecursive($path); } else { unlink($path); } } rmdir($dir); } // --- Blocks (content files) ------------------------------------------------- /** * True for a language-specific content file (e.g. "article.en.md"). The * site supports translations this way (see docs/content-system.md), but * /publiser only edits the base-language content - translations are listed * as read-only in publiserListExtraFiles() instead of being treated as * ordinary blocks, since they're a different language's version of the * SAME content, not another sequential block of it. */ function publiserIsLanguageVariant(string $filename): bool { return (bool) preg_match('/\.en\.(md|html|php)$/i', $filename); } function publiserListBlocks(string $dir): array { if (!is_dir($dir)) return []; $files = scandir($dir) ?: []; $blocks = []; foreach ($files as $f) { if ($f === '.' || $f === '..') continue; if (publiserIsLanguageVariant($f)) continue; $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION)); if (!in_array($ext, CONTENT_EXTENSIONS, true)) continue; $blocks[] = [ 'filename' => $f, 'ext' => $ext, 'type' => $ext === 'md' ? 'richtext' : 'code', ]; } usort($blocks, fn($a, $b) => strnatcmp($a['filename'], $b['filename'])); return $blocks; } /** The base-language title, mirroring app/helpers.php's extractTitle() but * restricted to publiserListBlocks() (i.e. skipping .en. translation * files) so a post with an English translation doesn't show its English * title/H1 by alphabetical accident ("article.en.md" sorts before * "article.md"). */ function publiserExtractTitle(string $dir): ?string { foreach (publiserListBlocks($dir) as $block) { $content = file_get_contents($dir . '/' . $block['filename']) ?: ''; if ($block['ext'] === 'md' && preg_match('/^#\s+(.+)$/m', $content, $m)) { return trim($m[1]); } if (in_array($block['ext'], ['html', 'php'], true) && preg_match('/]*>(.*?)<\/h1>/i', $content, $m)) { return trim(strip_tags($m[1])); } } return null; } function publiserListExtraFiles(string $dir): array { $extra = []; foreach (getSubdirectories($dir) as $sub) { $extra[] = ['type' => 'folder', 'name' => $sub]; } foreach ((glob("$dir/*.pdf") ?: []) as $pdf) { $extra[] = ['type' => 'pdf', 'name' => basename($pdf)]; } foreach ((glob("$dir/*") ?: []) as $f) { if (is_file($f) && publiserIsLanguageVariant(basename($f))) { $extra[] = ['type' => 'translation', 'name' => basename($f)]; } } return $extra; } function publiserSaveBlockContent(string $dir, string $filename, string $content): void { $path = $dir . '/' . basename($filename); if (!is_file($path)) throw new RuntimeException('Block not found'); publiserAtomicWrite($path, $content); } function publiserRenumberBlocks(string $dir, array $orderedFilenames): array { $temps = []; foreach ($orderedFilenames as $i => $filename) { $tmp = ".reorder-tmp-$i-" . uniqid(); rename("$dir/$filename", "$dir/$tmp"); $stem = preg_replace('/^\d+-/', '', pathinfo($filename, PATHINFO_FILENAME)); if ($stem === '' || $stem === null) $stem = 'block'; $temps[] = ['tmp' => $tmp, 'stem' => $stem, 'ext' => pathinfo($filename, PATHINFO_EXTENSION)]; } $result = []; foreach ($temps as $i => $t) { $newName = sprintf('%d-%s.%s', ($i + 1) * 10, $t['stem'], $t['ext']); rename("$dir/{$t['tmp']}", "$dir/$newName"); $result[] = $newName; } return $result; } function publiserReorderBlock(string $dir, string $filename, string $direction): array { $blocks = array_column(publiserListBlocks($dir), 'filename'); $idx = array_search($filename, $blocks, true); if ($idx === false) throw new RuntimeException('Block not found'); $swapWith = $direction === 'up' ? $idx - 1 : $idx + 1; if ($swapWith < 0 || $swapWith >= count($blocks)) return $blocks; [$blocks[$idx], $blocks[$swapWith]] = [$blocks[$swapWith], $blocks[$idx]]; return publiserRenumberBlocks($dir, $blocks); } function publiserAddBlock(string $dir, string $type = 'md'): string { $ext = in_array($type, CONTENT_EXTENSIONS, true) ? $type : 'md'; $stem = $ext === 'md' ? 'tekst' : $ext; $blocks = array_column(publiserListBlocks($dir), 'filename'); $hasUnprefixed = false; $maxPrefix = 0; foreach ($blocks as $f) { if (preg_match('/^(\d+)-/', $f, $m)) { $maxPrefix = max($maxPrefix, (int)$m[1]); } else { $hasUnprefixed = true; } } if ($hasUnprefixed && count($blocks) > 0) { $blocks = publiserRenumberBlocks($dir, $blocks); $maxPrefix = count($blocks) * 10; } $newPrefix = $maxPrefix + 10; $filename = sprintf('%d-%s.%s', $newPrefix, $stem, $ext); $i = 2; while (file_exists("$dir/$filename")) { $filename = sprintf('%d-%s-%d.%s', $newPrefix, $stem, $i, $ext); $i++; } touch("$dir/$filename"); return $filename; } function publiserDeleteBlock(string $dir, string $filename): void { $path = $dir . '/' . basename($filename); if (!is_file($path)) throw new RuntimeException('Block not found'); unlink($path); } // --- Images ------------------------------------------------------------ function publiserUniqueImageName(string $dir, string $originalName, string $ext): string { $base = publiserSlugify(pathinfo($originalName, PATHINFO_FILENAME)); if ($base === 'uten-tittel') $base = 'bilde'; $candidate = "$base.$ext"; $i = 2; while (file_exists("$dir/$candidate")) { $candidate = "{$base}-{$i}.$ext"; $i++; } return $candidate; } /** * Re-encodes an uploaded image via GD (resize to max width, recompress) and * saves it into $dir. $forceStem, if given, fixes the output basename * (used for cover images: always "cover."). */ function publiserSaveUploadedImage(string $dir, string $tmpPath, string $originalName, ?string $forceStem = null): string { $info = getimagesize($tmpPath); if (!$info) throw new RuntimeException('Filen er ikke et gyldig bilde'); [$width, $height, $type] = $info; $allowed = [ IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp', IMAGETYPE_GIF => 'gif', ]; if (!isset($allowed[$type])) throw new RuntimeException('Bildeformatet stottes ikke'); $ext = $allowed[$type]; $image = match ($type) { IMAGETYPE_JPEG => imagecreatefromjpeg($tmpPath), IMAGETYPE_PNG => imagecreatefrompng($tmpPath), IMAGETYPE_WEBP => imagecreatefromwebp($tmpPath), IMAGETYPE_GIF => imagecreatefromgif($tmpPath), }; if (!$image) throw new RuntimeException('Kunne ikke lese bildet'); if ($width > PUBLISER_MAX_IMAGE_WIDTH) { $newHeight = (int) round($height * (PUBLISER_MAX_IMAGE_WIDTH / $width)); $resized = imagecreatetruecolor(PUBLISER_MAX_IMAGE_WIDTH, $newHeight); if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) { imagealphablending($resized, false); imagesavealpha($resized, true); } imagecopyresampled($resized, $image, 0, 0, 0, 0, PUBLISER_MAX_IMAGE_WIDTH, $newHeight, $width, $height); imagedestroy($image); $image = $resized; } $basename = $forceStem !== null ? "{$forceStem}.{$ext}" : publiserUniqueImageName($dir, $originalName, $ext); $destPath = "$dir/$basename"; match ($type) { IMAGETYPE_JPEG => imagejpeg($image, $destPath, PUBLISER_IMAGE_QUALITY), IMAGETYPE_PNG => imagepng($image, $destPath, 6), IMAGETYPE_WEBP => imagewebp($image, $destPath, PUBLISER_IMAGE_QUALITY), IMAGETYPE_GIF => imagegif($image, $destPath), }; imagedestroy($image); return $basename; } function publiserSaveCoverImage(string $dir, string $tmpPath, string $originalName): string { foreach (COVER_IMAGE_EXTENSIONS as $ext) { $old = "$dir/cover.$ext"; if (file_exists($old)) unlink($old); } return publiserSaveUploadedImage($dir, $tmpPath, $originalName, 'cover'); } // --- Item loading & listing ---------------------------------------------- function publiserLoadItem(string $location, string $section, string $folder): ?array { $dir = match ($location) { 'live' => publiserLiveDir($section, $folder), 'draft' => publiserDraftDir($section, $folder), 'trash' => publiserTrashDir($section, $folder), default => throw new InvalidArgumentException("Unknown location: $location"), }; if (!is_dir($dir)) return null; $meta = publiserReadMetadata($dir); $base = array_filter($meta, fn($v) => !is_array($v)); $metaSections = array_filter($meta, fn($v) => is_array($v)); return [ 'location' => $location, 'section' => $section, 'folder' => $folder, 'dir' => $dir, 'metadata' => $base, 'metaSections' => $metaSections, 'blocks' => publiserListBlocks($dir), 'cover' => findCoverImage($dir), 'extra' => publiserListExtraFiles($dir), ]; } function publiserSummarize(string $location, string $section, string $folder, string $dir, array $meta): array { $title = $meta['title'] ?? publiserExtractTitle($dir) ?? $folder; $date = $meta['date'] ?? extractRawDateFromFolder($folder) ?? date('Y-m-d', filemtime($dir)); return [ 'location' => $location, 'section' => $section, 'folder' => $folder, 'title' => $title, 'summary' => $meta['summary'] ?? null, 'cover' => findCoverImage($dir), 'date' => $date, 'sortKey' => $date . $folder, 'status' => $meta['status'] ?? ($location === 'live' ? 'published' : 'draft'), 'publishAt' => $meta['publish_at'] ?? null, ]; } function publiserListLive(string $section): array { publiserAssertSection($section); $root = publiserContentRoot() . "/$section"; $items = []; foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $itemDir) { $folder = basename($itemDir); $items[] = publiserSummarize('live', $section, $folder, $itemDir, publiserReadMetadata($itemDir)); } usort($items, fn($a, $b) => strcmp($b['sortKey'], $a['sortKey'])); return $items; } function publiserListStaged(string $stage, string $section): array { publiserAssertSection($section); $location = $stage === 'trash' ? 'trash' : 'draft'; $root = publiserDataRoot() . "/$stage/$section"; $items = []; foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $itemDir) { $folder = basename($itemDir); $items[] = publiserSummarize($location, $section, $folder, $itemDir, publiserReadMetadata($itemDir)); } usort($items, fn($a, $b) => strcmp($b['sortKey'], $a['sortKey'])); return $items; } // --- Create / publish / unpublish / trash / restore ----------------------- function publiserCreateDraft(string $section, string $title): array { publiserAssertSection($section); $title = trim($title) !== '' ? trim($title) : 'Uten tittel'; $slug = publiserUniqueSlug($section, publiserSlugify($title)); $dir = publiserDraftDir($section, $slug); publiserEnsureDir($dir); publiserWriteMetadataFile($dir, ['title' => $title, 'status' => 'draft']); touch("$dir/10-tekst.md"); return ['section' => $section, 'folder' => $slug]; } function publiserPublish(string $section, string $folder, ?string $publishDate = null): array { publiserAssertSection($section); $srcDir = publiserDraftDir($section, $folder); if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke utkastet'); $meta = publiserReadMetadata($srcDir); $slug = $folder; $date = $publishDate ?: ($meta['date'] ?? date('Y-m-d')); $destFolder = $section === 'nyheter' ? "{$date}-{$slug}" : $slug; if ($destFolder !== $folder && publiserSlugTaken($section, $destFolder)) { // extremely unlikely (slug already unique-checked at draft creation), but stay safe $destFolder = publiserUniqueSlug($section, $destFolder); } $destDir = publiserLiveDir($section, $destFolder); publiserLockedRename($srcDir, $destDir); $meta['date'] = $date; unset($meta['status'], $meta['publish_at']); publiserWriteMetadataFile($destDir, $meta); return ['section' => $section, 'folder' => $destFolder]; } function publiserUnpublish(string $section, string $folder): array { publiserAssertSection($section); $srcDir = publiserLiveDir($section, $folder); if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke innlegget'); $meta = publiserReadMetadata($srcDir); $slug = publiserDeriveSlugFromLiveFolder($section, $folder, $meta); $slug = publiserUniqueSlug($section, $slug, $folder); $destDir = publiserDraftDir($section, $slug); publiserLockedRename($srcDir, $destDir); $meta['status'] = 'draft'; unset($meta['publish_at']); publiserWriteMetadataFile($destDir, $meta); return ['section' => $section, 'folder' => $slug]; } function publiserDeriveSlugFromLiveFolder(string $section, string $folder, array $meta): string { if (!empty($meta['slug']) && is_string($meta['slug'])) return publiserSlugify($meta['slug']); if ($section === 'nyheter' && preg_match('/^\d{4}-\d{2}-\d{2}-(.+)$/', $folder, $m)) { return $m[1]; } return $folder; } function publiserTrash(string $location, string $section, string $folder): void { publiserAssertSection($section); if (!in_array($location, ['live', 'draft'], true)) { throw new InvalidArgumentException("Cannot trash from $location"); } $srcDir = $location === 'live' ? publiserLiveDir($section, $folder) : publiserDraftDir($section, $folder); if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke innlegget'); $meta = publiserReadMetadata($srcDir); $meta['_publiser_trashed_from'] = $location; $meta['_publiser_trashed_at'] = date('c'); $trashFolder = $folder . '~' . time(); $destDir = publiserTrashDir($section, $trashFolder); publiserEnsureParentDir($destDir); publiserLockedRename($srcDir, $destDir); publiserWriteMetadataFile($destDir, $meta); } function publiserRestore(string $section, string $trashFolder): array { publiserAssertSection($section); $srcDir = publiserTrashDir($section, $trashFolder); if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke i papirkurven'); $meta = publiserReadMetadata($srcDir); unset($meta['_publiser_trashed_from'], $meta['_publiser_trashed_at']); $meta['status'] = 'draft'; unset($meta['publish_at']); $slug = preg_replace('/~\d+$/', '', $trashFolder) ?? $trashFolder; $slug = publiserUniqueSlug($section, $slug); $destDir = publiserDraftDir($section, $slug); publiserEnsureParentDir($destDir); publiserLockedRename($srcDir, $destDir); publiserWriteMetadataFile($destDir, $meta); return ['section' => $section, 'folder' => $slug]; } // --- Scheduling -------------------------------------------------------- function publiserSetSchedule(string $section, string $folder, ?string $publishAt): void { $dir = publiserDraftDir($section, $folder); if (!is_dir($dir)) throw new RuntimeException('Fant ikke utkastet'); $meta = publiserReadMetadata($dir); if ($publishAt) { $meta['status'] = 'scheduled'; $meta['publish_at'] = $publishAt; } else { $meta['status'] = 'draft'; unset($meta['publish_at']); } publiserWriteMetadataFile($dir, $meta); } /** Called on every site request via the scheduled-publisher global plugin. */ function publiserPublishDueScheduledItems(): int { $published = 0; foreach (PUBLISER_SECTIONS as $section) { $root = publiserDataRoot() . "/drafts/$section"; foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $dir) { $meta = publiserReadMetadata($dir); if (($meta['status'] ?? '') !== 'scheduled') continue; $publishAt = $meta['publish_at'] ?? null; if (!$publishAt) continue; $ts = strtotime($publishAt); if ($ts === false || $ts > time()) continue; try { publiserPublish($section, basename($dir), date('Y-m-d', $ts)); $published++; } catch (Throwable $e) { error_log('publiser scheduled publish failed: ' . $e->getMessage()); } } } return $published; }