Dodatkowe wersje językowe z AI

This commit is contained in:
2026-09-25 18:04:28 +02:00
parent a381ad09d8
commit 4fde92d323
23 changed files with 1231 additions and 148 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
addefc0876999fc32822c6f3368af33520f89f31de548559e069be12cc2685fb
+16 -132
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/menu_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
@@ -29,138 +31,9 @@ if ($lang === '' || !isset($languages[$lang])) {
$langConfig = $languages[$lang];
$source = (string) ($langConfig['source'] ?? 'upstream');
$ttl = max(60, (int) ($config['ttl_seconds'] ?? 1800));
$ttl = max(60, (int) ($config['ttl_seconds'] ?? 86400));
$timeout = max(3, (int) ($config['timeout_seconds'] ?? 12));
$cacheDir = __DIR__ . '/cache';
$cacheFile = $cacheDir . '/menu_' . $lang . '.json';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0755, true);
}
function readMenuCache(string $cacheFile): ?array
{
if (!is_file($cacheFile)) {
return null;
}
$raw = file_get_contents($cacheFile);
if ($raw === false || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
function writeMenuCache(string $cacheFile, array $payload): void
{
file_put_contents(
$cacheFile,
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
function fetchUpstreamMenu(string $url, int $timeout): ?array
{
$body = false;
$httpCode = 0;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0) {
$body = false;
}
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'header' => "Accept: application/json\r\n",
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $context);
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
$httpCode = (int) $m[1];
}
}
if ($body === false || ($httpCode > 0 && ($httpCode < 200 || $httpCode >= 300))) {
return null;
}
$decoded = json_decode($body, true);
return is_array($decoded) ? $decoded : null;
}
function mapUpstreamMenu(array $raw, string $lang, string $source): array
{
$categories = [];
foreach ($raw['categories'] ?? [] as $cat) {
if (!is_array($cat)) {
continue;
}
$id = trim((string) ($cat['id'] ?? ''));
$name = trim((string) ($cat['name'] ?? ''));
if ($id === '' || $name === '') {
continue;
}
$categories[] = ['id' => $id, 'name' => $name];
}
$sections = [];
foreach ($raw['tables'] ?? [] as $table) {
if (!is_array($table)) {
continue;
}
$sectionName = trim((string) ($table['name'] ?? ''));
if ($sectionName === '') {
continue;
}
$items = [];
foreach ($table['items'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$items[] = [
'position' => (string) ($item['id'] ?? ''),
'categoryId' => (string) ($item['category'] ?? ''),
'tag' => '',
'image' => (string) ($item['image'] ?? ''),
'title' => trim((string) ($item['name'] ?? '')),
'description' => trim((string) ($item['name_extra'] ?? '')),
'price' => (string) ($item['price'] ?? ''),
];
}
$sections[] = [
'categoryName' => $sectionName,
'items' => $items,
];
}
return [
'status' => 'success',
'lang' => $lang,
'source' => $source,
'cachedAt' => gmdate('c'),
'categories' => $categories,
'sections' => $sections,
];
}
$cacheFile = menuCachePath($lang);
$cached = readMenuCache($cacheFile);
$now = time();
@@ -177,13 +50,24 @@ if ($isFresh) {
if ($source === 'ai') {
if ($cached) {
// Stary AI-cache jest OK dłużej niż TTL — cron i tak odświeża raz/dzień.
echo json_encode($cached, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$from = strtolower((string) ($langConfig['from'] ?? 'pl'));
$fallback = readMenuCache(menuCachePath($from));
if ($fallback) {
$fallback['lang'] = $lang;
$fallback['source'] = 'ai_fallback_pl';
echo json_encode($fallback, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
http_response_code(503);
echo json_encode([
'status' => 'error',
'message' => 'AI menu not available yet',
'message' => 'AI menu not available yet — run scripts/refresh_menu_cache.php',
'lang' => $lang,
], JSON_UNESCAPED_UNICODE);
exit;
+315
View File
@@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
/**
* Wspólne funkcje cache/upstream menu (api/menu.php + scripts/refresh_menu_cache.php).
*/
function menuCacheDir(): string
{
$dir = __DIR__ . '/cache';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir;
}
function menuCachePath(string $lang): string
{
$safe = preg_replace('/[^a-z0-9_-]/i', '', strtolower($lang)) ?: 'pl';
return menuCacheDir() . '/menu_' . $safe . '.json';
}
function menuFingerprintPath(): string
{
return menuCacheDir() . '/menu_pl_fingerprint.txt';
}
function readMenuCache(string $cacheFile): ?array
{
if (!is_file($cacheFile)) {
return null;
}
$raw = file_get_contents($cacheFile);
if ($raw === false || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
function writeMenuCache(string $cacheFile, array $payload): void
{
file_put_contents(
$cacheFile,
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
function fetchUpstreamMenu(string $url, int $timeout): ?array
{
$body = false;
$httpCode = 0;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0) {
$body = false;
}
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'header' => "Accept: application/json\r\n",
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $context);
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
$httpCode = (int) $m[1];
}
}
if ($body === false || ($httpCode > 0 && ($httpCode < 200 || $httpCode >= 300))) {
return null;
}
$decoded = json_decode($body, true);
return is_array($decoded) ? $decoded : null;
}
function mapUpstreamMenu(array $raw, string $lang, string $source): array
{
$categories = [];
foreach ($raw['categories'] ?? [] as $cat) {
if (!is_array($cat)) {
continue;
}
$id = trim((string) ($cat['id'] ?? ''));
$name = trim((string) ($cat['name'] ?? ''));
if ($id === '' || $name === '') {
continue;
}
$categories[] = ['id' => $id, 'name' => $name];
}
$sections = [];
foreach ($raw['tables'] ?? [] as $table) {
if (!is_array($table)) {
continue;
}
$sectionName = trim((string) ($table['name'] ?? ''));
if ($sectionName === '') {
continue;
}
$items = [];
foreach ($table['items'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$items[] = [
'position' => (string) ($item['id'] ?? ''),
'categoryId' => (string) ($item['category'] ?? ''),
'tag' => '',
'image' => (string) ($item['image'] ?? ''),
'title' => trim((string) ($item['name'] ?? '')),
'description' => trim((string) ($item['name_extra'] ?? '')),
'price' => (string) ($item['price'] ?? ''),
];
}
$sections[] = [
'categoryName' => $sectionName,
'items' => $items,
];
}
$payload = [
'status' => 'success',
'lang' => $lang,
'source' => $source,
'cachedAt' => gmdate('c'),
'categories' => $categories,
'sections' => $sections,
];
$payload['contentFingerprint'] = menuContentFingerprint($payload);
return $payload;
}
/**
* Stabilny fingerprint treści tekstowych (bez cen/obrazków/daty cache).
*/
function menuContentFingerprint(array $menu): string
{
$parts = [];
foreach ($menu['categories'] ?? [] as $cat) {
if (!is_array($cat)) {
continue;
}
$parts[] = 'c|' . ($cat['id'] ?? '') . '|' . ($cat['name'] ?? '');
}
foreach ($menu['sections'] ?? [] as $section) {
if (!is_array($section)) {
continue;
}
$catName = (string) ($section['categoryName'] ?? '');
$parts[] = 's|' . $catName;
foreach ($section['items'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$parts[] = implode('|', [
'i',
(string) ($item['categoryId'] ?? ''),
(string) ($item['position'] ?? ''),
(string) ($item['title'] ?? ''),
(string) ($item['description'] ?? ''),
$catName,
]);
}
}
sort($parts, SORT_STRING);
return hash('sha256', implode("\n", $parts));
}
/**
* Mapa pozycji do tłumaczenia: key => [title, description, categoryName, categoryId, position, ...]
*/
function menuCollectTranslatableUnits(array $menu): array
{
$units = [];
$categoryNames = [];
foreach ($menu['categories'] ?? [] as $cat) {
if (!is_array($cat)) {
continue;
}
$id = (string) ($cat['id'] ?? '');
if ($id === '') {
continue;
}
$categoryNames[$id] = (string) ($cat['name'] ?? '');
$units['cat:' . $id] = [
'type' => 'category',
'id' => $id,
'text' => (string) ($cat['name'] ?? ''),
];
}
foreach ($menu['sections'] ?? [] as $sIdx => $section) {
if (!is_array($section)) {
continue;
}
$catName = (string) ($section['categoryName'] ?? '');
$units['sec:' . $sIdx] = [
'type' => 'section',
'index' => (int) $sIdx,
'text' => $catName,
];
foreach ($section['items'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$cid = (string) ($item['categoryId'] ?? '');
$pos = (string) ($item['position'] ?? '');
$key = 'item:' . $cid . ':' . $pos;
$units[$key] = [
'type' => 'item',
'categoryId' => $cid,
'position' => $pos,
'title' => (string) ($item['title'] ?? ''),
'description' => (string) ($item['description'] ?? ''),
'hash' => hash('sha256', ($item['title'] ?? '') . "\0" . ($item['description'] ?? '') . "\0" . $catName),
];
}
}
return $units;
}
function menuApplyTranslations(array $plMenu, array $translations, string $lang): array
{
$out = $plMenu;
$out['lang'] = $lang;
$out['source'] = 'ai';
$out['cachedAt'] = gmdate('c');
$out['sourceFingerprint'] = $plMenu['contentFingerprint'] ?? menuContentFingerprint($plMenu);
foreach ($out['categories'] ?? [] as $i => $cat) {
if (!is_array($cat)) {
continue;
}
$id = (string) ($cat['id'] ?? '');
$key = 'cat:' . $id;
if (isset($translations[$key]['text'])) {
$out['categories'][$i]['name'] = (string) $translations[$key]['text'];
}
}
foreach ($out['sections'] ?? [] as $sIdx => $section) {
if (!is_array($section)) {
continue;
}
$secKey = 'sec:' . $sIdx;
if (isset($translations[$secKey]['text'])) {
$out['sections'][$sIdx]['categoryName'] = (string) $translations[$secKey]['text'];
}
foreach ($section['items'] ?? [] as $iIdx => $item) {
if (!is_array($item)) {
continue;
}
$cid = (string) ($item['categoryId'] ?? '');
$pos = (string) ($item['position'] ?? '');
$key = 'item:' . $cid . ':' . $pos;
if (isset($translations[$key]['title'])) {
$out['sections'][$sIdx]['items'][$iIdx]['title'] = (string) $translations[$key]['title'];
}
if (array_key_exists('description', $translations[$key] ?? [])) {
$out['sections'][$sIdx]['items'][$iIdx]['description'] = (string) $translations[$key]['description'];
}
}
}
$out['contentFingerprint'] = menuContentFingerprint($out);
return $out;
}
function loadMenuAiConfig(): ?array
{
$path = __DIR__ . '/../config/menu_ai.local.php';
if (!is_file($path)) {
return null;
}
$cfg = require $path;
return is_array($cfg) ? $cfg : null;
}
+22 -2
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
/**
* Konfiguracja menu z upstreamu Karczmy + lista języków aplikacji.
*
* source=upstream → pobierane z WWW Karczmy (pl/en/de)
* source=ai → tłumaczenie offline z języka `from` (skrypt refresh raz/dzień)
*/
return [
'upstream_base' => 'https://www.karczmabiesiada.eu/restaurant_menu/export',
'ttl_seconds' => 1800,
'ttl_seconds' => 86400,
'timeout_seconds' => 12,
'languages' => [
'pl' => [
@@ -25,6 +28,23 @@ return [
'flag' => '🇩🇪',
'source' => 'upstream',
],
// później: 'uk' => ['label' => 'Українська', 'flag' => '🇺🇦', 'source' => 'ai', 'from' => 'pl'],
'es' => [
'label' => 'Español',
'flag' => '🇪🇸',
'source' => 'ai',
'from' => 'pl',
],
'it' => [
'label' => 'Italiano',
'flag' => '🇮🇹',
'source' => 'ai',
'from' => 'pl',
],
'ko' => [
'label' => '한국어',
'flag' => '🇰🇷',
'source' => 'ai',
'from' => 'pl',
],
],
];
+3 -3
View File
@@ -73,9 +73,9 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
<div class="geo-wifi-callout">
<p class="geo-wifi-callout-title" data-i18n="geo.wifi.title">📶 Wejdź bez zgody na lokalizację</p>
<p data-i18n="geo.wifi.connect">Połącz telefon z siecią Wi‑Fi restauracji:</p>
<p class="geo-wifi-network"><strong>HotSpot Karczmy</strong></p>
<p class="geo-wifi-password">Hasło: <strong>karczmabiesiada</strong></p>
<p data-i18n="geo.wifi.connect">Połącz telefon z otwartą siecią Wi‑Fi restauracji:</p>
<p class="geo-wifi-network"><strong data-i18n="geo.wifi.ssid">HotSpot</strong></p>
<p class="geo-wifi-open" data-i18n="geo.wifi.open">Sieć publiczna — bez hasła.</p>
<p data-i18n="geo.wifi.after" data-i18n-html>Po połączeniu <strong>odśwież stronę</strong> (lub zamknij i otwórz ponownie kod QR). Aplikacja wpuści Cię <strong>bez pytania o lokalizację</strong> — z pełnym dostępem do:</p>
<ul class="geo-wifi-list">
<li data-i18n="geo.wifi.li1">wezwania kelnera</li>
+1 -1
View File
@@ -361,7 +361,7 @@ html.is-lang-loading .app-scroll {
}
.geo-wifi-network,
.geo-wifi-password {
.geo-wifi-open {
font-size: 15px;
color: var(--text-main);
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<rect width="640" height="480" fill="#c60b1e"/>
<rect width="640" height="240" y="120" fill="#ffc400"/>
</svg>

After

Width:  |  Height:  |  Size: 178 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<rect width="213.3" height="480" fill="#009246"/>
<rect width="213.3" height="480" x="213.3" fill="#fff"/>
<rect width="213.4" height="480" x="426.6" fill="#ce2b37"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<rect width="640" height="480" fill="#fff"/>
<circle cx="320" cy="240" r="120" fill="none" stroke="#cd2e3a" stroke-width="48"/>
<circle cx="320" cy="240" r="40" fill="#0047a0"/>
<g fill="#0047a0">
<circle cx="320" cy="168" r="10"/>
<circle cx="370" cy="205" r="10"/>
<circle cx="370" cy="275" r="10"/>
<circle cx="320" cy="312" r="10"/>
<circle cx="270" cy="275" r="10"/>
<circle cx="270" cy="205" r="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 516 B

+3 -1
View File
@@ -22,7 +22,9 @@ export default {
"geo.btn.checking": "Prüfung…",
"geo.feature.other": "diese Funktion",
"geo.wifi.title": "📶 Ohne Standortfreigabe eintreten",
"geo.wifi.connect": "Verbinden Sie Ihr Telefon mit dem WLAN des Restaurants:",
"geo.wifi.connect": "Verbinden Sie Ihr Telefon mit dem offenen WLAN des Restaurants:",
"geo.wifi.ssid": "HotSpot",
"geo.wifi.open": "Öffentliches Netz — kein Passwort.",
"geo.wifi.after": "Nach der Verbindung <strong>Seite aktualisieren</strong> (oder QR-Code schließen und erneut öffnen). Die App lässt Sie <strong>ohne Standortabfrage</strong> hinein — mit vollem Zugang zu:",
"geo.wifi.li1": "Kellner rufen",
"geo.wifi.li2": "Rechnung anfordern",
+3 -1
View File
@@ -22,7 +22,9 @@ export default {
"geo.btn.checking": "Checking…",
"geo.feature.other": "this feature",
"geo.wifi.title": "📶 Enter without location permission",
"geo.wifi.connect": "Connect your phone to the restaurant Wi‑Fi:",
"geo.wifi.connect": "Connect your phone to the restaurant’s open Wi‑Fi:",
"geo.wifi.ssid": "HotSpot",
"geo.wifi.open": "Public network — no password.",
"geo.wifi.after": "After connecting, <strong>refresh the page</strong> (or close and open the QR code again). The app will let you in <strong>without asking for location</strong> — with full access to:",
"geo.wifi.li1": "calling a waiter",
"geo.wifi.li2": "requesting the bill",
+148
View File
@@ -0,0 +1,148 @@
export default {
"html.lang": "es",
"doc.title": "Karczma Biesiada – Tu pedido",
"loader.connecting": "Conectando con la cocina...",
"loader.msg1": "Calentando los hornos...",
"loader.msg2": "El chef revisa los ingredientes...",
"loader.msg3": "Conectando con el corazón del restaurante...",
"loader.msg4": "Casi listo...",
"loader.api_error": "Error de API: {message}",
"loader.connection": "Problema de conexión. Reintentando...",
"geo.title": "Bienvenido a Karczma",
"geo.lead": "Consulta el menú ahora — o confirma que estás aquí para llamar al camarero, seguir tu pedido y pedir la cuenta.",
"geo.lead_action": "Ya tienes el menú abierto. Para usar <b>{feature}</b>, confirma brevemente que estás en el restaurante.",
"geo.btn.menu_main": "Ir al menú",
"geo.btn.menu_sub": "sin ubicación",
"geo.btn.back_menu": "Volver al menú",
"geo.btn.locate": "De acuerdo, comprobar ubicación",
"geo.btn.retry": "Intentar de nuevo",
"geo.btn.check": "Comprobar ubicación",
"geo.btn.checking": "Comprobando…",
"geo.feature.other": "esta función",
"geo.wifi.title": "📶 Entrar sin permiso de ubicación",
"geo.wifi.connect": "Conecta el teléfono a la Wi‑Fi abierta del restaurante:",
"geo.wifi.ssid": "HotSpot",
"geo.wifi.open": "Red pública — sin contraseña.",
"geo.wifi.after": "Después de conectar, <strong>actualiza la página</strong> (o cierra y vuelve a abrir el código QR). La app te dejará entrar <strong>sin pedir la ubicación</strong> — con acceso completo a:",
"geo.wifi.li1": "llamar al camarero",
"geo.wifi.li2": "pedir la cuenta",
"geo.wifi.li3": "estado del pedido",
"geo.wifi.li4": "toda la app en la mesa",
"geo.feature.status": "el estado del pedido",
"geo.feature.waiter": "llamar al camarero",
"geo.feature.bill": "pedir la cuenta",
"geo.status.checking": "Comprobando tu ubicación…",
"geo.status.blocked": "El navegador bloqueó el acceso a la ubicación.",
"geo.status.https": "Esta página requiere una conexión HTTPS segura.",
"geo.status.https_short": "La geolocalización requiere HTTPS.",
"geo.status.unsupported": "Tu navegador no admite geolocalización.",
"geo.status.failed": "No se pudo obtener tu ubicación.",
"geo.status.outside": "Parece que estás fuera del restaurante (aprox. {dist} m, precisión GPS: ±{accuracy} m).",
"geo.hint.permission": "Activa la ubicación para este sitio en el navegador e inténtalo de nuevo.",
"geo.hint.https": "Abre la app con una dirección segura <b>https://</b> e inténtalo de nuevo.",
"geo.hint.retry": "Comprueba la señal, activa el GPS e inténtalo de nuevo.",
"geo.hint.outside": "Los navegadores a menudo dan una ubicación distinta a Google Maps. Prueba otra vez al aire libre o cerca de una ventana — o ve al menú sin ubicación.",
"table.choose": "Elige una mesa",
"table.label": "Mesa {name}",
"status.title": "Estado actual",
"status.waiting": "Esperando...",
"status.checking": "Comprobando qué se está preparando...",
"status.none": "No hay pedidos activos",
"status.none_meta": "Te invitamos a ver nuestro menú.",
"status.ready": "¡Listo para servir!",
"status.ready_meta": "Todos tus platos ya han salido de la cocina.",
"status.partial": "Parcialmente listo",
"status.partial_meta": "¡Las primeras delicias ya te esperan!",
"status.preparing": "En preparación",
"status.preparing_meta": "Nuestros cocineros están preparando tu pedido.",
"status.footer": "Pedido realizado a las {time} • Mesa {table}",
"orders.title": "Tus platos pedidos",
"orders.empty": "Si acabas de pedir, danos un momento para procesarlo.",
"orders.browse_menu": "Ver menú",
"history.title": "Tus pedidos anteriores",
"history.note": "Son platos de otras visitas visibles en este teléfono tras escanear códigos QR. Si visitaste sin escanear, no aparecerán aquí 🙂",
"history.clear": "Borrar historial",
"history.clear_confirm": "¿Seguro que quieres borrar el historial de pedidos anteriores?",
"history.entry_meta": "{table} • {date} {time}",
"orders.item_preparing": "🔥 En preparación",
"orders.item_ready": "✅ Listo",
"orders.item_ready_done": "✅ Listo (completado)",
"orders.item_fallback": "Artículo",
"menu.banner": "Confirma tu ubicación para llamar al camarero o pedir la cuenta.",
"menu.banner_btn": "Comprobar ahora",
"menu.search": "Buscar platos...",
"menu.all": "Todo",
"menu.load_error": "No se pudo cargar el menú.",
"menu.no_image": "Sin foto",
"menu.close": "Cerrar",
"menu.lang": "Idioma",
"nav.order": "Pedido",
"nav.menu": "Menú",
"nav.waiter": "Camarero",
"nav.bill": "Cuenta",
"waiter.title": "¿Llamar al personal?",
"waiter.body": "El camarero recibirá un aviso al instante y vendrá a tu mesa lo antes posible.",
"waiter.confirm": "Sí, llamar al camarero",
"waiter.cancel": "Cancelar",
"waiter.toast_ok": "¡Un camarero llegará enseguida!",
"waiter.toast_fail": "No se pudo enviar la solicitud. Inténtalo de nuevo en un momento.",
"waiter.blocked": "Ya se ha llamado al camarero. Espera a que el personal lo confirme en el panel.",
"waiter.queue_title": "Llamada al camarero",
"bill.title": "Pago",
"bill.loading": "⏳ Cargando cuentas...",
"bill.loading_empty": "No hay cuentas abiertas para pagar.",
"bill.loading_error": "Error al cargar las cuentas.",
"bill.multi": "Hay varias cuentas abiertas en esta mesa. ¿Cuál quieres pagar?",
"bill.review": "Resumen de la cuenta:",
"bill.total": "Total a pagar:",
"bill.back": "Volver",
"bill.request": "Pedir la cuenta",
"bill.payment": "Elige el método de pago preferido:",
"bill.card": "Tarjeta",
"bill.cash": "Efectivo",
"bill.back_summary": "Volver al resumen",
"bill.doc": "¿Qué documento necesitas?",
"bill.receipt": "Ticket",
"bill.invoice": "Factura",
"bill.nip_intro": "Introduce el NIP de la empresa para obtener los datos automáticamente.",
"bill.nip_label": "Número NIP",
"bill.gus": "Obtener del registro",
"bill.gus_searching": "Buscando...",
"bill.verify": "¿Son correctos los datos de factura siguientes?",
"bill.confirm_invoice": "¡Sí, quiero la factura!",
"bill.change_nip": "Cambiar NIP",
"bill.edit_company": "Editar manualmente",
"bill.edit_done": "Terminar edición",
"bill.street": "Calle y número",
"bill.zip": "CP",
"bill.city": "Ciudad",
"bill.toast_receipt": "¡El camarero traerá el ticket para pagar!",
"bill.toast_invoice": "¡Gracias! Se ha enviado la solicitud de factura.",
"bill.toast_fail": "No se pudo enviar la solicitud de cuenta. Inténtalo de nuevo en un momento.",
"bill.blocked": "Ya se ha enviado una solicitud de cuenta. Espera a que el personal la gestione.",
"bill.nip_invalid": "Introduce un NIP válido.",
"bill.gus_fail": "No se pudieron obtener los datos para este NIP.",
"bill.gus_error": "Error de conexión con el registro de empresas.",
"bill.payment_unknown": "desconocido",
"bill.queue_title": "Solicitud de cuenta",
"bill.queue_payment": "Forma de pago:",
"bill.queue_doc": "Documento:",
"bill.queue_nip": "NIP:",
"bill.queue_company": "Empresa:",
"bill.queue_address": "Dirección:",
"bill.doc_receipt": "ticket",
"bill.doc_invoice": "factura",
"bill.bill_fallback": "Cuenta",
"toast.sent": "¡Enviado!",
"lang.aria": "Elegir idioma",
"lang.loading": "Cargando idioma…",
};
+148
View File
@@ -0,0 +1,148 @@
export default {
"html.lang": "it",
"doc.title": "Karczma Biesiada – Il tuo ordine",
"loader.connecting": "Connessione alla cucina...",
"loader.msg1": "Accensione dei forni...",
"loader.msg2": "Lo chef controlla gli ingredienti...",
"loader.msg3": "Connessione al cuore del ristorante...",
"loader.msg4": "Quasi pronto...",
"loader.api_error": "Errore API: {message}",
"loader.connection": "Problema di connessione. Nuovo tentativo...",
"geo.title": "Benvenuti alla Karczma",
"geo.lead": "Sfoglia subito il menu — oppure conferma di essere qui per chiamare il cameriere, seguire l’ordine e chiedere il conto.",
"geo.lead_action": "Hai già il menu aperto. Per usare <b>{feature}</b>, conferma brevemente di essere nel ristorante.",
"geo.btn.menu_main": "Vai al menu",
"geo.btn.menu_sub": "senza posizione",
"geo.btn.back_menu": "Torna al menu",
"geo.btn.locate": "Ok, controlla la posizione",
"geo.btn.retry": "Riprova",
"geo.btn.check": "Controlla posizione",
"geo.btn.checking": "Controllo…",
"geo.feature.other": "questa funzione",
"geo.wifi.title": "📶 Entra senza permesso di posizione",
"geo.wifi.connect": "Collega il telefono al Wi‑Fi aperto del ristorante:",
"geo.wifi.ssid": "HotSpot",
"geo.wifi.open": "Rete pubblica — senza password.",
"geo.wifi.after": "Dopo la connessione, <strong>aggiorna la pagina</strong> (oppure chiudi e riapri il QR). L’app ti farà entrare <strong>senza chiedere la posizione</strong> — con accesso completo a:",
"geo.wifi.li1": "chiamata cameriere",
"geo.wifi.li2": "richiesta conto",
"geo.wifi.li3": "stato dell’ordine",
"geo.wifi.li4": "tutta l’app al tavolo",
"geo.feature.status": "lo stato dell’ordine",
"geo.feature.waiter": "la chiamata del cameriere",
"geo.feature.bill": "la richiesta del conto",
"geo.status.checking": "Controllo della tua posizione…",
"geo.status.blocked": "Il browser ha bloccato l’accesso alla posizione.",
"geo.status.https": "Questa pagina richiede una connessione HTTPS sicura.",
"geo.status.https_short": "La geolocalizzazione richiede HTTPS.",
"geo.status.unsupported": "Il tuo browser non supporta la geolocalizzazione.",
"geo.status.failed": "Impossibile ottenere la posizione.",
"geo.status.outside": "Sembra che tu sia fuori dal ristorante (circa {dist} m, precisione GPS: ±{accuracy} m).",
"geo.hint.permission": "Attiva la posizione per questo sito nelle impostazioni del browser e riprova.",
"geo.hint.https": "Apri l’app con un indirizzo sicuro <b>https://</b> e riprova.",
"geo.hint.retry": "Controlla il segnale, attiva il GPS e riprova.",
"geo.hint.outside": "I browser spesso indicano una posizione diversa da Google Maps. Riprova all’aperto o vicino a una finestra — oppure vai al menu senza posizione.",
"table.choose": "Scegli un tavolo",
"table.label": "Tavolo {name}",
"status.title": "Stato attuale",
"status.waiting": "In attesa...",
"status.checking": "Controlliamo cosa si sta preparando...",
"status.none": "Nessun ordine attivo",
"status.none_meta": "Ti invitiamo a consultare il menu.",
"status.ready": "Pronto da servire!",
"status.ready_meta": "Tutti i tuoi piatti hanno lasciato la cucina.",
"status.partial": "Parzialmente pronto",
"status.partial_meta": "Le prime prelibatezze ti aspettano!",
"status.preparing": "In preparazione",
"status.preparing_meta": "Il tuo ordine è in preparazione dai nostri cuochi.",
"status.footer": "Ordine effettuato alle {time} • Tavolo {table}",
"orders.title": "I tuoi piatti ordinati",
"orders.empty": "Se hai appena ordinato, concedici un momento per elaborarlo.",
"orders.browse_menu": "Sfoglia il menu",
"history.title": "I tuoi ordini precedenti",
"history.note": "Sono piatti di altre visite visibili su questo telefono dopo la scansione dei QR. Se sei venuto senza scansionare, non appariranno qui 🙂",
"history.clear": "Cancella cronologia",
"history.clear_confirm": "Vuoi davvero cancellare la cronologia degli ordini precedenti?",
"history.entry_meta": "{table} • {date} {time}",
"orders.item_preparing": "🔥 In preparazione",
"orders.item_ready": "✅ Pronto",
"orders.item_ready_done": "✅ Pronto (completato)",
"orders.item_fallback": "Articolo",
"menu.banner": "Conferma la posizione per chiamare il cameriere o chiedere il conto.",
"menu.banner_btn": "Controlla ora",
"menu.search": "Cerca piatti...",
"menu.all": "Tutto",
"menu.load_error": "Impossibile caricare il menu.",
"menu.no_image": "Nessuna foto",
"menu.close": "Chiudi",
"menu.lang": "Lingua",
"nav.order": "Ordine",
"nav.menu": "Menu",
"nav.waiter": "Cameriere",
"nav.bill": "Conto",
"waiter.title": "Chiamare il personale?",
"waiter.body": "Il cameriere riceverà una notifica immediata e verrà al tuo tavolo il prima possibile.",
"waiter.confirm": "Sì, chiama il cameriere",
"waiter.cancel": "Annulla",
"waiter.toast_ok": "Un cameriere arriverà a breve!",
"waiter.toast_fail": "Invio non riuscito. Riprova tra un momento.",
"waiter.blocked": "Il cameriere è già stato chiamato. Attendi la conferma del personale sul pannello.",
"waiter.queue_title": "Chiamata cameriere",
"bill.title": "Pagamento",
"bill.loading": "⏳ Caricamento conti...",
"bill.loading_empty": "Nessun conto aperto da pagare.",
"bill.loading_error": "Errore nel caricamento dei conti.",
"bill.multi": "Ci sono più conti aperti a questo tavolo. Quale vuoi pagare?",
"bill.review": "Riepilogo del conto:",
"bill.total": "Totale da pagare:",
"bill.back": "Indietro",
"bill.request": "Chiedi il conto",
"bill.payment": "Scegli il metodo di pagamento preferito:",
"bill.card": "Carta",
"bill.cash": "Contanti",
"bill.back_summary": "Torna al riepilogo",
"bill.doc": "Di quale documento hai bisogno?",
"bill.receipt": "Scontrino",
"bill.invoice": "Fattura",
"bill.nip_intro": "Inserisci la Partita IVA (NIP) per recuperare i dati automaticamente.",
"bill.nip_label": "Numero NIP",
"bill.gus": "Recupera dal registro",
"bill.gus_searching": "Ricerca...",
"bill.verify": "I dati della fattura qui sotto sono corretti?",
"bill.confirm_invoice": "Sì, voglio la fattura!",
"bill.change_nip": "Cambia NIP",
"bill.edit_company": "Modifica manualmente",
"bill.edit_done": "Fine modifica",
"bill.street": "Via e numero",
"bill.zip": "CAP",
"bill.city": "Città",
"bill.toast_receipt": "Il cameriere porterà lo scontrino da pagare!",
"bill.toast_invoice": "Grazie! La richiesta di fattura è stata inviata.",
"bill.toast_fail": "Invio della richiesta conto non riuscito. Riprova tra un momento.",
"bill.blocked": "Una richiesta di conto è già stata inviata. Attendi che il personale la gestisca.",
"bill.nip_invalid": "Inserisci un NIP valido.",
"bill.gus_fail": "Impossibile recuperare i dati per questo NIP.",
"bill.gus_error": "Errore di connessione al registro imprese.",
"bill.payment_unknown": "sconosciuto",
"bill.queue_title": "Richiesta conto",
"bill.queue_payment": "Metodo di pagamento:",
"bill.queue_doc": "Documento:",
"bill.queue_nip": "NIP:",
"bill.queue_company": "Azienda:",
"bill.queue_address": "Indirizzo:",
"bill.doc_receipt": "scontrino",
"bill.doc_invoice": "fattura",
"bill.bill_fallback": "Conto",
"toast.sent": "Inviato!",
"lang.aria": "Scegli la lingua",
"lang.loading": "Caricamento lingua…",
};
+148
View File
@@ -0,0 +1,148 @@
export default {
"html.lang": "ko",
"doc.title": "Karczma Biesiada – 주문 현황",
"loader.connecting": "주방에 연결 중...",
"loader.msg1": "오븐을 예열하는 중...",
"loader.msg2": "셰프가 재료를 확인하는 중...",
"loader.msg3": "레스토랑과 연결 중...",
"loader.msg4": "거의 준비됨...",
"loader.api_error": "API 오류: {message}",
"loader.connection": "연결 문제. 다시 시도 중...",
"geo.title": "Karczma에 오신 것을 환영합니다",
"geo.lead": "바로 메뉴를 보시거나, 웨이터 호출·주문 추적·계산을 위해 매장에 계신지 확인해 주세요.",
"geo.lead_action": "메뉴는 이미 열려 있습니다. <b>{feature}</b>을(를) 쓰려면 매장에 계신지 짧게 확인해 주세요.",
"geo.btn.menu_main": "메뉴로 이동",
"geo.btn.menu_sub": "위치 없이",
"geo.btn.back_menu": "메뉴로 돌아가기",
"geo.btn.locate": "동의, 위치 확인",
"geo.btn.retry": "다시 시도",
"geo.btn.check": "위치 확인",
"geo.btn.checking": "확인 중…",
"geo.feature.other": "이 기능",
"geo.wifi.title": "📶 위치 권한 없이 입장",
"geo.wifi.connect": "휴대폰을 레스토랑의 개방형 Wi‑Fi에 연결하세요:",
"geo.wifi.ssid": "HotSpot",
"geo.wifi.open": "공개 네트워크 — 비밀번호 없음.",
"geo.wifi.after": "연결 후 <strong>페이지를 새로고침</strong>하세요 (또는 QR을 닫았다가 다시 여세요). 앱이 <strong>위치를 묻지 않고</strong> 다음 기능에 전체 접근을 허용합니다:",
"geo.wifi.li1": "웨이터 호출",
"geo.wifi.li2": "계산 요청",
"geo.wifi.li3": "주문 상태",
"geo.wifi.li4": "테이블용 전체 앱",
"geo.feature.status": "주문 상태",
"geo.feature.waiter": "웨이터 호출",
"geo.feature.bill": "계산 요청",
"geo.status.checking": "위치를 확인하는 중…",
"geo.status.blocked": "브라우저가 위치 접근을 차단했습니다.",
"geo.status.https": "이 페이지는 안전한 HTTPS 연결이 필요합니다.",
"geo.status.https_short": "위치 확인에는 HTTPS가 필요합니다.",
"geo.status.unsupported": "브라우저가 위치 정보를 지원하지 않습니다.",
"geo.status.failed": "위치를 가져오지 못했습니다.",
"geo.status.outside": "레스토랑 밖에 계신 것 같습니다 (약 {dist} m, GPS 정확도: ±{accuracy} m).",
"geo.hint.permission": "브라우저 설정에서 이 사이트의 위치를 켠 뒤 다시 시도하세요.",
"geo.hint.https": "안전한 <b>https://</b> 주소로 앱을 연 뒤 다시 시도하세요.",
"geo.hint.retry": "신호와 GPS를 확인한 뒤 다시 시도하세요.",
"geo.hint.outside": "브라우저는 Google 지도와 다른 위치를 자주 표시합니다. 야외나 창가에서 다시 시도하거나 위치 없이 메뉴로 이동하세요.",
"table.choose": "테이블 선택",
"table.label": "테이블 {name}",
"status.title": "현재 상태",
"status.waiting": "대기 중...",
"status.checking": "요리 준비 상태를 확인하는 중...",
"status.none": "진행 중인 주문 없음",
"status.none_meta": "메뉴를 둘러보세요.",
"status.ready": "제공 준비 완료!",
"status.ready_meta": "주문하신 요리가 모두 주방을 떠났습니다.",
"status.partial": "일부 준비됨",
"status.partial_meta": "첫 요리들이 기다리고 있습니다!",
"status.preparing": "준비 중",
"status.preparing_meta": "셰프가 주문을 준비하고 있습니다.",
"status.footer": "{time}에 주문 • 테이블 {table}",
"orders.title": "주문한 요리",
"orders.empty": "방금 주문하셨다면 처리될 때까지 잠시만 기다려 주세요.",
"orders.browse_menu": "메뉴 보기",
"history.title": "이전 주문",
"history.note": "이 휴대폰에서 QR을 스캔한 다른 방문의 항목입니다. 코드 없이 방문했다면 여기에 없을 수 있습니다 🙂",
"history.clear": "기록 삭제",
"history.clear_confirm": "이전 주문 기록을 삭제할까요?",
"history.entry_meta": "{table} • {date} {time}",
"orders.item_preparing": "🔥 준비 중",
"orders.item_ready": "✅ 준비됨",
"orders.item_ready_done": "✅ 준비됨 (완료)",
"orders.item_fallback": "항목",
"menu.banner": "웨이터 호출이나 계산을 위해 위치를 확인해 주세요.",
"menu.banner_btn": "지금 확인",
"menu.search": "요리 검색...",
"menu.all": "전체",
"menu.load_error": "메뉴를 불러오지 못했습니다.",
"menu.no_image": "사진 없음",
"menu.close": "닫기",
"menu.lang": "언어",
"nav.order": "주문",
"nav.menu": "메뉴",
"nav.waiter": "웨이터",
"nav.bill": "계산",
"waiter.title": "직원을 호출할까요?",
"waiter.body": "웨이터가 즉시 알림을 받고 최대한 빨리 테이블로 옵니다.",
"waiter.confirm": "네, 웨이터 호출",
"waiter.cancel": "취소",
"waiter.toast_ok": "곧 웨이터가 찾아옵니다!",
"waiter.toast_fail": "요청을 보내지 못했습니다. 잠시 후 다시 시도하세요.",
"waiter.blocked": "이미 웨이터를 호출했습니다. 패널에서 확인할 때까지 기다려 주세요.",
"waiter.queue_title": "웨이터 호출",
"bill.title": "결제",
"bill.loading": "⏳ 계산서 불러오는 중...",
"bill.loading_empty": "결제할 열린 계산서가 없습니다.",
"bill.loading_error": "계산서를 불러오지 못했습니다.",
"bill.multi": "이 테이블에 열린 계산서가 여러 개입니다. 어느 것을 결제할까요?",
"bill.review": "계산서 요약:",
"bill.total": "결제 금액:",
"bill.back": "뒤로",
"bill.request": "계산 요청",
"bill.payment": "선호하는 결제 수단을 선택하세요:",
"bill.card": "카드",
"bill.cash": "현금",
"bill.back_summary": "요약으로 돌아가기",
"bill.doc": "어떤 서류가 필요하신가요?",
"bill.receipt": "영수증",
"bill.invoice": "세금계산서",
"bill.nip_intro": "회사 NIP를 입력하면 데이터를 자동으로 가져옵니다.",
"bill.nip_label": "NIP 번호",
"bill.gus": "등록부에서 가져오기",
"bill.gus_searching": "검색 중...",
"bill.verify": "아래 세금계산서 정보가 맞나요?",
"bill.confirm_invoice": "네, 세금계산서를 요청합니다!",
"bill.change_nip": "NIP 변경",
"bill.edit_company": "직접 수정",
"bill.edit_done": "수정 완료",
"bill.street": "도로명과 번호",
"bill.zip": "우편번호",
"bill.city": "도시",
"bill.toast_receipt": "웨이터가 결제용 영수증을 가져옵니다!",
"bill.toast_invoice": "감사합니다! 세금계산서 요청이 전송되었습니다.",
"bill.toast_fail": "계산 요청을 보내지 못했습니다. 잠시 후 다시 시도하세요.",
"bill.blocked": "이미 계산 요청이 전송되었습니다. 직원이 처리할 때까지 기다려 주세요.",
"bill.nip_invalid": "올바른 NIP를 입력하세요.",
"bill.gus_fail": "해당 NIP로 회사 정보를 가져오지 못했습니다.",
"bill.gus_error": "회사 등록부 연결 오류.",
"bill.payment_unknown": "알 수 없음",
"bill.queue_title": "계산 요청",
"bill.queue_payment": "결제 수단:",
"bill.queue_doc": "서류:",
"bill.queue_nip": "NIP:",
"bill.queue_company": "회사:",
"bill.queue_address": "주소:",
"bill.doc_receipt": "영수증",
"bill.doc_invoice": "세금계산서",
"bill.bill_fallback": "계산서",
"toast.sent": "전송됨!",
"lang.aria": "언어 선택",
"lang.loading": "언어 불러오는 중…",
};
+3 -1
View File
@@ -22,7 +22,9 @@ export default {
"geo.btn.checking": "Sprawdzanie…",
"geo.feature.other": "tę funkcję",
"geo.wifi.title": "📶 Wejdź bez zgody na lokalizację",
"geo.wifi.connect": "Połącz telefon z siecią Wi‑Fi restauracji:",
"geo.wifi.connect": "Połącz telefon z otwartą siecią Wi‑Fi restauracji:",
"geo.wifi.ssid": "HotSpot",
"geo.wifi.open": "Sieć publiczna — bez hasła.",
"geo.wifi.after": "Po połączeniu <strong>odśwież stronę</strong> (lub zamknij i otwórz ponownie kod QR). Aplikacja wpuści Cię <strong>bez pytania o lokalizację</strong> — z pełnym dostępem do:",
"geo.wifi.li1": "wezwania kelnera",
"geo.wifi.li2": "prośby o rachunek",
+15 -4
View File
@@ -1,9 +1,12 @@
import pl from "../locales/pl.js";
import en from "../locales/en.js";
import de from "../locales/de.js";
import es from "../locales/es.js";
import it from "../locales/it.js";
import ko from "../locales/ko.js";
const STORAGE_KEY = "karczma_lang";
const catalogs = { pl, en, de };
const catalogs = { pl, en, de, es, it, ko };
const LANG_BUSY_MIN_MS = 280;
let currentLang = "pl";
@@ -56,6 +59,9 @@ export function getAvailableLanguages() {
{ code: "pl", label: "Polski", flag: "🇵🇱" },
{ code: "en", label: "English", flag: "🇬🇧" },
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
{ code: "es", label: "Español", flag: "🇪🇸" },
{ code: "it", label: "Italiano", flag: "🇮🇹" },
{ code: "ko", label: "한국어", flag: "🇰🇷" },
];
}
@@ -283,9 +289,14 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
refresh();
onLangChange(() => refresh());
onLangBusyChange(() => {
if (langBusy) pickerApi.close();
paintButton();
onLangBusyChange((busy) => {
if (busy) {
pickerApi.close();
paintButton();
return;
}
// Po spinnerze przebuduj też opcje — inaczej zostają disabled=true z czasu busy.
refresh();
});
return { el: wrap, refresh, close: pickerApi.close };
+377
View File
@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
/**
* Odświeża cache menu raz na dobę (cron) lub ręcznie.
*
* 1) Upstream PL/EN/DE → api/cache/menu_{lang}.json
* 2) Języki source=ai (ES/IT/KO): tłumaczenie OpenAI ChatGPT z PL
* tylko gdy fingerprint PL się zmienił (lub brak cache AI).
*
* Użycie:
* php scripts/refresh_menu_cache.php
* php scripts/refresh_menu_cache.php --force-ai
*
* Cron (raz dziennie, np. 04:15):
* 15 4 * * * php /path/to/scripts/refresh_menu_cache.php >> /var/log/menu_refresh.log 2>&1
*
* Klucz OpenAI: skopiuj config/menu_ai.local.php.example → config/menu_ai.local.php
*/
require_once __DIR__ . '/../api/menu_helpers.php';
$forceAi = in_array('--force-ai', $argv ?? [], true);
$config = require __DIR__ . '/../config/menu.php';
$languages = is_array($config['languages'] ?? null) ? $config['languages'] : [];
$timeout = max(3, (int) ($config['timeout_seconds'] ?? 12));
$base = rtrim((string) ($config['upstream_base'] ?? ''), '/');
function logLine(string $msg): void
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
}
function targetLanguageName(string $code): string
{
return match ($code) {
'es' => 'Spanish',
'it' => 'Italian',
'ko' => 'Korean',
'uk' => 'Ukrainian',
'fr' => 'French',
default => $code,
};
}
/**
* @return array<string, array>|null
*/
function openaiTranslateBatch(array $aiCfg, string $targetLang, array $payloadItems): ?array
{
$apiKey = trim((string) ($aiCfg['api_key'] ?? ''));
$baseUrl = rtrim((string) ($aiCfg['base_url'] ?? 'https://api.openai.com/v1'), '/');
$model = (string) ($aiCfg['model'] ?? 'gpt-4o');
$timeout = max(30, (int) ($aiCfg['timeout_seconds'] ?? 90));
if ($apiKey === '' || str_contains($apiKey, 'REPLACE')) {
return null;
}
$langName = targetLanguageName($targetLang);
$system = <<<PROMPT
You translate a Polish restaurant menu (Karczma Biesiada — traditional Polish inn cuisine) into {$langName}.
Return ONLY a JSON object whose keys are the exact input "key" strings.
Examples:
{"cat:1":{"text":"..."},"sec:0":{"text":"..."},"item:3:386":{"title":"...","description":"..."}}
For category/section units use {"text":"..."}.
For item units use {"title":"...","description":"..."}.
Translate fully into {$langName}. Empty description stays "". Do not invent prices. Do not wrap in an array.
PROMPT;
$body = [
'model' => $model,
'temperature' => 0.2,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $system],
[
'role' => 'user',
'content' => json_encode(['items' => $payloadItems], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
],
];
$ch = curl_init($baseUrl . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0 || $raw === false || $httpCode < 200 || $httpCode >= 300) {
logLine("OpenAI HTTP {$httpCode} errno={$errno}");
if (is_string($raw) && $raw !== '') {
logLine('OpenAI body: ' . substr($raw, 0, 240));
}
return null;
}
$decoded = json_decode($raw, true);
$content = $decoded['choices'][0]['message']['content'] ?? '';
if (!is_string($content) || $content === '') {
logLine('OpenAI empty content');
return null;
}
$parsed = json_decode($content, true);
if (!is_array($parsed)) {
logLine('OpenAI response is not JSON object: ' . substr($content, 0, 200));
return null;
}
$out = normalizeOpenAiTranslationMap($parsed);
if ($out === []) {
logLine('OpenAI parsed 0 translation keys. Sample: ' . substr($content, 0, 280));
return null;
}
return $out;
}
/**
* Akceptuje mapę klucz→obiekt, {items|translations: mapa|lista}, albo listę {key,...}.
*
* @return array<string, array>
*/
function normalizeOpenAiTranslationMap(array $parsed): array
{
foreach (['items', 'translations', 'results', 'data'] as $wrap) {
if (isset($parsed[$wrap]) && is_array($parsed[$wrap])) {
$parsed = $parsed[$wrap];
break;
}
}
$out = [];
// Lista obiektów z polem key
$isList = array_is_list($parsed);
if ($isList) {
foreach ($parsed as $row) {
if (!is_array($row)) {
continue;
}
$key = (string) ($row['key'] ?? '');
if ($key === '') {
continue;
}
unset($row['key'], $row['type']);
$out[$key] = $row;
}
return $out;
}
foreach ($parsed as $key => $val) {
if (!is_array($val)) {
continue;
}
$keyStr = is_string($key) ? $key : (string) ($val['key'] ?? '');
if ($keyStr === '') {
continue;
}
unset($val['key'], $val['type']);
$out[$keyStr] = $val;
}
return $out;
}
function extractPrevItemHashes(array $aiMenu): array
{
$hashes = [];
if (!empty($aiMenu['itemHashes']) && is_array($aiMenu['itemHashes'])) {
return $aiMenu['itemHashes'];
}
return $hashes;
}
function buildAiMenuFromPl(array $plMenu, string $lang, array $aiCfg, bool $force, array $prevAi): array
{
$units = menuCollectTranslatableUnits($plMenu);
$prevHashes = extractPrevItemHashes($prevAi);
$prevTranslations = [];
// Odtwórz poprzednie tłumaczenia z cache AI (po kluczach).
if ($prevAi) {
foreach ($prevAi['categories'] ?? [] as $cat) {
if (!is_array($cat)) {
continue;
}
$id = (string) ($cat['id'] ?? '');
if ($id !== '') {
$prevTranslations['cat:' . $id] = ['text' => (string) ($cat['name'] ?? '')];
}
}
foreach ($prevAi['sections'] ?? [] as $sIdx => $section) {
if (!is_array($section)) {
continue;
}
$prevTranslations['sec:' . $sIdx] = ['text' => (string) ($section['categoryName'] ?? '')];
foreach ($section['items'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$key = 'item:' . ($item['categoryId'] ?? '') . ':' . ($item['position'] ?? '');
$prevTranslations[$key] = [
'title' => (string) ($item['title'] ?? ''),
'description' => (string) ($item['description'] ?? ''),
];
}
}
}
$toSend = [];
$newHashes = [];
foreach ($units as $key => $unit) {
if (($unit['type'] ?? '') === 'item') {
$hash = (string) ($unit['hash'] ?? '');
$newHashes[$key] = $hash;
$unchanged = !$force && isset($prevHashes[$key]) && $prevHashes[$key] === $hash && isset($prevTranslations[$key]);
if ($unchanged) {
continue;
}
$toSend[] = [
'key' => $key,
'type' => 'item',
'title' => $unit['title'] ?? '',
'description' => $unit['description'] ?? '',
];
continue;
}
// Kategorie / sekcje — zawsze dołączamy gdy force lub brak poprzedniego tłumaczenia.
if (!$force && isset($prevTranslations[$key]['text']) && $prevTranslations[$key]['text'] !== '') {
continue;
}
$toSend[] = [
'key' => $key,
'type' => $unit['type'],
'text' => $unit['text'] ?? '',
];
}
$merged = [];
$batchSize = max(5, min(40, (int) ($aiCfg['batch_size'] ?? 25)));
$apiHits = 0;
// Przy --force nie bierz poprzedniego (często PL po nieudanym parsowaniu).
if (!$force) {
$merged = $prevTranslations;
}
if ($toSend === []) {
logLine("AI {$lang}: nothing to translate (reuse cache)");
} else {
logLine('AI ' . $lang . ': translating ' . count($toSend) . ' units via OpenAI…');
for ($i = 0; $i < count($toSend); $i += $batchSize) {
$chunk = array_slice($toSend, $i, $batchSize);
$result = openaiTranslateBatch($aiCfg, $lang, $chunk);
if ($result === null) {
logLine("AI {$lang}: batch failed — aborting language (keeping old file if any)");
if ($prevAi && !$force) {
return $prevAi;
}
// force + fail → better return PL-labelled failure than silent Polish as "ai"
throw new RuntimeException("OpenAI batch failed for {$lang}");
}
$apiHits += count($result);
foreach ($result as $k => $v) {
$merged[$k] = $v;
}
logLine('AI ' . $lang . ': batch ok, +' . count($result) . ' keys (total api keys ' . $apiHits . ')');
}
}
$fallbackCount = 0;
foreach ($units as $key => $unit) {
if (isset($merged[$key])) {
continue;
}
$fallbackCount++;
if (($unit['type'] ?? '') === 'item') {
$merged[$key] = [
'title' => $unit['title'] ?? '',
'description' => $unit['description'] ?? '',
];
} else {
$merged[$key] = ['text' => $unit['text'] ?? ''];
}
}
if ($apiHits === 0 && $toSend !== []) {
throw new RuntimeException("AI {$lang}: no keys applied from OpenAI");
}
logLine("AI {$lang}: applied api={$apiHits}, fallback_pl={$fallbackCount}");
$out = menuApplyTranslations($plMenu, $merged, $lang);
$out['itemHashes'] = $newHashes;
return $out;
}
// --- upstream ---
foreach ($languages as $code => $meta) {
if (($meta['source'] ?? '') !== 'upstream') {
continue;
}
$code = (string) $code;
$url = $base . '/' . rawurlencode($code);
logLine("Upstream fetch {$code}: {$url}");
$raw = fetchUpstreamMenu($url, $timeout);
if ($raw === null) {
logLine("Upstream {$code}: FAILED — keeping old cache if any");
continue;
}
$mapped = mapUpstreamMenu($raw, $code, 'upstream');
writeMenuCache(menuCachePath($code), $mapped);
logLine("Upstream {$code}: OK (" . count($mapped['sections'] ?? []) . ' sections)');
}
$plMenu = readMenuCache(menuCachePath('pl'));
if (!$plMenu) {
logLine('No PL cache — abort AI step');
exit(1);
}
$fp = (string) ($plMenu['contentFingerprint'] ?? menuContentFingerprint($plMenu));
$fpFile = menuFingerprintPath();
$prevFp = is_file($fpFile) ? trim((string) file_get_contents($fpFile)) : '';
$plChanged = $forceAi || $prevFp === '' || !hash_equals($prevFp, $fp);
file_put_contents($fpFile, $fp, LOCK_EX);
logLine('PL fingerprint: ' . substr($fp, 0, 12) . '… changed=' . ($plChanged ? 'yes' : 'no'));
$aiCfg = loadMenuAiConfig();
if ($aiCfg === null) {
logLine('No config/menu_ai.local.php — skip AI languages (copy from .example)');
exit(0);
}
foreach ($languages as $code => $meta) {
if (($meta['source'] ?? '') !== 'ai') {
continue;
}
$code = (string) $code;
$cachePath = menuCachePath($code);
$prevAi = readMenuCache($cachePath) ?? [];
if (!$plChanged && $prevAi && !$forceAi) {
logLine("AI {$code}: skipped (PL unchanged)");
continue;
}
try {
$built = buildAiMenuFromPl($plMenu, $code, $aiCfg, $forceAi || !$prevAi, $prevAi);
writeMenuCache($cachePath, $built);
logLine("AI {$code}: wrote cache");
} catch (Throwable $e) {
logLine('AI ' . $code . ': ERROR ' . $e->getMessage());
}
}
logLine('Done.');