403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /polana/includes/import_menu.php
<?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();
}

Youez - 2016 - github.com/yon3zu
LinuXploit