]> git.laktatnebel.de Git - websitegenerator.git/commitdiff
Generator erster Entwurf
authorOle B. Rosentreter <ole@laktatnebel.de>
Mon, 16 Mar 2026 11:35:07 +0000 (12:35 +0100)
committerOle B. Rosentreter <ole@laktatnebel.de>
Mon, 16 Mar 2026 11:35:07 +0000 (12:35 +0100)
13 files changed:
pom.xml
roundtrip.sh [new file with mode: 0755]
src.tar.gz [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java
src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java
src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java
src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java
src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java
src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java
src/main/resources/logback.xml
templates/data.sql [new file with mode: 0644]
templates/schema.sql [new file with mode: 0644]

diff --git a/pom.xml b/pom.xml
index 69e8539f3a0ecb186612e748a74e68e92ae3d7f5..f820dc4d94f7b0e0fd03a6adb5ca395fa370326d 100644 (file)
--- a/pom.xml
+++ b/pom.xml
 
        <dependencyManagement>
                <dependencies>
-                       <dependency>
-                               <groupId>commons-lang</groupId>
-                               <artifactId>commons-lang</artifactId>
-                               <version>${commons-lang.version}</version>
-                       </dependency>
-                       <dependency>
-                               <groupId>de.laktatnebel.libs</groupId>
-                               <artifactId>filelib</artifactId>
-                               <version>${filelib.version}</version>
-                       </dependency>
-                       <dependency>
-                               <groupId>de.laktatnebel.libs</groupId>
-                               <artifactId>dblib</artifactId>
-                               <version>${dblib.version}</version>
-                       </dependency>
-                       <dependency>
-                               <groupId>de.laktatnebel.libs</groupId>
-                               <artifactId>propertylib</artifactId>
-                               <version>${propertylib.version}</version>
-                       </dependency>
-                       <dependency>
-                               <groupId>commons-io</groupId>
-                               <artifactId>commons-io</artifactId>
-                               <version>1.3.2</version>
-                       </dependency>
-                       <dependency>
-                               <groupId>commons-codec</groupId>
-                               <artifactId>commons-codec</artifactId>
-                               <version>1.10</version>
-                       </dependency>
 
                        <!-- ── Datenbank ─────────────────────────────────── -->
                        <dependency>
 
                <!-- slf4j + logback: bereits vom Parent (laktatnebelproductmaster) -->
 
-               <dependency>
-                       <groupId>commons-lang</groupId>
-                       <artifactId>commons-lang</artifactId>
-               </dependency>
-               <dependency>
-                       <groupId>de.laktatnebel.libs</groupId>
-                       <artifactId>filelib</artifactId>
-               </dependency>
-               <dependency>
-                       <groupId>de.laktatnebel.libs</groupId>
-                       <artifactId>dblib</artifactId>
-               </dependency>
-               <dependency>
-                       <groupId>de.laktatnebel.libs</groupId>
-                       <artifactId>propertylib</artifactId>
-               </dependency>
-               <dependency>
-                       <groupId>commons-io</groupId>
-                       <artifactId>commons-io</artifactId>
-               </dependency>
-               <dependency>
-                       <groupId>commons-codec</groupId>
-                       <artifactId>commons-codec</artifactId>
-               </dependency>
-               <!-- dependency> <groupId>net.jthink</groupId>
-               <artifactId>jaudiotagger</artifactId> 
-                       </dependency -->
        </dependencies>
 </project>
diff --git a/roundtrip.sh b/roundtrip.sh
new file mode 100755 (executable)
index 0000000..9e84968
--- /dev/null
@@ -0,0 +1,33 @@
+
+cd /home/oleb/tmp_work/
+
+cp -v *.js *.css /home/oleb/it/projekte/www.triathlon-coaching.com/static/
+cp -v *.sql /home/oleb/it/projekte/www.triathlon-coaching.com/db/
+
+#psql -U oleb -d laktatnebel -v schema=triathlon_coaching_com -f /home/oleb/it/projekte/www.triathlon-coaching.com/db/schema.sql
+#psql -U oleb -d laktatnebel -v schema=triathlon_coaching_com -f /home/oleb/it/projekte/www.triathlon-coaching.com/db/views.sql
+#psql -U oleb -d laktatnebel -v schema=triathlon_coaching_com -f /home/oleb/it/projekte/www.triathlon-coaching.com/db/seed.sql
+
+cp -v footer.ftlh head.ftlh header.ftlh /home/oleb/it/projekte/www.triathlon-coaching.com/templates/layouts/
+cp -v article.ftlh blog-grid.ftlh category-grid.ftlh contact.ftlh content-page.ftlh home.ftlh legal.ftlh tool-grid.ftlh /home/oleb/it/projekte/www.triathlon-coaching.com/templates/pages/
+cp -v card.ftlh nav.ftlh /home/oleb/it/projekte/www.triathlon-coaching.com/templates/macros/
+
+cp -v ImageProcessor.java SiteGenerator.java /home/oleb/it/projekte/websitegenerator/src/main/java/de/laktatnebel/product/websitegenerator/generator/
+cp -v PageRepository.java Database.java /home/oleb/it/projekte/websitegenerator/src/main/java/de/laktatnebel/product/websitegenerator/db/
+cp -v FreemarkerRenderer.java MarkdownRenderer.java /home/oleb/it/projekte/websitegenerator/src/main/java/de/laktatnebel/product/websitegenerator/renderer/
+cp -v Models.java /home/oleb/it/projekte/websitegenerator/src/main/java/de/laktatnebel/product/websitegenerator/model/
+cp -v SiteConfig.java /home/oleb/it/projekte/websitegenerator/src/main/java/de/laktatnebel/product/websitegenerator/config/
+cp -v BuildResult.java Main.java /home/oleb/it/projekte/websitegenerator/src/main/java/de/laktatnebel/product/websitegenerator/
+
+cd /home/oleb/it/projekte/websitegenerator
+mvn clean install
+
+cd /home/oleb/it/projekte/www.triathlon-coaching.com
+mvn clean
+
+cd /home/oleb/it/projekte/websitegenerator/target
+
+java -jar websitegenerator-0.0.1-SNAPSHOT.jar /home/oleb/it/projekte/www.triathlon-coaching.com/config-local.yaml
+
+cd /home/oleb/it/projekte/websitegenerator
+
diff --git a/src.tar.gz b/src.tar.gz
new file mode 100644 (file)
index 0000000..fbf89ed
Binary files /dev/null and b/src.tar.gz differ
index e5e4e2aec872cfdb063002a1bc9ef01305b037f3..9dde58b09d1163aef680d869d5be0395aac1ec3e 100644 (file)
@@ -17,6 +17,7 @@ public class SiteConfig {
     // Site
     public final String siteName;
     public final String baseUrl;
+    public final String contextPath;  // z.B. '' (prod) oder '/projekte/www.triathlon-coaching.com/target/www' (dev)
     public final String language;
     public final String copyright;
 
@@ -32,6 +33,11 @@ public class SiteConfig {
     public final Path mediaOriginalsPath;
     public final Path mediaOutputPath;
     public final String templatesPath;
+    public final Path   staticPath;
+    public final String dbSchema;
+
+    // Blog
+    public final double navTagCoverage;  // 0.0–1.0, z.B. 0.67 = Top-Tags die 2/3 abdecken
 
     // Build
     public final boolean cleanOutput;
@@ -52,8 +58,9 @@ public class SiteConfig {
 
         // Site
         siteName  = (String) site.get("name");
-        baseUrl   = (String) site.get("base_url");
-        language  = (String) site.get("language");
+        baseUrl      = (String) site.get("base_url");
+        contextPath  = (String) site.getOrDefault("context_path", "");
+        language     = (String) site.get("language");
         copyright = (String) site.get("copyright");
 
         // DB
@@ -68,6 +75,13 @@ public class SiteConfig {
         mediaOriginalsPath = Path.of((String) paths.get("media_originals"));
         mediaOutputPath   = Path.of((String) paths.get("media_output"));
         templatesPath     = (String) paths.get("templates");
+        String sp = (String) paths.getOrDefault("static", null);
+        staticPath        = sp != null ? Path.of(sp) : null;
+        dbSchema          = (String) db.getOrDefault("schema", "public");
+
+        // Blog
+        Map<String, Object> blog = raw.containsKey("blog") ? (Map<String, Object>) raw.get("blog") : Map.of();
+        navTagCoverage = ((Number) blog.getOrDefault("nav_tag_coverage", 0.67)).doubleValue();
 
         // Build
         cleanOutput  = (Boolean) build.getOrDefault("clean_output", false);
@@ -97,4 +111,16 @@ public class SiteConfig {
     public String jdbcUrl() {
         return "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + dbName;
     }
+
+    /**
+     * search_path als Connection-Init-SQL.
+     * Wird von Database.java als connectionInitSql gesetzt –
+     * das ist der zuverlässigste Weg beim PostgreSQL JDBC-Treiber.
+     */
+    public String schemaInitSql() {
+        if (dbSchema != null && !dbSchema.isBlank()) {
+            return "SET search_path TO " + dbSchema + ", public";
+        }
+        return null;
+    }
 }
index 38f90113b7645cab153d71ce4d4c3b4da17d92e6..cff08ab196f582864a084fddfe9aa3ed5dc6639d 100644 (file)
@@ -19,15 +19,14 @@ import java.io.Closeable;
  * parallele Build-Threads einsetzen (config: build.threads > 1).
  */
 public class Database implements Closeable {
-
+        
     private static final Logger log = LoggerFactory.getLogger(Database.class);
-
     private final HikariDataSource dataSource;
-    private final Jdbi jdbi;
-
+    private final Jdbi             jdbi;
     public Database(SiteConfig config) {
-        log.info("Datenbankverbindung aufbauen: {}", config.jdbcUrl());
-
         HikariConfig hc = new HikariConfig();
         hc.setJdbcUrl(config.jdbcUrl());
         hc.setUsername(config.dbUser);
@@ -36,23 +35,29 @@ public class Database implements Closeable {
         hc.setMinimumIdle(1);
         hc.setConnectionTimeout(10_000);
         hc.setPoolName("ssg-pool");
-
-        // PostgreSQL-spezifische Optimierungen
         hc.addDataSourceProperty("reWriteBatchedInserts", "true");
         hc.addDataSourceProperty("ApplicationName",       "tricoach-ssg");
-
+        // search_path: zuverlässigster Weg beim PostgreSQL JDBC-Treiber.
+        // connectionInitSql wird auf JEDER neuen Connection ausgeführt,
+        // direkt nach dem physischen Verbindungsaufbau.
+        String initSql = config.schemaInitSql();
+        if (initSql != null) {
+            hc.setConnectionInitSql(initSql);
+        }
+        log.info("DB: {} | initSql: {}", config.jdbcUrl(), initSql);
         this.dataSource = new HikariDataSource(hc);
         this.jdbi = Jdbi.create(dataSource)
-                .installPlugin(new SqlObjectPlugin());
-
+                        .installPlugin(new SqlObjectPlugin());
         log.info("Datenbankverbindung OK");
     }
-
-    public Jdbi jdbi() {
-        return jdbi;
-    }
-
-    /** Convenience: Query direkt ausführen */
+    public Jdbi jdbi() { return jdbi; }
     public <T> T withHandle(org.jdbi.v3.core.HandleCallback<T, Exception> callback) {
         try {
             return jdbi.withHandle(callback);
@@ -60,7 +65,7 @@ public class Database implements Closeable {
             throw new RuntimeException("DB-Fehler: " + e.getMessage(), e);
         }
     }
-
     @Override
     public void close() {
         if (dataSource != null && !dataSource.isClosed()) {
index 11f3a2c47562e5510a3c83e2d1b8f6947022d8fa..454cd9b2db81dd4ae291c4b8597cc92475925201 100644 (file)
@@ -11,11 +11,6 @@ import java.sql.SQLException;
 import java.time.OffsetDateTime;
 import java.util.List;
 
-/**
- * Alle Datenbankabfragen des Generators – gesammelt an einem Ort.
- * Verwendet JDBI's fluent API direkt (kein @SqlObject Interface nötig
- * für diese Größenordnung).
- */
 public class PageRepository {
 
     private static final Logger log = LoggerFactory.getLogger(PageRepository.class);
@@ -30,9 +25,6 @@ public class PageRepository {
     // Seiten
     // ────────────────────────────────────────────────────────────────
 
-    /**
-     * Alle zu publizierenden Seiten laden (nutzt den View v_published_content).
-     */
     public List<PageView> findAllPublished() {
         log.debug("Lade alle publizierten Seiten...");
         return db.withHandle(handle ->
@@ -42,14 +34,11 @@ public class PageRepository {
         );
     }
 
-    /**
-     * Seiten die jetzt geplant fällig sind (für Cronjob-Modus).
-     */
     public List<PageView> findDueScheduled() {
         return db.withHandle(handle ->
             handle.createQuery("""
                 SELECT * FROM v_published_content
-                WHERE status = 'scheduled'
+                WHERE status = 'scheduled'::content_status_enum
                   AND publish_at <= NOW()
                 ORDER BY slug
                 """)
@@ -58,9 +47,6 @@ public class PageRepository {
         );
     }
 
-    /**
-     * Eine einzelne Seite per Slug (für inkrementellen Build).
-     */
     public PageView findBySlug(String slug) {
         return db.withHandle(handle ->
             handle.createQuery("SELECT * FROM v_published_content WHERE slug = :slug")
@@ -71,13 +57,22 @@ public class PageRepository {
         );
     }
 
+    // ────────────────────────────────────────────────────────────────
+    // Blog-Cards  (eigener Mapper – v_blog_cards hat weniger Spalten)
+    // ────────────────────────────────────────────────────────────────
+
+    public List<BlogCard> findBlogCards() {
+        return db.withHandle(handle ->
+            handle.createQuery("SELECT * FROM v_blog_cards")
+                  .map(new BlogCardMapper())
+                  .list()
+        );
+    }
+
     // ────────────────────────────────────────────────────────────────
     // Navigation
     // ────────────────────────────────────────────────────────────────
 
-    /**
-     * Navigation komplett laden (flat, Baumaufbau im Generator).
-     */
     public List<NavItem> findNavFlat() {
         return db.withHandle(handle ->
             handle.createQuery("SELECT * FROM v_nav_tree ORDER BY parent_id NULLS FIRST, sort_order")
@@ -87,41 +82,37 @@ public class PageRepository {
     }
 
     // ────────────────────────────────────────────────────────────────
-    // Media (für Image-Processor)
+    // Media
     // ────────────────────────────────────────────────────────────────
 
-    /**
-     * Alle Bilder die noch keine WebP-Varianten haben.
-     */
     public List<Media> findUnprocessedMedia() {
         return db.withHandle(handle ->
             handle.createQuery("""
                 SELECT * FROM media
                 WHERE media_type = 'image'
                   AND path_webp_lg IS NULL
-                ORDER BY created_at
+                ORDER BY uploaded_at
                 """)
                   .map(new MediaMapper())
                   .list()
         );
     }
 
-    /**
-     * WebP-Pfade nach Verarbeitung zurückschreiben.
-     */
-    public void updateMediaPaths(int mediaId, String lg, String md, String sm) {
+    public void updateMediaPaths(int mediaId, String lg, String md, String sm, String hash) {
         db.withHandle(handle -> {
             handle.createUpdate("""
                 UPDATE media SET
                     path_webp_lg = :lg,
                     path_webp_md = :md,
-                    path_webp_sm = :sm
+                    path_webp_sm = :sm,
+                    file_hash    = :hash
                 WHERE id = :id
                 """)
-                  .bind("id", mediaId)
-                  .bind("lg", lg)
-                  .bind("md", md)
-                  .bind("sm", sm)
+                  .bind("id",   mediaId)
+                  .bind("lg",   lg)
+                  .bind("md",   md)
+                  .bind("sm",   sm)
+                  .bind("hash", hash)
                   .execute();
             return null;
         });
@@ -135,7 +126,7 @@ public class PageRepository {
         return db.withHandle(handle ->
             handle.createUpdate("""
                 INSERT INTO build_log (triggered_by, started_at, status)
-                VALUES (:by, NOW(), 'running')
+                VALUES (:by, NOW(), 'running'::build_status_enum)
                 """)
                   .bind("by", triggeredBy)
                   .executeAndReturnGeneratedKeys("id")
@@ -148,9 +139,9 @@ public class PageRepository {
         db.withHandle(handle -> {
             handle.createUpdate("""
                 UPDATE build_log SET
-                    finished_at  = NOW(),
-                    pages_built  = :pages,
-                    status       = :status,
+                    finished_at   = NOW(),
+                    pages_built   = :pages,
+                    status        = :status::build_status_enum,
                     error_message = :err
                 WHERE id = :id
                 """)
@@ -174,12 +165,14 @@ public class PageRepository {
                 rs.getInt("page_id"),
                 rs.getString("slug"),
                 rs.getString("template"),
+                rs.getString("locked_category"),
                 rs.getInt("content_id"),
                 rs.getString("title"),
                 rs.getString("subtitle"),
                 rs.getString("section_label"),
                 rs.getString("excerpt"),
                 rs.getString("body"),
+                rs.getString("body_secondary"),
                 rs.getString("meta_title"),
                 rs.getString("meta_desc"),
                 rs.getString("canonical_url"),
@@ -197,10 +190,31 @@ public class PageRepository {
         }
     }
 
+    static class BlogCardMapper implements RowMapper<BlogCard> {
+        @Override
+        public BlogCard map(ResultSet rs, StatementContext ctx) throws SQLException {
+            return new BlogCard(
+                rs.getInt("page_id"),
+                rs.getString("slug"),
+                rs.getString("title"),
+                rs.getString("section_label"),
+                rs.getString("excerpt"),
+                rs.getObject("publish_at",  OffsetDateTime.class),
+                rs.getObject("updated_at",  OffsetDateTime.class),
+                rs.getString("author_name"),
+                rs.getString("card_media_path"),
+                rs.getString("card_alt_text"),
+                rs.getString("tag_slugs"),
+                rs.getString("tag_labels"),
+                rs.getString("locked_category"),
+                rs.getString("category_slugs")
+            );
+        }
+    }
+
     static class NavItemFlatMapper implements RowMapper<NavItem> {
         @Override
         public NavItem map(ResultSet rs, StatementContext ctx) throws SQLException {
-            // parent_id kann NULL sein (Top-Level) → getObject liefert null statt 0
             Integer parentId = (Integer) rs.getObject("parent_id");
             return new NavItem(
                 rs.getInt("id"),
@@ -208,7 +222,7 @@ public class PageRepository {
                 rs.getString("label"),
                 rs.getString("url"),
                 rs.getBoolean("open_new_tab"),
-                new java.util.ArrayList<>()   // Kinder werden im SiteGenerator befüllt
+                new java.util.ArrayList<>()
             );
         }
     }
@@ -219,6 +233,7 @@ public class PageRepository {
             return new Media(
                 rs.getInt("id"),
                 rs.getString("path_original"),
+                rs.getString("filename"),
                 rs.getString("path_webp_lg"),
                 rs.getString("path_webp_md"),
                 rs.getString("path_webp_sm"),
@@ -226,7 +241,7 @@ public class PageRepository {
                 rs.getString("caption"),
                 rs.getInt("width_px"),
                 rs.getInt("height_px"),
-                rs.getString("sha256_hash")
+                rs.getString("file_hash")
             );
         }
     }
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java b/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java
new file mode 100644 (file)
index 0000000..cd1d1ba
--- /dev/null
@@ -0,0 +1,173 @@
+package de.laktatnebel.product.websitegenerator.generator;
+
+import de.laktatnebel.product.websitegenerator.config.SiteConfig;
+import de.laktatnebel.product.websitegenerator.db.PageRepository;
+import de.laktatnebel.product.websitegenerator.model.Models.Media;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.*;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+import java.util.List;
+
+/**
+ * Verarbeitet Bilder: Original → WebP-Varianten (lg/md/sm).
+ *
+ * Strategie:
+ *   1. Alle media-Einträge ohne WebP-Varianten laden
+ *   2. SHA-256 des Originals berechnen
+ *   3. Wenn Hash == media.file_hash → überspringen
+ *   4. Sonst: ImageMagick convert aufrufen
+ *   5. Pfade + Hash in DB zurückschreiben
+ *
+ * Voraussetzung auf dem Server:
+ *   apt install imagemagick
+ */
+public class ImageProcessor {
+
+    private static final Logger log = LoggerFactory.getLogger(ImageProcessor.class);
+
+    private final SiteConfig     config;
+    private final PageRepository repo;
+
+    public ImageProcessor(SiteConfig config, PageRepository repo) {
+        this.config = config;
+        this.repo   = repo;
+    }
+
+    /**
+     * Alle unverarbeiteten oder geänderten Bilder verarbeiten.
+     * @return Anzahl verarbeiteter Bilder
+     */
+    public int processAll() {
+        List<Media> pending = repo.findUnprocessedMedia();
+        if (pending.isEmpty()) {
+            log.debug("Keine neuen Bilder zu verarbeiten.");
+            return 0;
+        }
+
+        log.info("Bildverarbeitung: {} Bilder prüfen...", pending.size());
+        int processed = 0;
+
+        for (Media media : pending) {
+            try {
+                if (processImage(media)) processed++;
+            } catch (Exception e) {
+                log.error("Fehler bei Bild '{}': {}", media.pathOriginal(), e.getMessage(), e);
+            }
+        }
+
+        log.info("Bildverarbeitung abgeschlossen: {} neu generiert", processed);
+        return processed;
+    }
+
+    // ────────────────────────────────────────────────────────────────
+
+    private boolean processImage(Media media) throws Exception {
+        Path original = config.mediaOriginalsPath.resolve(media.pathOriginal());
+
+        if (!Files.exists(original)) {
+            log.warn("Original nicht gefunden: {}", original);
+            return false;
+        }
+
+        // Hash berechnen
+        String hash = sha256(original);
+
+        // Schon verarbeitet und unverändert?
+        if (hash.equals(media.sha256Hash())
+                && media.pathWebpLg() != null
+                && Files.exists(config.mediaOutputPath.resolve(media.pathWebpLg()))) {
+            log.debug("Unverändert, überspringe: {}", media.pathOriginal());
+            return false;
+        }
+
+        log.info("Verarbeite: {}", media.pathOriginal());
+
+        // Ausgabe-Dateiname: Originalname ohne Extension + Suffix
+        // filename() bevorzugen, Fallback: letztes Segment aus pathOriginal
+        String rawName = media.filename() != null
+            ? media.filename()
+            : Path.of(media.pathOriginal()).getFileName().toString();
+        String baseName = baseName(rawName);
+
+        String lgPath = "img/lg/" + baseName + "-1920.webp";
+        String mdPath = "img/md/" + baseName + "-800.webp";
+        String smPath = "img/sm/" + baseName + "-400.webp";
+
+        // Verzeichnisse anlegen
+        Files.createDirectories(config.mediaOutputPath.resolve("img/lg"));
+        Files.createDirectories(config.mediaOutputPath.resolve("img/md"));
+        Files.createDirectories(config.mediaOutputPath.resolve("img/sm"));
+
+        // ImageMagick aufrufen
+        convert(original, config.mediaOutputPath.resolve(lgPath), config.imageLg);
+        convert(original, config.mediaOutputPath.resolve(mdPath), config.imageMd);
+        convert(original, config.mediaOutputPath.resolve(smPath), config.imageSm);
+
+        // DB aktualisieren
+        repo.updateMediaPaths(media.id(), lgPath, mdPath, smPath, hash);
+
+        return true;
+    }
+
+    private void convert(Path input, Path output, int width) throws IOException, InterruptedException {
+        // ImageMagick 7: "magick", ImageMagick 6: "convert"
+        // Wir versuchen erst magick, dann convert
+        String cmd = resolveImageMagickCmd();
+
+        ProcessBuilder pb = new ProcessBuilder(
+            cmd,
+            input.toString(),
+            "-resize", width + "x>",          // nur verkleinern, nie vergrößern
+            "-quality", String.valueOf(config.imageQuality),
+            "-strip",                          // Exif entfernen
+            "-define", "webp:lossless=false",
+            output.toString()
+        );
+        pb.redirectErrorStream(true);
+
+        Process proc = pb.start();
+        String  out  = new String(proc.getInputStream().readAllBytes());
+        int     exit = proc.waitFor();
+
+        if (exit != 0) {
+            throw new IOException("ImageMagick Fehler (exit " + exit + "): " + out);
+        }
+        log.debug("→ {} ({}px)", output.getFileName(), width);
+    }
+
+    private String resolveImageMagickCmd() {
+        // ImageMagick 7 heißt "magick", 6 heißt "convert"
+        try {
+            Process p = new ProcessBuilder("magick", "-version")
+                .redirectErrorStream(true).start();
+            if (p.waitFor() == 0) return "magick";
+        } catch (Exception ignored) {}
+        return "convert";
+    }
+
+    private static String sha256(Path file) throws Exception {
+        MessageDigest digest = MessageDigest.getInstance("SHA-256");
+        try (InputStream in = Files.newInputStream(file)) {
+            byte[] buf = new byte[8192];
+            int    n;
+            while ((n = in.read(buf)) != -1) {
+                digest.update(buf, 0, n);
+            }
+        }
+        return HexFormat.of().formatHex(digest.digest());
+    }
+
+    private static String baseName(String filename) {
+        int dot = filename.lastIndexOf('.');
+        String name = dot > 0 ? filename.substring(0, dot) : filename;
+        // URL-safe: Leerzeichen und Sonderzeichen ersetzen
+        return name.toLowerCase()
+                   .replaceAll("[^a-z0-9]+", "-")
+                   .replaceAll("^-|-$", "");
+    }
+}
index 946b09d108ece6578ce65b75d93ca131ee24a3a8..fc294ffb190cdbad5525f88d5197abe7c2e531fd 100644 (file)
@@ -4,27 +4,25 @@ import de.laktatnebel.product.websitegenerator.BuildResult;
 import de.laktatnebel.product.websitegenerator.config.SiteConfig;
 import de.laktatnebel.product.websitegenerator.db.Database;
 import de.laktatnebel.product.websitegenerator.db.PageRepository;
+import de.laktatnebel.product.websitegenerator.model.Models.BlogCard;
 import de.laktatnebel.product.websitegenerator.model.Models.NavItem;
 import de.laktatnebel.product.websitegenerator.model.Models.PageView;
+import de.laktatnebel.product.websitegenerator.model.Models.Tag;
 import de.laktatnebel.product.websitegenerator.renderer.FreemarkerRenderer;
 import de.laktatnebel.product.websitegenerator.renderer.MarkdownRenderer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
+import java.nio.file.*;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import java.util.Arrays;
 
-/**
- * Orchestriert den gesamten Build-Prozess:
- *  1. DB lesen
- *  2. Navigation aufbauen (flat → Baum)
- *  3. Markdown → HTML rendern
- *  4. Freemarker-Template befüllen
- *  5. HTML-Datei schreiben
- */
 public class SiteGenerator {
 
     private static final Logger log = LoggerFactory.getLogger(SiteGenerator.class);
@@ -47,23 +45,38 @@ public class SiteGenerator {
             MarkdownRenderer   md   = new MarkdownRenderer();
             FreemarkerRenderer ftl  = new FreemarkerRenderer(config);
 
-            // ── 1. Build-Log starten ──────────────────────────────
             int buildLogId = repo.insertBuildLog("cli");
 
             try {
-                // ── 2. Navigation laden & Baum aufbauen ──────────
-                List<NavItem> navFlat = repo.findNavFlat();
-                List<NavItem> navTree = buildNavTree(navFlat);
+                // ── 1. Bilder verarbeiten ─────────────────────────
+                int imgs = new ImageProcessor(config, repo).processAll();
+                if (imgs > 0) log.info("{} Bild(er) zu WebP konvertiert", imgs);
+
+                // ── 2. Static-Files kopieren ──────────────────────
+                copyStaticFiles();
+
+                // ── 3. Navigation ─────────────────────────────────
+                List<NavItem> navTree = buildNavTree(repo.findNavFlat());
                 log.info("Navigation: {} Top-Level-Einträge", navTree.size());
 
-                // ── 3. Alle publizierten Seiten laden ─────────────
+                // ── 4. Blog-Cards laden ───────────────────────────
+                List<BlogCard> allCards = repo.findBlogCards();
+                List<Tag>      allTags  = collectAllTags(allCards);
+                log.info("Blog: {} Artikel, {} Tags", allCards.size(), allTags.size());
+
+                // ── 5. blog-data.js generieren ────────────────────
+                generateBlogDataJs(allCards);
+
+                // ── 5b. Nav-Tags berechnen ────────────────────────
+                List<Tag> navTags = computeNavTags(allCards);
+
+                // ── 6. Seiten generieren ──────────────────────────
                 List<PageView> pages = repo.findAllPublished();
                 log.info("{} Seiten gefunden", pages.size());
 
-                // ── 4. Seiten generieren ───────────────────────────
                 for (PageView page : pages) {
                     try {
-                        generatePage(page, navTree, md, ftl);
+                        generatePage(page, navTree, navTags, allCards, allTags, md, ftl);
                         built.incrementAndGet();
                     } catch (Exception e) {
                         log.error("Fehler bei Seite '{}': {}", page.slug(), e.getMessage(), e);
@@ -71,7 +84,9 @@ public class SiteGenerator {
                     }
                 }
 
-                // ── 5. Build-Log abschließen ───────────────────────
+                // ── 7. Sitemap ────────────────────────────────────
+                generateSitemap(pages);
+
                 String status = errors.get() > 0 ? "partial" : "success";
                 repo.finishBuildLog(buildLogId, built.get(), status, null);
 
@@ -85,91 +100,366 @@ public class SiteGenerator {
     }
 
     // ────────────────────────────────────────────────────────────────
-    // Einzelne Seite generieren
+    // blog-data.js generieren
+    // Enthält window.BLOG_POSTS – wird von blog.js gelesen
     // ────────────────────────────────────────────────────────────────
 
-    private void generatePage(PageView page, List<NavItem> navTree,
+    private void generateBlogDataJs(List<BlogCard> cards) throws IOException {
+        // Tag-Map aufbauen: slug → label
+        Map<String, String> tagMap = new LinkedHashMap<>();
+        for (BlogCard c : cards) {
+            for (Tag t : c.tags()) {
+                tagMap.putIfAbsent(t.slug(), t.label());
+            }
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("/* Generiert von triathlon-coaching SSG – nicht manuell bearbeiten */\n");
+        sb.append("window.CONTEXT_PATH = \"").append(escapeJs(config.contextPath)).append("\";\n\n");
+
+        // Tag-Labels Map: slug → Anzeigename
+        sb.append("window.TAG_LABELS = {");
+        int ti = 0;
+        for (Map.Entry<String, String> e : tagMap.entrySet()) {
+            if (ti++ > 0) sb.append(",");
+            sb.append("\"").append(escapeJs(e.getKey())).append("\":\"")
+              .append(escapeJs(e.getValue())).append("\"");
+        }
+        sb.append("};\n\n");
+
+        // POSTS Array mit Tag-Slugs
+        sb.append("window.BLOG_POSTS = [\n");
+        for (int i = 0; i < cards.size(); i++) {
+            BlogCard c = cards.get(i);
+
+            StringBuilder tags = new StringBuilder("[");
+            List<Tag> cardTags = c.tags();
+            for (int t = 0; t < cardTags.size(); t++) {
+                tags.append("\"").append(escapeJs(cardTags.get(t).slug())).append("\"");
+                if (t < cardTags.size() - 1) tags.append(",");
+            }
+            tags.append("]");
+
+            sb.append("  {")
+              .append("\"id\":").append(c.pageId()).append(",")
+              .append("\"slug\":\"").append(escapeJs(c.slug())).append("\",")
+              .append("\"title\":\"").append(escapeJs(c.title())).append("\",")
+              .append("\"categories\":[").append(
+                  c.categorySlugs() != null
+                      ? Arrays.stream(c.categorySlugs().split(","))
+                              .map(s -> "\"" + escapeJs(s.trim()) + "\"")
+                              .collect(Collectors.joining(","))
+                      : ""
+              ).append("],")
+              .append("\"tags\":").append(tags).append(",")
+              .append("\"excerpt\":\"").append(escapeJs(c.excerpt() != null ? c.excerpt() : "")).append("\",")
+              .append("\"date\":\"").append(escapeJs(c.displayDateFormatted())).append("\",")
+              .append("\"dateIso\":\"").append(escapeJs(c.displayDateIso())).append("\",")
+              .append("\"status\":\"published\"")
+              .append("}");
+
+            if (i < cards.size() - 1) sb.append(",");
+            sb.append("\n");
+        }
+        sb.append("];\n");
+
+        Path outFile = config.outputPath.resolve("blog-data.js");
+        if (!dryRun) {
+            Files.writeString(outFile, sb.toString(), StandardCharsets.UTF_8);
+            log.info("blog-data.js generiert ({} Artikel, {} Tags)", cards.size(), tagMap.size());
+        } else {
+            log.info("[dry-run] Würde blog-data.js generieren ({} Artikel)", cards.size());
+        }
+    }
+
+    private String escapeJs(String s) {
+        if (s == null) return "";
+        return s.replace("\\", "\\\\")
+                .replace("\"", "\\\"")
+                .replace("\n", "\\n")
+                .replace("\r", "");
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Seite generieren
+    // ────────────────────────────────────────────────────────────────
+
+    private void generatePage(PageView page, List<NavItem> navTree, List<Tag> navTags,
+                               List<BlogCard> allCards, List<Tag> allTags,
                                MarkdownRenderer md, FreemarkerRenderer ftl) throws Exception {
 
-        log.debug("Generiere: {} [{}]", page.slug(), page.template());
+        log.debug("Generiere: /{} [{}]", page.slug(), page.template());
 
-        String bodyHtml = md.render(page.body() != null ? page.body() : "");
+        String bodyHtml          = md.render(page.body()          != null ? page.body()          : "");
+        String bodySecondaryHtml = "home".equals(page.template())
+            ? renderTimeline(page.bodySecondary())
+            : md.render(page.bodySecondary() != null ? page.bodySecondary() : "");
 
         Map<String, Object> model = new HashMap<>();
-        model.put("page",     page);
-        model.put("bodyHtml", bodyHtml);
+        model.put("page",            page);
+        model.put("bodyHtml",        bodyHtml);
+        model.put("bodySecondaryHtml", bodySecondaryHtml);
         model.put("nav",      navTree);
         model.put("site", Map.of(
-            "name",      config.siteName,
-            "baseUrl",   config.baseUrl,
-            "language",  config.language,
-            "copyright", config.copyright
+            "name",        config.siteName,
+            "baseUrl",     config.baseUrl,
+            "contextPath", config.contextPath,
+            "language",    config.language,
+            "copyright",   config.copyright
         ));
+        model.put("navTags", navTags);
+
+        if ("blog-grid".equals(page.template())
+                || "category-grid".equals(page.template())
+                || "article".equals(page.template())) {
+
+            List<BlogCard> cards = filterCards(allCards, page.lockedCategory());
+            model.put("cards",     cards);
+            model.put("allCards",  allCards);
+            model.put("allTags",   allTags);
+            model.put("lockedCat", page.lockedCategory());
+
+            if ("article".equals(page.template())) {
+                model.put("related", relatedCards(allCards, page.slug(), 3));
+            }
+        }
 
-        String html = ftl.render(page.template() + ".ftlh", model);
+        String html       = ftl.render("pages/" + page.template() + ".ftlh", model);
+        Path   outputFile = resolveOutputPath(page.slug());
 
-        Path outputFile = resolveOutputPath(page.slug());
         if (!dryRun) {
             Files.createDirectories(outputFile.getParent());
             Files.writeString(outputFile, html, StandardCharsets.UTF_8);
-            log.debug("Geschrieben: {}", outputFile);
+            log.debug(" {}", outputFile);
         } else {
-            log.info("[dry-run] Würde schreiben: {}", outputFile);
+            log.info("[dry-run]  {}", outputFile);
         }
     }
 
     // ────────────────────────────────────────────────────────────────
-    // Output-Pfad berechnen
+    // Hilfsmethoden
     // ────────────────────────────────────────────────────────────────
 
-    private Path resolveOutputPath(String slug) {
-        if ("index".equals(slug)) {
-            return config.outputPath.resolve("index.html");
+    private List<BlogCard> filterCards(List<BlogCard> all, String lockedCategory) {
+        if (lockedCategory == null || lockedCategory.isBlank()) return all;
+        return all.stream()
+                  .filter(c -> {
+                      if (c.categorySlugs() == null) return false;
+                      for (String s : c.categorySlugs().split(",")) {
+                          if (lockedCategory.equalsIgnoreCase(s.trim())) return true;
+                      }
+                      return false;
+                  })
+                  .collect(Collectors.toList());
+    }
+
+    private List<BlogCard> relatedCards(List<BlogCard> all, String currentSlug, int count) {
+        // Tags des aktuellen Artikels aus der Liste holen
+        Set<String> currentTags = all.stream()
+                .filter(c -> c.slug().equals(currentSlug))
+                .findFirst()
+                .map(c -> c.tags().stream()
+                        .map(Tag::slug)
+                        .collect(Collectors.toSet()))
+                .orElse(Set.of());
+
+        // Artikel mit mindestens einem gemeinsamen Tag, absteigend nach Übereinstimmungen
+        return all.stream()
+                .filter(c -> !c.slug().equals(currentSlug))
+                .filter(c -> c.tags().stream().anyMatch(t -> currentTags.contains(t.slug())))
+                .sorted(Comparator.comparingLong(c ->
+                        -c.tags().stream().filter(t -> currentTags.contains(t.slug())).count()))
+                .limit(count)
+                .collect(Collectors.toList());
+    }
+
+    private List<Tag> collectAllTags(List<BlogCard> cards) {
+        Map<String, Tag> seen = new LinkedHashMap<>();
+        for (BlogCard card : cards) {
+            for (Tag tag : card.tags()) {
+                seen.putIfAbsent(tag.slug(), tag);
+            }
         }
-        return config.outputPath.resolve(slug).resolve("index.html");
+        return new ArrayList<>(seen.values());
     }
 
+
     // ────────────────────────────────────────────────────────────────
-    // Navigation: Flat-Liste → Baum
-    // Funktioniert weil v_nav_tree ORDER BY parent_id NULLS FIRST liefert:
-    // alle Eltern kommen vor ihren Kindern.
+    // Timeline-Rendering für Startseite
+    // Markdown: ## JAHR – Titel\n\nText → <div class="tl-item">
     // ────────────────────────────────────────────────────────────────
 
+    private String renderTimeline(String markdown) {
+        if (markdown == null || markdown.isBlank()) return "";
+
+        StringBuilder sb = new StringBuilder();
+        // Korrekt: split mit MULTILINE Flag im Regex selbst
+        String[] blocks = markdown.split("(?m)(?=^## )");
+
+        for (String block : blocks) {
+            block = block.trim();
+            if (block.isBlank()) continue;
+
+            // Erste Zeile = Header, Rest = Body-Text
+            int newline   = block.indexOf('\n');
+            String header = (newline > 0 ? block.substring(0, newline) : block)
+                                .replaceFirst("^## ", "").trim();
+            String body   = newline > 0 ? block.substring(newline).trim() : "";
+
+            // "1987 – Titel" aufteilen
+            String year  = "";
+            String title = header;
+            int dash = header.indexOf("–");
+            if (dash < 0) dash = header.indexOf("-");
+            if (dash > 0) {
+                year  = header.substring(0, dash).trim();
+                title = header.substring(dash + 1).trim();
+            }
+
+            sb.append("<div class=\"tl-item\">\n");
+            sb.append("  <div class=\"tl-year\"><span class=\"tl-dot\"></span>").append(year).append("</div>\n");
+            sb.append("  <div class=\"tl-card\">\n");
+            sb.append("    <h3>").append(title).append("</h3>\n");
+            if (!body.isBlank()) {
+                sb.append("    <p>").append(body.replace("\n", " ").trim()).append("</p>\n");
+            }
+            sb.append("  </div>\n");
+            sb.append("</div>\n");
+        }
+        return sb.toString();
+    }
+
+    private List<Tag> computeNavTags(List<BlogCard> cards) {
+        Map<String, Integer> freq   = new LinkedHashMap<>();
+        Map<String, String>  labels = new LinkedHashMap<>();
+        for (BlogCard card : cards) {
+            for (Tag tag : card.tags()) {
+                freq.merge(tag.slug(), 1, Integer::sum);
+                labels.putIfAbsent(tag.slug(), tag.label());
+            }
+        }
+        List<Map.Entry<String, Integer>> sorted = new ArrayList<>(freq.entrySet());
+        sorted.sort((a, b) -> b.getValue() - a.getValue());
+
+        int total  = freq.values().stream().mapToInt(Integer::intValue).sum();
+        int target = (int) Math.ceil(total * config.navTagCoverage);
+        int cumul  = 0;
+        List<Tag> result = new ArrayList<>();
+        for (Map.Entry<String, Integer> e : sorted) {
+            if (cumul >= target) break;
+            result.add(new Tag(e.getKey(), labels.get(e.getKey())));
+            cumul += e.getValue();
+        }
+        log.info("Nav-Tags: {}/{} Tags decken {}% ab",
+            result.size(), freq.size(), Math.round(config.navTagCoverage * 100));
+        return result;
+    }
+
+    private Path resolveOutputPath(String slug) {
+        if ("index".equals(slug)) return config.outputPath.resolve("index.html");
+        return config.outputPath.resolve(slug).resolve("index.html");
+    }
+
+    private void generateSitemap(List<PageView> pages) throws IOException {
+        if (dryRun || config.baseUrl.startsWith("http://localhost")) return;
+
+        DateTimeFormatter iso = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        StringBuilder sb = new StringBuilder();
+        sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
+        sb.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
+
+        for (PageView page : pages) {
+            String slug = page.slug();
+            String url  = "index".equals(slug)
+                ? config.baseUrl + "/"
+                : config.baseUrl + "/" + slug + "/";
+
+            // Priority nach Template-Typ
+            String priority = switch (page.template()) {
+                case "home"          -> "1.0";
+                case "blog-grid",
+                     "category-grid" -> "0.8";
+                case "article"       -> "0.7";
+                default              -> "0.5";
+            };
+
+            // changefreq nach Template-Typ
+            String changefreq = switch (page.template()) {
+                case "home"          -> "weekly";
+                case "blog-grid",
+                     "category-grid" -> "weekly";
+                case "article"       -> "monthly";
+                default              -> "yearly";
+            };
+
+            sb.append("  <url>\n");
+            sb.append("    <loc>").append(url).append("</loc>\n");
+            if (page.publishAt() != null)
+                sb.append("    <lastmod>").append(page.publishAt().format(iso)).append("</lastmod>\n");
+            sb.append("    <changefreq>").append(changefreq).append("</changefreq>\n");
+            sb.append("    <priority>").append(priority).append("</priority>\n");
+            sb.append("  </url>\n");
+        }
+
+        sb.append("</urlset>\n");
+        Files.writeString(config.outputPath.resolve("sitemap.xml"), sb.toString(), StandardCharsets.UTF_8);
+        log.info("sitemap.xml generiert ({} Einträge)", pages.size());
+
+        // robots.txt
+        generateRobotsTxt();
+    }
+
+    private void generateRobotsTxt() throws IOException {
+        String robots = "User-agent: *\n"
+            + "Allow: /\n"
+            + "Disallow: /api/\n"
+            + "\n"
+            + "Sitemap: " + config.baseUrl + "/sitemap.xml\n";
+        Files.writeString(config.outputPath.resolve("robots.txt"), robots, StandardCharsets.UTF_8);
+        log.info("robots.txt generiert");
+    }
+
+    private void copyStaticFiles() throws IOException {
+        if (config.staticPath == null || !Files.isDirectory(config.staticPath)) return;
+        Path src = config.staticPath;
+        Path dst = config.outputPath;
+
+        if (dryRun) { log.info("[dry-run] Static: {} → {}", src, dst); return; }
+
+        Files.walkFileTree(src, new SimpleFileVisitor<>() {
+            @Override
+            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+                Path target = dst.resolve(src.relativize(file));
+                Files.createDirectories(target.getParent());
+                Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
+                return FileVisitResult.CONTINUE;
+            }
+        });
+        log.debug("Static-Files kopiert: {} → {}", src, dst);
+    }
+
     private List<NavItem> buildNavTree(List<NavItem> flat) {
-        // id → mutable Kinderliste
         Map<Integer, List<NavItem>> childrenMap = new LinkedHashMap<>();
-        for (NavItem item : flat) {
-            childrenMap.put(item.id(), new ArrayList<>());
-        }
+        for (NavItem item : flat) childrenMap.put(item.id(), new ArrayList<>());
 
         List<NavItem> roots = new ArrayList<>();
-
         for (NavItem item : flat) {
             if (item.parentId() == null) {
                 roots.add(item);
             } else {
                 List<NavItem> siblings = childrenMap.get(item.parentId());
-                if (siblings != null) {
-                    siblings.add(item);
-                } else {
-                    log.warn("Nav-Item {} hat unbekannte parentId {}", item.id(), item.parentId());
-                }
+                if (siblings != null) siblings.add(item);
+                else log.warn("Nav-Item {} hat unbekannte parentId {}", item.id(), item.parentId());
             }
         }
-
-        // Kinder in die Records einbauen (Records sind immutable → neu erzeugen)
         return enrichChildren(roots, childrenMap);
     }
 
-    private List<NavItem> enrichChildren(List<NavItem> items,
-                                          Map<Integer, List<NavItem>> childrenMap) {
+    private List<NavItem> enrichChildren(List<NavItem> items, Map<Integer, List<NavItem>> childrenMap) {
         List<NavItem> result = new ArrayList<>();
         for (NavItem item : items) {
-            List<NavItem> kids = enrichChildren(
-                childrenMap.getOrDefault(item.id(), List.of()), childrenMap);
-            result.add(new NavItem(
-                item.id(), item.parentId(), item.label(),
-                item.url(), item.openNewTab(), kids));
+            List<NavItem> kids = enrichChildren(childrenMap.getOrDefault(item.id(), List.of()), childrenMap);
+            result.add(new NavItem(item.id(), item.parentId(), item.label(), item.url(), item.openNewTab(), kids));
         }
         return result;
     }
index 76b984e5e19385cdf9d59c6b990884ad4e5472e7..b2cdb2b2adb8d1441ab5df976cd45adb9331feca 100644 (file)
@@ -1,25 +1,30 @@
 package de.laktatnebel.product.websitegenerator.model;
 
 import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
 import java.util.List;
 
 /**
- * Domain-Modelle als Java Records (immutable, kein Boilerplate).
- * Alle Records sind als statische innere Records der Klasse Models definiert,
- * damit der Import "import com.tricoach.ssg.model.Models.*;" funktioniert.
+ * Domain-Modelle als Java Records.
  */
 public class Models {
 
+    // ============================================================
+    // PageView – vollständige Seite aus v_published_content
+    // ============================================================
     public record PageView(
         int            pageId,
         String         slug,
         String         template,
+        String         lockedCategory,
         int            contentId,
         String         title,
         String         subtitle,
         String         sectionLabel,
         String         excerpt,
         String         body,
+        String         bodySecondary,
         String         metaTitle,
         String         metaDesc,
         String         canonicalUrl,
@@ -34,21 +39,95 @@ public class Models {
         String         tagSlugs,
         String         tagLabels
     ) {
+        private static final DateTimeFormatter DISPLAY_FMT =
+            DateTimeFormatter.ofPattern("dd. MMMM yyyy", Locale.GERMAN);
+        private static final DateTimeFormatter ISO_FMT =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+        /** Lesbares Datum für HTML-Anzeige, z.B. "15. Januar 2025" */
+        public String publishAtFormatted() {
+            return publishAt != null ? publishAt.format(DISPLAY_FMT) : "";
+        }
+        /** ISO-Datum für datetime-Attribut, z.B. "2025-01-15" */
+        public String publishAtIso() {
+            return publishAt != null ? publishAt.format(ISO_FMT) : "";
+        }
+
+        public List<Tag> tags() {
+            if (tagSlugs == null || tagSlugs.isBlank()) return List.of();
+            String[] slugs  = tagSlugs.split(",");
+            String[] labels = tagLabels != null ? tagLabels.split(",") : slugs;
+            Tag[]    result = new Tag[slugs.length];
+            for (int i = 0; i < slugs.length; i++) {
+                result[i] = new Tag(
+                    slugs[i].trim(),
+                    i < labels.length ? labels[i].trim() : slugs[i].trim()
+                );
+            }
+            return List.of(result);
+        }
+    }
+
+    // ============================================================
+    // BlogCard – schlanke Vorschau aus v_blog_cards
+    // Nur die Felder die für Grid-Cards und Related-Posts nötig sind.
+    // ============================================================
+    public record BlogCard(
+        int            pageId,
+        String         slug,
+        String         title,
+        String         sectionLabel,
+        String         excerpt,
+        OffsetDateTime publishAt,
+        OffsetDateTime updatedAt,
+        String         authorName,
+        String         cardMediaPath,
+        String         cardAltText,
+        String         tagSlugs,
+        String         tagLabels,
+        String         lockedCategory,
+        String         categorySlugs  // komma-separiert, z.B. "blog,coaching"
+    ) {
+        private static final DateTimeFormatter DISPLAY_FMT =
+            DateTimeFormatter.ofPattern("dd. MMMM yyyy", Locale.GERMAN);
+        private static final DateTimeFormatter ISO_FMT =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+        /** Lesbares Datum für HTML-Anzeige, z.B. "15. Januar 2025" */
+        public String publishAtFormatted() {
+            return publishAt != null ? publishAt.format(DISPLAY_FMT) : "";
+        }
+        /** ISO-Datum für datetime-Attribut, z.B. "2025-01-15" */
+        public String publishAtIso() {
+            return publishAt != null ? publishAt.format(ISO_FMT) : "";
+        }
+        public String displayDateFormatted() {
+            OffsetDateTime d = publishAt != null ? publishAt : updatedAt;
+            return d != null ? d.format(DISPLAY_FMT) : "";
+        }
+        public String displayDateIso() {
+            OffsetDateTime d = publishAt != null ? publishAt : updatedAt;
+            return d != null ? d.format(ISO_FMT) : "";
+        }
+
         public List<Tag> tags() {
             if (tagSlugs == null || tagSlugs.isBlank()) return List.of();
             String[] slugs  = tagSlugs.split(",");
             String[] labels = tagLabels != null ? tagLabels.split(",") : slugs;
             Tag[]    result = new Tag[slugs.length];
             for (int i = 0; i < slugs.length; i++) {
-                result[i] = new Tag(0,
+                result[i] = new Tag(
                     slugs[i].trim(),
-                    i < labels.length ? labels[i].trim() : slugs[i].trim());
+                    i < labels.length ? labels[i].trim() : slugs[i].trim()
+                );
             }
             return List.of(result);
         }
     }
 
-    /** parentId == null → Top-Level-Eintrag */
+    // ============================================================
+    // NavItem – Navigationseintrag mit Kindern
+    // ============================================================
     public record NavItem(
         int           id,
         Integer       parentId,
@@ -58,13 +137,18 @@ public class Models {
         List<NavItem> children
     ) {}
 
-    public record Tag(int id, String slug, String label) {}
-
-    public record Author(int id, String slug, String name, String bio, String avatarPath) {}
+    // ============================================================
+    // Tag – Schlagwort
+    // ============================================================
+    public record Tag(String slug, String label) {}
 
+    // ============================================================
+    // Media – Bild/Datei mit Filesystem-Pfaden
+    // ============================================================
     public record Media(
         int    id,
         String pathOriginal,
+        String filename,
         String pathWebpLg,
         String pathWebpMd,
         String pathWebpSm,
index 89bafa4d4c96f076c4a16c94160f301669f36024..265df4ce9f457ad4f03a2adb07373df7cc6605cf 100644 (file)
@@ -13,31 +13,35 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Map;
 
-/**
- * Rendert Freemarker-Templates zu HTML-Strings.
- *
- * Templates werden aus dem Filesystem geladen (nicht aus dem JAR),
- * damit sie ohne Neubau des JARs angepasst werden können.
- *
- * Fallback: Falls kein externes Template-Verzeichnis existiert,
- * werden Templates aus dem JAR-Classpath geladen.
- */
 public class FreemarkerRenderer {
 
     private static final Logger log = LoggerFactory.getLogger(FreemarkerRenderer.class);
 
     private final Configuration cfg;
+    private final String        baseUrl;
 
     public FreemarkerRenderer(SiteConfig config) throws Exception {
+        this.baseUrl = config.baseUrl;
+
         cfg = new Configuration(Configuration.VERSION_2_3_33);
+
+        // ── Encoding ──────────────────────────────────────────────
         cfg.setDefaultEncoding(StandardCharsets.UTF_8.name());
         cfg.setOutputEncoding(StandardCharsets.UTF_8.name());
+
+        // ── Auto-Escaping DEAKTIVIEREN ─────────────────────────────
+        // Wir bauen HTML-Seiten – bodyHtml darf nicht escaped werden.
+        // Freemarker 2.3.24+ schaltet Auto-Escaping standardmäßig ein
+        // wenn outputFormat = HTML. Wir nutzen kein outputFormat,
+        // damit ${bodyHtml} als raw HTML durchkommt.
+        cfg.setOutputFormat(freemarker.core.UndefinedOutputFormat.INSTANCE);
+
         cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
         cfg.setLogTemplateExceptions(false);
         cfg.setWrapUncheckedExceptions(true);
         cfg.setFallbackOnNullLoopVariable(false);
 
-        // Templates aus Filesystem (bevorzugt) oder Classpath
+        // ── Template-Verzeichnis ───────────────────────────────────
         Path templateDir = Path.of(config.templatesPath);
         if (Files.isDirectory(templateDir)) {
             cfg.setDirectoryForTemplateLoading(templateDir.toFile());
@@ -48,15 +52,9 @@ public class FreemarkerRenderer {
         }
     }
 
-    /**
-     * Template rendern und als String zurückgeben.
-     *
-     * @param templateName  Dateiname, z.B. "article.ftlh"
-     * @param model         Template-Variablen
-     */
     public String render(String templateName, Map<String, Object> model) throws Exception {
-        Template template = cfg.getTemplate(templateName);
-        StringWriter out  = new StringWriter(8192);
+        Template     template = cfg.getTemplate(templateName, StandardCharsets.UTF_8.name());
+        StringWriter out      = new StringWriter(16384);
         template.process(model, out);
         return out.toString();
     }
index f27164864a840426cc3d9f5d26882fe337f2e551..eae91a2be60847dc0b4bb511cdbc8aa6b999979a 100644 (file)
@@ -12,6 +12,7 @@
     <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <file>${ssg.logdir:-/var/log/ssg}/build.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- %d{...} ist Logback-Datumsformat, KEINE Regex – kein \d nötig -->
             <fileNamePattern>${ssg.logdir:-/var/log/ssg}/build.%d{yyyy-MM-dd}.log</fileNamePattern>
             <maxHistory>30</maxHistory>
             <totalSizeCap>100MB</totalSizeCap>
     </appender>
 
     <!-- ── Logger ────────────────────────────────────────────────── -->
-
-    <!-- DB-Queries: INFO reicht, DEBUG nur zum Debuggen -->
     <logger name="de.laktatnebel.tricoach.ssg.db"        level="INFO"/>
     <logger name="de.laktatnebel.tricoach.ssg.generator"  level="INFO"/>
     <logger name="de.laktatnebel.tricoach.ssg.renderer"   level="INFO"/>
+    <logger name="com.zaxxer.hikari"                       level="WARN"/>
+    <logger name="freemarker"                              level="ERROR"/>
 
-    <!-- HikariCP: nur Warnungen, sonst zu geschwätzig -->
-    <logger name="com.zaxxer.hikari" level="WARN"/>
-
-    <!-- Freemarker: nur Fehler -->
-    <logger name="freemarker" level="ERROR"/>
-
-    <!-- Root -->
     <root level="INFO">
         <appender-ref ref="CONSOLE"/>
         <appender-ref ref="FILE"/>
diff --git a/templates/data.sql b/templates/data.sql
new file mode 100644 (file)
index 0000000..4fd7fff
--- /dev/null
@@ -0,0 +1,252 @@
+
+INSERT INTO webseite.navigation (navigation_name) VALUES
+       ('main'),
+       ('footer')
+       ;
+
+
+INSERT INTO webseite.staff (staff_role,staff_full_name,staff_name,staff_email,staff_picurl) VALUES
+       ('admin','Administrator','admin','webmaster@triathlon-coaching.com',NULL),
+       ('author','Ole Benjamin Rosentreter','oleb','kontakt@triathlon-coaching.com',NULL)
+       ;
+
+       
+INSERT INTO webseite.site (id_site_filename,site_rewrite_url,site_title,fk_typ_site,site_navigation) VALUES
+       ('ueber-mich.html','/ueber-mich','Über mich', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), null),
+        
+       ('kontakt.html','/kontakt','Schreib mir!', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'interactive'), null),
+       ('impressum.html','/impressum','Impressum', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), null),
+       ('agb.html','/agb','AGB', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), null),
+       ('disclaimer.html','/disclaimer-datenschutz','Disclaimer / Datenschutz', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), null),
+        
+       ('coaching-angebot.html','/coaching-angebot','Coaching', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), null),
+       ('standard-trainings-plaene.html','/standard-trainings-plaene','Standard Trainingspläne', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), 'coaching.html'),
+       ('langdistanz-trainings-plaene.html','/langgdistanz-trainings-plaene','Trainingspläne für Ironman & Langdistanz', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), 'standard-trainings-plaene.html'),
+       ('mitteldistanz-trainings-plaene.html','/mitteldistanz-trainings-plaene','Trainingspläne für 70.3 & Mitteldistanz', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), 'standard-trainings-plaene.html'),
+       ('kurzdistanz-trainings-plaene.html','/kurzdistanz-trainings-plaene','Trainingspläne für Kurz- bzw. olympische Distanz', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), 'standard-trainings-plaene.html'),
+       ('personal-coaching.html','/personal-coaching','Personal Coaching', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), 'coaching.html'),
+
+       ('training-camps.html','/training-camps','Camps', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), null),
+       ('training-camp-algarve.html','/training-camp-algarve','Tricamp Algarve', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), 'training-camps.html'),
+       ('training-camp-cesenatico.html','/training-camp-cesenatico','Tricamp Cesenatico', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), 'training-camps.html'),
+
+       ('blog.html','/blog','Blog', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'container'), null),
+       ('bahn-knigge-die-ungeschriebenen-gesetze-auf-der-bahn.html','/bahn-knigge-die-ungeschriebenen-gesetze-auf-der-bahn','Bahn-Knigge: Die ungeschriebenen Gesetze auf der Bahn', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), 'blog.html')
+       ('laufschuhe_kann_man_zu_viele_haben.html','/laufschuhe_kann_man_zu_viele_haben','Laufschuhe - Kann man zu viele haben?', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), 'blog.html')
+       ('warm-up_vor_einem_laufwettkampf.html','/warm-up_vor_einem_laufwettkampf','Warm-Up vor einem Laufwettkampf', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), 'blog.html')
+       ;
+
+INSERT INTO webseite.site_naviagtion (fk_site, fk_navigation, parent_nav) VALUES
+       ('ueber-mich.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), null, 0, true),
+       
+       ('coaching-angebot.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), null, 10, true),
+       ('personal-coaching.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'coaching.html', 0, true),
+       ('standard-trainings-plaene.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'coaching.html', 10, true),
+       ('langdistanz-trainings-plaene.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'standard-trainings-plaene.html', 0, true),
+       ('mitteldistanz-trainings-plaene.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'standard-trainings-plaene.html', 10, true),
+       ('kurzdistanz-trainings-plaene.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'standard-trainings-plaene.html', 20, true),
+
+       ('camps.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), null, 20, false),
+       ('training-camp-algarve.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'training-camps.html', 0, false),
+       ('training-camp-cesenatico.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), 'training-camps.html', 10, false),
+
+       ('blog.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), null, 30, true),
+       ('kontakt.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'main'), null, 40, true),
+       
+       ('kontakt.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'footer'), null, 0, true),
+       ('impressum.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'footer'), null, 10, true),
+       ('agb.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'footer'), null, 20, true),
+       ('disclaimer.html', (SELECT id_navigation FROM webseite.navigation WHERE navigation_name = 'footer'), null, 30, true)
+       ;
+       
+INSERT INTO webseite.keyword (keyword_name) VALUES
+       ('Athletik'),
+       ('Ausrüstung'),
+       ('Bike'),
+       ('Coaching'),
+       ('Equipment'),
+       ('Ernährung'),
+       ('Fahrrad'),
+       ('Gesundheit'),
+       ('Ironman'),
+       ('K3'),
+       ('Kohlenhydrate'),
+       ('Kraftausdauer'),
+       ('Langdistanz'),
+       ('Lauf'),
+       ('Laufen'),
+       ('Lifestyle'),
+       ('Marathon'),
+       ('Openwater'),
+       ('Psyche'),
+       ('Rad'),
+       ('Radfahren'),
+       ('Regeneration'),
+       ('Running'),
+       ('Schwimmen'),
+       ('Szene'),
+       ('Taktik'),
+       ('Technik'),
+       ('Tempo'),
+       ('Training'),
+       ('Triathlon'),
+       ('Triathlon.'),
+       ('Wettkampf')
+       ;
+       
+       
+INSERT INTO webseite.site_keyword (fk_site, fk_keyword) VALUES
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Athletik')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Ausrüstung')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Bike')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Coaching')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Equipment')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Ernährung')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Fahrrad')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Gesundheit')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Ironman')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'K3')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Kohlenhydrate')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Kraftausdauer')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Langdistanz')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Lauf')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Laufen')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Lifestyle')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Marathon')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Openwater')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Psyche')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Rad')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Radfahren')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Regeneration')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Schwimmen')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Szene')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Taktik')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Technik')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Tempo')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Training')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Triathlon')),
+       ('index.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Wettkampf')),
+       
+       ('bahn-knigge-die-ungeschriebenen-gesetze-auf-der-bahn.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Laufen')),
+       ('bahn-knigge-die-ungeschriebenen-gesetze-auf-der-bahn.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Training')),
+       
+       ('laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Laufen')),
+       ('laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Training')),
+       ('laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Triathlon')),
+       ('laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Marathon')),
+       ('laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Equipment')),
+       ('laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Gesundheit')),
+       
+       ('warm-up_vor_einem_laufwettkampf.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Laufen')),
+       ('warm-up_vor_einem_laufwettkampf.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Wettkampf')),
+       ('warm-up_vor_einem_laufwettkampf.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Marathon')),
+       ('warm-up_vor_einem_laufwettkampf.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Taktik')),
+       ('warm-up_vor_einem_laufwettkampf.html', (SELECT id_keyword FROM webseite.keyword WHERE keyword_name = 'Gesundheit')),
+
+       ;
+
+INSERT INTO webseite.version (version_date, version_lfdnr, fk_site_id, fk_version_author, version_content) VALUES
+       ( now(), 0, 'bahn-knigge-die-ungeschriebenen-gesetze-auf-der-bahn.html', (SELECT id_staff  FROM webseite.staff WHERE staff_name = 'oleb'), now(), null, 
+       '## Leichtathletik ist eine eigene Welt
+Auch wenn beim Triathlon Laufen dabei ist, Triathleten sind keine Läufer.
+Das fängt damit an, dass lächerliche ;-) 3000m schon eine Langstrecke sind und ein Sprinttriathlon im besten Fall etwas weniger als eine Stunde dauert.
+
+In der Leichtathletik gibt es natürlich noch Sprung, Stoß und Wurf, aber das nur der Vollständigkeit halber.
+Alles andere ist irgendwie Laufen und findet auf der Bahn statt.
+
+Die Innenbahn einer Leichtathletikanlage ist exat 400 m lang, gemessen in 30 cm Abstand zur Innenkante. Und auf der finden unsere Tempoläufe statt - aber auch die der Leichtathletik-Abteilung.
+Wenn Du nicht gerade zu den Allerschnellsten im Triathlon gehörst, zeigt Dir ein Bahnläufer in der Regel die Hacken. 60 Sekunden / Runde ( 2:30er Schnitt) kommen vor und sind eine völlig andere Welt.
+
+Damit das sportlich fair und respektvoll sowie unfallfrei von statten geht gibt es diese ungeschriebenen
+### Regeln:
+#### Fairness:
+Triathleten sind in der Regel zu Gast auf einer Leichtahletikanlage. Oft findet dort auch Nachwuchstraining statt - als erwachsener Sportler bist Du Vorbild.
+#### Wir laufen IMMER gegen den Uhrzeigersinn.
+Wer das mal anders machen möchte: gerne, solange Du alleine bist. Sobald jemand dazustösst: Kehrt marsch!
+#### Überholt wird rechts.
+Wie auf einer deutschen Autobahn: Rechts der Sportwagen, Links der LKW.
+Und: Fahre solange links, bis im Rückspiegel was Schnelleres auftaucht, und wechsle dann eine Spur nach rechts.
+#### Mache Platz.
+Wenn von hinten einer aufläuft, weichst Du nach links aus. Es reichen wenige Schritte auf Bahn 2, dann schlupft der Überholer durch.
+Warum? Das geht schneller als, wenn der Schnellere den langen Weg außen herum machen muß.
+#### Keine Kopfhörer.
+Man orientiert sich sehr viel duch Gehör. Du hörst, wenn von hinten jemand anrauscht oder besser anschnauft.
+#### Sein laut.
+Wenn Du überholen möchtest: Mache Dich bemerkbar. "Innen frei!" reicht völlig aus. Wenn Du das nicht mehr kannst, hört man Dich auch so - siehe oben.
+#### Trabpausen NIEMALS auf Bahn 1.
+Du kannst in den Innenraum auf den Rasen ausweichen, sofern der Platz frei ist und da keine Speere fliegen. Oder Du nimmst die Bahn 2 oder 3.
+Hierbei bitte darauf achten, den (Hürden-)Sprintern nicht vor die Füße zu laufen.
+#### NICHT Anhalten.
+Zumindest niemals auf der Innenbahn. Lieber ein paar Schritte auf Bahn 2 austrudeln.
+#### Wenn Du mit den Läufern trainierst:
+Insbesondere Bahnläufer sind Körperkontakt gewohnt; im Wettkampf wird auch schon mal der Ellebogen eingesetzt oder in die Hacken getreten.
+Das ist Triathleten in etwa so fremd, wie einem Beckenschwimmer, der beim Triathlon mitmacht und zum ersten Mal mit der Masse ins Wasser rennt.', 0),
+       ( now(), 0, 'laufschuhe_kann_man_zu_viele_haben.html', (SELECT id_staff  FROM webseite.staff WHERE staff_name = 'oleb'), now(), null, 
+       '## Laufschuhe
+Laufschuhe sind die mit Abstand wichtigsten Ausrüstungsgegenstände beim Laufen.
+Sie sollen den Fuß schützen und führen. Sie sollen die Laufbewegung unterstützen und die Gelenke schonen. Ganz schön viele Aufgaben für ein paar Hundert Gramm Plastik.
+
+Und es gibt für verschiedene Aufgaben Spezialisierungen - von Spikes bis zum Trail.
+### Best practices:
+#### Je mehr Du läufst, je mehr Schuhe solltest Du besitzen.
+Faustregel: Minimum 3 und pro Lauftag in der Woche 1 Paar. Wenn 5 Mal pro Woche läufst, hast Du 5 Paar Schuhe; Spikes, "reine" Wettkampfschuhe ausgenommen.
+#### Trenne Dich von alten Schätzchen.
+Persönliche Anmerkung: Ich kenne jemanden, der mir diesen Satz um die Ohren haut.
+Im Ernst: Ist das Ding durchgelaufen - dann in die Tonne damit.
+#### Mische die Marken.
+Laufschuhmarken haben alle ihre Eigenarten im Leisten oder im Aufbau der Sohlen. Die Hersteller machen sich allerhand Gedanken, wie sie den Schuh bauen und wenden ihre Erkenntnisse gerne auf mehrere Modelle an. Das ist ökonomisch sinnvoll, führt aber dazu, dass die Marken einen eigenen Charakter entwickeln, der sich nur langsam über mehrere Produktzyklen hinweg ändert.
+Wenn Du zu ähnliche Schuhe hast oder ein Marken-Fan bist, kann es zu Fehlstellungen führen. Manchmal verschwindet auch so manches Zipperlein, wenn Du den Schuh wechselst.
+#### Habe für verschiedene Lauftempi auch verschiedene Schuhe.
+Du hast verschiedene Lauftempi: Langsam, mittel, schnell, sehr schnell - das macht 1 Schuh zum "Rumschlappen", 1 für mittel bis langsame Läufe, 1 für mittel bis schnelle Läufe, 1 für schnell bis All-out. Wenn Du auf der Bahn trainierst, empfiehlt sich einer, der auch auf einer nassen Bahn gut haftet, sofern man keine Spikes tragen möchte.
+#### Habe für verschiedene Wettkämpfe auch verschiedene Schuhe.
+Ein schneller 10km auf der Straße braucht einen leichten, schnellen Schlappen ohne großes Profil und Dämpfung ist weniger relevant.
+Ein 100km Lauf im Gelände stellt das andere Extrem dar. Ordentliches Profil, Dämpfung und Führung für einen müden Bewegungsapparat sind in diesem Fall wichtiger als Gewicht.
+#### Unterschiede zwischen Wettkampf- und Trainingsschuh gibt es nicht wirklich.
+Dem Schuh ist es egal, ob Du eine Startnummer trägst oder nicht.
+Und Du solltest einen Schuh niemals das erste Mal im Wettkampf laufen.
+Dennoch entsteht im Läuferleben dann doch das eine oder andere Paar, dass dann doch nur im Wettkampf gelaufen wird. Das ist einfach auch Kopfsache.
+#### Berücksichtige Dein Lauftempo.
+Ein ehrlicher Blick in den Spiegel bitte: Wenn 55 Minuten auf 10km für Dich schnell sind, ist das Tempo nicht schnell. Dann reichen die Trainingslaufschuhe auch für den Wettkampf, da sich Dein Wettkampf-Laufstil vom Training nicht groß unterscheidet. Schnrittlänge, Kniehub, Schrittfrequenz - all das ist eben Dauerlauf.
+#### Für Triathlon gibt es keine Sonderregeln. Eigentlich.
+Im Triathlon-Wettkampf ist das Lauftempo auf gleicher Strecke geringer. Sprich 10km auf der Straße sind langsamer als 10km bei einer olympischen Distanz.
+Ein schneller Einstieg mit Gummischnürbändern sollte möglich ist, wie relevant das bei einem 4-Stunden-Marathon ist, kann sich jeder selber überlegen. In einem Ligarennen ist das sicher relevant.
+#### Und .... Spikes?
+Man unterscheidet zwischen Bahn- und Crossspikes. Kann man haben, muß man aber nicht. Muss man haben, wenn man ernsthaft Bahnläufe macht.
+#### Kaufe im Fachgeschäft.
+Natürlich kann man sich im Versandhandel 10 Paar Schuhe kaufen und 9 zurückschicken. Die Sinnhaftigkeit dessen ist eine Sache, die andere Sache ist die:
+Guckt der Versender zu, wenn Du den Schuh auf dem Laufband testet?
+#### Im Trainingslager:
+2 Paar mitnehmen. 1 Paar ist zu wenig.', 0),
+       ( now(), 0, 'warm-up_vor_einem_laufwettkampf.html', (SELECT id_staff  FROM webseite.staff WHERE staff_name = 'oleb'), now(), null, 
+       '## Warm-Up
+Aufgewärmt heißt, Muskeln und Stoffwechsel sind bereit zur unmittelbaren und hohen Leistungsabgabe.
+Sich warm fühlen ist was anderes. Das tue ich auch morgens, wenn ich aus dem Bett komme. Aber wir wissen, dass wir dann nicht lossprinten können. Außer zur Kaffeemaschine ;-)
+
+Und so gehts:
+* Startnummer abholen und anbringen.
+Ich trage beim Einlaufen schon die Wettkampfkleidung unten drunter, dann ist das Offizelle schon erledigt.
+* 45-60 min vor dem Start:
+Einlaufen, erst ultra gemütlich, dann steigern auf "schneller als Ga1".
+* Spätestens nach dem Einlaufen:
+Verdauung in Ordnung bringen. Je nachdem wie die Örtlichkeiten sind, solltest Du die (Warte-)Zeit dafür einplanen.
+* 25 min vor dem Start:
+Kurz dynamisch dehnen (Indoor, sofern kalt draußen und möglich)
+* 20 min vor dem Start:
+Lauf-ABC in leichte Steigerungen übergehen: Fußgelenkslauf, Skippings, Anfersen 1-2mal pro Übungen
+* 10 min vor dem Start:
+Traben, Gelenke durchbewegen, 1-2 lockere (!!!) Steigerungen
+* 5 min vor dem Start:
+Jacke / warme Oberbekleidung reinbringen / abgeben.
+Am Start leicht frieren ist okay!
+* 3 min vor dem Start:
+Ins Startfeld einreihen, rumtippeln, in Bewegung bleiben
+* Start:
+Vollgas! 🔥🔥🔥🔥🔥
+Als Faustregel gilt:
+Je kürzer die Strecke, je höher ist das Tempo und je wärmer muß ich sein. Bei 800m läuft der Körper schon auf Hochtouren, bei 100km reicht eine leichte Mobilisation.
+
+Falsch wäre (bspw, aber schon häufig so beobachtet) 10 min traben, 15 min Schlange stehen vor dem Dixi, 20min Smalltalk mit 75 verschiedenen Menschen im Gebäude, rausgehen, feststellen, dass es kalt ist, noch ne Jacke/Mütze/Hose extra anziehen, loslaufen, nach 4 km tatsächlich warm sein, um den Preis viele Körner verschossen zu haben und dann merken, dass der Kreislauf durchdreht, weil die Thermoregulation abgedreht wurde durch zu viele Klamotten.
+### Im Ziel
+Anziehen, auslaufen und verpflegen, Stretching und nach Hause fahren.
+Oder wenn es zu ungemütlich ist, zu Hause 30min locker auf der Rolle ausfahren und dann Stretching, Essen , Dehnen, Kuchen essen.', 0)
+;
diff --git a/templates/schema.sql b/templates/schema.sql
new file mode 100644 (file)
index 0000000..7d050d1
--- /dev/null
@@ -0,0 +1,118 @@
+CREATE TABLE webseite.config (
+       config_key varchar NOT NULL DEFAULT '',
+       config_value varchar NOT NULL DEFAULT '',
+
+       CONSTRAINT config_unique UNIQUE (config_key, config_value)
+);
+
+
+CREATE TABLE webseite.staff (
+       id_staff serial,
+       staff_role varchar NOT NULL DEFAULT 'author',
+       staff_full_name varchar NOT NULL DEFAULT '',
+       staff_name varchar NOT NULL DEFAULT '',
+       staff_email varchar NOT NULL DEFAULT '',
+       staff_picurl varchar DEFAULT NULL,
+       
+       CONSTRAINT staff_pkey PRIMARY KEY (id_staff)
+);
+
+
+CREATE TABLE webseite.typ_site (
+       id_typ_site serial ,
+       typ_site_name varchar NOT NULL,
+       
+       CONSTRAINT typ_site_pkey PRIMARY KEY (id_typ_site)
+);
+
+INSERT INTO webseite.typ_site (typ_site_name) VALUES
+       ('content'),
+       ('container'),
+       ('interactive')
+       ;
+
+
+CREATE TABLE webseite.navigation (
+       id_navigation serial ,
+       navigation_name varchar NOT NULL,
+       
+       CONSTRAINT navigation_pkey PRIMARY KEY (id_navigation)
+);
+
+
+CREATE TABLE webseite.site (
+       id_site_filename varchar NOT NULL DEFAULT 'index.html',
+       site_rewrite_url varchar NOT NULL DEFAULT '/',
+       site_title varchar NOT NULL DEFAULT 'Home',
+
+       fk_typ_site int4 NOT NULL DEFAULT 1,
+       
+       site_navigation varchar DEFAULT NULL, -- zugehöriger (Navigations-)Container
+       
+       CONSTRAINT site_pkey PRIMARY KEY (id_site_filename),
+
+       CONSTRAINT site_typ_site_fkey FOREIGN KEY (fk_typ_site) REFERENCES webseite.typ_site(id_typ_site) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+INSERT INTO webseite.site VALUES ('index.html', '/', 'Home', (SELECT id_typ_site FROM webseite.typ_site WHERE typ_site_name = 'content'), null);
+
+
+CREATE TABLE webseite.site_naviagtion (
+       fk_site varchar NOT NULL DEFAULT 'index.html',
+       fk_navigation int4 NOT NULL DEFAULT 0,
+       
+       parent_nav varchar DEFAULT NULL, -- übergeordneter Navigationsknoten, Eltern-Objekt
+       
+       navigation_position int4 DEFAULT 0,
+       navigation_status boolean default  false;
+
+       CONSTRAINT site_naviagtion_unique UNIQUE (fk_site, fk_navigation),
+
+       CONSTRAINT site_naviagtion_site_fkey FOREIGN KEY (fk_site) REFERENCES webseite.site(id_site_filename) ON DELETE CASCADE ON UPDATE CASCADE,
+       CONSTRAINT site_naviagtion_naviagtion_fkey FOREIGN KEY (fk_navigation) REFERENCES webseite.navigation(id_navigation) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+
+CREATE TABLE webseite.version (
+       version_date timestamp DEFAULT now(),
+
+       version_lfdnr int4 DEFAULT 0,
+       fk_site_id varchar NOT NULL DEFAULT 'index.html',
+       fk_version_author int4 DEFAULT 0,
+       
+       version_publish_date timestamp DEFAULT now(),
+       version_publish_duration interval DEFAULT null,
+       
+       version_content text,
+       
+       fk_version_language int4 default 0, 
+
+       CONSTRAINT version_pkey PRIMARY KEY (version_lfdnr, fk_site_id),
+
+       CONSTRAINT version_site_fkey FOREIGN KEY (fk_site_id) REFERENCES webseite.site(id_site_filename) ON DELETE CASCADE ON UPDATE CASCADE,
+       CONSTRAINT version_staff_fkey FOREIGN KEY (fk_version_author) REFERENCES webseite.staff(id_staff) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+
+CREATE TABLE webseite.keyword (
+       id_keyword serial ,
+       keyword_name varchar NOT NULL,
+       
+       CONSTRAINT keyword_pkey PRIMARY KEY  (id_keyword)
+);
+
+
+CREATE TABLE webseite.site_keyword (
+       fk_site varchar NOT NULL DEFAULT 'index.html',
+       fk_keyword int4 NOT NULL DEFAULT 0,
+       
+       CONSTRAINT site_keyword_unique UNIQUE (fk_site, fk_keyword),
+
+       CONSTRAINT site_keyword_site_fkey FOREIGN KEY (fk_site) REFERENCES webseite.site(id_site_filename) ON DELETE CASCADE ON UPDATE CASCADE,
+       CONSTRAINT site_keyword_keyword_fkey FOREIGN KEY (fk_keyword) REFERENCES webseite.keyword(id_keyword) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+
+
+
+