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
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,3 +10,5 @@ content/.user.ini
|
||||||
content/php.ini
|
content/php.ini
|
||||||
custom/assets/petition-map-data.json
|
custom/assets/petition-map-data.json
|
||||||
custom/data/petition-map-cache.json
|
custom/data/petition-map-cache.json
|
||||||
|
custom/data/publiser/
|
||||||
|
content/publiser/.htpasswd
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ FROM php:8.4.14-apache
|
||||||
# Enable Apache modules and custom config as root during build
|
# Enable Apache modules and custom config as root during build
|
||||||
RUN a2enmod rewrite headers
|
RUN a2enmod rewrite headers
|
||||||
|
|
||||||
|
# GD extension - used by /publiser to resize/recompress uploaded images
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libjpeg62-turbo-dev libpng-dev libwebp-dev \
|
||||||
|
&& docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||||
|
&& docker-php-ext-install gd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY apache.conf /etc/apache2/conf-available/custom.conf
|
COPY apache.conf /etc/apache2/conf-available/custom.conf
|
||||||
RUN a2enconf custom
|
RUN a2enconf custom
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,4 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "4040:80"
|
- "4040:80"
|
||||||
command: >
|
command: >
|
||||||
bash -c "chown -R www-data:www-data /var/www/custom/data /var/www/custom/assets && apache2-foreground"
|
bash -c "chown -R www-data:www-data /var/www/custom/data /var/www/custom/assets /var/www/html/nyheter /var/www/html/artikler && apache2-foreground"
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ DirectorySlash Off
|
||||||
|
|
||||||
# Block direct access to content source files
|
# Block direct access to content source files
|
||||||
<FilesMatch "\.(ini|md|html|php)$">
|
<FilesMatch "\.(ini|md|html|php)$">
|
||||||
# Allow only the entry point
|
# Allow only the entry point, and the standalone /publiser admin app
|
||||||
<If "%{REQUEST_URI} != '/index.php'">
|
<If "%{REQUEST_URI} != '/index.php' && %{REQUEST_URI} !~ m#^/publiser(/|$)#">
|
||||||
Require all denied
|
Require all denied
|
||||||
</If>
|
</If>
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
@ -30,6 +30,13 @@ DirectorySlash Off
|
||||||
RewriteCond %{REQUEST_URI} ^/app/
|
RewriteCond %{REQUEST_URI} ^/app/
|
||||||
RewriteRule ^(.*)$ /index.php [L,QSA]
|
RewriteRule ^(.*)$ /index.php [L,QSA]
|
||||||
|
|
||||||
|
# Standalone /publiser admin app - bypass the CMS entirely, dispatch
|
||||||
|
# everything to its own entry point (static assets like .css/.js are
|
||||||
|
# excluded so Apache serves those files directly)
|
||||||
|
RewriteCond %{REQUEST_URI} ^/publiser
|
||||||
|
RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|webp|gif|svg)$
|
||||||
|
RewriteRule ^publiser(/.*)?$ publiser/index.php [L,QSA]
|
||||||
|
|
||||||
# Don't rewrite if file exists
|
# Don't rewrite if file exists
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteRule ^(.*)$ /index.php [L,QSA]
|
RewriteRule ^(.*)$ /index.php [L,QSA]
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ DirectorySlash Off
|
||||||
|
|
||||||
# Block direct access to content source files
|
# Block direct access to content source files
|
||||||
<FilesMatch "\.(ini|md|html|php)$">
|
<FilesMatch "\.(ini|md|html|php)$">
|
||||||
# Allow only the entry point
|
# Allow only the entry point, and the standalone /publiser admin app
|
||||||
<If "%{REQUEST_URI} != '/index.php'">
|
<If "%{REQUEST_URI} != '/index.php' && %{REQUEST_URI} !~ m#^/publiser(/|$)#">
|
||||||
Require all denied
|
Require all denied
|
||||||
</If>
|
</If>
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
@ -30,6 +30,13 @@ DirectorySlash Off
|
||||||
RewriteCond %{REQUEST_URI} ^/app/
|
RewriteCond %{REQUEST_URI} ^/app/
|
||||||
RewriteRule ^(.*)$ /index.php [L,QSA]
|
RewriteRule ^(.*)$ /index.php [L,QSA]
|
||||||
|
|
||||||
|
# Standalone /publiser admin app - bypass the CMS entirely, dispatch
|
||||||
|
# everything to its own entry point (static assets like .css/.js are
|
||||||
|
# excluded so Apache serves those files directly)
|
||||||
|
RewriteCond %{REQUEST_URI} ^/publiser
|
||||||
|
RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|webp|gif|svg)$
|
||||||
|
RewriteRule ^publiser(/.*)?$ publiser/index.php [L,QSA]
|
||||||
|
|
||||||
# Don't rewrite if file exists
|
# Don't rewrite if file exists
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteRule ^(.*)$ /index.php [L,QSA]
|
RewriteRule ^(.*)$ /index.php [L,QSA]
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,3 @@ Uten førerkort blir hverdagen veldig vanskelig: matbutikk, legeavtaler og famil
|
||||||
Denne rettssaken er en unik mulighet til å utfordre regelverket og skape reell endring. Ditt bidrag går direkte til rettssaken – en kamp for rettferdighet, medisinsk frihet og et verdig liv for alle pasienter. Bidra nå, og vær med på å endre regelverket!
|
Denne rettssaken er en unik mulighet til å utfordre regelverket og skape reell endring. Ditt bidrag går direkte til rettssaken – en kamp for rettferdighet, medisinsk frihet og et verdig liv for alle pasienter. Bidra nå, og vær med på å endre regelverket!
|
||||||
|
|
||||||
Gi ditt bidrag i dag – hver krone teller! Les Glenn Dahl [sin historie på NRK.no](https://www.nrk.no/norge/nektes-a-kjore-bil-fordi-han-bruker-medisinsk-cannabis-1.17210314).
|
Gi ditt bidrag i dag – hver krone teller! Les Glenn Dahl [sin historie på NRK.no](https://www.nrk.no/norge/nektes-a-kjore-bil-fordi-han-bruker-medisinsk-cannabis-1.17210314).
|
||||||
|
|
||||||
<iframe style="padding-top:1rem" width='350' height='535' src='https://www.spleis.no/project/483880/embed' frameborder='0'></iframe>
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<iframe style="padding-top:1rem" width='350' height='535' src='https://www.spleis.no/project/483880/embed' frameborder='0'></iframe>
|
||||||
15
content/publiser/.htaccess
Normal file
15
content/publiser/.htaccess
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
DirectorySlash On
|
||||||
|
|
||||||
|
# Auth is checked in PHP (index.php), not via Apache AuthUserFile: relative
|
||||||
|
# AuthUserFile paths resolve against ServerRoot, not this directory, and
|
||||||
|
# that differs per environment (podman dev container vs cPanel). Some SAPIs
|
||||||
|
# (LSAPI/CGI/FastCGI, used on cPanel) strip the Authorization header from
|
||||||
|
# PHP by default, so forward it explicitly via an internal env var.
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<Files ".htpasswd">
|
||||||
|
Require all denied
|
||||||
|
</Files>
|
||||||
726
content/publiser/index.php
Normal file
726
content/publiser/index.php
Normal file
|
|
@ -0,0 +1,726 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /publiser - standalone publishing tool for content/nyheter and content/artikler.
|
||||||
|
*
|
||||||
|
* This file is served directly by Apache (see the passthrough rule in
|
||||||
|
* content/.htaccess.base) and is deliberately NOT routed through the CMS -
|
||||||
|
* no Context, no templates, no site chrome. It only reuses the pure,
|
||||||
|
* hook-free helpers from app/helpers.php via custom/plugins/publiser-lib.php.
|
||||||
|
*
|
||||||
|
* Access is protected by Basic Auth, checked here in PHP against a bcrypt
|
||||||
|
* .htpasswd (see custom/tools/set-publiser-password.php) rather than via
|
||||||
|
* Apache's AuthUserFile - that directive needs an absolute path that
|
||||||
|
* differs between the podman dev container and cPanel, which this avoids.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../custom/plugins/publiser-lib.php';
|
||||||
|
|
||||||
|
function publiserRequireAuth(): void {
|
||||||
|
$htpasswd = __DIR__ . '/.htpasswd';
|
||||||
|
|
||||||
|
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||||
|
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||||
|
|
||||||
|
// Some SAPIs (LSAPI/CGI/FastCGI) don't populate PHP_AUTH_*; fall back to
|
||||||
|
// parsing the raw header forwarded by .htaccess's RewriteRule.
|
||||||
|
if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Basic\s+(.+)$/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
|
||||||
|
$decoded = base64_decode($m[1], true) ?: '';
|
||||||
|
if (str_contains($decoded, ':')) {
|
||||||
|
[$user, $pass] = explode(':', $decoded, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$valid = false;
|
||||||
|
if ($user !== null && $pass !== null && is_file($htpasswd)) {
|
||||||
|
foreach (file($htpasswd, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
|
[$lineUser, $hash] = array_pad(explode(':', $line, 2), 2, '');
|
||||||
|
if ($hash !== '' && hash_equals($lineUser, (string)$user) && password_verify((string)$pass, $hash)) {
|
||||||
|
$valid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$valid) {
|
||||||
|
header('WWW-Authenticate: Basic realm="Stopp lidelsen - publisering"');
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Palogging kreves.";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
publiserRequireAuth();
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['publiser_csrf'])) {
|
||||||
|
$_SESSION['publiser_csrf'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
$csrf = $_SESSION['publiser_csrf'];
|
||||||
|
|
||||||
|
// --- small helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
function h(?string $s): string {
|
||||||
|
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserBaseUrl(): string {
|
||||||
|
return '/publiser/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserListUrl(string $tab, string $section = 'nyheter'): string {
|
||||||
|
return publiserBaseUrl() . '?view=list&tab=' . urlencode($tab) . '§ion=' . urlencode($section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserEditUrl(string $location, string $section, string $folder, array $flags = []): string {
|
||||||
|
$url = publiserBaseUrl() . '?view=edit&location=' . urlencode($location)
|
||||||
|
. '§ion=' . urlencode($section) . '&folder=' . urlencode($folder);
|
||||||
|
foreach ($flags as $k => $v) {
|
||||||
|
$url .= '&' . urlencode($k) . '=' . urlencode((string)$v);
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserPublicUrl(string $section, string $folder): string {
|
||||||
|
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserImageUrl(string $location, string $section, string $folder, string $filename): string {
|
||||||
|
if ($location === 'live') {
|
||||||
|
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/' . rawurlencode($filename);
|
||||||
|
}
|
||||||
|
return publiserBaseUrl() . '?action=image&location=' . urlencode($location)
|
||||||
|
. '§ion=' . urlencode($section) . '&folder=' . urlencode($folder) . '&file=' . urlencode($filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserCheckCsrf(): void {
|
||||||
|
global $csrf;
|
||||||
|
$token = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!hash_equals($csrf, $token)) {
|
||||||
|
http_response_code(403);
|
||||||
|
die('Ugyldig forespørsel (utløpt økt). Last siden på nytt og prøv igjen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserRedirect(string $url): never {
|
||||||
|
header("Location: $url");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserItemDir(string $location, string $section, string $folder): string {
|
||||||
|
return match ($location) {
|
||||||
|
'live' => publiserLiveDir($section, $folder),
|
||||||
|
'draft' => publiserDraftDir($section, $folder),
|
||||||
|
'trash' => publiserTrashDir($section, $folder),
|
||||||
|
default => throw new RuntimeException('Ugyldig plassering'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- minimal Markdown -> HTML for populating the WYSIWYG editor on load ----
|
||||||
|
// (mirrors the bounded tag set publiser.js can serialize back to Markdown)
|
||||||
|
|
||||||
|
function publiserEscapeInline(string $text): string {
|
||||||
|
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserInlineMdToHtml(string $text, callable $imageUrlResolver): string {
|
||||||
|
$text = publiserEscapeInline($text);
|
||||||
|
|
||||||
|
$text = preg_replace_callback('/!\[(.*?)\]\((.*?)\)/', function ($m) use ($imageUrlResolver) {
|
||||||
|
$alt = $m[1];
|
||||||
|
$file = $m[2];
|
||||||
|
$realFile = html_entity_decode($file, ENT_QUOTES, 'UTF-8');
|
||||||
|
$url = htmlspecialchars($imageUrlResolver($realFile), ENT_QUOTES, 'UTF-8');
|
||||||
|
return '<img alt="' . $alt . '" src="' . $url . '" data-filename="' . $file . '">';
|
||||||
|
}, $text) ?? $text;
|
||||||
|
|
||||||
|
$text = preg_replace_callback('/\[(.*?)\]\((.*?)\)/', function ($m) {
|
||||||
|
return '<a href="' . $m[2] . '">' . $m[1] . '</a>';
|
||||||
|
}, $text) ?? $text;
|
||||||
|
|
||||||
|
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text) ?? $text;
|
||||||
|
$text = preg_replace('/(?<!\*)\*([^*]+?)\*(?!\*)/', '<em>$1</em>', $text) ?? $text;
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserBlockMdToHtml(string $md, callable $imageUrlResolver): string {
|
||||||
|
$md = str_replace("\r\n", "\n", trim($md));
|
||||||
|
if ($md === '') return '<p><br></p>';
|
||||||
|
$blocks = preg_split('/\n{2,}/', $md) ?: [];
|
||||||
|
$html = [];
|
||||||
|
|
||||||
|
foreach ($blocks as $block) {
|
||||||
|
$block = trim($block);
|
||||||
|
if ($block === '') continue;
|
||||||
|
$lines = explode("\n", $block);
|
||||||
|
|
||||||
|
if (preg_match('/^<[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?>/', $lines[0])) {
|
||||||
|
// Raw HTML embedded in markdown (e.g. an <iframe> embed) - render
|
||||||
|
// it live but non-editable, and store the exact original source
|
||||||
|
// in data-raw-html so the JS serializer can pass it through
|
||||||
|
// byte-for-byte on save instead of trying to reconstruct it from
|
||||||
|
// the rendered DOM.
|
||||||
|
$html[] = '<div class="p-raw-html" contenteditable="false" data-raw-html="' . htmlspecialchars($block, ENT_QUOTES, 'UTF-8') . '">' . $block . '</div>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^### (.*)$/', $lines[0], $m)) {
|
||||||
|
$html[] = '<h3>' . publiserInlineMdToHtml($m[1], $imageUrlResolver) . '</h3>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^## (.*)$/', $lines[0], $m)) {
|
||||||
|
$html[] = '<h2>' . publiserInlineMdToHtml($m[1], $imageUrlResolver) . '</h2>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count($lines) === 1 && preg_match('/^!\[(.*?)\]\((.*?)\)$/', $lines[0])) {
|
||||||
|
$html[] = '<p>' . publiserInlineMdToHtml($lines[0], $imageUrlResolver) . '</p>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($lines[0], '> ')) {
|
||||||
|
$inner = implode(' ', array_map(fn($l) => preg_replace('/^>\s?/', '', $l), $lines));
|
||||||
|
$html[] = '<blockquote><p>' . publiserInlineMdToHtml($inner, $imageUrlResolver) . '</p></blockquote>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^[-*] /', $lines[0])) {
|
||||||
|
$items = array_map(fn($l) => publiserInlineMdToHtml(preg_replace('/^[-*] /', '', $l), $imageUrlResolver), $lines);
|
||||||
|
$html[] = '<ul>' . implode('', array_map(fn($i) => "<li>$i</li>", $items)) . '</ul>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^\d+\. /', $lines[0])) {
|
||||||
|
$items = array_map(fn($l) => publiserInlineMdToHtml(preg_replace('/^\d+\. /', '', $l), $imageUrlResolver), $lines);
|
||||||
|
$html[] = '<ol>' . implode('', array_map(fn($i) => "<li>$i</li>", $items)) . '</ol>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html[] = '<p>' . publiserInlineMdToHtml(implode(' ', $lines), $imageUrlResolver) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $html) ?: '<p><br></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's visible <h1> comes from a leading "# Title" line in the first
|
||||||
|
* content file (extractTitle() in app/helpers.php relies on the same
|
||||||
|
* convention) - there's no separate metadata-driven heading in page.php.
|
||||||
|
* The editor's "Tittel" field owns that line: stripped here for display/
|
||||||
|
* editing, re-prepended in publiserApplyFormSave() on save.
|
||||||
|
*/
|
||||||
|
function publiserStripLeadingH1(string $md): string {
|
||||||
|
$trimmed = ltrim($md, "\n");
|
||||||
|
if (preg_match('/^#[ \t]+.*(?:\n+|$)/', $trimmed, $m)) {
|
||||||
|
return (string)substr($trimmed, strlen($m[0]));
|
||||||
|
}
|
||||||
|
return $md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- form save (metadata + any blocks the user actually touched) ----------
|
||||||
|
|
||||||
|
function publiserApplyFormSave(string $dir, array $post): void {
|
||||||
|
$title = trim((string)($post['title'] ?? ''));
|
||||||
|
$fields = [
|
||||||
|
'title' => $title,
|
||||||
|
'summary' => trim((string)($post['summary'] ?? '')),
|
||||||
|
'tags' => trim((string)($post['tags'] ?? '')),
|
||||||
|
'categories' => trim((string)($post['categories'] ?? '')),
|
||||||
|
];
|
||||||
|
if (!empty($post['date'])) {
|
||||||
|
$fields['date'] = $post['date'];
|
||||||
|
}
|
||||||
|
publiserSaveMetadata($dir, $fields);
|
||||||
|
|
||||||
|
// The first richtext block carries the page's visible <h1> (see
|
||||||
|
// publiserStripLeadingH1()) - it's rewritten every save to stay in sync
|
||||||
|
// with the title field, even if its body wasn't otherwise touched.
|
||||||
|
$titleBlockFilename = null;
|
||||||
|
foreach (publiserListBlocks($dir) as $b) {
|
||||||
|
if ($b['type'] === 'richtext') {
|
||||||
|
$titleBlockFilename = $b['filename'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = $post['block_content'] ?? [];
|
||||||
|
$dirty = $post['block_dirty'] ?? [];
|
||||||
|
if (is_array($contents)) {
|
||||||
|
foreach ($contents as $filename => $content) {
|
||||||
|
$filename = basename((string)$filename);
|
||||||
|
if (!is_file("$dir/$filename")) continue;
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
if ($filename === $titleBlockFilename) {
|
||||||
|
if ($title !== '') {
|
||||||
|
publiserSaveBlockContent($dir, $filename, "# {$title}\n\n" . ltrim($content, "\n"));
|
||||||
|
} elseif (!empty($dirty[$filename])) {
|
||||||
|
publiserSaveBlockContent($dir, $filename, $content);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($dirty[$filename])) continue; // untouched blocks are never rewritten
|
||||||
|
publiserSaveBlockContent($dir, $filename, $content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- image upload / serve endpoints ----------------------------------------
|
||||||
|
|
||||||
|
if (($_GET['action'] ?? '') === 'image') {
|
||||||
|
$location = (string)($_GET['location'] ?? 'draft');
|
||||||
|
$section = (string)($_GET['section'] ?? '');
|
||||||
|
$folder = (string)($_GET['folder'] ?? '');
|
||||||
|
$file = basename((string)($_GET['file'] ?? ''));
|
||||||
|
try {
|
||||||
|
$dir = publiserItemDir($location, $section, $folder);
|
||||||
|
$path = "$dir/$file";
|
||||||
|
if (!is_file($path)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'][$ext] ?? 'application/octet-stream';
|
||||||
|
header("Content-Type: $mime");
|
||||||
|
header('Cache-Control: private, max-age=60');
|
||||||
|
readfile($path);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(404);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($_GET['action'] ?? '') === 'upload-image' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
try {
|
||||||
|
publiserCheckCsrf();
|
||||||
|
$location = (string)($_POST['location'] ?? 'draft');
|
||||||
|
$section = (string)($_POST['section'] ?? '');
|
||||||
|
$folder = (string)($_POST['folder'] ?? '');
|
||||||
|
$target = (string)($_POST['target'] ?? 'inline');
|
||||||
|
|
||||||
|
$dir = publiserItemDir($location, $section, $folder);
|
||||||
|
if (!is_dir($dir)) throw new RuntimeException('Fant ikke innlegget');
|
||||||
|
|
||||||
|
if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Ingen fil mottatt');
|
||||||
|
}
|
||||||
|
if ($_FILES['image']['size'] > 15 * 1024 * 1024) {
|
||||||
|
throw new RuntimeException('Bildet er for stort (maks 15 MB)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $target === 'cover'
|
||||||
|
? publiserSaveCoverImage($dir, $_FILES['image']['tmp_name'], $_FILES['image']['name'])
|
||||||
|
: publiserSaveUploadedImage($dir, $_FILES['image']['tmp_name'], $_FILES['image']['name']);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'filename' => $filename,
|
||||||
|
'url' => publiserImageUrl($location, $section, $folder, $filename),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- POST actions (create / save / publish / unpublish / trash / restore) -
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
publiserCheckCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isset($_POST['create'])) {
|
||||||
|
$title = trim((string)($_POST['title'] ?? ''));
|
||||||
|
$newSection = (string)($_POST['new_section'] ?? 'nyheter');
|
||||||
|
$created = publiserCreateDraft($newSection, $title);
|
||||||
|
publiserRedirect(publiserEditUrl('draft', $created['section'], $created['folder']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['restore'])) {
|
||||||
|
$result = publiserRestore((string)$_POST['section'], (string)$_POST['folder']);
|
||||||
|
publiserRedirect(publiserEditUrl('draft', $result['section'], $result['folder'], ['restored' => 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$postSection = (string)($_POST['section'] ?? '');
|
||||||
|
$postFolder = (string)($_POST['folder'] ?? '');
|
||||||
|
$postLocation = (string)($_POST['location'] ?? 'draft');
|
||||||
|
|
||||||
|
$dir = publiserItemDir($postLocation, $postSection, $postFolder);
|
||||||
|
if (!is_dir($dir)) throw new RuntimeException('Fant ikke innlegget');
|
||||||
|
|
||||||
|
if (isset($_POST['delete_item'])) {
|
||||||
|
publiserTrash($postLocation, $postSection, $postFolder);
|
||||||
|
publiserRedirect(publiserListUrl('drafts', $postSection));
|
||||||
|
}
|
||||||
|
|
||||||
|
// every other action first persists whatever's currently in the form
|
||||||
|
publiserApplyFormSave($dir, $_POST);
|
||||||
|
|
||||||
|
if (isset($_POST['add_block'])) {
|
||||||
|
publiserAddBlock($dir, (string)$_POST['add_block']);
|
||||||
|
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||||
|
}
|
||||||
|
if (isset($_POST['reorder_up'])) {
|
||||||
|
publiserReorderBlock($dir, (string)$_POST['reorder_up'], 'up');
|
||||||
|
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||||
|
}
|
||||||
|
if (isset($_POST['reorder_down'])) {
|
||||||
|
publiserReorderBlock($dir, (string)$_POST['reorder_down'], 'down');
|
||||||
|
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||||
|
}
|
||||||
|
if (isset($_POST['delete_block'])) {
|
||||||
|
publiserDeleteBlock($dir, (string)$_POST['delete_block']);
|
||||||
|
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||||
|
}
|
||||||
|
if (isset($_POST['unschedule'])) {
|
||||||
|
publiserSetSchedule($postSection, $postFolder, null);
|
||||||
|
publiserRedirect(publiserEditUrl('draft', $postSection, $postFolder));
|
||||||
|
}
|
||||||
|
if (isset($_POST['unpublish'])) {
|
||||||
|
$result = publiserUnpublish($postSection, $postFolder);
|
||||||
|
publiserRedirect(publiserEditUrl('draft', $result['section'], $result['folder'], ['unpublished' => 1]));
|
||||||
|
}
|
||||||
|
if (isset($_POST['publish'])) {
|
||||||
|
$scheduleAt = trim((string)($_POST['schedule_at'] ?? ''));
|
||||||
|
if ($scheduleAt !== '' && strtotime($scheduleAt) > time()) {
|
||||||
|
publiserSetSchedule($postSection, $postFolder, $scheduleAt);
|
||||||
|
publiserRedirect(publiserEditUrl('draft', $postSection, $postFolder, ['scheduled' => 1]));
|
||||||
|
}
|
||||||
|
$result = publiserPublish($postSection, $postFolder);
|
||||||
|
publiserRedirect(publiserEditUrl('live', $result['section'], $result['folder'], ['published' => 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain save
|
||||||
|
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder, ['saved' => 1]));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
publiserShell('Feil', '<div class="p-notice p-notice-error"><p>' . h($e->getMessage()) . '</p><p><a href="' . h(publiserListUrl('published')) . '">Tilbake</a></p></div>');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- page shell --------------------------------------------------------
|
||||||
|
|
||||||
|
function publiserShell(string $title, string $body): void {
|
||||||
|
$view = $_GET['view'] ?? 'list';
|
||||||
|
$currentTab = $view === 'list' ? ($_GET['tab'] ?? 'published') : null;
|
||||||
|
$navItems = [
|
||||||
|
'published' => 'Publisert',
|
||||||
|
'scheduled' => 'Planlagt',
|
||||||
|
'drafts' => 'Utkast',
|
||||||
|
'trash' => 'Papirkurv',
|
||||||
|
];
|
||||||
|
$cssHash = file_exists(__DIR__ . '/publiser.css') ? hash_file('md5', __DIR__ . '/publiser.css') : '0';
|
||||||
|
$jsHash = file_exists(__DIR__ . '/publiser.js') ? hash_file('md5', __DIR__ . '/publiser.js') : '0';
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="no">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= h($title) ?> - Publisering</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="stylesheet" href="/app/styles/base.css">
|
||||||
|
<link rel="stylesheet" href="/publiser/publiser.css?v=<?= h($cssHash) ?>">
|
||||||
|
</head>
|
||||||
|
<body class="p-app">
|
||||||
|
<header class="p-topbar">
|
||||||
|
<nav class="p-topbar-inner">
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($navItems as $key => $label): ?>
|
||||||
|
<li><a class="button <?= $currentTab === $key ? 'active' : '' ?>" href="<?= h(publiserListUrl($key)) ?>"><?= h($label) ?></a></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<li><a class="button" href="<?= h(publiserBaseUrl()) ?>?view=new">+ Nytt innlegg</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="p-main">
|
||||||
|
<?= $body ?>
|
||||||
|
</main>
|
||||||
|
<script src="/publiser/publiser.js?v=<?= h($jsHash) ?>"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- views ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function publiserRenderList(string $csrf): void {
|
||||||
|
$tab = (string)($_GET['tab'] ?? 'published');
|
||||||
|
$section = (string)($_GET['section'] ?? 'nyheter');
|
||||||
|
if (!in_array($section, PUBLISER_SECTIONS, true)) $section = 'nyheter';
|
||||||
|
|
||||||
|
$items = match ($tab) {
|
||||||
|
'scheduled' => array_values(array_filter(publiserListStaged('drafts', $section), fn($i) => $i['status'] === 'scheduled')),
|
||||||
|
'drafts' => array_values(array_filter(publiserListStaged('drafts', $section), fn($i) => $i['status'] !== 'scheduled')),
|
||||||
|
'trash' => publiserListStaged('trash', $section),
|
||||||
|
default => publiserListLive($section),
|
||||||
|
};
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
<div class="p-header-row">
|
||||||
|
<h1>Innhold</h1>
|
||||||
|
<div class="p-section-switch">
|
||||||
|
<a class="<?= $section === 'nyheter' ? 'active' : '' ?>" href="<?= h(publiserListUrl($tab, 'nyheter')) ?>">Nyheter</a>
|
||||||
|
<a class="<?= $section === 'artikler' ? 'active' : '' ?>" href="<?= h(publiserListUrl($tab, 'artikler')) ?>">Artikler</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (isset($_GET['deleted'])): ?><div class="p-notice">Flyttet til papirkurven.</div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($items)): ?>
|
||||||
|
<p class="p-empty">Ingen innlegg her ennå.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="p-list">
|
||||||
|
<?php foreach ($items as $item): ?>
|
||||||
|
<li class="p-list-item">
|
||||||
|
<?php if ($item['cover']): ?>
|
||||||
|
<img class="p-list-cover" src="<?= h(publiserImageUrl($item['location'], $item['section'], $item['folder'], $item['cover'])) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="p-list-cover p-list-cover-empty"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="p-list-info">
|
||||||
|
<div class="p-list-title"><?= h($item['title']) ?></div>
|
||||||
|
<div class="p-list-meta">
|
||||||
|
<?= h($item['date']) ?>
|
||||||
|
<?php if ($tab === 'scheduled' && $item['publishAt']): ?>
|
||||||
|
· publiseres <?= h($item['publishAt']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-list-actions">
|
||||||
|
<?php if ($tab === 'trash'): ?>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||||
|
<input type="hidden" name="section" value="<?= h($item['section']) ?>">
|
||||||
|
<input type="hidden" name="folder" value="<?= h($item['folder']) ?>">
|
||||||
|
<button type="submit" name="restore" value="1" class="button">Gjenopprett</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="button" href="<?= h(publiserEditUrl($item['location'], $item['section'], $item['folder'])) ?>">Rediger</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
publiserShell('Innhold', ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserRenderNew(string $csrf): void {
|
||||||
|
ob_start(); ?>
|
||||||
|
<h1>Nytt innlegg</h1>
|
||||||
|
<form method="post" class="p-form p-form-new">
|
||||||
|
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||||
|
<label>Type
|
||||||
|
<select name="new_section">
|
||||||
|
<option value="nyheter">Nyhet</option>
|
||||||
|
<option value="artikler">Artikkel</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tittel
|
||||||
|
<input type="text" name="title" required autofocus placeholder="Skriv en tittel...">
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="create" value="1" class="button active">Opprett</button>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
publiserShell('Nytt innlegg', ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
function publiserRenderEditor(string $csrf): void {
|
||||||
|
$location = (string)($_GET['location'] ?? 'draft');
|
||||||
|
$section = (string)($_GET['section'] ?? '');
|
||||||
|
$folder = (string)($_GET['folder'] ?? '');
|
||||||
|
|
||||||
|
$item = publiserLoadItem($location, $section, $folder);
|
||||||
|
if (!$item) {
|
||||||
|
publiserShell('Ikke funnet', '<p>Fant ikke innlegget.</p><p><a href="' . h(publiserListUrl('published')) . '">Tilbake</a></p>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $item['metadata'];
|
||||||
|
if (empty($meta['title'])) {
|
||||||
|
// Legacy items often have no explicit title field - the site
|
||||||
|
// derives it from the first content file's H1 (publiserExtractTitle()).
|
||||||
|
$meta['title'] = publiserExtractTitle($item['dir']) ?? $folder;
|
||||||
|
}
|
||||||
|
$resolver = fn(string $file) => publiserImageUrl($location, $section, $folder, $file);
|
||||||
|
|
||||||
|
if ($location === 'live') {
|
||||||
|
$backTab = 'published';
|
||||||
|
} elseif (($meta['status'] ?? '') === 'scheduled') {
|
||||||
|
$backTab = 'scheduled';
|
||||||
|
} else {
|
||||||
|
$backTab = 'drafts';
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
<div class="p-editor-topline">
|
||||||
|
<a class="p-back" href="<?= h(publiserListUrl($backTab, $section)) ?>">← Tilbake til listen</a>
|
||||||
|
<?php if ($location === 'live'): ?>
|
||||||
|
<a class="p-view-live" href="<?= h(publiserPublicUrl($section, $folder)) ?>" target="_blank" rel="noopener">Se siden ↗</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (isset($_GET['saved'])): ?><div class="p-notice">Lagret.</div><?php endif; ?>
|
||||||
|
<?php if (isset($_GET['published'])): ?><div class="p-notice">Publisert.</div><?php endif; ?>
|
||||||
|
<?php if (isset($_GET['unpublished'])): ?><div class="p-notice">Avpublisert - ligger nå i utkast.</div><?php endif; ?>
|
||||||
|
<?php if (isset($_GET['scheduled'])): ?><div class="p-notice">Planlagt publisering satt.</div><?php endif; ?>
|
||||||
|
<?php if (isset($_GET['restored'])): ?><div class="p-notice">Gjenopprettet til utkast.</div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($location === 'trash'): ?>
|
||||||
|
<h1><?= h($meta['title'] ?? $folder) ?></h1>
|
||||||
|
<p class="p-muted">Ligger i papirkurven.</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||||
|
<input type="hidden" name="section" value="<?= h($section) ?>">
|
||||||
|
<input type="hidden" name="folder" value="<?= h($folder) ?>">
|
||||||
|
<button type="submit" name="restore" value="1" class="button active">Gjenopprett til utkast</button>
|
||||||
|
</form>
|
||||||
|
<?php publiserShell(h($meta['title'] ?? $folder), ob_get_clean()); return; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" id="editor-form" class="p-editor p-form" data-location="<?= h($location) ?>" data-section="<?= h($section) ?>" data-folder="<?= h($folder) ?>" data-upload-url="<?= h(publiserBaseUrl() . '?action=upload-image') ?>" data-csrf="<?= h($csrf) ?>">
|
||||||
|
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||||
|
<input type="hidden" name="location" value="<?= h($location) ?>">
|
||||||
|
<input type="hidden" name="section" value="<?= h($section) ?>">
|
||||||
|
<input type="hidden" name="folder" value="<?= h($folder) ?>">
|
||||||
|
|
||||||
|
<div class="p-editor-grid">
|
||||||
|
<div class="p-editor-main">
|
||||||
|
<input type="text" name="title" class="p-title-input" value="<?= h($meta['title'] ?? '') ?>" placeholder="Tittel" required>
|
||||||
|
|
||||||
|
<div class="p-blocks">
|
||||||
|
<?php $titleBlockSeen = false; ?>
|
||||||
|
<?php foreach ($item['blocks'] as $block): ?>
|
||||||
|
<?php
|
||||||
|
$raw = file_get_contents($item['dir'] . '/' . $block['filename']) ?: '';
|
||||||
|
$isTitleBlock = !$titleBlockSeen && $block['type'] === 'richtext';
|
||||||
|
if ($isTitleBlock) {
|
||||||
|
$titleBlockSeen = true;
|
||||||
|
$raw = publiserStripLeadingH1($raw);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="p-block" data-filename="<?= h($block['filename']) ?>" data-type="<?= h($block['type']) ?>">
|
||||||
|
<div class="p-block-toolbar">
|
||||||
|
<?php if ($block['type'] === 'richtext'): ?>
|
||||||
|
<button type="button" data-cmd="bold" title="Fet"><strong>B</strong></button>
|
||||||
|
<button type="button" data-cmd="italic" title="Kursiv"><em>I</em></button>
|
||||||
|
<button type="button" data-cmd="h2" title="Overskrift">H2</button>
|
||||||
|
<button type="button" data-cmd="h3" title="Underoverskrift">H3</button>
|
||||||
|
<button type="button" data-cmd="ul" title="Punktliste">• Liste</button>
|
||||||
|
<button type="button" data-cmd="ol" title="Nummerert liste">1. Liste</button>
|
||||||
|
<button type="button" data-cmd="quote" title="Sitat">” Sitat</button>
|
||||||
|
<button type="button" data-cmd="link" title="Lenke">🔗 Lenke</button>
|
||||||
|
<button type="button" data-cmd="image" title="Sett inn bilde">📷 Bilde</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="p-block-label">Egendefinert kode (<?= h(strtoupper($block['ext'])) ?>) - vises ikke visuelt her</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="p-block-spacer"></span>
|
||||||
|
<button type="submit" name="reorder_up" value="<?= h($block['filename']) ?>" title="Flytt opp">↑</button>
|
||||||
|
<button type="submit" name="reorder_down" value="<?= h($block['filename']) ?>" title="Flytt ned">↓</button>
|
||||||
|
<button type="submit" name="delete_block" value="<?= h($block['filename']) ?>" class="danger" data-confirm="Slette denne blokken?" title="Slett blokk">×</button>
|
||||||
|
</div>
|
||||||
|
<?php if ($block['type'] === 'richtext'): ?>
|
||||||
|
<div class="p-richtext" contenteditable="true"><?= publiserBlockMdToHtml($raw, $resolver) ?></div>
|
||||||
|
<textarea class="p-block-hidden" name="block_content[<?= h($block['filename']) ?>]" hidden><?= h($raw) ?></textarea>
|
||||||
|
<input type="hidden" name="block_dirty[<?= h($block['filename']) ?>]" value="0" class="p-dirty-flag">
|
||||||
|
<?php else: ?>
|
||||||
|
<textarea class="p-code" rows="8" name="block_content[<?= h($block['filename']) ?>]"><?= h($raw) ?></textarea>
|
||||||
|
<input type="hidden" name="block_dirty[<?= h($block['filename']) ?>]" value="1" class="p-dirty-flag">
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-add-block">
|
||||||
|
<button type="submit" name="add_block" value="md" class="button">+ Tekst</button>
|
||||||
|
<button type="submit" name="add_block" value="html" class="button">+ Egendefinert HTML (avansert)</button>
|
||||||
|
<button type="submit" name="add_block" value="php" class="button">+ Egendefinert PHP (avansert)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($item['extra'])): ?>
|
||||||
|
<div class="p-extra-files">
|
||||||
|
<p class="p-muted">Andre filer i denne mappen (rediger disse direkte i kildekoden):</p>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($item['extra'] as $extra): ?>
|
||||||
|
<?php
|
||||||
|
$icon = match ($extra['type']) {
|
||||||
|
'folder' => '📁',
|
||||||
|
'translation' => '🌐',
|
||||||
|
default => '📄',
|
||||||
|
};
|
||||||
|
$note = $extra['type'] === 'translation' ? ' (engelsk oversettelse)' : '';
|
||||||
|
?>
|
||||||
|
<li><?= h($icon) ?> <?= h($extra['name']) ?><?= h($note) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-editor-sidebar">
|
||||||
|
<div class="p-side-box">
|
||||||
|
<p class="p-side-label">Metadata</p>
|
||||||
|
<label>Sammendrag
|
||||||
|
<textarea name="summary" rows="3" placeholder="Vises i lister og deling"><?= h($meta['summary'] ?? '') ?></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Stikkord (kommaseparert)
|
||||||
|
<input type="text" name="tags" value="<?= h($meta['tags'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>Kategori
|
||||||
|
<input type="text" name="categories" value="<?= h($meta['categories'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<?php if ($section === 'nyheter'): ?>
|
||||||
|
<label>Dato
|
||||||
|
<input type="date" name="date" value="<?= h($meta['date'] ?? date('Y-m-d')) ?>">
|
||||||
|
</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-side-box">
|
||||||
|
<p class="p-side-label">Forsidebilde</p>
|
||||||
|
<div class="p-cover-drop" data-target="cover">
|
||||||
|
<?php if ($item['cover']): ?>
|
||||||
|
<img src="<?= h(publiserImageUrl($location, $section, $folder, $item['cover'])) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<span>Slipp bilde her eller klikk for å velge</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="file" accept="image/*" hidden>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-side-box p-actions-box">
|
||||||
|
<button type="submit" name="save" value="1" class="button active">Lagre</button>
|
||||||
|
|
||||||
|
<?php if ($location === 'draft'): ?>
|
||||||
|
<?php if (($meta['status'] ?? '') === 'scheduled'): ?>
|
||||||
|
<p class="p-muted">Planlagt: <?= h($meta['publish_at'] ?? '') ?></p>
|
||||||
|
<button type="submit" name="unschedule" value="1" class="button">Avbryt planlegging</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<label>Planlegg publisering (valgfritt)
|
||||||
|
<input type="datetime-local" name="schedule_at" value="<?= h($meta['publish_at'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="publish" value="1" class="button active">Publiser</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<button type="submit" name="unpublish" value="1" class="button">Avpubliser</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<button type="submit" name="delete_item" value="1" class="button danger" data-confirm="Flytte dette innlegget til papirkurven?">Slett</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
publiserShell(h($meta['title'] ?? $folder), ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dispatch --------------------------------------------------------------
|
||||||
|
|
||||||
|
$view = (string)($_GET['view'] ?? 'list');
|
||||||
|
match ($view) {
|
||||||
|
'new' => publiserRenderNew($csrf),
|
||||||
|
'edit' => publiserRenderEditor($csrf),
|
||||||
|
default => publiserRenderList($csrf),
|
||||||
|
};
|
||||||
376
content/publiser/publiser.css
Normal file
376
content/publiser/publiser.css
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
/* /publiser admin app - reuses the site's CSS variables (base.css) for
|
||||||
|
brand consistency, but is otherwise a self-contained layout. Follows the
|
||||||
|
site convention of margin-top-only spacing (global reset zeroes
|
||||||
|
margin-bottom). */
|
||||||
|
|
||||||
|
/* Reuses the site's own .button class for nav/action links (base.css), so
|
||||||
|
they inherit real site styling instead of parallel custom classes. The
|
||||||
|
header bar itself has no site equivalent (the public site's header is a
|
||||||
|
narrower, logo-anchored layout) - .p-topbar-inner matches .p-main's width
|
||||||
|
instead, left-aligned. */
|
||||||
|
|
||||||
|
.p-app {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-grey);
|
||||||
|
background: var(--color-green-light);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-topbar {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 3px #00000022 solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-topbar-inner {
|
||||||
|
max-width: 64rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: .8rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-topbar-inner ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
list-style: none;
|
||||||
|
gap: .6rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-main {
|
||||||
|
max-width: 64rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--color-green);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-muted { color: #777; font-size: .9rem }
|
||||||
|
|
||||||
|
.p-editor-topline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-back, .p-view-live {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-notice {
|
||||||
|
margin-top: .8rem;
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
background: var(--color-green-light);
|
||||||
|
border: 1px solid var(--color-green);
|
||||||
|
border-radius: .4rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
}
|
||||||
|
.p-notice-error {
|
||||||
|
background: #fdecea;
|
||||||
|
border-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- list view --- */
|
||||||
|
|
||||||
|
.p-header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-section-switch a {
|
||||||
|
padding: .3rem .7rem;
|
||||||
|
border-radius: .4rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.p-section-switch a.active {
|
||||||
|
background: var(--color-green);
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-empty { margin-top: 2rem; color: #777 }
|
||||||
|
|
||||||
|
.p-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: .5rem;
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
border: 1px solid #00000012;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-list-cover {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: .4rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.p-list-cover-empty { background: #eee }
|
||||||
|
|
||||||
|
.p-list-info { flex: 1; min-width: 0 }
|
||||||
|
.p-list-title {
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.p-list-meta { font-size: .85rem; color: #777; margin-top: .1rem }
|
||||||
|
|
||||||
|
.p-list-actions form { margin: 0 }
|
||||||
|
|
||||||
|
/* --- buttons & forms --- */
|
||||||
|
|
||||||
|
/* base.css's .button has no destructive/danger variant - the one addition
|
||||||
|
needed on top of the site's real button styling. */
|
||||||
|
.button.danger { outline-color: #c0392b; color: #c0392b }
|
||||||
|
.button.danger:hover, .button.danger:focus { background-color: #c0392b; color: #fff; outline: none }
|
||||||
|
|
||||||
|
.p-form label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.p-side-box .p-form label:first-of-type,
|
||||||
|
.p-side-box label:first-of-type { margin-top: .6rem }
|
||||||
|
|
||||||
|
.p-form input[type=text],
|
||||||
|
.p-form input[type=date],
|
||||||
|
.p-form input[type=datetime-local],
|
||||||
|
.p-form textarea,
|
||||||
|
.p-form select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: .35rem;
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border: 1px solid #00000022;
|
||||||
|
border-radius: .4rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: .95rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
background: #fbfbfa;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.p-form input[type=text]:focus,
|
||||||
|
.p-form input[type=date]:focus,
|
||||||
|
.p-form input[type=datetime-local]:focus,
|
||||||
|
.p-form textarea:focus,
|
||||||
|
.p-form select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-green);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.p-form textarea { resize: vertical }
|
||||||
|
/* .p-form textarea's `display: block` above outranks the browser's native
|
||||||
|
[hidden] { display: none }, which un-hides this fallback field (it holds
|
||||||
|
the untouched block's raw content and must never actually show). Force it
|
||||||
|
back off with higher specificity. */
|
||||||
|
.p-form textarea.p-block-hidden { display: none }
|
||||||
|
|
||||||
|
.p-form-new { max-width: 28rem }
|
||||||
|
.p-form-new button { margin-top: 1.5rem }
|
||||||
|
|
||||||
|
/* --- editor --- */
|
||||||
|
|
||||||
|
.p-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 18rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 55rem) {
|
||||||
|
.p-editor-grid { grid-template-columns: 1fr }
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-title-input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #00000015;
|
||||||
|
padding: .3rem 0;
|
||||||
|
background: transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.p-title-input:focus { outline: none; border-bottom-color: var(--color-green) }
|
||||||
|
|
||||||
|
.p-blocks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-block {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #00000015;
|
||||||
|
border-radius: .5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-block-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .2rem;
|
||||||
|
padding: .4rem;
|
||||||
|
background: #fafaf8;
|
||||||
|
border-bottom: 1px solid #00000012;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-block-toolbar button {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
padding: .3rem .55rem;
|
||||||
|
border-radius: .3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
.p-block-toolbar button:hover { background: #eee }
|
||||||
|
.p-block-toolbar button.danger { color: #c0392b }
|
||||||
|
.p-block-toolbar button.danger:hover { background: #fdecea }
|
||||||
|
|
||||||
|
.p-block-spacer { flex: 1 }
|
||||||
|
.p-block-label { font-size: .85rem; color: #777; padding: 0 .4rem }
|
||||||
|
|
||||||
|
.p-richtext {
|
||||||
|
min-height: 6rem;
|
||||||
|
padding: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.p-richtext:focus { outline: none; background: #fffef8 }
|
||||||
|
.p-richtext h2, .p-richtext h3 { margin-top: .6em; color: var(--color-green) }
|
||||||
|
.p-richtext p { margin-top: .8em }
|
||||||
|
.p-richtext p:first-child { margin-top: 0 }
|
||||||
|
.p-richtext img { max-width: 100%; border-radius: .3rem; margin-top: .5em }
|
||||||
|
.p-richtext blockquote {
|
||||||
|
margin-top: .8em;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 3px solid var(--color-green);
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.p-richtext ul, .p-richtext ol { margin-top: .5em; padding-left: 1.4rem }
|
||||||
|
|
||||||
|
.p-raw-html {
|
||||||
|
margin-top: .8em;
|
||||||
|
padding: .6rem;
|
||||||
|
border: 1px dashed #00000030;
|
||||||
|
border-radius: .4rem;
|
||||||
|
background: #fafaf8;
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.p-raw-html::before {
|
||||||
|
content: "Innebygd kode - kan flyttes/slettes, men ikke redigeres her";
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .5em;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: #888;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-code {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: .85rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.p-code:focus { outline: none; background: #fffef8 }
|
||||||
|
|
||||||
|
.p-add-block {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-extra-files {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: .8rem;
|
||||||
|
background: #fafaf8;
|
||||||
|
border-radius: .4rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.p-extra-files ul { margin-top: .3rem; padding-left: 1.2rem }
|
||||||
|
|
||||||
|
.p-editor-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-side-box {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #00000015;
|
||||||
|
border-radius: .6rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
box-shadow: 0 1px 2px #00000008;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-side-label {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-green);
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
border-bottom: 1px solid #00000012;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-actions-box { display: flex; flex-direction: column; gap: .6rem }
|
||||||
|
.p-actions-box .button { width: 100%; text-align: center; box-sizing: border-box }
|
||||||
|
.p-actions-box label { margin-top: .8rem }
|
||||||
|
.p-actions-box .p-muted { margin-top: 0 }
|
||||||
|
|
||||||
|
.p-cover-drop {
|
||||||
|
margin-top: .5rem;
|
||||||
|
border: 2px dashed #00000030;
|
||||||
|
border-radius: .4rem;
|
||||||
|
min-height: 6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: #777;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.p-cover-drop.dragover { border-color: var(--color-green); background: var(--color-green-light) }
|
||||||
|
.p-cover-drop img { width: 100%; height: 8rem; object-fit: cover; border-radius: .3rem }
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
.p-main { padding: 1rem .7rem 4rem }
|
||||||
|
.p-list-item { flex-wrap: wrap }
|
||||||
|
}
|
||||||
382
content/publiser/publiser.js
Normal file
382
content/publiser/publiser.js
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
/* /publiser block editor: rich-text toolbar for .md blocks, plain code
|
||||||
|
* editing for .html/.php blocks, image upload (cover + inline), and a
|
||||||
|
* small Markdown serializer for the bounded tag set the toolbar produces.
|
||||||
|
* No external dependencies - contenteditable + document.execCommand for
|
||||||
|
* the handful of inline/list commands (still functional everywhere,
|
||||||
|
* despite being spec-deprecated), manual DOM ops for block-level toggles.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
document.execCommand('defaultParagraphSeparator', false, 'p');
|
||||||
|
|
||||||
|
// --- Markdown serialization (HTML -> MD, mirrors index.php's MD -> HTML) --
|
||||||
|
|
||||||
|
function serializeChildren(node) {
|
||||||
|
return Array.from(node.childNodes).map(serializeNode).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNode(node) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Not escaping markdown-significant characters (*, [, ] ...) in plain
|
||||||
|
// text: round-tripping literal asterisks/brackets through the parser
|
||||||
|
// is a rare, low-severity edge case not worth the added complexity.
|
||||||
|
return node.textContent;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||||
|
|
||||||
|
if (node.hasAttribute('data-raw-html')) {
|
||||||
|
// Non-editable raw-HTML island (see index.php's publiserBlockMdToHtml)
|
||||||
|
// - pass the original source through unchanged rather than trying to
|
||||||
|
// reconstruct it from the rendered DOM.
|
||||||
|
return `\n\n${node.getAttribute('data-raw-html')}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = node.tagName.toLowerCase();
|
||||||
|
switch (tag) {
|
||||||
|
case 'strong':
|
||||||
|
case 'b': {
|
||||||
|
const inner = serializeChildren(node).trim();
|
||||||
|
return inner ? `**${inner}**` : '';
|
||||||
|
}
|
||||||
|
case 'em':
|
||||||
|
case 'i': {
|
||||||
|
const inner = serializeChildren(node).trim();
|
||||||
|
return inner ? `*${inner}*` : '';
|
||||||
|
}
|
||||||
|
case 'a': {
|
||||||
|
const href = node.getAttribute('href') || '';
|
||||||
|
const inner = serializeChildren(node).trim();
|
||||||
|
return href ? `[${inner}](${href})` : inner;
|
||||||
|
}
|
||||||
|
case 'img': {
|
||||||
|
const file = node.getAttribute('data-filename') || node.getAttribute('src') || '';
|
||||||
|
const alt = node.getAttribute('alt') || '';
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
case 'br':
|
||||||
|
return ' ';
|
||||||
|
case 'h2':
|
||||||
|
return `\n\n## ${serializeChildren(node).trim()}\n\n`;
|
||||||
|
case 'h3':
|
||||||
|
return `\n\n### ${serializeChildren(node).trim()}\n\n`;
|
||||||
|
case 'blockquote': {
|
||||||
|
const text = serializeChildren(node).trim().replace(/\s+/g, ' ');
|
||||||
|
return text ? `\n\n> ${text}\n\n` : '';
|
||||||
|
}
|
||||||
|
case 'ul': {
|
||||||
|
const items = Array.from(node.children)
|
||||||
|
.filter((c) => c.tagName === 'LI')
|
||||||
|
.map((li) => `- ${serializeChildren(li).trim()}`)
|
||||||
|
.join('\n');
|
||||||
|
return items ? `\n\n${items}\n\n` : '';
|
||||||
|
}
|
||||||
|
case 'ol': {
|
||||||
|
const items = Array.from(node.children)
|
||||||
|
.filter((c) => c.tagName === 'LI')
|
||||||
|
.map((li, i) => `${i + 1}. ${serializeChildren(li).trim()}`)
|
||||||
|
.join('\n');
|
||||||
|
return items ? `\n\n${items}\n\n` : '';
|
||||||
|
}
|
||||||
|
case 'li':
|
||||||
|
return serializeChildren(node);
|
||||||
|
case 'p':
|
||||||
|
case 'div': {
|
||||||
|
const inner = serializeChildren(node).trim();
|
||||||
|
return inner ? `\n\n${inner}\n\n` : '';
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return serializeChildren(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRichText(root) {
|
||||||
|
let out = Array.from(root.childNodes).map(serializeNode).join('');
|
||||||
|
out = out.replace(/[ \t]+\n/g, '\n');
|
||||||
|
out = out.replace(/\n{3,}/g, '\n\n');
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dirty tracking ---------------------------------------------------
|
||||||
|
|
||||||
|
function markDirty(richDiv) {
|
||||||
|
const block = richDiv.closest('.p-block');
|
||||||
|
if (!block) return;
|
||||||
|
const dirtyFlag = block.querySelector('.p-dirty-flag');
|
||||||
|
const hidden = block.querySelector('.p-block-hidden');
|
||||||
|
if (dirtyFlag) dirtyFlag.value = '1';
|
||||||
|
if (hidden) hidden.value = serializeRichText(richDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- selection helpers --------------------------------------------------
|
||||||
|
|
||||||
|
function saveSelection(richDiv) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) return null;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (!richDiv.contains(range.startContainer)) return null;
|
||||||
|
return range.cloneRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSelection(range) {
|
||||||
|
if (!range) return;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeCaretAtEnd(el) {
|
||||||
|
el.focus();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentBlock(richDiv) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel.rangeCount) return null;
|
||||||
|
let node = sel.getRangeAt(0).startContainer;
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||||
|
while (node && node !== richDiv && !['P', 'DIV', 'H2', 'H3', 'BLOCKQUOTE', 'LI'].includes(node.tagName)) {
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return node === richDiv ? null : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBlockTag(richDiv, tagName) {
|
||||||
|
const block = getCurrentBlock(richDiv);
|
||||||
|
if (!block) return;
|
||||||
|
const targetTag = block.tagName === tagName ? 'P' : tagName;
|
||||||
|
const replacement = document.createElement(targetTag);
|
||||||
|
replacement.innerHTML = block.innerHTML || '<br>';
|
||||||
|
block.replaceWith(replacement);
|
||||||
|
placeCaretAtEnd(replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- image upload -------------------------------------------------------
|
||||||
|
|
||||||
|
async function uploadImage(file, form, target) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', file);
|
||||||
|
fd.append('csrf', form.dataset.csrf);
|
||||||
|
fd.append('location', form.dataset.location);
|
||||||
|
fd.append('section', form.dataset.section);
|
||||||
|
fd.append('folder', form.dataset.folder);
|
||||||
|
fd.append('target', target);
|
||||||
|
try {
|
||||||
|
const res = await fetch(form.dataset.uploadUrl, { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Opplasting feilet: ' + (data.error || 'ukjent feil'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
alert('Opplasting feilet: ' + err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertImageAtRange(richDiv, range, imgData) {
|
||||||
|
const el = document.createElement('img');
|
||||||
|
el.src = imgData.url;
|
||||||
|
el.alt = '';
|
||||||
|
el.setAttribute('data-filename', imgData.filename);
|
||||||
|
|
||||||
|
restoreSelection(range);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel.rangeCount && richDiv.contains(sel.getRangeAt(0).startContainer)) {
|
||||||
|
const r = sel.getRangeAt(0);
|
||||||
|
r.deleteContents();
|
||||||
|
r.insertNode(el);
|
||||||
|
r.setStartAfter(el);
|
||||||
|
r.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(r);
|
||||||
|
} else {
|
||||||
|
richDiv.appendChild(el);
|
||||||
|
}
|
||||||
|
markDirty(richDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAndInsertImage(richDiv, file, range) {
|
||||||
|
const form = richDiv.closest('form');
|
||||||
|
const data = await uploadImage(file, form, 'inline');
|
||||||
|
if (!data) return;
|
||||||
|
insertImageAtRange(richDiv, range, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImageInsert(richDiv) {
|
||||||
|
const savedRange = saveSelection(richDiv);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
if (input.files[0]) uploadAndInsertImage(richDiv, input.files[0], savedRange);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- toolbar commands -----------------------------------------------
|
||||||
|
|
||||||
|
function execToolbarCommand(cmd, richDiv) {
|
||||||
|
richDiv.focus();
|
||||||
|
switch (cmd) {
|
||||||
|
case 'bold':
|
||||||
|
document.execCommand('bold');
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
document.execCommand('italic');
|
||||||
|
break;
|
||||||
|
case 'ul':
|
||||||
|
document.execCommand('insertUnorderedList');
|
||||||
|
break;
|
||||||
|
case 'ol':
|
||||||
|
document.execCommand('insertOrderedList');
|
||||||
|
break;
|
||||||
|
case 'link': {
|
||||||
|
const url = prompt('Lenke (URL):', 'https://');
|
||||||
|
if (url) document.execCommand('createLink', false, url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'h2':
|
||||||
|
toggleBlockTag(richDiv, 'H2');
|
||||||
|
break;
|
||||||
|
case 'h3':
|
||||||
|
toggleBlockTag(richDiv, 'H3');
|
||||||
|
break;
|
||||||
|
case 'quote':
|
||||||
|
toggleBlockTag(richDiv, 'BLOCKQUOTE');
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
triggerImageInsert(richDiv);
|
||||||
|
return; // markDirty happens after upload completes
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markDirty(richDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wire up rich text blocks -------------------------------------------
|
||||||
|
|
||||||
|
document.querySelectorAll('.p-block').forEach((block) => {
|
||||||
|
const richDiv = block.querySelector('.p-richtext');
|
||||||
|
if (!richDiv) return;
|
||||||
|
|
||||||
|
richDiv.addEventListener('input', () => markDirty(richDiv));
|
||||||
|
|
||||||
|
richDiv.addEventListener('paste', (e) => {
|
||||||
|
const cd = e.clipboardData || window.clipboardData;
|
||||||
|
const imageFile = Array.from(cd.files || []).find((f) => f.type.startsWith('image/'));
|
||||||
|
e.preventDefault();
|
||||||
|
if (imageFile) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
||||||
|
uploadAndInsertImage(richDiv, imageFile, range);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = cd.getData('text/plain');
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
markDirty(richDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
richDiv.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
richDiv.classList.add('dragover');
|
||||||
|
});
|
||||||
|
richDiv.addEventListener('dragleave', () => richDiv.classList.remove('dragover'));
|
||||||
|
richDiv.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
richDiv.classList.remove('dragover');
|
||||||
|
const file = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
|
if (!file || !file.type.startsWith('image/')) return;
|
||||||
|
|
||||||
|
let range = null;
|
||||||
|
if (document.caretRangeFromPoint) {
|
||||||
|
range = document.caretRangeFromPoint(e.clientX, e.clientY);
|
||||||
|
} else if (document.caretPositionFromPoint) {
|
||||||
|
const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
|
||||||
|
if (pos) {
|
||||||
|
range = document.createRange();
|
||||||
|
range.setStart(pos.offsetNode, pos.offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploadAndInsertImage(richDiv, file, range);
|
||||||
|
});
|
||||||
|
|
||||||
|
block.querySelectorAll('.p-block-toolbar [data-cmd]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => execToolbarCommand(btn.dataset.cmd, richDiv));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- cover image dropzone ------------------------------------------------
|
||||||
|
|
||||||
|
function wireCoverDrop(drop) {
|
||||||
|
const form = drop.closest('form');
|
||||||
|
let input = drop.querySelector('input[type=file]');
|
||||||
|
|
||||||
|
async function handleFile(file) {
|
||||||
|
if (!file || !file.type.startsWith('image/')) return;
|
||||||
|
const previousContent = drop.innerHTML;
|
||||||
|
drop.textContent = 'Laster opp...';
|
||||||
|
const data = await uploadImage(file, form, 'cover');
|
||||||
|
if (!data) {
|
||||||
|
drop.innerHTML = previousContent;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop.innerHTML = '';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = data.url;
|
||||||
|
drop.appendChild(img);
|
||||||
|
const newInput = document.createElement('input');
|
||||||
|
newInput.type = 'file';
|
||||||
|
newInput.accept = 'image/*';
|
||||||
|
newInput.hidden = true;
|
||||||
|
drop.appendChild(newInput);
|
||||||
|
input = newInput;
|
||||||
|
newInput.addEventListener('change', () => handleFile(newInput.files[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop.addEventListener('click', () => input && input.click());
|
||||||
|
if (input) input.addEventListener('change', () => handleFile(input.files[0]));
|
||||||
|
|
||||||
|
drop.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
drop.classList.add('dragover');
|
||||||
|
});
|
||||||
|
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
|
||||||
|
drop.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
drop.classList.remove('dragover');
|
||||||
|
handleFile(e.dataTransfer.files && e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.p-cover-drop').forEach(wireCoverDrop);
|
||||||
|
|
||||||
|
// --- confirmations for destructive submit buttons -----------------------
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-confirm]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
if (!confirm(btn.dataset.confirm)) e.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- safety net: resync any dirty block right before submit -------------
|
||||||
|
|
||||||
|
const form = document.getElementById('editor-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
form.querySelectorAll('.p-block[data-type="richtext"]').forEach((block) => {
|
||||||
|
const richDiv = block.querySelector('.p-richtext');
|
||||||
|
const dirtyFlag = block.querySelector('.p-dirty-flag');
|
||||||
|
if (richDiv && dirtyFlag && dirtyFlag.value === '1') {
|
||||||
|
block.querySelector('.p-block-hidden').value = serializeRichText(richDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -3,7 +3,7 @@ default = "no"
|
||||||
available = "no"
|
available = "no"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
enabled = "languages"
|
enabled = "languages, scheduled-publisher"
|
||||||
|
|
||||||
[feed]
|
[feed]
|
||||||
exclude_files = "nyhetsbrev, newsletter"
|
exclude_files = "nyhetsbrev, newsletter"
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
57
custom/tools/set-publiser-password.php
Normal file
57
custom/tools/set-publiser-password.php
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Set or reset the Basic Auth login for /publiser.
|
||||||
|
*
|
||||||
|
* Writes a bcrypt-hashed .htpasswd file (no dependency on the `htpasswd`
|
||||||
|
* binary being installed - Apache's mod_authn_file accepts $2y$ hashes).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php custom/tools/set-publiser-password.php <username> <password>
|
||||||
|
* php custom/tools/set-publiser-password.php (interactive)
|
||||||
|
*/
|
||||||
|
|
||||||
|
$htpasswdPath = dirname(__DIR__, 2) . '/content/publiser/.htpasswd';
|
||||||
|
|
||||||
|
function prompt(string $question): string {
|
||||||
|
echo $question;
|
||||||
|
return trim(fgets(STDIN));
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptHidden(string $question): string {
|
||||||
|
$isWindows = stripos(PHP_OS, 'WIN') === 0;
|
||||||
|
if (!$isWindows) {
|
||||||
|
system('stty -echo');
|
||||||
|
}
|
||||||
|
$value = prompt($question);
|
||||||
|
if (!$isWindows) {
|
||||||
|
system('stty echo');
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$args = array_slice($argv, 1);
|
||||||
|
|
||||||
|
$username = $args[0] ?? prompt('Username: ');
|
||||||
|
$password = $args[1] ?? promptHidden('Password: ');
|
||||||
|
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
fwrite(STDERR, "Username and password are required.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($username, ':')) {
|
||||||
|
fwrite(STDERR, "Username cannot contain ':'.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
file_put_contents($htpasswdPath, "$username:$hash\n");
|
||||||
|
// World-readable: the web server user (www-data, varies by host/container)
|
||||||
|
// needs read access. This is safe - Apache denies direct HTTP access to
|
||||||
|
// this exact filename (content/publiser/.htaccess), and reaching it any
|
||||||
|
// other way already requires shell/filesystem access to the server.
|
||||||
|
chmod($htpasswdPath, 0644);
|
||||||
|
|
||||||
|
echo "Wrote $htpasswdPath for user \"$username\".\n";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue