From: Ole B. Rosentreter Date: Mon, 16 Mar 2026 11:35:07 +0000 (+0100) Subject: Generator erster Entwurf X-Git-Url: https://git.laktatnebel.de/?a=commitdiff_plain;h=964cc57756961d9d14cd8d9d5b19ecff9260e54c;p=websitegenerator.git Generator erster Entwurf --- diff --git a/pom.xml b/pom.xml index 69e8539..f820dc4 100644 --- a/pom.xml +++ b/pom.xml @@ -35,36 +35,6 @@ - - commons-lang - commons-lang - ${commons-lang.version} - - - de.laktatnebel.libs - filelib - ${filelib.version} - - - de.laktatnebel.libs - dblib - ${dblib.version} - - - de.laktatnebel.libs - propertylib - ${propertylib.version} - - - commons-io - commons-io - 1.3.2 - - - commons-codec - commons-codec - 1.10 - @@ -172,32 +142,5 @@ - - commons-lang - commons-lang - - - de.laktatnebel.libs - filelib - - - de.laktatnebel.libs - dblib - - - de.laktatnebel.libs - propertylib - - - commons-io - commons-io - - - commons-codec - commons-codec - - diff --git a/roundtrip.sh b/roundtrip.sh new file mode 100755 index 0000000..9e84968 --- /dev/null +++ b/roundtrip.sh @@ -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 index 0000000..fbf89ed Binary files /dev/null and b/src.tar.gz differ diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java index e5e4e2a..9dde58b 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java @@ -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 blog = raw.containsKey("blog") ? (Map) 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; + } } diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java b/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java index 38f9011..cff08ab 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java @@ -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 withHandle(org.jdbi.v3.core.HandleCallback 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()) { diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java b/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java index 11f3a2c..454cd9b 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java @@ -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 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 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 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 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 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 { + @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 { @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 index 0000000..cd1d1ba --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java @@ -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 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("^-|-$", ""); + } +} diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java b/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java index 946b09d..fc294ff 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java @@ -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 navFlat = repo.findNavFlat(); - List 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 navTree = buildNavTree(repo.findNavFlat()); log.info("Navigation: {} Top-Level-Einträge", navTree.size()); - // ── 3. Alle publizierten Seiten laden ───────────── + // ── 4. Blog-Cards laden ─────────────────────────── + List allCards = repo.findBlogCards(); + List allTags = collectAllTags(allCards); + log.info("Blog: {} Artikel, {} Tags", allCards.size(), allTags.size()); + + // ── 5. blog-data.js generieren ──────────────────── + generateBlogDataJs(allCards); + + // ── 5b. Nav-Tags berechnen ──────────────────────── + List navTags = computeNavTags(allCards); + + // ── 6. Seiten generieren ────────────────────────── List 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 navTree, + private void generateBlogDataJs(List cards) throws IOException { + // Tag-Map aufbauen: slug → label + Map 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 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 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 navTree, List navTags, + List allCards, List 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 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 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 filterCards(List 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 relatedCards(List all, String currentSlug, int count) { + // Tags des aktuellen Artikels aus der Liste holen + Set 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 collectAllTags(List cards) { + Map 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 →
// ──────────────────────────────────────────────────────────────── + 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("
\n"); + sb.append("
").append(year).append("
\n"); + sb.append("
\n"); + sb.append("

").append(title).append("

\n"); + if (!body.isBlank()) { + sb.append("

").append(body.replace("\n", " ").trim()).append("

\n"); + } + sb.append("
\n"); + sb.append("
\n"); + } + return sb.toString(); + } + + private List computeNavTags(List cards) { + Map freq = new LinkedHashMap<>(); + Map 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> 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 result = new ArrayList<>(); + for (Map.Entry 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 pages) throws IOException { + if (dryRun || config.baseUrl.startsWith("http://localhost")) return; + + DateTimeFormatter iso = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\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(" \n"); + sb.append(" ").append(url).append("\n"); + if (page.publishAt() != null) + sb.append(" ").append(page.publishAt().format(iso)).append("\n"); + sb.append(" ").append(changefreq).append("\n"); + sb.append(" ").append(priority).append("\n"); + sb.append(" \n"); + } + + sb.append("\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 buildNavTree(List flat) { - // id → mutable Kinderliste Map> childrenMap = new LinkedHashMap<>(); - for (NavItem item : flat) { - childrenMap.put(item.id(), new ArrayList<>()); - } + for (NavItem item : flat) childrenMap.put(item.id(), new ArrayList<>()); List roots = new ArrayList<>(); - for (NavItem item : flat) { if (item.parentId() == null) { roots.add(item); } else { List 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 enrichChildren(List items, - Map> childrenMap) { + private List enrichChildren(List items, Map> childrenMap) { List result = new ArrayList<>(); for (NavItem item : items) { - List 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 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; } diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java b/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java index 76b984e..b2cdb2b 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java @@ -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 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 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 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, diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java b/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java index 89bafa4..265df4c 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java @@ -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 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(); } diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index f271648..eae91a2 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -12,6 +12,7 @@ ${ssg.logdir:-/var/log/ssg}/build.log + ${ssg.logdir:-/var/log/ssg}/build.%d{yyyy-MM-dd}.log 30 100MB @@ -22,19 +23,12 @@ - - + + - - - - - - - diff --git a/templates/data.sql b/templates/data.sql new file mode 100644 index 0000000..4fd7fff --- /dev/null +++ b/templates/data.sql @@ -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 index 0000000..7d050d1 --- /dev/null +++ b/templates/schema.sql @@ -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 +); + + + + +