Build system with zip download

This commit is contained in:
Djeex
2025-08-19 19:29:06 +02:00
parent 4ac176f8a9
commit e9a3a5a189
7 changed files with 320 additions and 102 deletions

View File

@ -1,7 +1,14 @@
# --- Imports ---
import logging
import yaml
import subprocess
import zipfile
import os
from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory, render_template
from flask import (
Flask, jsonify, request, send_from_directory, render_template,
send_file, after_this_request
)
from src.py.builder.gallery_builder import (
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
)
@ -19,61 +26,64 @@ app = Flask(
static_url_path=""
)
# --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
# --- Photos directory (configurable) ---
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint ---
app.register_blueprint(upload_bp)
# --- Helper functions for theme editor ---
# --- Theme editor helper functions ---
def get_theme_name():
"""Get current theme name from site.yaml."""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f:
site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name):
"""Load theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml):
"""Save theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name):
"""List local font files for a theme."""
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
if not fonts_dir.exists():
return []
# Return full filenames, not just stem
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- Routes ---
# --- ROUTES ---
# --- Main page ---
@app.route("/")
def index():
"""Serve the main HTML page."""
return render_template("index.html")
# --- Gallery & Hero API ---
@app.route("/api/gallery", methods=["GET"])
def get_gallery():
"""Return JSON list of gallery images from YAML."""
"""Get gallery images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("gallery", {}).get("images", []))
@app.route("/api/hero", methods=["GET"])
def get_hero():
"""Return JSON list of hero images from YAML."""
"""Get hero images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("hero", {}).get("images", []))
@app.route("/api/gallery/update", methods=["POST"])
def update_gallery_api():
"""Update gallery images in YAML from frontend JSON."""
"""Update gallery images."""
images = request.json
data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = images
@ -82,7 +92,7 @@ def update_gallery_api():
@app.route("/api/hero/update", methods=["POST"])
def update_hero_api():
"""Update hero images in YAML from frontend JSON."""
"""Update hero images."""
images = request.json
data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = images
@ -91,49 +101,48 @@ def update_hero_api():
@app.route("/api/gallery/refresh", methods=["POST"])
def refresh_gallery():
"""Refresh gallery YAML from photos/gallery folder."""
"""Refresh gallery images from disk."""
update_gallery()
return jsonify({"status": "ok"})
@app.route("/api/hero/refresh", methods=["POST"])
def refresh_hero():
"""Refresh hero YAML from photos/hero folder."""
"""Refresh hero images from disk."""
update_hero()
return jsonify({"status": "ok"})
# --- Gallery & Hero photo deletion ---
@app.route("/api/gallery/delete", methods=["POST"])
def delete_gallery_photo():
"""Delete a gallery photo from disk and return status."""
"""Delete a gallery photo."""
data = request.json
src = data.get("src")
file_path = PHOTOS_DIR / "gallery" / src
if file_path.exists():
file_path.unlink()
return {"status": "ok"}
return {"error": "File not found"}, 404
return {"error": "File not found"}, 404
@app.route("/api/hero/delete", methods=["POST"])
def delete_hero_photo():
"""Delete a hero photo from disk and return status."""
"""Delete a hero photo."""
data = request.json
src = data.get("src")
file_path = PHOTOS_DIR / "hero" / src
if file_path.exists():
file_path.unlink()
return {"status": "ok"}
return {"error": "File not found"}, 404
return {"error": "File not found"}, 404
@app.route("/api/gallery/delete_all", methods=["POST"])
def delete_all_gallery_photos():
"""Delete all gallery photos from disk and YAML."""
"""Delete all gallery photos."""
gallery_dir = PHOTOS_DIR / "gallery"
deleted = 0
# Remove all files in gallery folder
for file in gallery_dir.glob("*"):
if file.is_file():
file.unlink()
deleted += 1
# Clear YAML gallery images
data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = []
save_yaml(data, GALLERY_YAML)
@ -141,68 +150,69 @@ def delete_all_gallery_photos():
@app.route("/api/hero/delete_all", methods=["POST"])
def delete_all_hero_photos():
"""Delete all hero photos from disk and YAML."""
"""Delete all hero photos."""
hero_dir = PHOTOS_DIR / "hero"
deleted = 0
# Remove all files in hero folder
for file in hero_dir.glob("*"):
if file.is_file():
file.unlink()
deleted += 1
# Clear YAML hero images
data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = []
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
# --- Serve photos ---
@app.route("/photos/<section>/<path:filename>")
def photos(section, filename):
"""Serve uploaded photos from disk for a specific section."""
"""Serve a photo from a section."""
return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>")
def serve_photo(filename):
"""Serve uploaded photos from disk (generic)."""
"""Serve a photo from the photos directory."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename)
# --- Site info page & API ---
@app.route("/site-info")
def site_info():
"""Serve the site info editor page."""
"""Render site info editor page."""
return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"])
def get_site_info():
"""Return the site info YAML as JSON."""
"""Get site info YAML as JSON."""
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
return jsonify(data)
@app.route("/api/site-info", methods=["POST"])
def update_site_info():
"""Update the site info YAML from frontend JSON."""
"""Update site info YAML."""
data = request.json
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme management ---
@app.route("/api/themes")
def list_themes():
"""List available themes (folders in config/themes)."""
"""List available themes."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes)
# --- Thumbnail upload/remove ---
@app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail():
"""Upload a thumbnail image and update site.yaml."""
"""Upload thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"]
file = request.files.get("file")
if not file:
return {"error": "No file provided"}, 400
return {"error": "No file provided"}, 400
filename = "thumbnail.png"
file.save(PHOTOS_DIR / filename)
# Update site.yaml
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
data.setdefault("social", {})["thumbnail"] = filename
@ -212,13 +222,11 @@ def upload_thumbnail():
@app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail():
"""Remove the thumbnail image and update site.yaml."""
"""Remove thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"]
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
# Remove thumbnail file if exists
if thumbnail_path.exists():
thumbnail_path.unlink()
# Update site.yaml to remove thumbnail key
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
if "social" in data and "thumbnail" in data["social"]:
@ -227,14 +235,14 @@ def remove_thumbnail():
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme upload ---
@app.route("/api/theme/upload", methods=["POST"])
def upload_theme():
"""Upload a custom theme folder and save it in config/themes."""
"""Upload a custom theme folder."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
files = request.files.getlist("files")
if not files:
return jsonify({"error": "No files provided"}), 400
# Get folder name from first file's webkitRelativePath
return jsonify({"error": "No files provided"}), 400
first_path = files[0].filename
folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
theme_folder = themes_dir / folder_name
@ -246,14 +254,15 @@ def upload_theme():
file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name})
# --- Theme Editor API ---
# --- Theme editor page & API ---
@app.route("/theme-editor")
def theme_editor():
"""Serve the theme editor page."""
"""Render theme editor page."""
return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info():
"""Get or update theme.yaml for current theme."""
theme_name = get_theme_name()
if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name)
@ -272,25 +281,25 @@ def api_theme_info():
@app.route("/api/local-fonts")
def api_local_fonts():
"""List local fonts for a theme."""
theme_name = request.args.get("theme")
fonts = get_local_fonts(theme_name)
return jsonify(fonts)
# --- Favicon upload/remove ---
@app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon():
"""Upload favicon to theme folder and update theme.yaml."""
"""Upload favicon for a theme."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "Missing file or theme"}), 400
return jsonify({"error": "Missing file or theme"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
return jsonify({"error": "Invalid file type"}), 400
return jsonify({"error": "Invalid file type"}), 400
filename = "favicon" + ext
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename)
# Update theme.yaml
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
@ -301,18 +310,16 @@ def upload_favicon():
@app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon():
"""Remove favicon from theme folder and update theme.yaml."""
"""Remove favicon for a theme."""
data = request.get_json()
theme_name = data.get("theme")
if not theme_name:
return jsonify({"error": "Missing theme"}), 400
return jsonify({"error": "Missing theme"}), 400
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
# Remove favicon file
for ext in [".png", ".jpg", ".jpeg", ".ico"]:
favicon_path = theme_dir / f"favicon{ext}"
if favicon_path.exists():
favicon_path.unlink()
# Update theme.yaml
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
@ -322,21 +329,24 @@ def remove_favicon():
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Serve theme assets ---
@app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename):
"""Serve a theme asset file."""
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename)
# --- Font upload/remove ---
@app.route("/api/font/upload", methods=["POST"])
def upload_font():
"""Upload a font file to the theme's fonts folder (only .woff/.woff2 allowed)."""
"""Upload a font file for a theme."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "Missing file or theme"}), 400
return jsonify({"error": "Missing theme or font"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".woff", ".woff2"]:
return jsonify({"error": "Invalid font file type"}), 400
return jsonify({"error": "Invalid font file type"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename)
@ -344,18 +354,90 @@ def upload_font():
@app.route("/api/font/remove", methods=["POST"])
def remove_font():
"""Remove a font file from the theme's fonts folder."""
"""Remove a font file for a theme."""
data = request.get_json()
theme_name = data.get("theme")
font = data.get("font")
if not theme_name or not font:
return jsonify({"error": "Missing theme or font"}), 400
return jsonify({"error": "Missing theme or font"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
font_path = fonts_dir / font
if font_path.exists():
font_path.unlink()
return jsonify({"status": "ok"})
return jsonify({"error": "Font not found"}), 404
return jsonify({"error": "Font not found"}), 404
# --- Build & Download ZIP ---
@app.route("/api/build", methods=["POST"])
def trigger_build():
"""
Validate site.yaml and run build.py.
Does NOT create zip here; zip is created on demand in download route.
"""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
output_folder = Path(__file__).resolve().parents[3] / "output"
if not site_yaml_path.exists():
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
with open(site_yaml_path, "r") as f:
site_data = yaml.safe_load(f) or {}
# Dynamically check all main sections and nested keys
main_sections = list(site_data.keys())
for section in main_sections:
value = site_data.get(section)
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
if isinstance(value, dict):
for k, v in value.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
elif isinstance(value, list):
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
for idx, item in enumerate(value):
if isinstance(item, dict):
for k, v in item.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
elif item is None or item == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
else:
if value is None or value == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
try:
subprocess.run(["python3", "build.py"], check=True)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"status": "error", "message": f"{str(e)}"}), 500
@app.route("/download-output-zip", methods=["POST"])
def download_output_zip():
"""
Create output zip on demand and send it to the user.
Zip is deleted after sending.
"""
output_folder = Path(__file__).resolve().parents[3] / "output"
zip_path = Path(__file__).resolve().parents[3] / "site_output.zip" # Store in lumeex/ root
# Create zip on demand
with zipfile.ZipFile(zip_path, "w") as zipf:
for root, dirs, files in os.walk(output_folder):
for file in files:
file_path = Path(root) / file
zipf.write(file_path, file_path.relative_to(output_folder))
@after_this_request
def remove_file(response):
try:
os.remove(zip_path)
except Exception:
pass
return response
return send_file(zip_path, as_attachment=True)
# --- Run server ---
if __name__ == "__main__":