Implement a minimal, dependency-free publishing tool to manage news and articles. The app includes: - A Markdown-based block editor with rich-text capabilities. - Image upload and resizing using GD. - Support for drafts, scheduled publishing, and a trash system. - Integrated Basic Auth protection via PHP and .htpasswd. - A global plugin for automatic execution of scheduled posts. The tool reuses core site logic in custom/plugins/publiser-lib.php to ensure consistency between the editor and the live site.
691 lines
26 KiB
PHP
691 lines
26 KiB
PHP
<?php
|
|
/**
|
|
* Shared library for the /publiser publishing tool.
|
|
*
|
|
* Used by both content/publiser/index.php (the admin app) and
|
|
* custom/plugins/global/scheduled-publisher.php (the auto-publish hook),
|
|
* so there is exactly one implementation of what "publishing a post" means.
|
|
*
|
|
* Deliberately reuses app/ helpers (findCoverImage, extractRawDateFromFolder,
|
|
* getSubdirectories, extractTitle, CONTENT_EXTENSIONS, COVER_IMAGE_EXTENSIONS)
|
|
* rather than duplicating them, so the tool and the live site can never
|
|
* disagree about what a "cover image" or "date" is. app/ is a separate git
|
|
* repo but is always deployed alongside this one (see README's symlink
|
|
* setup); custom/plugins/page/* already depends on it the same way.
|
|
*/
|
|
|
|
define('PUBLISER_ROOT', dirname(__DIR__, 2));
|
|
define('PUBLISER_MAX_IMAGE_WIDTH', 1600);
|
|
define('PUBLISER_IMAGE_QUALITY', 82);
|
|
define('PUBLISER_SECTIONS', ['nyheter', 'artikler']);
|
|
|
|
require_once PUBLISER_ROOT . '/app/hooks.php';
|
|
require_once PUBLISER_ROOT . '/app/constants.php';
|
|
require_once PUBLISER_ROOT . '/app/helpers.php';
|
|
|
|
// --- Paths ---------------------------------------------------------------
|
|
|
|
/**
|
|
* The content/ directory. Resolved via DOCUMENT_ROOT (same approach as
|
|
* app/config.php's createContext()) rather than a path relative to this
|
|
* file, because the dev container mounts content/ at /var/www/html (named
|
|
* to match Apache's default docroot) while custom/ and app/ keep their
|
|
* real names - a fixed relative path from here would only be correct in
|
|
* production, where content/custom/app are plain siblings.
|
|
*/
|
|
function publiserContentRoot(): string {
|
|
$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? (PUBLISER_ROOT . '/content');
|
|
return rtrim($docRoot, '/');
|
|
}
|
|
|
|
function publiserDataRoot(): string {
|
|
return PUBLISER_ROOT . '/custom/data/publiser';
|
|
}
|
|
|
|
function publiserAssertSection(string $section): void {
|
|
if (!in_array($section, PUBLISER_SECTIONS, true)) {
|
|
throw new InvalidArgumentException("Unknown section: $section");
|
|
}
|
|
}
|
|
|
|
function publiserLiveDir(string $section, string $folder): string {
|
|
publiserAssertSection($section);
|
|
return publiserContentRoot() . "/$section/" . basename($folder);
|
|
}
|
|
|
|
function publiserDraftDir(string $section, string $folder): string {
|
|
publiserAssertSection($section);
|
|
return publiserDataRoot() . "/drafts/$section/" . basename($folder);
|
|
}
|
|
|
|
function publiserTrashDir(string $section, string $folder): string {
|
|
publiserAssertSection($section);
|
|
return publiserDataRoot() . "/trash/$section/" . basename($folder);
|
|
}
|
|
|
|
function publiserEnsureDir(string $dir): void {
|
|
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
|
|
throw new RuntimeException("Could not create directory: $dir");
|
|
}
|
|
}
|
|
|
|
function publiserEnsureParentDir(string $path): void {
|
|
publiserEnsureDir(dirname($path));
|
|
}
|
|
|
|
// --- Slugs -----------------------------------------------------------------
|
|
|
|
function publiserSlugify(string $text): string {
|
|
$map = [
|
|
'æ' => '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[^>]*>(.*?)<\/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.<ext>").
|
|
*/
|
|
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;
|
|
}
|