From b609c8596c27bece790d265131a6c5ed70e991f0 Mon Sep 17 00:00:00 2001
From: Ruben
Date: Sun, 30 Aug 2026 21:54:17 +0200
Subject: [PATCH] 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.
---
.gitignore | 2 +
Containerfile | 7 +
compose.yaml | 2 +-
content/.htaccess | 11 +-
content/.htaccess.base | 11 +-
.../10-spleis-into.md | 2 -
.../20-spleis-embed.html | 1 +
content/publiser/.htaccess | 15 +
content/publiser/index.php | 726 ++++++++++++++++++
content/publiser/publiser.css | 376 +++++++++
content/publiser/publiser.js | 382 +++++++++
custom/config.ini | 2 +-
custom/plugins/global/scheduled-publisher.php | 24 +
custom/plugins/publiser-lib.php | 691 +++++++++++++++++
custom/tools/set-publiser-password.php | 57 ++
15 files changed, 2301 insertions(+), 8 deletions(-)
create mode 100644 content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/20-spleis-embed.html
create mode 100644 content/publiser/.htaccess
create mode 100644 content/publiser/index.php
create mode 100644 content/publiser/publiser.css
create mode 100644 content/publiser/publiser.js
create mode 100644 custom/plugins/global/scheduled-publisher.php
create mode 100644 custom/plugins/publiser-lib.php
create mode 100644 custom/tools/set-publiser-password.php
diff --git a/.gitignore b/.gitignore
index e4401d9..7491cbe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ content/.user.ini
content/php.ini
custom/assets/petition-map-data.json
custom/data/petition-map-cache.json
+custom/data/publiser/
+content/publiser/.htpasswd
diff --git a/Containerfile b/Containerfile
index 60fe44c..dfc0b20 100644
--- a/Containerfile
+++ b/Containerfile
@@ -3,6 +3,13 @@ FROM php:8.4.14-apache
# Enable Apache modules and custom config as root during build
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
RUN a2enconf custom
diff --git a/compose.yaml b/compose.yaml
index 694c31f..b8e0017 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -10,4 +10,4 @@ services:
ports:
- "4040:80"
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"
diff --git a/content/.htaccess b/content/.htaccess
index 379c049..632ef79 100644
--- a/content/.htaccess
+++ b/content/.htaccess
@@ -2,8 +2,8 @@ DirectorySlash Off
# Block direct access to content source files
- # Allow only the entry point
-
+ # Allow only the entry point, and the standalone /publiser admin app
+
Require all denied
@@ -30,6 +30,13 @@ DirectorySlash Off
RewriteCond %{REQUEST_URI} ^/app/
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
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [L,QSA]
diff --git a/content/.htaccess.base b/content/.htaccess.base
index ffeacd3..042b87a 100644
--- a/content/.htaccess.base
+++ b/content/.htaccess.base
@@ -2,8 +2,8 @@ DirectorySlash Off
# Block direct access to content source files
- # Allow only the entry point
-
+ # Allow only the entry point, and the standalone /publiser admin app
+
Require all denied
@@ -30,6 +30,13 @@ DirectorySlash Off
RewriteCond %{REQUEST_URI} ^/app/
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
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [L,QSA]
diff --git a/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/10-spleis-into.md b/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/10-spleis-into.md
index 82252c6..0cee1a5 100644
--- a/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/10-spleis-into.md
+++ b/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/10-spleis-into.md
@@ -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!
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).
-
-
diff --git a/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/20-spleis-embed.html b/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/20-spleis-embed.html
new file mode 100644
index 0000000..092bbfd
--- /dev/null
+++ b/content/nyheter/2026-03-10-SPLEIS-forerkort-tingretten/20-spleis-embed.html
@@ -0,0 +1 @@
+
diff --git a/content/publiser/.htaccess b/content/publiser/.htaccess
new file mode 100644
index 0000000..693842f
--- /dev/null
+++ b/content/publiser/.htaccess
@@ -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.
+
+ RewriteEngine On
+ RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+
+
+
+ Require all denied
+
diff --git a/content/publiser/index.php b/content/publiser/index.php
new file mode 100644
index 0000000..410f1c5
--- /dev/null
+++ b/content/publiser/index.php
@@ -0,0 +1,726 @@
+ $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 '
';
+ }, $text) ?? $text;
+
+ $text = preg_replace_callback('/\[(.*?)\]\((.*?)\)/', function ($m) {
+ return '' . $m[1] . '';
+ }, $text) ?? $text;
+
+ $text = preg_replace('/\*\*(.+?)\*\*/', '$1', $text) ?? $text;
+ $text = preg_replace('/(?$1', $text) ?? $text;
+
+ return $text;
+}
+
+function publiserBlockMdToHtml(string $md, callable $imageUrlResolver): string {
+ $md = str_replace("\r\n", "\n", trim($md));
+ if ($md === '') return '
';
+ $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
Tilbake
');
+ 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(); ?>
+
+
+ Lagret.
+ Publisert.
+ Avpublisert - ligger nå i utkast.
+ Planlagt publisering satt.
+ Gjenopprettet til utkast.
+
+
+ = h($meta['title'] ?? $folder) ?>
+ Ligger i papirkurven.
+
+
+
+
+
+ publiserRenderNew($csrf),
+ 'edit' => publiserRenderEditor($csrf),
+ default => publiserRenderList($csrf),
+};
diff --git a/content/publiser/publiser.css b/content/publiser/publiser.css
new file mode 100644
index 0000000..94ee24c
--- /dev/null
+++ b/content/publiser/publiser.css
@@ -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 }
+}
diff --git a/content/publiser/publiser.js b/content/publiser/publiser.js
new file mode 100644
index 0000000..df072ca
--- /dev/null
+++ b/content/publiser/publiser.js
@@ -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 || '
';
+ 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);
+ }
+ });
+ });
+ }
+})();
diff --git a/custom/config.ini b/custom/config.ini
index bc92570..202a0aa 100644
--- a/custom/config.ini
+++ b/custom/config.ini
@@ -3,7 +3,7 @@ default = "no"
available = "no"
[plugins]
-enabled = "languages"
+enabled = "languages, scheduled-publisher"
[feed]
exclude_files = "nyhetsbrev, newsletter"
diff --git a/custom/plugins/global/scheduled-publisher.php b/custom/plugins/global/scheduled-publisher.php
new file mode 100644
index 0000000..1e15a6f
--- /dev/null
+++ b/custom/plugins/global/scheduled-publisher.php
@@ -0,0 +1,24 @@
+getMessage());
+ }
+ return $ctx;
+});
diff --git a/custom/plugins/publiser-lib.php b/custom/plugins/publiser-lib.php
new file mode 100644
index 0000000..3b5d993
--- /dev/null
+++ b/custom/plugins/publiser-lib.php
@@ -0,0 +1,691 @@
+ 'ae', 'ø' => 'o', 'å' => 'a', 'Æ' => 'ae', 'Ø' => 'o', 'Å' => 'a',
+ 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ü' => 'u', 'ö' => 'o', 'ä' => 'a',
+ ];
+ $text = strtr($text, $map);
+ $text = mb_strtolower($text, 'UTF-8');
+ $text = preg_replace('/[^a-z0-9]+/u', '-', $text) ?? '';
+ $text = trim($text, '-');
+ $text = preg_replace('/-+/', '-', $text) ?? '';
+ return $text === '' ? 'uten-tittel' : $text;
+}
+
+function publiserSlugTaken(string $section, string $slug, ?string $excludeLiveFolder = null): bool {
+ if (is_dir(publiserDraftDir($section, $slug))) return true;
+ $liveRoot = publiserContentRoot() . "/$section";
+ foreach ((glob("$liveRoot/*", GLOB_ONLYDIR) ?: []) as $d) {
+ $name = basename($d);
+ if ($excludeLiveFolder !== null && $name === $excludeLiveFolder) continue;
+ if ($name === $slug) return true;
+ if (preg_match('/^\d{4}-\d{2}-\d{2}-' . preg_quote($slug, '/') . '$/', $name)) return true;
+ }
+ return false;
+}
+
+/** $excludeLiveFolder: skip this live folder in the collision check - used
+ * when re-deriving a slug for an item that is itself being moved (e.g.
+ * unpublish), so it doesn't collide with its own current live folder. */
+function publiserUniqueSlug(string $section, string $baseSlug, ?string $excludeLiveFolder = null): string {
+ $slug = $baseSlug;
+ $i = 2;
+ while (publiserSlugTaken($section, $slug, $excludeLiveFolder)) {
+ $slug = "{$baseSlug}-{$i}";
+ $i++;
+ }
+ return $slug;
+}
+
+// --- metadata.ini read/write ------------------------------------------------
+
+function publiserReadMetadata(string $dir): array {
+ $path = "$dir/metadata.ini";
+ if (!file_exists($path)) return [];
+ // TYPED (unlike the plain parse_ini_file() the CMS itself uses to read
+ // metadata) keeps bare `true`/`false` as real booleans instead of
+ // PHP's "1"/"" normal-mode quirk, so publiserIniValue() can round-trip
+ // them back to bare true/false instead of a quoted "1" string.
+ return parse_ini_file($path, true, INI_SCANNER_TYPED) ?: [];
+}
+
+function publiserIniValue(mixed $value): string {
+ if (is_bool($value)) return $value ? 'true' : 'false';
+ if (is_int($value) || is_float($value)) return (string)$value;
+ $str = (string)$value;
+ return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $str) . '"';
+}
+
+/** Writes a full metadata array back to disk. Array-valued keys become [section] blocks (e.g. "en"). */
+function publiserWriteMetadataFile(string $dir, array $meta): void {
+ $lines = [];
+ $sectionBlocks = [];
+ foreach ($meta as $key => $value) {
+ if (is_array($value)) {
+ $sectionBlocks[$key] = $value;
+ continue;
+ }
+ if ($value === null || $value === '') continue;
+ $lines[] = "$key = " . publiserIniValue($value);
+ }
+ foreach ($sectionBlocks as $name => $fields) {
+ $sectionLines = [];
+ foreach ($fields as $k => $v) {
+ if ($v === null || $v === '' || is_array($v)) continue;
+ $sectionLines[] = "$k = " . publiserIniValue($v);
+ }
+ if (!$sectionLines) continue;
+ $lines[] = '';
+ $lines[] = "[$name]";
+ array_push($lines, ...$sectionLines);
+ }
+ publiserAtomicWrite("$dir/metadata.ini", implode("\n", $lines) . "\n");
+}
+
+function publiserSaveMetadata(string $dir, array $fields): void {
+ $existing = publiserReadMetadata($dir);
+ foreach ($fields as $k => $v) {
+ if ($v === null) {
+ unset($existing[$k]);
+ continue;
+ }
+ $existing[$k] = $v;
+ }
+ publiserWriteMetadataFile($dir, $existing);
+}
+
+function publiserAtomicWrite(string $path, string $content): void {
+ $fp = fopen($path, 'c');
+ if (!$fp) throw new RuntimeException("Cannot open for writing: $path");
+ if (!flock($fp, LOCK_EX)) {
+ fclose($fp);
+ throw new RuntimeException("Cannot lock: $path");
+ }
+ ftruncate($fp, 0);
+ rewind($fp);
+ fwrite($fp, $content);
+ fflush($fp);
+ flock($fp, LOCK_UN);
+ fclose($fp);
+}
+
+// --- Locked move (publish / unpublish / trash / restore all go through this) ---
+
+function publiserLockedRename(string $src, string $dest): void {
+ if (!is_dir($src)) throw new RuntimeException("Not found: $src");
+ if (file_exists($dest)) throw new RuntimeException("Already exists: $dest");
+
+ $lockPath = $src . '/.publiser-lock';
+ $fp = fopen($lockPath, 'c');
+ if (!$fp) throw new RuntimeException('Could not create lock file');
+
+ if (!flock($fp, LOCK_EX | LOCK_NB)) {
+ fclose($fp);
+ throw new RuntimeException('Item is being modified by another request, try again');
+ }
+
+ clearstatcache(true, $src);
+ if (!is_dir($src)) {
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ throw new RuntimeException('Item was already moved');
+ }
+
+ @unlink($lockPath);
+ $ok = publiserMoveDir($src, $dest);
+ flock($fp, LOCK_UN);
+ fclose($fp);
+
+ if (!$ok) throw new RuntimeException("Failed to move $src to $dest");
+}
+
+/**
+ * Moves a directory, falling back to recursive copy+delete if rename()
+ * fails (EXDEV - crossing a filesystem/mount boundary). content/ and
+ * custom/ are separate bind mounts in the dev container even though
+ * they're the same host filesystem, so this isn't just a theoretical case.
+ */
+function publiserMoveDir(string $src, string $dest): bool {
+ if (@rename($src, $dest)) {
+ return true;
+ }
+ try {
+ publiserCopyRecursive($src, $dest);
+ publiserRemoveRecursive($src);
+ return true;
+ } catch (Throwable $e) {
+ if (is_dir($dest)) {
+ publiserRemoveRecursive($dest);
+ }
+ error_log('publiser: move fallback failed: ' . $e->getMessage());
+ return false;
+ }
+}
+
+function publiserCopyRecursive(string $src, string $dest): void {
+ publiserEnsureDir($dest);
+ foreach ((scandir($src) ?: []) as $item) {
+ if ($item === '.' || $item === '..') continue;
+ $srcPath = "$src/$item";
+ $destPath = "$dest/$item";
+ if (is_dir($srcPath)) {
+ publiserCopyRecursive($srcPath, $destPath);
+ } elseif (!copy($srcPath, $destPath)) {
+ throw new RuntimeException("Failed to copy $srcPath to $destPath");
+ }
+ }
+}
+
+function publiserRemoveRecursive(string $dir): void {
+ foreach ((scandir($dir) ?: []) as $item) {
+ if ($item === '.' || $item === '..') continue;
+ $path = "$dir/$item";
+ if (is_dir($path)) {
+ publiserRemoveRecursive($path);
+ } else {
+ unlink($path);
+ }
+ }
+ rmdir($dir);
+}
+
+// --- Blocks (content files) -------------------------------------------------
+
+/**
+ * True for a language-specific content file (e.g. "article.en.md"). The
+ * site supports translations this way (see docs/content-system.md), but
+ * /publiser only edits the base-language content - translations are listed
+ * as read-only in publiserListExtraFiles() instead of being treated as
+ * ordinary blocks, since they're a different language's version of the
+ * SAME content, not another sequential block of it.
+ */
+function publiserIsLanguageVariant(string $filename): bool {
+ return (bool) preg_match('/\.en\.(md|html|php)$/i', $filename);
+}
+
+function publiserListBlocks(string $dir): array {
+ if (!is_dir($dir)) return [];
+ $files = scandir($dir) ?: [];
+ $blocks = [];
+ foreach ($files as $f) {
+ if ($f === '.' || $f === '..') continue;
+ if (publiserIsLanguageVariant($f)) continue;
+ $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
+ if (!in_array($ext, CONTENT_EXTENSIONS, true)) continue;
+ $blocks[] = [
+ 'filename' => $f,
+ 'ext' => $ext,
+ 'type' => $ext === 'md' ? 'richtext' : 'code',
+ ];
+ }
+ usort($blocks, fn($a, $b) => strnatcmp($a['filename'], $b['filename']));
+ return $blocks;
+}
+
+/** The base-language title, mirroring app/helpers.php's extractTitle() but
+ * restricted to publiserListBlocks() (i.e. skipping .en. translation
+ * files) so a post with an English translation doesn't show its English
+ * title/H1 by alphabetical accident ("article.en.md" sorts before
+ * "article.md"). */
+function publiserExtractTitle(string $dir): ?string {
+ foreach (publiserListBlocks($dir) as $block) {
+ $content = file_get_contents($dir . '/' . $block['filename']) ?: '';
+ if ($block['ext'] === 'md' && preg_match('/^#\s+(.+)$/m', $content, $m)) {
+ return trim($m[1]);
+ }
+ if (in_array($block['ext'], ['html', 'php'], true) && preg_match('/]*>(.*?)<\/h1>/i', $content, $m)) {
+ return trim(strip_tags($m[1]));
+ }
+ }
+ return null;
+}
+
+function publiserListExtraFiles(string $dir): array {
+ $extra = [];
+ foreach (getSubdirectories($dir) as $sub) {
+ $extra[] = ['type' => 'folder', 'name' => $sub];
+ }
+ foreach ((glob("$dir/*.pdf") ?: []) as $pdf) {
+ $extra[] = ['type' => 'pdf', 'name' => basename($pdf)];
+ }
+ foreach ((glob("$dir/*") ?: []) as $f) {
+ if (is_file($f) && publiserIsLanguageVariant(basename($f))) {
+ $extra[] = ['type' => 'translation', 'name' => basename($f)];
+ }
+ }
+ return $extra;
+}
+
+function publiserSaveBlockContent(string $dir, string $filename, string $content): void {
+ $path = $dir . '/' . basename($filename);
+ if (!is_file($path)) throw new RuntimeException('Block not found');
+ publiserAtomicWrite($path, $content);
+}
+
+function publiserRenumberBlocks(string $dir, array $orderedFilenames): array {
+ $temps = [];
+ foreach ($orderedFilenames as $i => $filename) {
+ $tmp = ".reorder-tmp-$i-" . uniqid();
+ rename("$dir/$filename", "$dir/$tmp");
+ $stem = preg_replace('/^\d+-/', '', pathinfo($filename, PATHINFO_FILENAME));
+ if ($stem === '' || $stem === null) $stem = 'block';
+ $temps[] = ['tmp' => $tmp, 'stem' => $stem, 'ext' => pathinfo($filename, PATHINFO_EXTENSION)];
+ }
+ $result = [];
+ foreach ($temps as $i => $t) {
+ $newName = sprintf('%d-%s.%s', ($i + 1) * 10, $t['stem'], $t['ext']);
+ rename("$dir/{$t['tmp']}", "$dir/$newName");
+ $result[] = $newName;
+ }
+ return $result;
+}
+
+function publiserReorderBlock(string $dir, string $filename, string $direction): array {
+ $blocks = array_column(publiserListBlocks($dir), 'filename');
+ $idx = array_search($filename, $blocks, true);
+ if ($idx === false) throw new RuntimeException('Block not found');
+ $swapWith = $direction === 'up' ? $idx - 1 : $idx + 1;
+ if ($swapWith < 0 || $swapWith >= count($blocks)) return $blocks;
+ [$blocks[$idx], $blocks[$swapWith]] = [$blocks[$swapWith], $blocks[$idx]];
+ return publiserRenumberBlocks($dir, $blocks);
+}
+
+function publiserAddBlock(string $dir, string $type = 'md'): string {
+ $ext = in_array($type, CONTENT_EXTENSIONS, true) ? $type : 'md';
+ $stem = $ext === 'md' ? 'tekst' : $ext;
+
+ $blocks = array_column(publiserListBlocks($dir), 'filename');
+ $hasUnprefixed = false;
+ $maxPrefix = 0;
+ foreach ($blocks as $f) {
+ if (preg_match('/^(\d+)-/', $f, $m)) {
+ $maxPrefix = max($maxPrefix, (int)$m[1]);
+ } else {
+ $hasUnprefixed = true;
+ }
+ }
+ if ($hasUnprefixed && count($blocks) > 0) {
+ $blocks = publiserRenumberBlocks($dir, $blocks);
+ $maxPrefix = count($blocks) * 10;
+ }
+
+ $newPrefix = $maxPrefix + 10;
+ $filename = sprintf('%d-%s.%s', $newPrefix, $stem, $ext);
+ $i = 2;
+ while (file_exists("$dir/$filename")) {
+ $filename = sprintf('%d-%s-%d.%s', $newPrefix, $stem, $i, $ext);
+ $i++;
+ }
+ touch("$dir/$filename");
+ return $filename;
+}
+
+function publiserDeleteBlock(string $dir, string $filename): void {
+ $path = $dir . '/' . basename($filename);
+ if (!is_file($path)) throw new RuntimeException('Block not found');
+ unlink($path);
+}
+
+// --- Images ------------------------------------------------------------
+
+function publiserUniqueImageName(string $dir, string $originalName, string $ext): string {
+ $base = publiserSlugify(pathinfo($originalName, PATHINFO_FILENAME));
+ if ($base === 'uten-tittel') $base = 'bilde';
+ $candidate = "$base.$ext";
+ $i = 2;
+ while (file_exists("$dir/$candidate")) {
+ $candidate = "{$base}-{$i}.$ext";
+ $i++;
+ }
+ return $candidate;
+}
+
+/**
+ * Re-encodes an uploaded image via GD (resize to max width, recompress) and
+ * saves it into $dir. $forceStem, if given, fixes the output basename
+ * (used for cover images: always "cover.").
+ */
+function publiserSaveUploadedImage(string $dir, string $tmpPath, string $originalName, ?string $forceStem = null): string {
+ $info = getimagesize($tmpPath);
+ if (!$info) throw new RuntimeException('Filen er ikke et gyldig bilde');
+ [$width, $height, $type] = $info;
+
+ $allowed = [
+ IMAGETYPE_JPEG => 'jpg',
+ IMAGETYPE_PNG => 'png',
+ IMAGETYPE_WEBP => 'webp',
+ IMAGETYPE_GIF => 'gif',
+ ];
+ if (!isset($allowed[$type])) throw new RuntimeException('Bildeformatet stottes ikke');
+ $ext = $allowed[$type];
+
+ $image = match ($type) {
+ IMAGETYPE_JPEG => imagecreatefromjpeg($tmpPath),
+ IMAGETYPE_PNG => imagecreatefrompng($tmpPath),
+ IMAGETYPE_WEBP => imagecreatefromwebp($tmpPath),
+ IMAGETYPE_GIF => imagecreatefromgif($tmpPath),
+ };
+ if (!$image) throw new RuntimeException('Kunne ikke lese bildet');
+
+ if ($width > PUBLISER_MAX_IMAGE_WIDTH) {
+ $newHeight = (int) round($height * (PUBLISER_MAX_IMAGE_WIDTH / $width));
+ $resized = imagecreatetruecolor(PUBLISER_MAX_IMAGE_WIDTH, $newHeight);
+ if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
+ imagealphablending($resized, false);
+ imagesavealpha($resized, true);
+ }
+ imagecopyresampled($resized, $image, 0, 0, 0, 0, PUBLISER_MAX_IMAGE_WIDTH, $newHeight, $width, $height);
+ imagedestroy($image);
+ $image = $resized;
+ }
+
+ $basename = $forceStem !== null ? "{$forceStem}.{$ext}" : publiserUniqueImageName($dir, $originalName, $ext);
+ $destPath = "$dir/$basename";
+
+ match ($type) {
+ IMAGETYPE_JPEG => imagejpeg($image, $destPath, PUBLISER_IMAGE_QUALITY),
+ IMAGETYPE_PNG => imagepng($image, $destPath, 6),
+ IMAGETYPE_WEBP => imagewebp($image, $destPath, PUBLISER_IMAGE_QUALITY),
+ IMAGETYPE_GIF => imagegif($image, $destPath),
+ };
+ imagedestroy($image);
+
+ return $basename;
+}
+
+function publiserSaveCoverImage(string $dir, string $tmpPath, string $originalName): string {
+ foreach (COVER_IMAGE_EXTENSIONS as $ext) {
+ $old = "$dir/cover.$ext";
+ if (file_exists($old)) unlink($old);
+ }
+ return publiserSaveUploadedImage($dir, $tmpPath, $originalName, 'cover');
+}
+
+// --- Item loading & listing ----------------------------------------------
+
+function publiserLoadItem(string $location, string $section, string $folder): ?array {
+ $dir = match ($location) {
+ 'live' => publiserLiveDir($section, $folder),
+ 'draft' => publiserDraftDir($section, $folder),
+ 'trash' => publiserTrashDir($section, $folder),
+ default => throw new InvalidArgumentException("Unknown location: $location"),
+ };
+ if (!is_dir($dir)) return null;
+
+ $meta = publiserReadMetadata($dir);
+ $base = array_filter($meta, fn($v) => !is_array($v));
+ $metaSections = array_filter($meta, fn($v) => is_array($v));
+
+ return [
+ 'location' => $location,
+ 'section' => $section,
+ 'folder' => $folder,
+ 'dir' => $dir,
+ 'metadata' => $base,
+ 'metaSections' => $metaSections,
+ 'blocks' => publiserListBlocks($dir),
+ 'cover' => findCoverImage($dir),
+ 'extra' => publiserListExtraFiles($dir),
+ ];
+}
+
+function publiserSummarize(string $location, string $section, string $folder, string $dir, array $meta): array {
+ $title = $meta['title'] ?? publiserExtractTitle($dir) ?? $folder;
+ $date = $meta['date'] ?? extractRawDateFromFolder($folder) ?? date('Y-m-d', filemtime($dir));
+ return [
+ 'location' => $location,
+ 'section' => $section,
+ 'folder' => $folder,
+ 'title' => $title,
+ 'summary' => $meta['summary'] ?? null,
+ 'cover' => findCoverImage($dir),
+ 'date' => $date,
+ 'sortKey' => $date . $folder,
+ 'status' => $meta['status'] ?? ($location === 'live' ? 'published' : 'draft'),
+ 'publishAt' => $meta['publish_at'] ?? null,
+ ];
+}
+
+function publiserListLive(string $section): array {
+ publiserAssertSection($section);
+ $root = publiserContentRoot() . "/$section";
+ $items = [];
+ foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $itemDir) {
+ $folder = basename($itemDir);
+ $items[] = publiserSummarize('live', $section, $folder, $itemDir, publiserReadMetadata($itemDir));
+ }
+ usort($items, fn($a, $b) => strcmp($b['sortKey'], $a['sortKey']));
+ return $items;
+}
+
+function publiserListStaged(string $stage, string $section): array {
+ publiserAssertSection($section);
+ $location = $stage === 'trash' ? 'trash' : 'draft';
+ $root = publiserDataRoot() . "/$stage/$section";
+ $items = [];
+ foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $itemDir) {
+ $folder = basename($itemDir);
+ $items[] = publiserSummarize($location, $section, $folder, $itemDir, publiserReadMetadata($itemDir));
+ }
+ usort($items, fn($a, $b) => strcmp($b['sortKey'], $a['sortKey']));
+ return $items;
+}
+
+// --- Create / publish / unpublish / trash / restore -----------------------
+
+function publiserCreateDraft(string $section, string $title): array {
+ publiserAssertSection($section);
+ $title = trim($title) !== '' ? trim($title) : 'Uten tittel';
+ $slug = publiserUniqueSlug($section, publiserSlugify($title));
+ $dir = publiserDraftDir($section, $slug);
+ publiserEnsureDir($dir);
+ publiserWriteMetadataFile($dir, ['title' => $title, 'status' => 'draft']);
+ touch("$dir/10-tekst.md");
+ return ['section' => $section, 'folder' => $slug];
+}
+
+function publiserPublish(string $section, string $folder, ?string $publishDate = null): array {
+ publiserAssertSection($section);
+ $srcDir = publiserDraftDir($section, $folder);
+ if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke utkastet');
+
+ $meta = publiserReadMetadata($srcDir);
+ $slug = $folder;
+ $date = $publishDate ?: ($meta['date'] ?? date('Y-m-d'));
+
+ $destFolder = $section === 'nyheter' ? "{$date}-{$slug}" : $slug;
+ if ($destFolder !== $folder && publiserSlugTaken($section, $destFolder)) {
+ // extremely unlikely (slug already unique-checked at draft creation), but stay safe
+ $destFolder = publiserUniqueSlug($section, $destFolder);
+ }
+ $destDir = publiserLiveDir($section, $destFolder);
+
+ publiserLockedRename($srcDir, $destDir);
+
+ $meta['date'] = $date;
+ unset($meta['status'], $meta['publish_at']);
+ publiserWriteMetadataFile($destDir, $meta);
+
+ return ['section' => $section, 'folder' => $destFolder];
+}
+
+function publiserUnpublish(string $section, string $folder): array {
+ publiserAssertSection($section);
+ $srcDir = publiserLiveDir($section, $folder);
+ if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke innlegget');
+
+ $meta = publiserReadMetadata($srcDir);
+ $slug = publiserDeriveSlugFromLiveFolder($section, $folder, $meta);
+ $slug = publiserUniqueSlug($section, $slug, $folder);
+ $destDir = publiserDraftDir($section, $slug);
+
+ publiserLockedRename($srcDir, $destDir);
+ $meta['status'] = 'draft';
+ unset($meta['publish_at']);
+ publiserWriteMetadataFile($destDir, $meta);
+
+ return ['section' => $section, 'folder' => $slug];
+}
+
+function publiserDeriveSlugFromLiveFolder(string $section, string $folder, array $meta): string {
+ if (!empty($meta['slug']) && is_string($meta['slug'])) return publiserSlugify($meta['slug']);
+ if ($section === 'nyheter' && preg_match('/^\d{4}-\d{2}-\d{2}-(.+)$/', $folder, $m)) {
+ return $m[1];
+ }
+ return $folder;
+}
+
+function publiserTrash(string $location, string $section, string $folder): void {
+ publiserAssertSection($section);
+ if (!in_array($location, ['live', 'draft'], true)) {
+ throw new InvalidArgumentException("Cannot trash from $location");
+ }
+ $srcDir = $location === 'live' ? publiserLiveDir($section, $folder) : publiserDraftDir($section, $folder);
+ if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke innlegget');
+
+ $meta = publiserReadMetadata($srcDir);
+ $meta['_publiser_trashed_from'] = $location;
+ $meta['_publiser_trashed_at'] = date('c');
+
+ $trashFolder = $folder . '~' . time();
+ $destDir = publiserTrashDir($section, $trashFolder);
+ publiserEnsureParentDir($destDir);
+ publiserLockedRename($srcDir, $destDir);
+ publiserWriteMetadataFile($destDir, $meta);
+}
+
+function publiserRestore(string $section, string $trashFolder): array {
+ publiserAssertSection($section);
+ $srcDir = publiserTrashDir($section, $trashFolder);
+ if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke i papirkurven');
+
+ $meta = publiserReadMetadata($srcDir);
+ unset($meta['_publiser_trashed_from'], $meta['_publiser_trashed_at']);
+ $meta['status'] = 'draft';
+ unset($meta['publish_at']);
+
+ $slug = preg_replace('/~\d+$/', '', $trashFolder) ?? $trashFolder;
+ $slug = publiserUniqueSlug($section, $slug);
+ $destDir = publiserDraftDir($section, $slug);
+
+ publiserEnsureParentDir($destDir);
+ publiserLockedRename($srcDir, $destDir);
+ publiserWriteMetadataFile($destDir, $meta);
+
+ return ['section' => $section, 'folder' => $slug];
+}
+
+// --- Scheduling --------------------------------------------------------
+
+function publiserSetSchedule(string $section, string $folder, ?string $publishAt): void {
+ $dir = publiserDraftDir($section, $folder);
+ if (!is_dir($dir)) throw new RuntimeException('Fant ikke utkastet');
+ $meta = publiserReadMetadata($dir);
+ if ($publishAt) {
+ $meta['status'] = 'scheduled';
+ $meta['publish_at'] = $publishAt;
+ } else {
+ $meta['status'] = 'draft';
+ unset($meta['publish_at']);
+ }
+ publiserWriteMetadataFile($dir, $meta);
+}
+
+/** Called on every site request via the scheduled-publisher global plugin. */
+function publiserPublishDueScheduledItems(): int {
+ $published = 0;
+ foreach (PUBLISER_SECTIONS as $section) {
+ $root = publiserDataRoot() . "/drafts/$section";
+ foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $dir) {
+ $meta = publiserReadMetadata($dir);
+ if (($meta['status'] ?? '') !== 'scheduled') continue;
+ $publishAt = $meta['publish_at'] ?? null;
+ if (!$publishAt) continue;
+ $ts = strtotime($publishAt);
+ if ($ts === false || $ts > time()) continue;
+ try {
+ publiserPublish($section, basename($dir), date('Y-m-d', $ts));
+ $published++;
+ } catch (Throwable $e) {
+ error_log('publiser scheduled publish failed: ' . $e->getMessage());
+ }
+ }
+ }
+ return $published;
+}
diff --git a/custom/tools/set-publiser-password.php b/custom/tools/set-publiser-password.php
new file mode 100644
index 0000000..780e02a
--- /dev/null
+++ b/custom/tools/set-publiser-password.php
@@ -0,0 +1,57 @@
+#!/usr/bin/env php
+
+ * 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";