Add standalone /publiser admin application
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.
This commit is contained in:
parent
c7ec163a36
commit
b609c8596c
15 changed files with 2301 additions and 8 deletions
24
custom/plugins/global/scheduled-publisher.php
Normal file
24
custom/plugins/global/scheduled-publisher.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
/**
|
||||
* Scheduled Publisher Plugin
|
||||
*
|
||||
* Publishes due scheduled posts created via /publiser. Runs on every page
|
||||
* request (piggybacking on normal site traffic) instead of relying on a
|
||||
* cron job - there's nothing to configure on the host, and with regular
|
||||
* traffic a scheduled post goes live within moments of its scheduled time.
|
||||
*
|
||||
* Usage: add to custom/config.ini:
|
||||
* [plugins]
|
||||
* enabled = "languages, scheduled-publisher"
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../publiser-lib.php';
|
||||
|
||||
Hooks::add(Hook::CONTEXT_READY, function (Context $ctx, array $config) {
|
||||
try {
|
||||
publiserPublishDueScheduledItems();
|
||||
} catch (Throwable $e) {
|
||||
error_log('scheduled-publisher: ' . $e->getMessage());
|
||||
}
|
||||
return $ctx;
|
||||
});
|
||||
691
custom/plugins/publiser-lib.php
Normal file
691
custom/plugins/publiser-lib.php
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
<?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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue