Files
flixcooks-website/helpers.php
T

583 lines
20 KiB
PHP
Raw Normal View History

2026-03-22 22:16:44 +01:00
<?php
// Shared helper functions for the FlixCooks food blog.
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/config.php';
function handle_database_unavailable(DatabaseUnavailableException $e): void {
http_response_code(503);
$dbError = $e->getMessage();
include __DIR__ . '/maintenance/db-unavailable.php';
exit;
}
function db_table_has_column(PDO $pdo, string $table, string $column): bool {
$stmt = $pdo->prepare(
'SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?'
);
$stmt->execute([$table, $column]);
return (bool) $stmt->fetchColumn();
}
function apply_recipe_schema(PDO $pdo): void {
$path = __DIR__ . '/scripts/schema.sql';
if (!file_exists($path)) {
throw new RuntimeException('Missing scripts/schema.sql');
}
$pdo->exec(file_get_contents($path));
}
function recipe_empty_i18n(): array {
return [
'title' => '',
'description' => '',
'category' => '',
'difficulty' => '',
'tags' => [],
'ingredients' => [],
'utensils' => [],
'steps' => [],
'step_videos' => [],
'step_timers' => [],
];
}
function recipe_base_from_row(array $row): array {
return [
'slug' => $row['slug'],
'hero' => $row['hero'] ?? '',
'prep_time' => (int) ($row['prep_time'] ?? 0),
'cook_time' => (int) ($row['cook_time'] ?? 0),
'total_time' => (int) ($row['total_time'] ?? 0),
'servings' => (int) ($row['servings'] ?? 2),
'featured' => (bool) ($row['featured'] ?? false),
'coming_soon' => (bool) ($row['coming_soon'] ?? false),
'nutrition' => [
'calories' => (int) ($row['calories'] ?? 0),
'protein' => (int) ($row['protein'] ?? 0),
'carbs' => (int) ($row['carbs'] ?? 0),
'fat' => (int) ($row['fat'] ?? 0),
],
'i18n' => [
'en' => recipe_empty_i18n(),
'de' => recipe_empty_i18n(),
],
];
}
function ensure_recipe_schema(PDO $pdo): void {
static $ready = false;
if ($ready) {
return;
}
apply_recipe_schema($pdo);
$ready = true;
}
function require_database(): PDO {
2026-05-22 19:07:03 +02:00
$pdo = get_db_connection();
if (!$pdo) {
$message = getenv('DATABASE_URL')
? 'Database connection failed. Check DATABASE_URL and that Postgres is running.'
: 'DATABASE_URL is not set in .env.';
throw new DatabaseUnavailableException($message);
}
ensure_recipe_schema($pdo);
return $pdo;
}
function init_db(): void {
require_database();
}
function hydrate_recipes_from_db(PDO $pdo): array {
$recipes = [];
$stmt = $pdo->query(
'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$recipes[$row['slug']] = recipe_base_from_row($row);
}
if ($recipes === []) {
return [];
}
$stmt = $pdo->query('SELECT * FROM recipe_translations');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$slug = $row['recipe_slug'];
$lang = $row['lang'];
if (!isset($recipes[$slug])) {
continue;
2026-05-22 19:07:03 +02:00
}
$recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [
'title' => $row['title'],
'description' => $row['description'],
'category' => $row['category'],
'difficulty' => $row['difficulty'],
]);
}
$stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
2026-05-22 19:07:03 +02:00
}
2026-03-22 22:16:44 +01:00
}
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content'];
}
}
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_utensils ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['utensils'][] = $row['content'];
}
}
$stmt = $pdo->query(
'SELECT recipe_slug, lang, content, video_url, timer_minutes
FROM recipe_steps ORDER BY sort_order, id'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
continue;
}
$lang = &$recipes[$row['recipe_slug']]['i18n'][$row['lang']];
$lang['steps'][] = $row['content'];
$lang['step_videos'][] = $row['video_url'] ?? '';
$lang['step_timers'][] = $row['timer_minutes'] !== null ? (string) $row['timer_minutes'] : '';
unset($lang);
}
$list = array_values($recipes);
foreach ($list as &$recipe) {
2026-03-22 22:16:44 +01:00
if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
$recipe['hero'] = normalize_asset_path($recipe['hero']);
}
}
unset($recipe);
return $list;
2026-03-22 22:16:44 +01:00
}
2026-05-22 16:16:34 +02:00
function load_recipes(): array {
$pdo = require_database();
return hydrate_recipes_from_db($pdo);
2026-05-22 16:16:34 +02:00
}
function load_recipe_by_slug(string $slug): ?array {
foreach (load_recipes() as $recipe) {
if (($recipe['slug'] ?? '') === $slug) {
return $recipe;
}
}
return null;
2026-05-22 16:16:34 +02:00
}
function clear_featured_recipes(?PDO $pdo = null): void {
$pdo = $pdo ?? require_database();
$pdo->exec('UPDATE recipes SET featured = FALSE');
}
function delete_recipe(string $slug): bool {
$pdo = require_database();
$stmt = $pdo->prepare('DELETE FROM recipes WHERE slug = ?');
$stmt->execute([$slug]);
return $stmt->rowCount() > 0;
}
function save_recipe(array $recipe, ?PDO $pdo = null): bool {
if (empty($recipe['slug'])) {
return false;
2026-05-22 19:07:03 +02:00
}
$pdo = $pdo ?? require_database();
$nutrition = $recipe['nutrition'] ?? [];
$slug = $recipe['slug'];
2026-05-22 19:07:03 +02:00
try {
$pdo->beginTransaction();
if (!empty($recipe['featured'])) {
clear_featured_recipes($pdo);
}
$stmt = $pdo->prepare(
'INSERT INTO recipes (
slug, hero, prep_time, cook_time, total_time, servings,
featured, coming_soon, calories, protein, carbs, fat, updated_at
) VALUES (
:slug, :hero, :prep_time, :cook_time, :total_time, :servings,
:featured, :coming_soon, :calories, :protein, :carbs, :fat, NOW()
)
ON CONFLICT (slug) DO UPDATE SET
hero = EXCLUDED.hero,
prep_time = EXCLUDED.prep_time,
cook_time = EXCLUDED.cook_time,
total_time = EXCLUDED.total_time,
servings = EXCLUDED.servings,
featured = EXCLUDED.featured,
coming_soon = EXCLUDED.coming_soon,
calories = EXCLUDED.calories,
protein = EXCLUDED.protein,
carbs = EXCLUDED.carbs,
fat = EXCLUDED.fat,
updated_at = NOW()'
);
$stmt->bindValue(':slug', $slug);
$stmt->bindValue(':hero', $recipe['hero'] ?? '');
$stmt->bindValue(':prep_time', (int) ($recipe['prep_time'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':cook_time', (int) ($recipe['cook_time'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':total_time', (int) ($recipe['total_time'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':servings', (int) ($recipe['servings'] ?? 2), PDO::PARAM_INT);
$stmt->bindValue(':featured', !empty($recipe['featured']), PDO::PARAM_BOOL);
$stmt->bindValue(':coming_soon', !empty($recipe['coming_soon']), PDO::PARAM_BOOL);
$stmt->bindValue(':calories', (int) ($nutrition['calories'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':protein', (int) ($nutrition['protein'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':carbs', (int) ($nutrition['carbs'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':fat', (int) ($nutrition['fat'] ?? 0), PDO::PARAM_INT);
$stmt->execute();
$pdo->prepare('DELETE FROM recipe_translations WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_tags WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_ingredients WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_utensils WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_steps WHERE recipe_slug = ?')->execute([$slug]);
$translationStmt = $pdo->prepare(
'INSERT INTO recipe_translations (recipe_slug, lang, title, description, category, difficulty)
VALUES (?, ?, ?, ?, ?, ?)'
);
$tagStmt = $pdo->prepare(
'INSERT INTO recipe_tags (recipe_slug, lang, tag, sort_order) VALUES (?, ?, ?, ?)'
);
$ingredientStmt = $pdo->prepare(
'INSERT INTO recipe_ingredients (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
);
$utensilStmt = $pdo->prepare(
'INSERT INTO recipe_utensils (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
);
$stepStmt = $pdo->prepare(
'INSERT INTO recipe_steps (recipe_slug, lang, content, video_url, timer_minutes, sort_order)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach (['en', 'de'] as $lang) {
$block = $recipe['i18n'][$lang] ?? [];
$translationStmt->execute([
$slug,
$lang,
$block['title'] ?? '',
$block['description'] ?? '',
$block['category'] ?? '',
$block['difficulty'] ?? '',
]);
$tagOrder = 0;
foreach (array_values($block['tags'] ?? []) as $tag) {
$tag = trim((string) $tag);
if ($tag !== '') {
$tagStmt->execute([$slug, $lang, $tag, $tagOrder++]);
}
}
$ingredientOrder = 0;
foreach (array_values($block['ingredients'] ?? []) as $line) {
$line = trim((string) $line);
if ($line !== '') {
$ingredientStmt->execute([$slug, $lang, $line, $ingredientOrder++]);
}
}
$utensilOrder = 0;
foreach (array_values($block['utensils'] ?? []) as $line) {
$line = trim((string) $line);
if ($line !== '') {
$utensilStmt->execute([$slug, $lang, $line, $utensilOrder++]);
}
}
$steps = array_values($block['steps'] ?? []);
$videos = array_values($block['step_videos'] ?? []);
$timers = array_values($block['step_timers'] ?? []);
$stepOrder = 0;
foreach ($steps as $i => $step) {
$step = trim((string) $step);
if ($step === '') {
continue;
}
$video = trim((string) ($videos[$i] ?? ''));
$timerRaw = $timers[$i] ?? '';
$timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null;
$stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $stepOrder++]);
2026-05-22 19:07:03 +02:00
}
}
2026-05-22 19:07:03 +02:00
$pdo->commit();
return true;
} catch (\Exception $e) {
2026-05-22 19:07:03 +02:00
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('save_recipe failed: ' . $e->getMessage());
2026-05-22 19:07:03 +02:00
return false;
}
2026-03-22 22:16:44 +01:00
}
/**
* Default settings for imprint and privacy pages.
*/
function default_site_settings(): array {
$today = date('Y-m-d');
return [
'imprint' => [
'de' => [
'owner_name' => 'FlixCooks (bitte anpassen)',
'address' => 'Straße Hausnummer, PLZ Ort, Österreich',
'email' => 'contact@flixcooks.at',
'phone' => '+43 660 0000000',
'legal_form' => 'Kleines Impressum; kein Firmenbucheintrag/UID hinterlegt.',
'business_purpose' => 'Foodblog & Rezeptmarketing.',
'wko_membership' => '',
'authority' => '',
'uid' => '',
'odr' => 'https://ec.europa.eu/consumers/odr',
'last_updated' => $today,
],
'en' => [
'owner_name' => 'FlixCooks (please update)',
'address' => 'Street number, ZIP City, Austria',
'email' => 'contact@flixcooks.at',
'phone' => '+43 660 0000000',
'legal_form' => 'Small website notice; no commercial register/UID provided.',
'business_purpose' => 'Food blog & recipe marketing.',
'wko_membership' => '',
'authority' => '',
'uid' => '',
'odr' => 'https://consumer-redress.ec.europa.eu/',
'last_updated' => $today,
],
],
'privacy' => [
'de' => [
'controller' => 'FlixCooks (bitte anpassen)',
'contact_email' => 'contact@flixcooks.at',
'contact_phone' => '+43 660 0000000',
'address' => 'Straße Hausnummer, PLZ Ort, Österreich',
'hosting_provider' => 'Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland',
'log_retention_days' => 30,
'cookie_statement' => 'Keine Tracking- oder Marketing-Cookies; nur technisch notwendige.',
'purposes' => 'Betrieb und Bereitstellung der Website, Beantwortung von Kontaktanfragen.',
'last_updated' => $today,
],
'en' => [
'controller' => 'FlixCooks (please update)',
'contact_email' => 'contact@flixcooks.at',
'contact_phone' => '+43 660 0000000',
'address' => 'Street number, ZIP City, Austria',
'hosting_provider' => 'Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Germany',
'log_retention_days' => 30,
'cookie_statement' => 'No tracking or marketing cookies; only technically necessary cookies.',
'purposes' => 'Operating and providing the website, responding to contact requests.',
'last_updated' => $today,
],
],
];
}
/**
* Load site-wide settings for legal pages from Postgres.
*/
function load_site_settings(bool $refresh = false): array {
static $cache = null;
if ($cache !== null && !$refresh) {
return $cache;
}
$settings = default_site_settings();
$pdo = require_database();
$stmt = $pdo->query(
'SELECT section, lang, setting_key, setting_value FROM site_settings ORDER BY section, lang, setting_key'
);
$hasRows = false;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$section = $row['section'];
$lang = $row['lang'];
$key = $row['setting_key'];
if (!isset($settings[$section][$lang])) {
continue;
}
$settings[$section][$lang][$key] = $key === 'log_retention_days'
? (int) $row['setting_value']
: $row['setting_value'];
$hasRows = true;
}
$cache = $settings;
return $cache;
}
/**
* Persist site-wide settings for legal pages in Postgres.
*/
function save_site_settings(array $settings, ?PDO $pdo = null): bool {
$pdo = $pdo ?? require_database();
try {
$pdo->beginTransaction();
$pdo->exec("DELETE FROM site_settings WHERE section IN ('imprint', 'privacy')");
$stmt = $pdo->prepare(
'INSERT INTO site_settings (section, lang, setting_key, setting_value, updated_at)
VALUES (?, ?, ?, ?, NOW())
ON CONFLICT (section, lang, setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
updated_at = NOW()'
);
foreach (['imprint', 'privacy'] as $section) {
foreach (['de', 'en'] as $lang) {
foreach (($settings[$section][$lang] ?? []) as $key => $value) {
$stmt->execute([$section, $lang, $key, (string) $value]);
}
}
}
$pdo->commit();
return true;
} catch (\Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('save_site_settings failed: ' . $e->getMessage());
return false;
}
}
2026-03-22 22:16:44 +01:00
function slugify(string $text): string {
$text = strtolower($text);
$text = preg_replace('~[^a-z0-9]+~', '-', $text);
$text = trim($text, '-');
return $text ?: uniqid('recipe-');
}
function find_recipe_by_slug(array $recipes, string $slug): ?array {
foreach ($recipes as $recipe) {
if (($recipe['slug'] ?? '') === $slug) {
return $recipe;
}
}
return null;
}
function localize_recipe(array $recipe, string $lang): array {
$localized = $recipe;
$i18n = $recipe['i18n'][$lang] ?? ($recipe['i18n']['en'] ?? []);
foreach ($i18n as $key => $value) {
$localized[$key] = $value;
}
return $localized;
}
function localize_recipes(array $recipes, string $lang): array {
return array_map(function ($recipe) use ($lang) {
return localize_recipe($recipe, $lang);
}, $recipes);
}
function filter_recipes(array $recipes, ?string $query = null, ?string $tag = null, string $lang = 'en'): array {
$query = $query ? strtolower($query) : null;
$tag = $tag ? strtolower($tag) : null;
$localized = localize_recipes($recipes, $lang);
return array_values(array_filter($localized, function ($recipe) use ($query, $tag) {
$matchesQuery = true;
if ($query) {
$haystack = strtolower(($recipe['title'] ?? '') . ' ' . ($recipe['description'] ?? ''));
$matchesQuery = strpos($haystack, $query) !== false;
}
$matchesTag = true;
if ($tag) {
$tags = array_map('strtolower', $recipe['tags'] ?? []);
$matchesTag = in_array($tag, $tags, true);
}
return $matchesQuery && $matchesTag;
}));
}
function format_minutes(int $minutes): string {
if ($minutes < 60) {
return $minutes . ' min';
}
$hours = intdiv($minutes, 60);
$mins = $minutes % 60;
return $hours . ' hr' . ($hours > 1 ? 's ' : ' ') . ($mins ? $mins . ' min' : '');
}
function e(?string $value): string {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function normalize_asset_path(string $path): string {
if (!str_starts_with($path, '/assets/')) {
return $path;
}
$absolute = __DIR__ . $path;
if (file_exists($absolute)) {
return $path;
}
$dir = dirname($absolute);
$target = basename($absolute);
if (!is_dir($dir)) {
return $path;
}
foreach (scandir($dir) ?: [] as $entry) {
if (strcasecmp($entry, $target) === 0) {
return rtrim(dirname($path), '/') . '/' . $entry;
}
}
return $path;
}
/**
* Build a URL to the current path with a specific language parameter, preserving other query params.
*/
function lang_url(string $lang): string {
$params = $_GET;
$params['lang'] = $lang;
$path = strtok($_SERVER['REQUEST_URI'], '?') ?: '/';
return $path . '?' . http_build_query($params);
}
// Fallback polyfills for environments without mbstring extension
if (!function_exists('mb_substr')) {
function mb_substr(string $string, int $start, ?int $length = null, ?string $encoding = null): string {
return substr($string, $start, $length ?? strlen($string));
}
}
if (!function_exists('mb_strlen')) {
function mb_strlen(string $string, ?string $encoding = null): int {
return strlen($string);
}
}