| Server IP : 188.114.96.3 / Your IP : 216.73.216.143 Web Server : Apache System : Linux sv058.home.net.pl 6.8.2 husak_dirty_pages_fix #1 SMP PREEMPT_DYNAMIC Tue Apr 2 22:06:51 CEST 2024 x86_64 User : ( 3194769) PHP Version : 8.5.12 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /polana/includes/ |
Upload File : |
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
function ensure_import_schema(): void
{
db()->exec(
"CREATE TABLE IF NOT EXISTS media_library (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
file_path VARCHAR(500) NOT NULL,
original_name VARCHAR(255) DEFAULT NULL,
alt_text VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_media_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
);
db()->exec(
"CREATE TABLE IF NOT EXISTS menu_imports (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
menu_id INT UNSIGNED NOT NULL,
source_name VARCHAR(255) DEFAULT NULL,
source_path VARCHAR(500) DEFAULT NULL,
imported_items INT NOT NULL DEFAULT 0,
imported_categories INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_import_menu
FOREIGN KEY (menu_id) REFERENCES menus(id) ON DELETE CASCADE,
INDEX idx_import_menu (menu_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
);
}
function normalize_menu_line(string $line): string
{
$line = html_entity_decode($line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$line = preg_replace('/[\x{00A0}\x{2007}\x{202F}]/u', ' ', $line) ?? $line;
$line = preg_replace('/\s+/u', ' ', $line) ?? $line;
return trim($line);
}
/**
* Minimalny odczyt jednego pliku z DOCX (ZIP) bez wymogu rozszerzenia php-zip.
* Obsługuje STORE i DEFLATE. Nie rozpakowuje archiwum na dysk, sprawdza limity.
*/
function polana_docx_zip_entry(string $filePath, string $entryName): string
{
$maxCompressed = 21 * 1024 * 1024;
$maxUncompressed = 16 * 1024 * 1024;
if (!is_file($filePath) || filesize($filePath) > $maxCompressed) {
throw new RuntimeException('Nieprawidłowy lub zbyt duży dokument DOCX (limit 20 MB).');
}
if (class_exists('ZipArchive')) {
$zip = new ZipArchive();
if ($zip->open($filePath) !== true) {
throw new RuntimeException('Nie udało się otworzyć archiwum DOCX.');
}
try {
$stat = $zip->statName($entryName);
if ($stat === false) {
throw new RuntimeException('Brak dokumentu Word wewnątrz pliku DOCX.');
}
if (($stat['size'] ?? 0) > $maxUncompressed) {
throw new RuntimeException('Dokument Word jest zbyt duży po rozpakowaniu.');
}
$data = $zip->getFromName($entryName);
} finally {
$zip->close();
}
if (!is_string($data)) {
throw new RuntimeException('Nie udało się odczytać treści pliku Word.');
}
return $data;
}
// Hosting może nie mieć ext-zip. DOCX to standardowy ZIP; czytamy central directory.
$zipData = file_get_contents($filePath);
if (!is_string($zipData)) {
throw new RuntimeException('Nie udało się odczytać pliku Word.');
}
$length = strlen($zipData);
$tailStart = max(0, $length - 65557);
$eocd = strrpos(substr($zipData, $tailStart), "PK\x05\x06");
if ($eocd === false) {
throw new RuntimeException('DOCX nie jest prawidłowym archiwum ZIP.');
}
$endOffset = $tailStart + $eocd;
if ($endOffset + 22 > $length) {
throw new RuntimeException('Archiwum DOCX jest uszkodzone.');
}
$directory = unpack('ventries/Vsize/Voffset', substr($zipData, $endOffset + 10, 10));
$entries = (int)$directory['entries'];
$directoryOffset = (int)$directory['offset'];
$directorySize = (int)$directory['size'];
if ($entries < 1 || $entries > 10000 || $directoryOffset < 0 ||
$directoryOffset + $directorySize > $length) {
throw new RuntimeException('Nieprawidłowa struktura archiwum DOCX.');
}
$pos = $directoryOffset;
for ($i = 0; $i < $entries; $i++) {
if ($pos + 46 > $length || substr($zipData, $pos, 4) !== "PK\x01\x02") {
throw new RuntimeException('Uszkodzony katalog ZIP dokumentu Word.');
}
// Pozycje nagłówka central directory: flagi +8, metoda +10, crc +16,
// packed +20, unpacked +24, name +28, extra +30, comment +32, local +42.
$h = [
'flags' => unpack('v', substr($zipData, $pos + 8, 2))[1],
'method' => unpack('v', substr($zipData, $pos + 10, 2))[1],
'packed' => unpack('V', substr($zipData, $pos + 20, 4))[1],
'unpacked' => unpack('V', substr($zipData, $pos + 24, 4))[1],
'name' => unpack('v', substr($zipData, $pos + 28, 2))[1],
'extra' => unpack('v', substr($zipData, $pos + 30, 2))[1],
'comment' => unpack('v', substr($zipData, $pos + 32, 2))[1],
'local' => unpack('V', substr($zipData, $pos + 42, 4))[1],
];
$name = substr($zipData, $pos + 46, $h['name']);
$pos += 46 + $h['name'] + $h['extra'] + $h['comment'];
if ($pos > $directoryOffset + $directorySize) {
throw new RuntimeException('Nieprawidłowy wpis archiwum DOCX.');
}
if ($name !== $entryName) {
continue;
}
if (($h['flags'] & 1) || !in_array($h['method'], [0, 8], true)) {
throw new RuntimeException('Plik Word ma nieobsługiwaną kompresję lub jest szyfrowany.');
}
if ($h['unpacked'] > $maxUncompressed || $h['packed'] > $maxCompressed) {
throw new RuntimeException('Zbyt duża zawartość dokumentu Word.');
}
$lo = $h['local'];
if ($lo < 0 || $lo + 30 > $length || substr($zipData, $lo, 4) !== "PK\x03\x04") {
throw new RuntimeException('DOCX zawiera uszkodzony wpis pliku.');
}
$localName = unpack('v', substr($zipData, $lo + 26, 2))[1];
$localExtra = unpack('v', substr($zipData, $lo + 28, 2))[1];
$dataPos = $lo + 30 + $localName + $localExtra;
if ($dataPos + $h['packed'] > $length) {
throw new RuntimeException('Zbyt krótki plik Word.');
}
$packedData = substr($zipData, $dataPos, $h['packed']);
if ($h['method'] === 8 && !function_exists('gzinflate')) {
throw new RuntimeException('Serwer nie ma modułu zlib potrzebnego do odczytu DOCX.');
}
$data = $h['method'] === 0 ? $packedData : @gzinflate($packedData, $maxUncompressed);
if (!is_string($data) || strlen($data) !== $h['unpacked']) {
throw new RuntimeException('Nie udało się rozpakować treści Worda.');
}
return $data;
}
throw new RuntimeException('Brak głównej treści dokumentu w DOCX.');
}
function polana_docx_paragraph_text(string $fragment): string
{
preg_match_all('~<w:(t|tab|br|cr)(?=[\\s/>])[^>]*>(.*?)</w:\\1>|<w:(tab|br|cr)\\b[^>]*/>~su',
$fragment, $matches, PREG_SET_ORDER);
$parts = [];
foreach ($matches as $m) {
$tag = ($m[1] ?? '') ?: ($m[3] ?? '');
$parts[] = $tag === 't' ? html_entity_decode($m[2], ENT_QUOTES | ENT_XML1, 'UTF-8') : ' ';
}
return normalize_menu_line(implode('', $parts));
}
function docx_to_lines(string $filePath): array
{
$xml = polana_docx_zip_entry($filePath, 'word/document.xml');
if (strlen(trim($xml)) === 0) {
throw new RuntimeException('Plik DOCX nie zawiera treści tekstowej.');
}
if (stripos($xml, '<!DOCTYPE') !== false || stripos($xml, '<!ENTITY') !== false) {
throw new RuntimeException('Nieobsługiwana struktura XML w dokumencie Word.');
}
$lines = [];
if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
$dom = new DOMDocument();
$previous = libxml_use_internal_errors(true);
try {
if (!$dom->loadXML($xml, LIBXML_NONET | LIBXML_COMPACT)) {
throw new RuntimeException('Uszkodzony XML dokumentu Word.');
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$paragraphs = $xpath->query('//w:body//w:p');
if ($paragraphs !== false) {
foreach ($paragraphs as $paragraph) {
$parts = [];
foreach ($xpath->query('.//w:t|.//w:tab|.//w:br|.//w:cr', $paragraph) as $node) {
$parts[] = $node->localName === 't' ? $node->textContent : ' ';
}
$line = normalize_menu_line(implode('', $parts));
if ($line !== '') {
$lines[] = $line;
}
}
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
} else {
// Fallback na współdzielonym hostingu bez ext-dom; ograniczona składnia WordprocessingML.
if (!preg_match('/<w:document\\b/', $xml)) {
throw new RuntimeException('Nieprawidłowa zawartość XML dokumentu Word.');
}
preg_match_all('~<w:p(?=[\\s>])[^>]*>(.*?)</w:p>~su', $xml, $paragraphs);
foreach ($paragraphs[1] as $fragment) {
$line = polana_docx_paragraph_text($fragment);
if ($line !== '') {
$lines[] = $line;
}
}
}
// Word w tabelach: cenę pojedynczą lub podwójną dołącz do nazwy.
$joined = [];
foreach ($lines as $line) {
if (preg_match('~^\d{1,4}(?:[.,]\d{1,2})?(?:\s*/\s*\d{1,4}(?:[.,]\d{1,2})?)?\s*(?:zł|pln)?$~iu', $line)
&& $joined && !preg_match('~\s+\d{1,4}(?:[.,]\d{1,2})?(?:\s*/\s*\d{1,4}(?:[.,]\d{1,2})?)?\s*(?:zł|pln)?$~iu', end($joined))) {
$joined[count($joined) - 1] .= ' ' . $line;
} else {
$joined[] = $line;
}
}
if (!$joined) {
throw new RuntimeException('Nie znaleziono tekstu w DOCX. Sprawdź, czy menu nie jest wyłącznie obrazem.');
}
// Nie stosować array_unique: identyczne dania w różnych sekcjach muszą zostać zachowane.
return $joined;
}
function text_to_lines(string $text): array
{
$raw = preg_split('/\R/u', $text) ?: [];
$lines = [];
foreach ($raw as $line) {
$line = normalize_menu_line($line);
if ($line !== '') {
$lines[] = $line;
}
}
return $lines;
}
function polana_import_lower(string $s): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($s, 'UTF-8');
}
return strtolower(strtr($s, ['Ą'=>'ą','Ć'=>'ć','Ę'=>'ę','Ł'=>'ł','Ń'=>'ń','Ó'=>'ó','Ś'=>'ś','Ź'=>'ź','Ż'=>'ż']));
}
function polana_import_strlen(string $s): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($s, 'UTF-8');
}
if (function_exists('iconv_strlen')) {
return (int)iconv_strlen($s, 'UTF-8');
}
return preg_match_all('/./us', $s) ?: strlen($s);
}
function should_skip_import_line(string $line): bool
{
$l = polana_import_lower($line);
$skip = [
'polana smaków',
'moja polana smaków',
'restauracja andrzeja polana',
'andrzej polan',
'www.andrzejpolan.eu',
'instagram.com/',
];
foreach ($skip as $needle) {
if ($l === $needle || str_contains($l, $needle)) {
return true;
}
}
return false;
}
/** Cena 50/236 = kieliszek/butelka; cena 25 = pojedyncza. */
function polana_wine_price_pair(string $line): ?array
{
if (!preg_match('~^(.*?)\s+(\d{1,4}(?:[,.]\d{1,2})?)\s*/\s*(\d{1,4}(?:[,.]\d{1,2})?)\s*(?:zł|pln)?$~iu', $line, $m)) {
return null;
}
$name = trim($m[1]);
if ($name === '') return null;
$glass = (float)str_replace(',', '.', $m[2]);
$bottle = (float)str_replace(',', '.', $m[3]);
if ($glass <= 0 || $bottle <= 0) return null;
$volumeA = 'kieliszek';
$volumeB = 'butelka';
// Nazwy i jednostki z dokumentu: 125ml/0,75l, 150 ml / 750 ml.
// Usuń tylko końcowe pojemności; pozostaw producenta i nazwę wina.
if (preg_match('~^(.*?)\s+(\d{1,4}(?:[,.]\d{1,3})?)\s*(ml|l)\s*/\s*(\d{1,4}(?:[,.]\d{1,3})?)\s*(ml|l)$~iu', $name, $v)) {
$name = trim($v[1]);
$volumeA = str_replace('.', ',', $v[2]) . ' ' . polana_import_lower($v[3]);
$volumeB = str_replace('.', ',', $v[4]) . ' ' . polana_import_lower($v[5]);
}
if ($name === '') return null;
$fmt = static fn(float $price): string => rtrim(rtrim(number_format($price, 2, ',', ''), '0'), ',');
return [
'name' => $name,
'price' => null,
'price_text' => $volumeA . ' – ' . $fmt($glass) . ' zł / ' . $volumeB . ' – ' . $fmt($bottle) . ' zł',
'description' => '',
];
}
/** Gdy Word wklei kilka pozycji w jednym wierszu, rozbij je po granicy cena → nowa nazwa. */
function polana_import_expand_inline_lines(array $lines): array
{
$out = [];
foreach ($lines as $sourceLine) {
$sourceLine = normalize_menu_line((string)$sourceLine);
if ($sourceLine === '') continue;
// Nie dzielić jednostek 125 ml, ale dzielić "58 Śledź" i "46 Zupa".
$expanded = preg_replace('~(?<=\d)\h+(?=\p{Lu}\p{L})~u', "\n", $sourceLine) ?? $sourceLine;
foreach (explode("\n", $expanded) as $piece) {
$piece = trim($piece);
if ($piece === '') continue;
// W Wordzie „Przed zupą Tatar... 58” bywa jednym akapitem.
if (preg_match('~^(Przed zupą|Nasze przystawki|Zupy gorące|Zupa|Dania główne|Słodkie zakończenie)\s+(.+\s+\d{1,4}(?:[.,]\d{1,2})?(?:\s*zł)?)$~iu', $piece, $m)) {
$out[] = normalize_menu_line($m[1]);
$out[] = normalize_menu_line($m[2]);
} else {
$out[] = $piece;
}
}
}
return $out;
}
/** Typowe nagłówki, ale nie nazwy dań zawierające przecinki. */
function polana_import_is_category(string $line, int $index): bool
{
$line = normalize_menu_line($line);
if ($line === '' || preg_match('/[,;.!?]$/u', $line)) return false;
if (preg_match('/(?:\d{1,4}(?:[,.]\d{1,2})?)\s*(?:zł|pln)?$/iu', $line)) return false;
if (polana_import_strlen($line) > 72) return false;
// Konkretne działy zamiast starej, błędnej reguły „każde <=90 znaków”.
if (preg_match('~^(?:nasze\s+)?(?:przystawki|przed\s+zupą|zup[ay]|zupy\s+gorące|dania\s+główne|drugie\s+dania|dania\s+mięsne|dania\s+rybne|dania\s+wegetariańskie|desery|słodkie\s+zakończenie|słodkości|napoje(?:\s+bezalkoholowe|\s+zimne|\s+gorące)?|kawy|herbaty|soki|lemoniady|koktajle|wina(?:\s+.+)?|winnica(?:\s+.+)?|piwa(?:\s+.+)?|aperitify|alkohole)(?:\s*[-–:]\s*.*)?$~iu', $line)) {
return true;
}
// Pierwszy, krótki nagłówek własny dopuszczamy jako kategorię.
// Kolejne nieopisane zdania są traktowane jako fragmenty dań do połączenia z ceną.
return $index === 0 && polana_import_strlen($line) <= 44 && !str_contains($line, ',')
&& (preg_match('/\S+/u', $line) === 1);
}
/** Rozpoznaje pojedynczą pozycję lub zapis kieliszek/butelka. */
function polana_import_price_item(string $line): ?array
{
$pair = polana_wine_price_pair($line);
if ($pair !== null) return $pair;
if (!preg_match('/^(.*?)\s+(\d{1,5}(?:[.,]\d{1,2})?)\s*(?:zł|pln)?$/iu', $line, $m)) {
return null;
}
$name = trim($m[1]);
if ($name === '') return null;
return [
'name' => $name,
'price' => (float)str_replace(',', '.', $m[2]),
'price_text' => '',
'description' => '',
];
}
function parse_menu_lines(array $inputLines): array
{
$lines = [];
foreach ($inputLines as $line) {
$line = normalize_menu_line((string)$line);
if ($line !== '' && !should_skip_import_line($line)) $lines[] = $line;
}
$lines = polana_import_expand_inline_lines($lines);
$result = ['title' => '', 'categories' => [], 'unparsed' => []];
if (!$lines) return $result;
if (preg_match('/^(?:menu|karta(?: dań)?|jadłospis)\b/iu', $lines[0])
&& polana_import_price_item($lines[0]) === null) {
$result['title'] = array_shift($lines);
}
$current = -1;
$pending = [];
$ensure = static function () use (&$result, &$current): void {
if ($current < 0) {
$result['categories'][] = ['name' => 'Pozostałe', 'items' => []];
$current = count($result['categories']) - 1;
}
};
$flushPending = static function () use (&$pending, &$result): void {
if ($pending) {
// Niczego nie gubimy: te linie trafią do ręcznej kontroli w podglądzie.
$result['unparsed'][] = implode(' ', $pending);
$pending = [];
}
};
foreach ($lines as $i => $line) {
if (polana_import_is_category($line, $i)) {
$flushPending();
$result['categories'][] = ['name' => $line, 'items' => []];
$current = count($result['categories']) - 1;
continue;
}
$item = polana_import_price_item($line);
// Cena w osobnym akapicie Worda: „Rosół..., ziemniak,” + „żebro...” + „35”.
$onlyPrice = preg_match('~^\d{1,5}(?:[.,]\d{1,2})?(?:\s*/\s*\d{1,5}(?:[.,]\d{1,2})?)?\s*(?:zł|pln)?$~iu', $line) === 1;
if ($pending && ($item !== null || $onlyPrice)) {
$combined = implode(' ', $pending) . ' ' . $line;
$joinedItem = polana_import_price_item($combined);
if ($joinedItem !== null) {
$item = $joinedItem;
$pending = [];
}
}
if ($item !== null) {
$flushPending();
$ensure();
$result['categories'][$current]['items'][] = $item;
continue;
}
if ($onlyPrice) {
$result['unparsed'][] = $line;
continue;
}
// Brak ceny nie oznacza kategorii. Oczekujemy na dalszy fragment potrawy.
$pending[] = $line;
if (count($pending) > 8 || polana_import_strlen(implode(' ', $pending)) > 2000) {
$flushPending();
}
}
$flushPending();
// Nie usuwamy pustych rozpoznanych kategorii: w edytorze można je poprawić.
return $result;
}
/** Podgląd jako lista edytowalnych wierszy: nagłówki oraz dania. */
function polana_import_editor_rows(array $parsed): array
{
$rows = [];
foreach (($parsed['categories'] ?? []) as $category) {
$rows[] = ['kind' => 'category', 'name' => (string)($category['name'] ?? ''),
'price' => '', 'price_text' => '', 'description' => ''];
foreach (($category['items'] ?? []) as $item) {
$rows[] = ['kind' => 'item', 'name' => (string)($item['name'] ?? ''),
'price' => isset($item['price']) ? (string)$item['price'] : '',
'price_text' => (string)($item['price_text'] ?? ''),
'description' => (string)($item['description'] ?? '')];
}
}
if (!empty($parsed['unparsed'])) {
$rows[] = ['kind' => 'category', 'name' => 'DO SPRAWDZENIA — popraw lub usuń',
'price' => '', 'price_text' => '', 'description' => ''];
foreach ($parsed['unparsed'] as $line) {
$rows[] = ['kind' => 'item', 'name' => (string)$line,
'price' => '', 'price_text' => '', 'description' => ''];
}
}
return $rows;
}
/** Walidacja wierszy z formularza; nigdy nie ufamy JSON-owi z przeglądarki. */
function polana_import_editor_to_parsed(string $json): array
{
if ($json === '' || strlen($json) > 350000) {
throw new RuntimeException('Brak danych edytora lub zbyt duża karta. Odśwież podgląd.');
}
try { $rows = json_decode($json, true, 16, JSON_THROW_ON_ERROR); }
catch (JsonException $e) { throw new RuntimeException('Nie udało się odczytać zmian edytora.'); }
if (!is_array($rows) || array_values($rows) !== $rows || count($rows) > 1200) {
throw new RuntimeException('Nieprawidłowa lista pozycji w edytorze.');
}
$result = ['title' => '', 'categories' => [], 'unparsed' => []];
$current = -1;
$total = 0;
foreach ($rows as $rowNo => $row) {
if (!is_array($row)) throw new RuntimeException('Błędna pozycja w edytorze.');
$kind = (string)($row['kind'] ?? '');
$name = normalize_menu_line((string)($row['name'] ?? ''));
if ($name === '' || polana_import_strlen($name) > 240) {
throw new RuntimeException('Wiersz ' . ($rowNo + 1) . ': wpisz nazwę (maksymalnie 240 znaków).');
}
if ($kind === 'category') {
if (count($result['categories']) >= 100) throw new RuntimeException('Za dużo kategorii (maks. 100).');
$result['categories'][] = ['name' => $name, 'items' => []];
$current = count($result['categories']) - 1;
continue;
}
if ($kind !== 'item') throw new RuntimeException('Wiersz ' . ($rowNo + 1) . ': nieprawidłowy typ.');
if ($current < 0) throw new RuntimeException('Pierwszy wiersz musi być kategorią. Dodaj kategorię nad daniem.');
$priceRaw = trim((string)($row['price'] ?? ''));
$priceText = normalize_menu_line((string)($row['price_text'] ?? ''));
if (polana_import_strlen($priceText) > 240) {
throw new RuntimeException('Wiersz ' . ($rowNo + 1) . ': za długa cena tekstowa.');
}
$price = null;
if ($priceRaw !== '') {
if (!preg_match('/^\d{1,5}(?:[.,]\d{1,2})?$/D', $priceRaw)) {
throw new RuntimeException('Wiersz ' . ($rowNo + 1) . ': cena powinna być liczbą, np. 35 lub 35,50.');
}
$price = (float)str_replace(',', '.', $priceRaw);
}
if ($price === null && $priceText === '') {
throw new RuntimeException('Wiersz ' . ($rowNo + 1) . ' („' . $name . '”): podaj cenę albo cenę tekstową. Możesz też usunąć wiersz.');
}
$description = trim((string)($row['description'] ?? ''));
if (polana_import_strlen($description) > 1500) {
throw new RuntimeException('Wiersz ' . ($rowNo + 1) . ': za długi opis.');
}
$result['categories'][$current]['items'][] = [
'name' => $name, 'price' => $price, 'price_text' => $priceText, 'description' => $description,
];
if (++$total > 1000) throw new RuntimeException('Za dużo pozycji (maks. 1000).');
}
if ($total < 1) throw new RuntimeException('Karta musi zawierać co najmniej jedno danie lub napój.');
return $result;
}
function create_menu_from_parsed(
array $parsed,
string $menuName,
?string $validFrom,
?string $sourceName = null,
?string $sourcePath = null,
string $menuType = 'food'
): int {
ensure_import_schema();
if (!$parsed['categories']) {
throw new RuntimeException(
'Nie znaleziono pozycji z cenami. Sprawdź format dokumentu albo użyj wklejania tekstu.'
);
}
$pdo = db();
$pdo->beginTransaction();
try {
$name = trim($menuName);
if ($name === '') {
$name = trim((string)($parsed['title'] ?? ''));
}
if ($name === '') {
$name = 'Menu ' . date('m/Y');
}
$slug = 'menu-' . date('Ymd-His') . '-' . random_int(100, 999);
$stmt = $pdo->prepare(
'INSERT INTO menus
(name, slug, status, valid_from, intro, seo_title, seo_description, menu_type)
VALUES (?, ?, "draft", ?, ?, ?, ?, ?)'
);
$stmt->execute([
$name,
$slug,
$validFrom ?: null,
'Aktualna karta restauracji Moja Polana Smaków.',
$name . ' | Moja Polana Smaków',
'Aktualne menu restauracji Moja Polana Smaków Andrzeja Polana.',
polana_menu_type($menuType),
]);
$menuId = (int)$pdo->lastInsertId();
$categoryCount = 0;
$itemCount = 0;
$catStmt = $pdo->prepare(
'INSERT INTO menu_categories
(menu_id, name, description, sort_order, active)
VALUES (?, ?, "", ?, 1)'
);
$itemStmt = $pdo->prepare(
'INSERT INTO menu_items
(category_id, name, description, price, price_text,
available, active, featured, sort_order)
VALUES (?, ?, ?, ?, ?, 1, 1, 0, ?)'
);
$catSort = 10;
foreach ($parsed['categories'] as $category) {
$catStmt->execute([
$menuId,
$category['name'],
$catSort,
]);
$categoryId = (int)$pdo->lastInsertId();
$categoryCount++;
$catSort += 10;
$itemSort = 10;
foreach ($category['items'] as $item) {
$itemStmt->execute([
$categoryId,
$item['name'],
$item['description'] ?? '',
$item['price'] ?? null,
$item['price_text'] ?? '',
$itemSort,
]);
$itemCount++;
$itemSort += 10;
}
}
$logStmt = $pdo->prepare(
'INSERT INTO menu_imports
(menu_id, source_name, source_path, imported_items, imported_categories)
VALUES (?, ?, ?, ?, ?)'
);
$logStmt->execute([
$menuId,
$sourceName,
$sourcePath,
$itemCount,
$categoryCount,
]);
$pdo->commit();
admin_log(
'menu_import',
sprintf(
'Import menu #%d: %d kategorii, %d pozycji',
$menuId,
$categoryCount,
$itemCount
)
);
return $menuId;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function save_source_docx(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
throw new RuntimeException('Wybierz plik DOCX.');
}
$uploadError = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($uploadError !== UPLOAD_ERR_OK) {
if (in_array($uploadError, [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) {
throw new RuntimeException('Word przekracza limit uploadu serwera PHP. Zmniejsz plik lub zwiększ upload_max_filesize/post_max_size.');
}
if ($uploadError === UPLOAD_ERR_PARTIAL) {
throw new RuntimeException('Plik Word przesłano tylko częściowo. Spróbuj ponownie.');
}
throw new RuntimeException('Błąd wysyłania DOCX (kod ' . $uploadError . ').');
}
if (($file['size'] ?? 0) > 20 * 1024 * 1024) {
throw new RuntimeException('Plik DOCX jest za duży. Limit: 20 MB.');
}
$original = basename((string)($file['name'] ?? 'menu.docx'));
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
if ($ext !== 'docx') {
throw new RuntimeException('Obsługiwany jest plik Word w formacie .docx.');
}
if (!is_uploaded_file((string)($file['tmp_name'] ?? ''))) {
throw new RuntimeException('Nieprawidłowy plik tymczasowy Worda.');
}
$dir = dirname(__DIR__) . '/uploads/menu_sources';
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Nie można utworzyć katalogu dla plików Word.');
}
$stored = date('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.docx';
$absolute = $dir . '/' . $stored;
if (!move_uploaded_file((string)$file['tmp_name'], $absolute)) {
throw new RuntimeException('Nie udało się zapisać dokumentu Word.');
}
return [
'absolute' => $absolute,
'public' => '/uploads/menu_sources/' . $stored,
'original' => $original,
];
}
function normalize_multi_uploads(array $files): array
{
$out = [];
if (!isset($files['name']) || !is_array($files['name'])) {
return $out;
}
foreach ($files['name'] as $i => $name) {
if (($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
$out[] = [
'name' => $name,
'type' => $files['type'][$i] ?? '',
'tmp_name' => $files['tmp_name'][$i] ?? '',
'error' => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $files['size'][$i] ?? 0,
];
}
return $out;
}
function store_media_file(array $file, string $alt = ''): int
{
ensure_import_schema();
$path = upload_file(
$file,
'uploads/dishes',
['image/jpeg', 'image/png', 'image/webp'],
10 * 1024 * 1024
);
if (!$path) {
throw new RuntimeException('Nie wybrano zdjęcia.');
}
$stmt = db()->prepare(
'INSERT INTO media_library (file_path, original_name, alt_text)
VALUES (?, ?, ?)'
);
$stmt->execute([
$path,
basename((string)($file['name'] ?? '')),
trim($alt),
]);
return (int)db()->lastInsertId();
}
function media_library(int $limit = 100): array
{
ensure_import_schema();
$limit = max(1, min($limit, 300));
return db()->query(
'SELECT * FROM media_library
ORDER BY id DESC
LIMIT ' . $limit
)->fetchAll();
}