From b653cea25226ef58295e3149faedc249609cf24d Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Mon, 16 Feb 2026 21:55:24 +0100 Subject: [PATCH] Add installer functionality for WordPress with FTP and database configuration - Create SQL migration for prompt templates used in article and image generation. - Add migration to change publish interval from days to hours in the sites table. - Implement InstallerController to handle installation requests and validation. - Develop FtpService for FTP connections and file uploads. - Create InstallerService to manage the WordPress installation process, including downloading, extracting, and configuring WordPress. - Add index view for the installer with form inputs for FTP, database, and WordPress admin settings. - Implement progress tracking for the installation process with AJAX polling. --- .env | 2 + .env.example | 1 + .vscode/ftp-kr.sync.cache.json | 570 ++++++++++++++++++++++ config/routes.php | 10 + migrations/001_initial.sql | 5 +- migrations/002_global_topics.sql | 185 ++++--- migrations/003_site_credentials.sql | 18 + migrations/004_prompt_templates.sql | 5 + migrations/005_publish_interval_hours.sql | 21 + src/Controllers/ArticleController.php | 75 +++ src/Controllers/CategoryController.php | 164 +++++++ src/Controllers/GlobalTopicController.php | 8 + src/Controllers/InstallerController.php | 109 +++++ src/Controllers/PublishController.php | 42 ++ src/Controllers/SettingsController.php | 15 +- src/Controllers/SiteController.php | 17 +- src/Controllers/TopicController.php | 12 + src/Helpers/Validator.php | 8 + src/Models/Article.php | 3 +- src/Models/Site.php | 2 +- src/Models/Topic.php | 2 +- src/Services/FtpService.php | 140 ++++++ src/Services/ImageService.php | 12 +- src/Services/InstallerService.php | 442 +++++++++++++++++ src/Services/OpenAIService.php | 16 +- src/Services/WordPressService.php | 63 +++ templates/articles/show.php | 68 ++- templates/categories/index.php | 308 ++++++++++-- templates/dashboard/index.php | 46 +- templates/global-topics/index.php | 58 ++- templates/installer/index.php | 372 ++++++++++++++ templates/layout/sidebar.php | 5 + templates/settings/index.php | 12 + templates/sites/create.php | 6 +- templates/sites/edit.php | 147 +++++- templates/sites/index.php | 2 +- templates/topics/index.php | 132 +++-- 37 files changed, 2899 insertions(+), 204 deletions(-) create mode 100644 .vscode/ftp-kr.sync.cache.json create mode 100644 migrations/003_site_credentials.sql create mode 100644 migrations/004_prompt_templates.sql create mode 100644 migrations/005_publish_interval_hours.sql create mode 100644 src/Controllers/InstallerController.php create mode 100644 src/Services/FtpService.php create mode 100644 src/Services/InstallerService.php create mode 100644 templates/installer/index.php diff --git a/.env b/.env index a64e235..e0bf954 100644 --- a/.env +++ b/.env @@ -12,3 +12,5 @@ PEXELS_API_KEY= APP_URL=https://backpro.projectpro.pl APP_SECRET=bP7x9kR3mW2vN5qT8sY1 + +PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1 \ No newline at end of file diff --git a/.env.example b/.env.example index b815e12..dfcefe3 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,4 @@ PEXELS_API_KEY= APP_URL=https://backpro.projectpro.pl APP_SECRET=change-this-to-random-string +PUBLISH_TRIGGER_TOKEN=change-this-to-long-random-token diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json new file mode 100644 index 0000000..acde375 --- /dev/null +++ b/.vscode/ftp-kr.sync.cache.json @@ -0,0 +1,570 @@ +{ + "ftp://host700513.hostido.net.pl:21@www@backpro.projectpro.pl": { + "public_html": { + "assets": { + "css": { + "app.css": { + "type": "-", + "size": 1058, + "lmtime": 1771149935949, + "modified": false + } + }, + "js": { + "app.js": { + "type": "-", + "size": 3372, + "lmtime": 1771151245969, + "modified": false + } + } + }, + ".claude": { + "settings.local.json": { + "type": "-", + "size": 383, + "lmtime": 1771150401075, + "modified": false + } + }, + "composer.json": { + "type": "-", + "size": 329, + "lmtime": 1771149562456, + "modified": false + }, + "composer.lock": { + "type": "-", + "size": 38524, + "lmtime": 1771150404757, + "modified": false + }, + "composer.phar": { + "type": "-", + "size": 3288946, + "lmtime": 1771150388646, + "modified": false + }, + "composer-setup.php": { + "type": "-", + "size": 59524, + "lmtime": 1771150386867, + "modified": false + }, + "composer-temp.phar": { + "type": "-", + "size": 3288946, + "lmtime": 1771150388646, + "modified": false + }, + "config": { + "routes.php": { + "type": "-", + "size": 2162, + "lmtime": 1771151153771, + "modified": false + } + }, + "cron": { + "publish.php": { + "type": "-", + "size": 1078, + "lmtime": 1771149803282, + "modified": false + } + }, + "docs": { + "API.md": { + "type": "-", + "size": 5465, + "lmtime": 1771149511946, + "modified": false + }, + "CRON.md": { + "type": "-", + "size": 4340, + "lmtime": 1771149543293, + "modified": false + }, + "DATABASE.md": { + "type": "-", + "size": 7265, + "lmtime": 1771151285551, + "modified": false + }, + "PLAN.md": { + "type": "-", + "size": 6324, + "lmtime": 1771151257785, + "modified": false + } + }, + ".env": { + "type": "-", + "size": 267, + "lmtime": 1771150568472, + "modified": false + }, + ".env.example": { + "type": "-", + "size": 230, + "lmtime": 1771149564730, + "modified": false + }, + ".htaccess": { + "type": "-", + "size": 701, + "lmtime": 1771149568342, + "modified": false + }, + "index.php": { + "type": "-", + "size": 129, + "lmtime": 1771149647799, + "modified": false + }, + "install.php": { + "type": "-", + "size": 2372, + "lmtime": 1771151235557, + "modified": false + }, + "migrations": { + "001_initial.sql": { + "type": "-", + "size": 3130, + "lmtime": 1771150614485, + "modified": false + }, + "002_global_topics.sql": { + "type": "-", + "size": 5933, + "lmtime": 1771151084583, + "modified": false + } + }, + "src": { + "Controllers": { + "ArticleController.php": { + "type": "-", + "size": 1070, + "lmtime": 1771149717983, + "modified": false + }, + "AuthController.php": { + "type": "-", + "size": 2450, + "lmtime": 1771150758725, + "modified": false + }, + "CategoryController.php": { + "type": "-", + "size": 1345, + "lmtime": 1771149714554, + "modified": false + }, + "DashboardController.php": { + "type": "-", + "size": 858, + "lmtime": 1771149691435, + "modified": false + }, + "GlobalTopicController.php": { + "type": "-", + "size": 2791, + "lmtime": 1771151114783, + "modified": false + }, + "PublishController.php": { + "type": "-", + "size": 1160, + "lmtime": 1771149721444, + "modified": false + }, + "SettingsController.php": { + "type": "-", + "size": 1108, + "lmtime": 1771149725522, + "modified": false + }, + "SiteController.php": { + "type": "-", + "size": 5284, + "lmtime": 1771151386055, + "modified": false + }, + "TopicController.php": { + "type": "-", + "size": 3007, + "lmtime": 1771151187064, + "modified": false + } + }, + "Core": { + "App.php": { + "type": "-", + "size": 772, + "lmtime": 1771149963154, + "modified": false + }, + "Auth.php": { + "type": "-", + "size": 1712, + "lmtime": 1771149625623, + "modified": false + }, + "Config.php": { + "type": "-", + "size": 1471, + "lmtime": 1771149597340, + "modified": false + }, + "Controller.php": { + "type": "-", + "size": 808, + "lmtime": 1771149609985, + "modified": false + }, + "Database.php": { + "type": "-", + "size": 849, + "lmtime": 1771149600495, + "modified": false + }, + "Model.php": { + "type": "-", + "size": 2280, + "lmtime": 1771149617300, + "modified": false + }, + "Router.php": { + "type": "-", + "size": 2125, + "lmtime": 1771149607213, + "modified": false + }, + "View.php": { + "type": "-", + "size": 1078, + "lmtime": 1771149621361, + "modified": false + } + }, + "Helpers": { + "Logger.php": { + "type": "-", + "size": 1131, + "lmtime": 1771149676956, + "modified": false + }, + "Validator.php": { + "type": "-", + "size": 1495, + "lmtime": 1771149682031, + "modified": false + } + }, + "Models": { + "Article.php": { + "type": "-", + "size": 2641, + "lmtime": 1771149673238, + "modified": false + }, + "GlobalTopic.php": { + "type": "-", + "size": 1067, + "lmtime": 1771151102980, + "modified": false + }, + "Site.php": { + "type": "-", + "size": 856, + "lmtime": 1771149662297, + "modified": false + }, + "Topic.php": { + "type": "-", + "size": 1272, + "lmtime": 1771151194863, + "modified": false + }, + "User.php": { + "type": "-", + "size": 126, + "lmtime": 1771149659403, + "modified": false + } + }, + "Services": { + "ImageService.php": { + "type": "-", + "size": 5398, + "lmtime": 1771149772358, + "modified": false + }, + "OpenAIService.php": { + "type": "-", + "size": 3704, + "lmtime": 1771149756796, + "modified": false + }, + "TopicBalancer.php": { + "type": "-", + "size": 435, + "lmtime": 1771149775990, + "modified": false + }, + "WordPressService.php": { + "type": "-", + "size": 3832, + "lmtime": 1771149744155, + "modified": false + }, + "PublisherService.php": { + "type": "-", + "size": 4762, + "lmtime": 1771149793307, + "modified": false + } + } + }, + "storage": { + "logs": {} + }, + "templates": { + "articles": { + "index.php": { + "type": "-", + "size": 3091, + "lmtime": 1771149908879, + "modified": false + }, + "show.php": { + "type": "-", + "size": 2169, + "lmtime": 1771149917898, + "modified": false + } + }, + "auth": { + "login.php": { + "type": "-", + "size": 1223, + "lmtime": 1771149825676, + "modified": false + }, + "change-password.php": { + "type": "-", + "size": 1394, + "lmtime": 1771150766881, + "modified": false + } + }, + "categories": { + "index.php": { + "type": "-", + "size": 1908, + "lmtime": 1771149900365, + "modified": false + } + }, + "dashboard": { + "index.php": { + "type": "-", + "size": 5509, + "lmtime": 1771149840774, + "modified": false + } + }, + "layout": { + "header.php": { + "type": "-", + "size": 623, + "lmtime": 1771149821599, + "modified": false + }, + "main.php": { + "type": "-", + "size": 1764, + "lmtime": 1771149813250, + "modified": false + }, + "sidebar.php": { + "type": "-", + "size": 1645, + "lmtime": 1771151160479, + "modified": false + } + }, + "settings": { + "index.php": { + "type": "-", + "size": 4584, + "lmtime": 1771150927229, + "modified": false + } + }, + "sites": { + "create.php": { + "type": "-", + "size": 4785, + "lmtime": 1771151739488, + "modified": false + }, + "edit.php": { + "type": "-", + "size": 8662, + "lmtime": 1771151434705, + "modified": false + }, + "index.php": { + "type": "-", + "size": 4210, + "lmtime": 1771149851437, + "modified": false + } + }, + "topics": { + "index.php": { + "type": "-", + "size": 7946, + "lmtime": 1771151220487, + "modified": false + } + }, + "global-topics": { + "index.php": { + "type": "-", + "size": 10389, + "lmtime": 1771151146370, + "modified": false + } + } + }, + "vendor": { + "composer": { + "installed.json": { + "type": "-", + "size": 40002, + "lmtime": 1771150406683, + "modified": false + }, + "installed.php": { + "type": "-", + "size": 6501, + "lmtime": 1771150406694, + "modified": false + }, + "InstalledVersions.php": { + "type": "-", + "size": 17395, + "lmtime": 1771150406172, + "modified": false + }, + "tmp-2dfd2a959f1c463e83d0bf5b824a1ffe.zip": { + "type": "-", + "size": 95, + "lmtime": 1771150405319, + "modified": true + }, + "tmp-70450ac5e9a781eb1bd9ea14d04715d6.zip~": { + "type": "-", + "size": 96, + "lmtime": 1771150405410, + "modified": true + }, + "ClassLoader.php": { + "type": "-", + "size": 16378, + "lmtime": 1769683253000, + "modified": false + }, + "LICENSE": { + "type": "-", + "size": 1070, + "lmtime": 1769683253000, + "modified": false + }, + "autoload_classmap.php": { + "type": "-", + "size": 689, + "lmtime": 1771150406998, + "modified": false + }, + "autoload_files.php": { + "type": "-", + "size": 735, + "lmtime": 1771150406999, + "modified": false + }, + "autoload_namespaces.php": { + "type": "-", + "size": 139, + "lmtime": 1771150406997, + "modified": false + }, + "autoload_psr4.php": { + "type": "-", + "size": 1070, + "lmtime": 1771150406998, + "modified": false + }, + "autoload_real.php": { + "type": "-", + "size": 1672, + "lmtime": 1771150407035, + "modified": false + }, + "autoload_static.php": { + "type": "-", + "size": 4300, + "lmtime": 1771150407033, + "modified": false + }, + "platform_check.php": { + "type": "-", + "size": 917, + "lmtime": 1771150407034, + "modified": false + } + }, + "graham-campbell": { + "result-type": {} + }, + "guzzlehttp": { + "guzzle": {}, + "promises": {}, + "psr7": {} + }, + "phpoption": { + "phpoption": {} + }, + "psr": { + "http-client": {}, + "http-factory": {}, + "http-message": {} + }, + "ralouphie": { + "getallheaders": {} + }, + "symfony": { + "deprecation-contracts": {}, + "polyfill-ctype": {}, + "polyfill-mbstring": {}, + "polyfill-php80": {} + }, + "vlucas": { + "phpdotenv": {} + }, + "autoload.php": { + "type": "-", + "size": 748, + "lmtime": 1771150407034, + "modified": false + } + } + } + }, + "$version": 1 +} \ No newline at end of file diff --git a/config/routes.php b/config/routes.php index c1ba4c2..02abbda 100644 --- a/config/routes.php +++ b/config/routes.php @@ -37,14 +37,24 @@ $router->post('/topics/{id}/delete', 'TopicController', 'destroy'); // Categories (sync from WordPress) $router->get('/sites/{id}/categories', 'CategoryController', 'index'); $router->post('/sites/{id}/categories/sync', 'CategoryController', 'sync'); +$router->post('/sites/{id}/categories/create', 'CategoryController', 'create'); +$router->post('/sites/{id}/categories/from-topics', 'CategoryController', 'createFromTopics'); // Articles $router->get('/articles', 'ArticleController', 'index'); $router->get('/articles/{id}', 'ArticleController', 'show'); +$router->post('/articles/{id}/replace-image', 'ArticleController', 'replaceImage'); // Publish (manual trigger) $router->post('/publish/run', 'PublishController', 'run'); $router->post('/publish/site/{id}', 'PublishController', 'runForSite'); +$router->get('/publish/token-run', 'PublishController', 'runByToken'); +$router->post('/publish/token-run', 'PublishController', 'runByToken'); + +// Installer (Remote WordPress Installation) +$router->get('/installer', 'InstallerController', 'index'); +$router->post('/installer', 'InstallerController', 'install'); +$router->get('/installer/status/{id}', 'InstallerController', 'status'); // Settings $router->get('/settings', 'SettingsController', 'index'); diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql index 12c6aa3..425af37 100644 --- a/migrations/001_initial.sql +++ b/migrations/001_initial.sql @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS sites ( url VARCHAR(255) NOT NULL, api_user VARCHAR(100) NOT NULL, api_token VARCHAR(255) NOT NULL, - publish_interval_days INT NOT NULL DEFAULT 3, + publish_interval_hours INT NOT NULL DEFAULT 24, last_published_at DATETIME NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, is_multisite TINYINT(1) NOT NULL DEFAULT 0, @@ -79,4 +79,5 @@ INSERT INTO settings (`key`, value) VALUES ('openai_model', 'gpt-4o'), ('image_provider', 'freepik'), ('article_min_words', '800'), - ('article_max_words', '1200'); + ('article_max_words', '1200') +ON DUPLICATE KEY UPDATE value = value; diff --git a/migrations/002_global_topics.sql b/migrations/002_global_topics.sql index fcd0b3c..aba01d7 100644 --- a/migrations/002_global_topics.sql +++ b/migrations/002_global_topics.sql @@ -14,112 +14,107 @@ CREATE INDEX idx_global_topics_parent ON global_topics(parent_id); -- Link topics to global_topics library ALTER TABLE topics ADD COLUMN global_topic_id INT NULL AFTER site_id; --- Seed: top-level categories +-- Seed: top-level categories (idempotent) +INSERT INTO global_topics (parent_id, name, description) +SELECT seed.parent_id, seed.name, seed.description +FROM ( + SELECT NULL AS parent_id, 'Polityka' AS name, 'Tematy związane z polityką krajową i międzynarodową' AS description + UNION ALL SELECT NULL, 'Zdrowie', 'Zdrowie, medycyna, profilaktyka, wellness' + UNION ALL SELECT NULL, 'Sport', 'Dyscypliny sportowe, wydarzenia, treningi' + UNION ALL SELECT NULL, 'Technologia', 'IT, gadżety, sztuczna inteligencja, innowacje' + UNION ALL SELECT NULL, 'Biznes i Finanse', 'Ekonomia, inwestycje, przedsiębiorczość' + UNION ALL SELECT NULL, 'Rozrywka', 'Film, muzyka, gry, kultura popularna' + UNION ALL SELECT NULL, 'Nauka', 'Odkrycia naukowe, kosmos, badania' + UNION ALL SELECT NULL, 'Edukacja', 'Szkolnictwo, kursy, rozwój osobisty' + UNION ALL SELECT NULL, 'Podróże', 'Turystyka, przewodniki, inspiracje podróżnicze' + UNION ALL SELECT NULL, 'Motoryzacja', 'Samochody, motocykle, nowości motoryzacyjne' + UNION ALL SELECT NULL, 'Dom i Ogród', 'Aranżacja wnętrz, ogrodnictwo, DIY' + UNION ALL SELECT NULL, 'Kuchnia', 'Przepisy, diety, kuchnie świata' + UNION ALL SELECT NULL, 'Moda i Uroda', 'Trendy, pielęgnacja, kosmetyki' + UNION ALL SELECT NULL, 'Prawo', 'Porady prawne, zmiany w przepisach' +) AS seed +WHERE NOT EXISTS ( + SELECT 1 + FROM global_topics gt + WHERE gt.parent_id <=> seed.parent_id + AND gt.name = seed.name +); -INSERT INTO global_topics (id, parent_id, name, description) VALUES -(1, NULL, 'Polityka', 'Tematy związane z polityką krajową i międzynarodową'), -(2, NULL, 'Zdrowie', 'Zdrowie, medycyna, profilaktyka, wellness'), -(3, NULL, 'Sport', 'Dyscypliny sportowe, wydarzenia, treningi'), -(4, NULL, 'Technologia', 'IT, gadżety, sztuczna inteligencja, innowacje'), -(5, NULL, 'Biznes i Finanse', 'Ekonomia, inwestycje, przedsiębiorczość'), -(6, NULL, 'Rozrywka', 'Film, muzyka, gry, kultura popularna'), -(7, NULL, 'Nauka', 'Odkrycia naukowe, kosmos, badania'), -(8, NULL, 'Edukacja', 'Szkolnictwo, kursy, rozwój osobisty'), -(9, NULL, 'Podróże', 'Turystyka, przewodniki, inspiracje podróżnicze'), -(10, NULL, 'Motoryzacja', 'Samochody, motocykle, nowości motoryzacyjne'), -(11, NULL, 'Dom i Ogród', 'Aranżacja wnętrz, ogrodnictwo, DIY'), -(12, NULL, 'Kuchnia', 'Przepisy, diety, kuchnie świata'), -(13, NULL, 'Moda i Uroda', 'Trendy, pielęgnacja, kosmetyki'), -(14, NULL, 'Prawo', 'Porady prawne, zmiany w przepisach'); +-- Seed: subtopics (idempotent) +INSERT INTO global_topics (parent_id, name, description) +SELECT parent.id, seed.name, seed.description +FROM ( + SELECT 'Polityka' AS parent_name, 'Polityka krajowa' AS name, 'Sejm, rząd, partie polityczne, wybory w Polsce' AS description + UNION ALL SELECT 'Polityka', 'Polityka międzynarodowa', 'Dyplomacja, konflikty, organizacje międzynarodowe' + UNION ALL SELECT 'Polityka', 'Unia Europejska', 'Polityka UE, fundusze europejskie, regulacje' --- Seed: subtopics + UNION ALL SELECT 'Zdrowie', 'Medycyna', 'Choroby, leczenie, nowinki medyczne' + UNION ALL SELECT 'Zdrowie', 'Zdrowy styl życia', 'Dieta, aktywność fizyczna, profilaktyka' + UNION ALL SELECT 'Zdrowie', 'Zdrowie psychiczne', 'Psychologia, stres, mindfulness, terapia' + UNION ALL SELECT 'Zdrowie', 'Suplementy i żywienie', 'Witaminy, suplementy diety, superfoods' --- Polityka -INSERT INTO global_topics (parent_id, name, description) VALUES -(1, 'Polityka krajowa', 'Sejm, rząd, partie polityczne, wybory w Polsce'), -(1, 'Polityka międzynarodowa', 'Dyplomacja, konflikty, organizacje międzynarodowe'), -(1, 'Unia Europejska', 'Polityka UE, fundusze europejskie, regulacje'); + UNION ALL SELECT 'Sport', 'Piłka nożna', 'Liga, transfery, reprezentacja, piłka nożna na świecie' + UNION ALL SELECT 'Sport', 'Koszykówka', 'NBA, polska liga, Euroliga' + UNION ALL SELECT 'Sport', 'Siatkówka', 'PlusLiga, reprezentacja Polski, siatkówka plażowa' + UNION ALL SELECT 'Sport', 'Sporty walki', 'MMA, boks, judo, karate' + UNION ALL SELECT 'Sport', 'Fitness i siłownia', 'Treningi, plany treningowe, ćwiczenia' + UNION ALL SELECT 'Sport', 'Bieganie', 'Maratony, technika biegu, sprzęt biegowy' --- Zdrowie -INSERT INTO global_topics (parent_id, name, description) VALUES -(2, 'Medycyna', 'Choroby, leczenie, nowinki medyczne'), -(2, 'Zdrowy styl życia', 'Dieta, aktywność fizyczna, profilaktyka'), -(2, 'Zdrowie psychiczne', 'Psychologia, stres, mindfulness, terapia'), -(2, 'Suplementy i żywienie', 'Witaminy, suplementy diety, superfoods'); + UNION ALL SELECT 'Technologia', 'Sztuczna inteligencja', 'AI, machine learning, ChatGPT, automatyzacja' + UNION ALL SELECT 'Technologia', 'Smartfony i gadżety', 'Recenzje, nowości, porównania urządzeń' + UNION ALL SELECT 'Technologia', 'Programowanie', 'Języki programowania, frameworki, poradniki' + UNION ALL SELECT 'Technologia', 'Cyberbezpieczeństwo', 'Ochrona danych, hakerzy, prywatność w sieci' + UNION ALL SELECT 'Technologia', 'Gaming', 'Gry komputerowe, konsole, esport' --- Sport -INSERT INTO global_topics (parent_id, name, description) VALUES -(3, 'Piłka nożna', 'Liga, transfery, reprezentacja, piłka nożna na świecie'), -(3, 'Koszykówka', 'NBA, polska liga, Euroliga'), -(3, 'Siatkówka', 'PlusLiga, reprezentacja Polski, siatkówka plażowa'), -(3, 'Sporty walki', 'MMA, boks, judo, karate'), -(3, 'Fitness i siłownia', 'Treningi, plany treningowe, ćwiczenia'), -(3, 'Bieganie', 'Maratony, technika biegu, sprzęt biegowy'); + UNION ALL SELECT 'Biznes i Finanse', 'Inwestycje', 'Giełda, kryptowaluty, nieruchomości, fundusze' + UNION ALL SELECT 'Biznes i Finanse', 'Przedsiębiorczość', 'Zakładanie firmy, startupy, zarządzanie' + UNION ALL SELECT 'Biznes i Finanse', 'Oszczędzanie', 'Finanse osobiste, budżet domowy, porady' + UNION ALL SELECT 'Biznes i Finanse', 'Marketing', 'Marketing cyfrowy, SEO, social media, reklama' --- Technologia -INSERT INTO global_topics (parent_id, name, description) VALUES -(4, 'Sztuczna inteligencja', 'AI, machine learning, ChatGPT, automatyzacja'), -(4, 'Smartfony i gadżety', 'Recenzje, nowości, porównania urządzeń'), -(4, 'Programowanie', 'Języki programowania, frameworki, poradniki'), -(4, 'Cyberbezpieczeństwo', 'Ochrona danych, hakerzy, prywatność w sieci'), -(4, 'Gaming', 'Gry komputerowe, konsole, esport'); + UNION ALL SELECT 'Rozrywka', 'Film i seriale', 'Recenzje, premiery, Netflix, kino' + UNION ALL SELECT 'Rozrywka', 'Muzyka', 'Artyści, festiwale, nowości muzyczne' + UNION ALL SELECT 'Rozrywka', 'Książki', 'Recenzje książek, bestsellery, literatura' --- Biznes i Finanse -INSERT INTO global_topics (parent_id, name, description) VALUES -(5, 'Inwestycje', 'Giełda, kryptowaluty, nieruchomości, fundusze'), -(5, 'Przedsiębiorczość', 'Zakładanie firmy, startupy, zarządzanie'), -(5, 'Oszczędzanie', 'Finanse osobiste, budżet domowy, porady'), -(5, 'Marketing', 'Marketing cyfrowy, SEO, social media, reklama'); + UNION ALL SELECT 'Nauka', 'Kosmos', 'NASA, SpaceX, odkrycia astronomiczne' + UNION ALL SELECT 'Nauka', 'Ekologia', 'Zmiany klimatu, recykling, energia odnawialna' + UNION ALL SELECT 'Nauka', 'Historia', 'Ciekawostki historyczne, ważne wydarzenia' --- Rozrywka -INSERT INTO global_topics (parent_id, name, description) VALUES -(6, 'Film i seriale', 'Recenzje, premiery, Netflix, kino'), -(6, 'Muzyka', 'Artyści, festiwale, nowości muzyczne'), -(6, 'Książki', 'Recenzje książek, bestsellery, literatura'); + UNION ALL SELECT 'Edukacja', 'Rozwój osobisty', 'Produktywność, nawyki, motywacja' + UNION ALL SELECT 'Edukacja', 'Języki obce', 'Nauka angielskiego, techniki zapamiętywania' --- Nauka -INSERT INTO global_topics (parent_id, name, description) VALUES -(7, 'Kosmos', 'NASA, SpaceX, odkrycia astronomiczne'), -(7, 'Ekologia', 'Zmiany klimatu, recykling, energia odnawialna'), -(7, 'Historia', 'Ciekawostki historyczne, ważne wydarzenia'); + UNION ALL SELECT 'Podróże', 'Podróże po Polsce', 'Atrakcje turystyczne, szlaki, regiony' + UNION ALL SELECT 'Podróże', 'Podróże zagraniczne', 'Egzotyczne kierunki, tanie loty, porady' + UNION ALL SELECT 'Podróże', 'Camping i outdoor', 'Turystyka górska, biwakowanie, sprzęt' --- Edukacja -INSERT INTO global_topics (parent_id, name, description) VALUES -(8, 'Rozwój osobisty', 'Produktywność, nawyki, motywacja'), -(8, 'Języki obce', 'Nauka angielskiego, techniki zapamiętywania'); + UNION ALL SELECT 'Motoryzacja', 'Samochody elektryczne', 'Tesla, EV, ładowarki, przyszłość elektromobilności' + UNION ALL SELECT 'Motoryzacja', 'Testy i recenzje aut', 'Porównania, testy drogowe, nowości' + UNION ALL SELECT 'Motoryzacja', 'Porady kierowców', 'Ubezpieczenia, prawo jazdy, serwis' --- Podróże -INSERT INTO global_topics (parent_id, name, description) VALUES -(9, 'Podróże po Polsce', 'Atrakcje turystyczne, szlaki, regiony'), -(9, 'Podróże zagraniczne', 'Egzotyczne kierunki, tanie loty, porady'), -(9, 'Camping i outdoor', 'Turystyka górska, biwakowanie, sprzęt'); + UNION ALL SELECT 'Dom i Ogród', 'Aranżacja wnętrz', 'Design, meble, dekoracje, trendy' + UNION ALL SELECT 'Dom i Ogród', 'Ogrodnictwo', 'Rośliny, uprawy, pielęgnacja ogrodu' + UNION ALL SELECT 'Dom i Ogród', 'Remonty i DIY', 'Majsterkowanie, poradniki remontowe' --- Motoryzacja -INSERT INTO global_topics (parent_id, name, description) VALUES -(10, 'Samochody elektryczne', 'Tesla, EV, ładowarki, przyszłość elektromobilności'), -(10, 'Testy i recenzje aut', 'Porównania, testy drogowe, nowości'), -(10, 'Porady kierowców', 'Ubezpieczenia, prawo jazdy, serwis'); + UNION ALL SELECT 'Kuchnia', 'Przepisy', 'Obiady, desery, dania na szybko' + UNION ALL SELECT 'Kuchnia', 'Diety', 'Keto, weganizm, intermittent fasting' + UNION ALL SELECT 'Kuchnia', 'Kuchnie świata', 'Włoska, azjatycka, meksykańska' --- Dom i Ogród -INSERT INTO global_topics (parent_id, name, description) VALUES -(11, 'Aranżacja wnętrz', 'Design, meble, dekoracje, trendy'), -(11, 'Ogrodnictwo', 'Rośliny, uprawy, pielęgnacja ogrodu'), -(11, 'Remonty i DIY', 'Majsterkowanie, poradniki remontowe'); + UNION ALL SELECT 'Moda i Uroda', 'Moda damska', 'Trendy, stylizacje, inspiracje' + UNION ALL SELECT 'Moda i Uroda', 'Moda męska', 'Garnitury, casual, street style' + UNION ALL SELECT 'Moda i Uroda', 'Pielęgnacja', 'Kosmetyki, zabiegi, skincare' --- Kuchnia -INSERT INTO global_topics (parent_id, name, description) VALUES -(12, 'Przepisy', 'Obiady, desery, dania na szybko'), -(12, 'Diety', 'Keto, weganizm, intermittent fasting'), -(12, 'Kuchnie świata', 'Włoska, azjatycka, meksykańska'); - --- Moda i Uroda -INSERT INTO global_topics (parent_id, name, description) VALUES -(13, 'Moda damska', 'Trendy, stylizacje, inspiracje'), -(13, 'Moda męska', 'Garnitury, casual, street style'), -(13, 'Pielęgnacja', 'Kosmetyki, zabiegi, skincare'); - --- Prawo -INSERT INTO global_topics (parent_id, name, description) VALUES -(14, 'Prawo pracy', 'Umowy, zwolnienia, prawa pracownika'), -(14, 'Prawo cywilne', 'Umowy, spadki, nieruchomości'), -(14, 'Prawo gospodarcze', 'Działalność gospodarcza, podatki, ZUS'); + UNION ALL SELECT 'Prawo', 'Prawo pracy', 'Umowy, zwolnienia, prawa pracownika' + UNION ALL SELECT 'Prawo', 'Prawo cywilne', 'Umowy, spadki, nieruchomości' + UNION ALL SELECT 'Prawo', 'Prawo gospodarcze', 'Działalność gospodarcza, podatki, ZUS' +) AS seed +INNER JOIN ( + SELECT name, MIN(id) AS id + FROM global_topics + WHERE parent_id IS NULL + GROUP BY name +) AS parent ON parent.name = seed.parent_name +WHERE NOT EXISTS ( + SELECT 1 + FROM global_topics gt + WHERE gt.parent_id = parent.id + AND gt.name = seed.name +); diff --git a/migrations/003_site_credentials.sql b/migrations/003_site_credentials.sql new file mode 100644 index 0000000..a7ebbbd --- /dev/null +++ b/migrations/003_site_credentials.sql @@ -0,0 +1,18 @@ +-- Site credentials (FTP, database, WP admin) +-- Saved during remote WordPress installation for future reference + +ALTER TABLE sites ADD COLUMN ftp_host VARCHAR(255) NULL AFTER is_multisite; +ALTER TABLE sites ADD COLUMN ftp_port INT NULL DEFAULT 21 AFTER ftp_host; +ALTER TABLE sites ADD COLUMN ftp_user VARCHAR(255) NULL AFTER ftp_port; +ALTER TABLE sites ADD COLUMN ftp_pass VARCHAR(255) NULL AFTER ftp_user; +ALTER TABLE sites ADD COLUMN ftp_path VARCHAR(255) NULL AFTER ftp_pass; + +ALTER TABLE sites ADD COLUMN db_host VARCHAR(255) NULL AFTER ftp_path; +ALTER TABLE sites ADD COLUMN db_name VARCHAR(255) NULL AFTER db_host; +ALTER TABLE sites ADD COLUMN db_user VARCHAR(255) NULL AFTER db_name; +ALTER TABLE sites ADD COLUMN db_pass VARCHAR(255) NULL AFTER db_user; +ALTER TABLE sites ADD COLUMN db_prefix VARCHAR(50) NULL DEFAULT 'wp_' AFTER db_pass; + +ALTER TABLE sites ADD COLUMN wp_admin_user VARCHAR(255) NULL AFTER db_prefix; +ALTER TABLE sites ADD COLUMN wp_admin_pass VARCHAR(255) NULL AFTER wp_admin_user; +ALTER TABLE sites ADD COLUMN wp_admin_email VARCHAR(255) NULL AFTER wp_admin_pass; diff --git a/migrations/004_prompt_templates.sql b/migrations/004_prompt_templates.sql new file mode 100644 index 0000000..e093735 --- /dev/null +++ b/migrations/004_prompt_templates.sql @@ -0,0 +1,5 @@ +-- Add prompt templates for article and image generation +INSERT INTO settings (`key`, value) VALUES + ('article_generation_prompt', 'Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, optymalizowane pod SEO. Artykuł powinien mieć {min_words}-{max_words} słów, zawierać nagłówki H2 i H3, być angażujący i merytoryczny. Formatuj treść w HTML (bez tagów , , ). Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {"title": "tytuł artykułu", "content": "treść HTML artykułu"}'), + ('image_generation_prompt', 'Professional blog header image about {topic_name}: {article_title}, high quality, photorealistic') +ON DUPLICATE KEY UPDATE value = value; diff --git a/migrations/005_publish_interval_hours.sql b/migrations/005_publish_interval_hours.sql new file mode 100644 index 0000000..3f10938 --- /dev/null +++ b/migrations/005_publish_interval_hours.sql @@ -0,0 +1,21 @@ +-- Publish interval migration: days -> hours + +ALTER TABLE sites ADD COLUMN publish_interval_hours INT NOT NULL DEFAULT 24 AFTER api_token; + +SET @has_old_interval_days := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sites' + AND COLUMN_NAME = 'publish_interval_days' +); + +SET @backpro_sql := IF( + @has_old_interval_days > 0, + 'UPDATE sites SET publish_interval_hours = CASE WHEN publish_interval_days IS NULL OR publish_interval_days < 1 THEN 24 ELSE publish_interval_days * 24 END', + 'SELECT 1' +); + +PREPARE backpro_stmt FROM @backpro_sql; +EXECUTE backpro_stmt; +DEALLOCATE PREPARE backpro_stmt; diff --git a/src/Controllers/ArticleController.php b/src/Controllers/ArticleController.php index 8565ff0..45b643d 100644 --- a/src/Controllers/ArticleController.php +++ b/src/Controllers/ArticleController.php @@ -5,6 +5,8 @@ namespace App\Controllers; use App\Core\Auth; use App\Core\Controller; use App\Models\Article; +use App\Services\WordPressService; +use App\Helpers\Logger; class ArticleController extends Controller { @@ -41,4 +43,77 @@ class ArticleController extends Controller $this->view('articles/show', ['article' => $article]); } + + public function replaceImage(string $id): void + { + Auth::requireLogin(); + + $article = Article::findWithRelations((int) $id); + if (!$article) { + $this->json(['success' => false, 'message' => 'Artykuł nie znaleziony.']); + return; + } + + if (empty($article['wp_post_id'])) { + $this->json(['success' => false, 'message' => 'Artykuł nie jest opublikowany w WordPress.']); + return; + } + + $site = [ + 'url' => $article['site_url'], + 'api_user' => $article['site_api_user'], + 'api_token' => $article['site_api_token'], + ]; + + // Validate uploaded file + if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { + $this->json(['success' => false, 'message' => 'Nie przesłano pliku lub wystąpił błąd uploadu.']); + return; + } + + $file = $_FILES['image']; + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mimeType, $allowedTypes)) { + $this->json(['success' => false, 'message' => 'Niedozwolony format pliku. Dozwolone: JPG, PNG, GIF, WebP.']); + return; + } + + $imageData = file_get_contents($file['tmp_name']); + $filename = $file['name'] ?: ('article-' . time() . '.jpg'); + + $wp = new WordPressService(); + + // 1. Delete old featured image from WP + $oldMediaId = $wp->getPostFeaturedMedia($site, $article['wp_post_id']); + if ($oldMediaId) { + $wp->deleteMedia($site, $oldMediaId); + Logger::info("Deleted old media ID: {$oldMediaId} for article {$id}", 'image'); + } + + // 2. Upload new image to WP + $newMediaId = $wp->uploadMedia($site, $imageData, $filename); + if (!$newMediaId) { + $this->json(['success' => false, 'message' => 'Nie udało się wgrać zdjęcia do WordPress.']); + return; + } + + // 3. Set as featured image on the post + $updated = $wp->updatePostFeaturedMedia($site, $article['wp_post_id'], $newMediaId); + if (!$updated) { + $this->json(['success' => false, 'message' => 'Nie udało się ustawić zdjęcia jako wyróżniającego.']); + return; + } + + Logger::info("Replaced image for article {$id}: old={$oldMediaId}, new={$newMediaId}", 'image'); + + $this->json([ + 'success' => true, + 'message' => 'Zdjęcie zostało podmienione.', + 'media_id' => $newMediaId, + ]); + } } diff --git a/src/Controllers/CategoryController.php b/src/Controllers/CategoryController.php index 5d2a564..a390882 100644 --- a/src/Controllers/CategoryController.php +++ b/src/Controllers/CategoryController.php @@ -5,6 +5,7 @@ namespace App\Controllers; use App\Core\Auth; use App\Core\Controller; use App\Models\Site; +use App\Models\Topic; use App\Services\WordPressService; class CategoryController extends Controller @@ -23,9 +24,19 @@ class CategoryController extends Controller $wp = new WordPressService(); $categories = $wp->getCategories($site); + $topics = Topic::findBySiteWithGlobal((int) $id); + $hasTopicsWithoutCategory = false; + foreach ($topics as $t) { + if (!empty($t['global_topic_id']) && empty($t['wp_category_id'])) { + $hasTopicsWithoutCategory = true; + break; + } + } + $this->view('categories/index', [ 'site' => $site, 'categories' => $categories, + 'hasTopicsWithoutCategory' => $hasTopicsWithoutCategory, ]); } @@ -51,4 +62,157 @@ class CategoryController extends Controller $this->redirect("/sites/{$id}/categories"); } + + public function create(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->json(['success' => false, 'message' => 'Strona nie znaleziona.']); + return; + } + + $name = trim($this->input('name', '')); + if ($name === '') { + $this->json(['success' => false, 'message' => 'Nazwa kategorii jest wymagana.']); + return; + } + + $parent = (int) $this->input('parent', 0); + + $wp = new WordPressService(); + $result = $wp->createCategory($site, $name, $parent); + + if (!$result || !isset($result['id'])) { + $this->json(['success' => false, 'message' => 'Nie udało się utworzyć kategorii w WordPress.']); + return; + } + + $this->json([ + 'success' => true, + 'message' => "Utworzono kategorię \"{$result['name']}\" (ID: {$result['id']})", + 'category' => [ + 'id' => $result['id'], + 'name' => $result['name'], + 'slug' => $result['slug'], + 'parent' => $result['parent'], + ], + ]); + } + + public function createFromTopics(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->json(['success' => false, 'message' => 'Strona nie znaleziona.']); + return; + } + + $wp = new WordPressService(); + + // Get existing WP categories + $existingCategories = $wp->getCategories($site); + if ($existingCategories === false) { + $this->json(['success' => false, 'message' => 'Nie udało się pobrać kategorii z WordPress.']); + return; + } + + // Index existing by name (lowercase) for duplicate detection + $existingByName = []; + foreach ($existingCategories as $cat) { + $existingByName[mb_strtolower($cat['name'])] = $cat; + } + + // Get site topics with global topic info + $topics = Topic::findBySiteWithGlobal((int) $id); + + // Group topics by parent category + $groups = []; + foreach ($topics as $topic) { + if (empty($topic['global_topic_id'])) { + continue; // skip custom topics without global link + } + $parentName = $topic['global_category_name'] ?? null; + $childName = $topic['global_topic_name'] ?? $topic['name']; + + if (!$parentName) { + // Topic is linked to a top-level global topic (it IS a category, not a child) + // Create as standalone category + $parentName = $childName; + $childName = null; + } + + if (!isset($groups[$parentName])) { + $groups[$parentName] = []; + } + if ($childName) { + $groups[$parentName][] = [ + 'topic_id' => $topic['id'], + 'name' => $childName, + ]; + } + } + + $created = 0; + $skipped = 0; + $assigned = 0; + $errors = []; + + foreach ($groups as $parentName => $children) { + // Find or create parent category in WP + $parentKey = mb_strtolower($parentName); + if (isset($existingByName[$parentKey])) { + $parentWpId = $existingByName[$parentKey]['id']; + $skipped++; + } else { + $parentResult = $wp->createCategory($site, $parentName, 0); + if (!$parentResult || !isset($parentResult['id'])) { + $errors[] = "Nie udało się utworzyć kategorii \"{$parentName}\""; + continue; + } + $parentWpId = $parentResult['id']; + $existingByName[$parentKey] = $parentResult; + $created++; + } + + // Create child categories + foreach ($children as $child) { + $childKey = mb_strtolower($child['name']); + if (isset($existingByName[$childKey])) { + $childWpId = $existingByName[$childKey]['id']; + $skipped++; + } else { + $childResult = $wp->createCategory($site, $child['name'], $parentWpId); + if (!$childResult || !isset($childResult['id'])) { + $errors[] = "Nie udało się utworzyć podkategorii \"{$child['name']}\""; + continue; + } + $childWpId = $childResult['id']; + $existingByName[$childKey] = $childResult; + $created++; + } + + // Assign wp_category_id to the topic + Topic::update($child['topic_id'], ['wp_category_id' => $childWpId]); + $assigned++; + } + } + + $message = "Utworzono {$created} kategorii, pominięto {$skipped} istniejących, przypisano {$assigned} tematów."; + if (!empty($errors)) { + $message .= ' Błędy: ' . implode('; ', $errors); + } + + $this->json([ + 'success' => true, + 'message' => $message, + 'created' => $created, + 'skipped' => $skipped, + 'assigned' => $assigned, + 'errors' => $errors, + ]); + } } diff --git a/src/Controllers/GlobalTopicController.php b/src/Controllers/GlobalTopicController.php index f43d28b..1df6462 100644 --- a/src/Controllers/GlobalTopicController.php +++ b/src/Controllers/GlobalTopicController.php @@ -95,8 +95,16 @@ class GlobalTopicController extends Controller { Auth::requireLogin(); + $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; + GlobalTopic::delete((int) $id); + if ($isAjax) { + $this->json(['success' => true, 'message' => 'Usunięto.']); + return; + } + $this->flash('success', 'Temat został usunięty.'); $this->redirect('/global-topics'); } diff --git a/src/Controllers/InstallerController.php b/src/Controllers/InstallerController.php new file mode 100644 index 0000000..0cf9b38 --- /dev/null +++ b/src/Controllers/InstallerController.php @@ -0,0 +1,109 @@ +view('installer/index', []); + } + + public function install(): void + { + Auth::requireLogin(); + + $validator = new Validator(); + + // FTP + $validator + ->required('ftp_host', $this->input('ftp_host'), 'Host FTP') + ->required('ftp_user', $this->input('ftp_user'), 'Użytkownik FTP') + ->required('ftp_pass', $this->input('ftp_pass'), 'Hasło FTP') + ->required('ftp_path', $this->input('ftp_path'), 'Ścieżka FTP'); + + // Database + $validator + ->required('db_host', $this->input('db_host'), 'Host bazy danych') + ->required('db_name', $this->input('db_name'), 'Nazwa bazy danych') + ->required('db_user', $this->input('db_user'), 'Użytkownik bazy danych') + ->required('db_pass', $this->input('db_pass'), 'Hasło bazy danych'); + + // WordPress admin + $validator + ->required('site_url', $this->input('site_url'), 'URL strony') + ->url('site_url', $this->input('site_url'), 'URL strony') + ->required('site_title', $this->input('site_title'), 'Tytuł strony') + ->required('admin_user', $this->input('admin_user'), 'Login administratora') + ->required('admin_pass', $this->input('admin_pass'), 'Hasło administratora') + ->minLength('admin_pass', $this->input('admin_pass'), 8, 'Hasło administratora') + ->required('admin_email', $this->input('admin_email'), 'E-mail administratora') + ->email('admin_email', $this->input('admin_email'), 'E-mail administratora'); + + if (!$validator->isValid()) { + $this->json(['success' => false, 'message' => $validator->getFirstError()]); + return; + } + + $progressId = $this->input('progress_id', ''); + if (empty($progressId) || !preg_match('/^[a-zA-Z0-9]{10,30}$/', $progressId)) { + $this->json(['success' => false, 'message' => 'Nieprawidłowy identyfikator postępu.']); + return; + } + + $config = [ + 'ftp_host' => $this->input('ftp_host'), + 'ftp_user' => $this->input('ftp_user'), + 'ftp_pass' => $this->input('ftp_pass'), + 'ftp_path' => rtrim($this->input('ftp_path'), '/'), + 'ftp_port' => (int) ($this->input('ftp_port', '21')), + 'ftp_ssl' => (bool) $this->input('ftp_ssl'), + 'db_host' => $this->input('db_host'), + 'db_name' => $this->input('db_name'), + 'db_user' => $this->input('db_user'), + 'db_pass' => $this->input('db_pass'), + 'db_prefix' => $this->input('db_prefix', 'wp_'), + 'site_url' => rtrim($this->input('site_url'), '/'), + 'site_title' => $this->input('site_title'), + 'admin_user' => $this->input('admin_user'), + 'admin_pass' => $this->input('admin_pass'), + 'admin_email' => $this->input('admin_email'), + 'language' => $this->input('language', 'pl_PL'), + ]; + + $installer = new InstallerService(); + $result = $installer->install($config, $progressId); + + // Cleanup progress file after completion + InstallerService::cleanupProgress($progressId); + + $this->json($result); + } + + public function status(string $id): void + { + Auth::requireLogin(); + + if (!preg_match('/^[a-zA-Z0-9]{10,30}$/', $id)) { + $this->json(['percent' => 0, 'message' => 'Nieprawidłowy ID', 'status' => 'failed']); + return; + } + + $progress = InstallerService::getProgress($id); + + if ($progress === null) { + $this->json(['percent' => 0, 'message' => 'Oczekiwanie na start...', 'status' => 'waiting']); + return; + } + + $this->json($progress); + } +} diff --git a/src/Controllers/PublishController.php b/src/Controllers/PublishController.php index cbd7c87..7ef9e37 100644 --- a/src/Controllers/PublishController.php +++ b/src/Controllers/PublishController.php @@ -3,12 +3,42 @@ namespace App\Controllers; use App\Core\Auth; +use App\Core\Config; use App\Core\Controller; +use App\Helpers\Logger; use App\Models\Site; use App\Services\PublisherService; class PublishController extends Controller { + public function runByToken(): void + { + $configuredToken = (string) Config::get('PUBLISH_TRIGGER_TOKEN', ''); + $providedToken = (string) $this->input('token', ''); + + if ($providedToken === '') { + $providedToken = (string) ($_SERVER['HTTP_X_PUBLISH_TOKEN'] ?? ''); + } + + if ($configuredToken === '') { + Logger::warning('Token publish trigger called, but PUBLISH_TRIGGER_TOKEN is not configured.', 'publish'); + $this->json(['success' => false, 'message' => 'Token trigger is disabled.'], 503); + return; + } + + if ($providedToken === '' || !hash_equals($configuredToken, $providedToken)) { + $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); + Logger::warning("Invalid publish token attempt from {$ip}", 'publish'); + $this->json(['success' => false, 'message' => 'Forbidden'], 403); + return; + } + + $publisher = new PublisherService(); + $result = $publisher->publishNext(); + + $this->json($result, 200); + } + public function run(): void { Auth::requireLogin(); @@ -29,8 +59,15 @@ class PublishController extends Controller { Auth::requireLogin(); + $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; + $site = Site::find((int) $id); if (!$site) { + if ($isAjax) { + $this->json(['success' => false, 'message' => 'Strona nie znaleziona.']); + return; + } $this->flash('danger', 'Strona nie znaleziona.'); $this->redirect('/sites'); return; @@ -39,6 +76,11 @@ class PublishController extends Controller $publisher = new PublisherService(); $result = $publisher->publishForSite($site); + if ($isAjax) { + $this->json($result); + return; + } + if ($result['success']) { $this->flash('success', $result['message']); } else { diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index df18754..e58678b 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -5,6 +5,8 @@ namespace App\Controllers; use App\Core\Auth; use App\Core\Config; use App\Core\Controller; +use App\Services\ImageService; +use App\Services\OpenAIService; class SettingsController extends Controller { @@ -17,6 +19,17 @@ class SettingsController extends Controller 'image_provider', 'article_min_words', 'article_max_words', + 'article_generation_prompt', + 'image_generation_prompt', + ]; + + private array $settingDefaults = [ + 'openai_model' => 'gpt-4o', + 'image_provider' => 'freepik', + 'article_min_words' => '800', + 'article_max_words' => '1200', + 'article_generation_prompt' => OpenAIService::DEFAULT_ARTICLE_PROMPT_TEMPLATE, + 'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE, ]; public function index(): void @@ -25,7 +38,7 @@ class SettingsController extends Controller $settings = []; foreach ($this->settingKeys as $key) { - $settings[$key] = Config::getDbSetting($key, ''); + $settings[$key] = Config::getDbSetting($key, $this->settingDefaults[$key] ?? ''); } $this->view('settings/index', ['settings' => $settings]); diff --git a/src/Controllers/SiteController.php b/src/Controllers/SiteController.php index a216116..c09cb71 100644 --- a/src/Controllers/SiteController.php +++ b/src/Controllers/SiteController.php @@ -56,7 +56,7 @@ class SiteController extends Controller 'url' => rtrim($this->input('url'), '/'), 'api_user' => $this->input('api_user'), 'api_token' => $this->input('api_token'), - 'publish_interval_days' => (int) ($this->input('publish_interval_days', 3)), + 'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)), 'is_active' => $this->input('is_active') ? 1 : 0, 'is_multisite' => $this->input('is_multisite') ? 1 : 0, ]); @@ -128,9 +128,22 @@ class SiteController extends Controller 'url' => rtrim($this->input('url'), '/'), 'api_user' => $this->input('api_user'), 'api_token' => $this->input('api_token'), - 'publish_interval_days' => (int) ($this->input('publish_interval_days', 3)), + 'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)), 'is_active' => $this->input('is_active') ? 1 : 0, 'is_multisite' => $this->input('is_multisite') ? 1 : 0, + 'ftp_host' => $this->input('ftp_host') ?: null, + 'ftp_port' => $this->input('ftp_port') ? (int) $this->input('ftp_port') : null, + 'ftp_user' => $this->input('ftp_user') ?: null, + 'ftp_pass' => $this->input('ftp_pass') ?: null, + 'ftp_path' => $this->input('ftp_path') ?: null, + 'db_host' => $this->input('db_host') ?: null, + 'db_name' => $this->input('db_name') ?: null, + 'db_user' => $this->input('db_user') ?: null, + 'db_pass' => $this->input('db_pass') ?: null, + 'db_prefix' => $this->input('db_prefix') ?: null, + 'wp_admin_user' => $this->input('wp_admin_user') ?: null, + 'wp_admin_pass' => $this->input('wp_admin_pass') ?: null, + 'wp_admin_email' => $this->input('wp_admin_email') ?: null, ]); $this->flash('success', 'Strona została zaktualizowana.'); diff --git a/src/Controllers/TopicController.php b/src/Controllers/TopicController.php index f999431..507b74f 100644 --- a/src/Controllers/TopicController.php +++ b/src/Controllers/TopicController.php @@ -81,8 +81,15 @@ class TopicController extends Controller { Auth::requireLogin(); + $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; + $topic = Topic::find((int) $id); if (!$topic) { + if ($isAjax) { + $this->json(['success' => false, 'message' => 'Temat nie znaleziony.']); + return; + } $this->flash('danger', 'Temat nie znaleziony.'); $this->redirect('/sites'); return; @@ -91,6 +98,11 @@ class TopicController extends Controller $siteId = $topic['site_id']; Topic::delete((int) $id); + if ($isAjax) { + $this->json(['success' => true, 'message' => 'Temat został usunięty.']); + return; + } + $this->flash('success', 'Temat został usunięty.'); $this->redirect("/sites/{$siteId}/topics"); } diff --git a/src/Helpers/Validator.php b/src/Helpers/Validator.php index 7f825c6..cb8628e 100644 --- a/src/Helpers/Validator.php +++ b/src/Helpers/Validator.php @@ -38,6 +38,14 @@ class Validator return $this; } + public function email(string $field, mixed $value, string $label = ''): self + { + if (!empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + $this->errors[$field] = ($label ?: $field) . ' musi być prawidłowym adresem e-mail.'; + } + return $this; + } + public function isValid(): bool { return empty($this->errors); diff --git a/src/Models/Article.php b/src/Models/Article.php index 3f6701f..85491f4 100644 --- a/src/Models/Article.php +++ b/src/Models/Article.php @@ -42,7 +42,8 @@ class Article extends Model public static function findWithRelations(int $id): ?array { $stmt = self::db()->prepare( - "SELECT a.*, t.name as topic_name, s.name as site_name, s.url as site_url + "SELECT a.*, t.name as topic_name, s.name as site_name, s.url as site_url, + s.api_user as site_api_user, s.api_token as site_api_token FROM articles a JOIN topics t ON a.topic_id = t.id JOIN sites s ON a.site_id = s.id diff --git a/src/Models/Site.php b/src/Models/Site.php index dda0302..009fc7e 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -19,7 +19,7 @@ class Site extends Model WHERE is_active = 1 AND ( last_published_at IS NULL - OR DATE_ADD(last_published_at, INTERVAL publish_interval_days DAY) <= NOW() + OR DATE_ADD(last_published_at, INTERVAL publish_interval_hours HOUR) <= NOW() ) ORDER BY last_published_at ASC"; $stmt = self::db()->query($sql); diff --git a/src/Models/Topic.php b/src/Models/Topic.php index 40f4883..b85e1dc 100644 --- a/src/Models/Topic.php +++ b/src/Models/Topic.php @@ -22,7 +22,7 @@ class Topic extends Model LEFT JOIN global_topics g ON t.global_topic_id = g.id LEFT JOIN global_topics gp ON g.parent_id = gp.id WHERE t.site_id = :site_id - ORDER BY t.name ASC" + ORDER BY COALESCE(gp.name, g.name, t.name) ASC, t.name ASC" ); $stmt->execute(['site_id' => $siteId]); return $stmt->fetchAll(); diff --git a/src/Services/FtpService.php b/src/Services/FtpService.php new file mode 100644 index 0000000..b893388 --- /dev/null +++ b/src/Services/FtpService.php @@ -0,0 +1,140 @@ +host = $host; + $this->user = $user; + $this->pass = $pass; + $this->port = $port; + $this->ssl = $ssl; + } + + public function setProgressCallback(callable $callback): void + { + $this->progressCallback = $callback; + } + + public function connect(): void + { + if ($this->ssl) { + $this->connection = @ftp_ssl_connect($this->host, $this->port, 30); + } else { + $this->connection = @ftp_connect($this->host, $this->port, 30); + } + + if (!$this->connection) { + throw new \RuntimeException("Nie można połączyć z FTP: {$this->host}:{$this->port}"); + } + + if (!@ftp_login($this->connection, $this->user, $this->pass)) { + throw new \RuntimeException("Logowanie FTP nieudane dla użytkownika: {$this->user}"); + } + + ftp_pasv($this->connection, true); + + Logger::info("FTP connected to {$this->host}", 'installer'); + } + + public function uploadDirectory(string $localDir, string $remoteDir): void + { + // Count total files before starting (only on first/top-level call) + if ($this->totalFiles === 0) { + $this->totalFiles = $this->countFiles($localDir); + $this->uploadedFiles = 0; + } + + $this->ensureDirectory($remoteDir); + + $items = scandir($localDir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $localPath = $localDir . '/' . $item; + $remotePath = $remoteDir . '/' . $item; + + if (is_dir($localPath)) { + $this->uploadDirectory($localPath, $remotePath); + } else { + $this->uploadFile($localPath, $remotePath); + $this->uploadedFiles++; + + // Report progress every 50 files to avoid excessive writes + if ($this->progressCallback && $this->uploadedFiles % 50 === 0) { + ($this->progressCallback)($this->uploadedFiles, $this->totalFiles); + } + } + } + } + + public function uploadFile(string $localPath, string $remotePath): void + { + if (!@ftp_put($this->connection, $remotePath, $localPath, FTP_BINARY)) { + throw new \RuntimeException("FTP upload failed: {$remotePath}"); + } + } + + public function ensureDirectory(string $path): void + { + $parts = explode('/', trim($path, '/')); + $current = ''; + + foreach ($parts as $part) { + $current .= '/' . $part; + @ftp_mkdir($this->connection, $current); + } + } + + public function disconnect(): void + { + if ($this->connection) { + @ftp_close($this->connection); + $this->connection = null; + Logger::info("FTP disconnected", 'installer'); + } + } + + public function __destruct() + { + $this->disconnect(); + } + + private function countFiles(string $dir): int + { + $count = 0; + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + $count += $this->countFiles($path); + } else { + $count++; + } + } + return $count; + } +} diff --git a/src/Services/ImageService.php b/src/Services/ImageService.php index 1a30fa6..21ed6dc 100644 --- a/src/Services/ImageService.php +++ b/src/Services/ImageService.php @@ -9,6 +9,8 @@ use App\Helpers\Logger; class ImageService { + public const DEFAULT_FREEPIK_PROMPT_TEMPLATE = 'Professional blog header image about {topic_name}: {article_title}, high quality, photorealistic'; + private Client $client; public function __construct() @@ -31,6 +33,11 @@ class ImageService private function generateFreepik(string $articleTitle, string $topicName): ?array { $apiKey = Config::getDbSetting('freepik_api_key', Config::get('FREEPIK_API_KEY')); + $promptTemplate = Config::getDbSetting('image_generation_prompt', self::DEFAULT_FREEPIK_PROMPT_TEMPLATE); + + if (!is_string($promptTemplate) || trim($promptTemplate) === '') { + $promptTemplate = self::DEFAULT_FREEPIK_PROMPT_TEMPLATE; + } if (empty($apiKey)) { Logger::warning('Freepik API key not configured, falling back to Pexels', 'image'); @@ -38,7 +45,10 @@ class ImageService } try { - $prompt = "Professional blog header image about {$topicName}: {$articleTitle}, high quality, photorealistic"; + $prompt = strtr($promptTemplate, [ + '{topic_name}' => $topicName, + '{article_title}' => $articleTitle, + ]); $response = $this->client->post('https://api.freepik.com/v1/ai/text-to-image', [ 'headers' => [ diff --git a/src/Services/InstallerService.php b/src/Services/InstallerService.php new file mode 100644 index 0000000..75e39cb --- /dev/null +++ b/src/Services/InstallerService.php @@ -0,0 +1,442 @@ +http = new Client(['timeout' => 60, 'verify' => false]); + } + + private static function progressFilePath(string $id): string + { + return sys_get_temp_dir() . '/backpro_progress_' . $id . '.json'; + } + + private function updateProgress(int $percent, string $message, string $status = 'in_progress'): void + { + if (empty($this->progressId)) { + return; + } + $data = [ + 'percent' => min($percent, 100), + 'message' => $message, + 'status' => $status, + 'time' => date('H:i:s'), + ]; + @file_put_contents(self::progressFilePath($this->progressId), json_encode($data), LOCK_EX); + } + + public static function getProgress(string $id): ?array + { + $file = self::progressFilePath($id); + if (!file_exists($file)) { + return null; + } + $data = @file_get_contents($file); + return $data ? json_decode($data, true) : null; + } + + public static function cleanupProgress(string $id): void + { + @unlink(self::progressFilePath($id)); + } + + /** + * @return array{success: bool, message: string, site_id: int|null} + */ + public function install(array $config, string $progressId = ''): array + { + set_time_limit(600); + ini_set('memory_limit', '256M'); + + $this->progressId = $progressId; + + Logger::info("Starting WordPress installation for {$config['site_url']}", 'installer'); + $this->updateProgress(5, 'Pobieranie WordPress...'); + + try { + // Step 1: Download WordPress + $zipPath = $this->downloadWordPress($config['language']); + $this->updateProgress(15, 'Rozpakowywanie archiwum...'); + + // Step 2: Extract ZIP + $wpSourceDir = $this->extractZip($zipPath); + $this->updateProgress(25, 'Generowanie wp-config.php...'); + + // Step 3: Generate wp-config.php + $this->generateWpConfig($wpSourceDir, $config); + $this->updateProgress(30, 'Łączenie z serwerem FTP...'); + + // Step 4: Upload via FTP + $this->uploadViaFtp($wpSourceDir, $config); + $this->updateProgress(85, 'Uruchamianie instalacji WordPress...'); + + // Step 5: Trigger WordPress installation + $this->triggerInstallation($config); + $this->updateProgress(92, 'Tworzenie Application Password...'); + + // Step 6: Create Application Password + $appPassword = $this->createApplicationPassword($config); + $this->updateProgress(97, 'Rejestracja strony w BackPRO...'); + + // Step 7: Register site in BackPRO (with all credentials) + $siteId = Site::create([ + 'name' => $config['site_title'], + 'url' => $config['site_url'], + 'api_user' => $config['admin_user'], + 'api_token' => $appPassword, + 'publish_interval_hours' => 24, + 'is_active' => 1, + 'is_multisite' => 0, + 'ftp_host' => $config['ftp_host'], + 'ftp_port' => $config['ftp_port'], + 'ftp_user' => $config['ftp_user'], + 'ftp_pass' => $config['ftp_pass'], + 'ftp_path' => $config['ftp_path'], + 'db_host' => $config['db_host'], + 'db_name' => $config['db_name'], + 'db_user' => $config['db_user'], + 'db_pass' => $config['db_pass'], + 'db_prefix' => $config['db_prefix'], + 'wp_admin_user' => $config['admin_user'], + 'wp_admin_pass' => $config['admin_pass'], + 'wp_admin_email' => $config['admin_email'], + ]); + + Logger::info("WordPress installed and site registered (ID: {$siteId})", 'installer'); + + $this->updateProgress(100, 'Instalacja zakończona pomyślnie!', 'completed'); + $this->cleanup(); + + return [ + 'success' => true, + 'message' => "WordPress zainstalowany pomyślnie! Strona \"{$config['site_title']}\" została dodana do BackPRO.", + 'site_id' => $siteId, + ]; + } catch (\Throwable $e) { + Logger::error("Installation failed: " . $e->getMessage(), 'installer'); + $this->updateProgress(0, 'Błąd: ' . $e->getMessage(), 'failed'); + $this->cleanup(); + + return [ + 'success' => false, + 'message' => 'Błąd instalacji: ' . $e->getMessage(), + 'site_id' => null, + ]; + } + } + + private function downloadWordPress(string $language): string + { + Logger::info("Downloading WordPress ({$language})", 'installer'); + + $url = ($language === 'pl_PL') + ? 'https://pl.wordpress.org/latest-pl_PL.zip' + : 'https://wordpress.org/latest.zip'; + + $this->tempDir = sys_get_temp_dir() . '/backpro_wp_' . uniqid(); + if (!mkdir($this->tempDir, 0755, true)) { + throw new \RuntimeException('Nie można utworzyć katalogu tymczasowego'); + } + + $zipPath = $this->tempDir . '/wordpress.zip'; + + $this->http->get($url, [ + 'sink' => $zipPath, + 'timeout' => 120, + 'headers' => [ + 'User-Agent' => 'BackPRO/1.0 (WordPress Installer)', + ], + ]); + + if (!file_exists($zipPath) || filesize($zipPath) < 1000000) { + throw new \RuntimeException('Pobieranie WordPress nie powiodło się'); + } + + Logger::info("Downloaded WordPress (" . round(filesize($zipPath) / 1048576, 1) . " MB)", 'installer'); + return $zipPath; + } + + private function extractZip(string $zipPath): string + { + Logger::info("Extracting WordPress ZIP", 'installer'); + + $zip = new \ZipArchive(); + $result = $zip->open($zipPath); + + if ($result !== true) { + throw new \RuntimeException("Nie można otworzyć pliku ZIP (kod błędu: {$result})"); + } + + $extractDir = $this->tempDir . '/extracted'; + $zip->extractTo($extractDir); + $zip->close(); + + $wpDir = $extractDir . '/wordpress'; + if (!is_dir($wpDir)) { + throw new \RuntimeException('Rozpakowany archiwum nie zawiera katalogu wordpress/'); + } + + @unlink($zipPath); + + Logger::info("Extracted to {$wpDir}", 'installer'); + return $wpDir; + } + + private function generateWpConfig(string $wpDir, array $config): void + { + Logger::info("Generating wp-config.php", 'installer'); + + $salts = $this->fetchSalts(); + + $dbHost = addcslashes($config['db_host'], "'\\"); + $dbName = addcslashes($config['db_name'], "'\\"); + $dbUser = addcslashes($config['db_user'], "'\\"); + $dbPass = addcslashes($config['db_pass'], "'\\"); + $dbPrefix = addcslashes($config['db_prefix'], "'\\"); + + $wpConfig = <<http->get('https://api.wordpress.org/secret-key/1.1/salt/', ['timeout' => 10]); + $salts = $response->getBody()->getContents(); + + if (str_contains($salts, 'define(')) { + return $salts; + } + } catch (GuzzleException $e) { + Logger::warning("Cannot fetch salts from API, generating locally", 'installer'); + } + + $keys = [ + 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', + 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT', + ]; + $lines = []; + foreach ($keys as $key) { + $salt = bin2hex(random_bytes(32)); + $lines[] = "define( '{$key}', '{$salt}' );"; + } + return implode("\n", $lines); + } + + private function uploadViaFtp(string $wpDir, array $config): void + { + Logger::info("Starting FTP upload to {$config['ftp_host']}:{$config['ftp_path']}", 'installer'); + + $ftp = new FtpService( + $config['ftp_host'], + $config['ftp_user'], + $config['ftp_pass'], + $config['ftp_port'], + $config['ftp_ssl'] + ); + + // FTP progress callback: maps file count to 35-85% range + $ftp->setProgressCallback(function (int $uploaded, int $total) { + $ftpPercent = ($total > 0) ? ($uploaded / $total) : 0; + $percent = 35 + (int) ($ftpPercent * 50); // 35% to 85% + $this->updateProgress($percent, "Wgrywanie plików FTP... ({$uploaded}/{$total})"); + }); + + try { + $ftp->connect(); + $this->updateProgress(35, 'Wgrywanie plików na serwer FTP...'); + $ftp->uploadDirectory($wpDir, $config['ftp_path']); + Logger::info("FTP upload completed", 'installer'); + } finally { + $ftp->disconnect(); + } + } + + private function triggerInstallation(array $config): void + { + Logger::info("Triggering WordPress installation at {$config['site_url']}", 'installer'); + + $installUrl = $config['site_url'] . '/wp-admin/install.php?step=2'; + + try { + $response = $this->http->post($installUrl, [ + 'form_params' => [ + 'weblog_title' => $config['site_title'], + 'user_name' => $config['admin_user'], + 'admin_password' => $config['admin_pass'], + 'admin_password2' => $config['admin_pass'], + 'admin_email' => $config['admin_email'], + 'blog_public' => 0, + ], + 'timeout' => 60, + 'allow_redirects' => true, + ]); + + $body = $response->getBody()->getContents(); + + if ( + str_contains($body, 'wp-login.php') || + str_contains($body, 'Success') || + str_contains($body, 'Udane') || + str_contains($body, 'install-success') + ) { + Logger::info("WordPress installation triggered successfully", 'installer'); + return; + } + + Logger::warning("WordPress install response unclear, HTTP " . $response->getStatusCode(), 'installer'); + } catch (GuzzleException $e) { + throw new \RuntimeException("Instalacja WordPress nie powiodła się: " . $e->getMessage()); + } + } + + private function createApplicationPassword(array $config): string + { + Logger::info("Creating Application Password via WP cookie auth", 'installer'); + + sleep(3); + + $jar = new CookieJar(); + $siteUrl = $config['site_url']; + + try { + // Step 1: Login via wp-login.php to get auth cookies + Logger::info("Logging in to WordPress admin", 'installer'); + $this->http->post($siteUrl . '/wp-login.php', [ + 'form_params' => [ + 'log' => $config['admin_user'], + 'pwd' => $config['admin_pass'], + 'wp-submit' => 'Log In', + 'redirect_to' => $siteUrl . '/wp-admin/', + 'testcookie' => '1', + ], + 'cookies' => $jar, + 'allow_redirects' => true, + 'timeout' => 30, + 'headers' => ['User-Agent' => 'BackPRO/1.0'], + ]); + + // Step 2: Get REST API nonce via admin-ajax + Logger::info("Fetching REST API nonce", 'installer'); + $nonceResponse = $this->http->get($siteUrl . '/wp-admin/admin-ajax.php?action=rest-nonce', [ + 'cookies' => $jar, + 'timeout' => 15, + 'headers' => ['User-Agent' => 'BackPRO/1.0'], + ]); + $nonce = trim($nonceResponse->getBody()->getContents()); + + if (empty($nonce) || $nonce === '0' || $nonce === '-1') { + throw new \RuntimeException('Nie udało się pobrać nonce REST API (logowanie nieudane?)'); + } + + Logger::info("Got REST nonce, creating Application Password", 'installer'); + + // Step 3: Create Application Password with cookie auth + nonce + $apiUrl = $siteUrl . '/?rest_route=/wp/v2/users/me/application-passwords'; + $response = $this->http->post($apiUrl, [ + 'cookies' => $jar, + 'headers' => [ + 'X-WP-Nonce' => $nonce, + 'User-Agent' => 'BackPRO/1.0', + ], + 'json' => [ + 'name' => 'BackPRO ' . date('Y-m-d H:i'), + ], + 'timeout' => 30, + ]); + + $data = json_decode($response->getBody()->getContents(), true); + + if (!isset($data['password'])) { + throw new \RuntimeException('Odpowiedź API nie zawiera hasła aplikacji'); + } + + Logger::info("Application Password created successfully", 'installer'); + return $data['password']; + } catch (GuzzleException $e) { + throw new \RuntimeException("Nie można utworzyć Application Password: " . $e->getMessage()); + } + } + + private function cleanup(): void + { + if (!empty($this->tempDir) && is_dir($this->tempDir)) { + $this->deleteDirectory($this->tempDir); + Logger::info("Cleaned up temp directory", 'installer'); + } + } + + private function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . '/' . $item; + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } +} diff --git a/src/Services/OpenAIService.php b/src/Services/OpenAIService.php index cbd36bd..ef6dd50 100644 --- a/src/Services/OpenAIService.php +++ b/src/Services/OpenAIService.php @@ -9,6 +9,8 @@ use App\Helpers\Logger; class OpenAIService { + public const DEFAULT_ARTICLE_PROMPT_TEMPLATE = 'Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, optymalizowane pod SEO. Artykuł powinien mieć {min_words}-{max_words} słów, zawierać nagłówki H2 i H3, być angażujący i merytoryczny. Formatuj treść w HTML (bez tagów , , ). Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {"title": "tytuł artykułu", "content": "treść HTML artykułu"}'; + private Client $client; public function __construct() @@ -25,6 +27,11 @@ class OpenAIService $model = Config::getDbSetting('openai_model', Config::get('OPENAI_MODEL', 'gpt-4o')); $minWords = Config::getDbSetting('article_min_words', '800'); $maxWords = Config::getDbSetting('article_max_words', '1200'); + $systemPromptTemplate = Config::getDbSetting('article_generation_prompt', self::DEFAULT_ARTICLE_PROMPT_TEMPLATE); + + if (!is_string($systemPromptTemplate) || trim($systemPromptTemplate) === '') { + $systemPromptTemplate = self::DEFAULT_ARTICLE_PROMPT_TEMPLATE; + } if (empty($apiKey)) { Logger::error('OpenAI API key not configured', 'openai'); @@ -35,11 +42,10 @@ class OpenAIService ? implode("\n- ", $existingTitles) : '(brak - to pierwszy artykuł z tego tematu)'; - $systemPrompt = "Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, " - . "optymalizowane pod SEO. Artykuł powinien mieć {$minWords}-{$maxWords} słów, " - . "zawierać nagłówki H2 i H3, być angażujący i merytoryczny. " - . "Formatuj treść w HTML (bez tagów , , ). " - . "Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {\"title\": \"tytuł artykułu\", \"content\": \"treść HTML artykułu\"}"; + $systemPrompt = strtr($systemPromptTemplate, [ + '{min_words}' => (string) $minWords, + '{max_words}' => (string) $maxWords, + ]); $userPrompt = "Napisz artykuł na temat: {$topicName}\n"; if (!empty($topicDescription)) { diff --git a/src/Services/WordPressService.php b/src/Services/WordPressService.php index 6b37515..c1beffd 100644 --- a/src/Services/WordPressService.php +++ b/src/Services/WordPressService.php @@ -49,6 +49,24 @@ class WordPressService } } + public function createCategory(array $site, string $name, int $parent = 0): ?array + { + try { + $response = $this->client->post($site['url'] . '/wp-json/wp/v2/categories', [ + 'auth' => [$site['api_user'], $site['api_token']], + 'json' => [ + 'name' => $name, + 'parent' => $parent, + ], + ]); + + return json_decode($response->getBody()->getContents(), true); + } catch (GuzzleException $e) { + Logger::error("WP createCategory failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); + return null; + } + } + public function uploadMedia(array $site, string $imageData, string $filename): ?int { try { @@ -104,6 +122,51 @@ class WordPressService } } + public function getPostFeaturedMedia(array $site, int $wpPostId): ?int + { + try { + $response = $this->client->get($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [ + 'auth' => [$site['api_user'], $site['api_token']], + 'query' => ['_fields' => 'featured_media'], + ]); + + $data = json_decode($response->getBody()->getContents(), true); + $mediaId = $data['featured_media'] ?? 0; + return $mediaId > 0 ? $mediaId : null; + } catch (GuzzleException $e) { + Logger::error("WP getPostFeaturedMedia failed: " . $e->getMessage(), 'wordpress'); + return null; + } + } + + public function updatePostFeaturedMedia(array $site, int $wpPostId, int $mediaId): bool + { + try { + $this->client->post($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [ + 'auth' => [$site['api_user'], $site['api_token']], + 'json' => ['featured_media' => $mediaId], + ]); + return true; + } catch (GuzzleException $e) { + Logger::error("WP updatePostFeaturedMedia failed: " . $e->getMessage(), 'wordpress'); + return false; + } + } + + public function deleteMedia(array $site, int $mediaId): bool + { + try { + $this->client->delete($site['url'] . '/wp-json/wp/v2/media/' . $mediaId, [ + 'auth' => [$site['api_user'], $site['api_token']], + 'query' => ['force' => true], + ]); + return true; + } catch (GuzzleException $e) { + Logger::error("WP deleteMedia failed: " . $e->getMessage(), 'wordpress'); + return false; + } + } + private function getMimeType(string $filename): string { $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); diff --git a/templates/articles/show.php b/templates/articles/show.php index 08a6e0c..be7a8cb 100644 --- a/templates/articles/show.php +++ b/templates/articles/show.php @@ -23,10 +23,18 @@ -
- - Post WordPress ID: - | Zobacz na stronie +
+
+ + Post WordPress ID: + | Zobacz na stronie +
+
+ + +
@@ -49,3 +57,55 @@
+ + + + diff --git a/templates/categories/index.php b/templates/categories/index.php index b27133f..e5bdc15 100644 --- a/templates/categories/index.php +++ b/templates/categories/index.php @@ -1,45 +1,285 @@

Kategorie WordPress:

- ← Powrót do stron + ← Powrót do strony +
+
+ + + +
+ +
-
- -
-
-
- - - - - - - - - - - - - - +
+
+ +
+ +
+ +
+
+
IDNazwaSlugIlość postów
Kliknij "Synchronizuj" aby pobrać kategorie z WordPressa
+ - - - - + + + + + - - - -
IDNazwaSlugParentPosty
+ + + + Kliknij "Synchronizuj" aby pobrać kategorie z WordPressa + + strcasecmp($a['name'], $b['name'])); + // Sort children by name within each group + foreach ($children as &$group) { + usort($group, fn($a, $b) => strcasecmp($a['name'], $b['name'])); + } + unset($group); + // Build ordered list: parent -> its children -> next parent... + $sorted = []; + foreach ($parents as $p) { + $sorted[] = $p; + if (isset($children[$p['id']])) { + foreach ($children[$p['id']] as $c) { + $sorted[] = $c; + } + } + } + // Orphan children (parent not in list) + foreach ($children as $pid => $group) { + if (!isset($parents[$pid])) { + foreach ($group as $c) { + $sorted[] = $c; + } + } + } + ?> + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + Użyj ID kategorii przy konfiguracji tematów, aby artykuły trafiały do odpowiednich kategorii w WordPressie. +
+ + +
+ +
+
Utwórz kategorię
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + + +
+
Auto-tworzenie z tematyk
+
+

+ Automatycznie utworzy kategorie w WordPress na podstawie tematyk przypisanych do tej strony. + Struktura parent/child zostanie odtworzona z biblioteki tematów. +

+

+ Istniejące kategorie (o tej samej nazwie) nie będą duplikowane. + Po utworzeniu, wp_category_id zostanie przypisane do odpowiednich tematów. +

+
+
+
-
- - Użyj ID kategorii przy konfiguracji tematów, aby artykuły trafiały do odpowiednich kategorii w WordPressie. -
+ diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php index f3d706b..753a431 100644 --- a/templates/dashboard/index.php +++ b/templates/dashboard/index.php @@ -49,11 +49,12 @@ Nazwa Status Ostatnia publikacja + - Brak stron. Dodaj pierwszą + Brak stron. Dodaj pierwszą @@ -68,6 +69,13 @@ -' ?> + + + + + @@ -118,3 +126,39 @@ + + diff --git a/templates/global-topics/index.php b/templates/global-topics/index.php index 052e8b1..8413496 100644 --- a/templates/global-topics/index.php +++ b/templates/global-topics/index.php @@ -31,12 +31,9 @@ data-description=""> Edytuj kategorię -
- -
+ -
- -
+ @@ -188,5 +184,47 @@ document.addEventListener('DOMContentLoaded', function() { new bootstrap.Modal(document.getElementById('editGlobalModal')).show(); }); }); + + // AJAX delete + document.querySelectorAll('.btn-delete-global').forEach(function(btn) { + btn.addEventListener('click', function() { + var id = this.dataset.id; + var isCategory = this.dataset.type === 'category'; + var msg = isCategory ? 'Usunąć kategorię i wszystkie jej tematy?' : 'Usunąć ten temat?'; + if (!confirm(msg)) return; + + var el = isCategory ? this.closest('.accordion-item') : this.closest('tr'); + var origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ''; + + fetch('/global-topics/' + id + '/delete', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + el.style.transition = 'opacity .3s'; + el.style.opacity = '0'; + setTimeout(function() { + el.remove(); + if (!isCategory) { + var tbody = el.closest && document.querySelector('table tbody'); + } + }, 300); + } else { + alert(data.message || 'Błąd usuwania'); + btn.disabled = false; + btn.innerHTML = origHtml; + } + }) + .catch(function() { + alert('Błąd połączenia'); + btn.disabled = false; + btn.innerHTML = origHtml; + }); + }); + }); }); diff --git a/templates/installer/index.php b/templates/installer/index.php new file mode 100644 index 0000000..0a2f7d6 --- /dev/null +++ b/templates/installer/index.php @@ -0,0 +1,372 @@ +

Instalator WordPress

+ +
+
+
+ + +
+
Dane FTP
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
Ścieżka na serwerze, gdzie mają być wgrane pliki WordPress
+
+
+ + +
+
+
+ + +
+
Baza danych
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
Zmień domyślny "wp_" dla lepszego bezpieczeństwa
+
+
+
+ + +
+
Administrator WordPress
+
+
+ + +
Pełny URL strony (bez końcowego /)
+
+
+ + +
+
+
+ + +
+
+ + +
Min. 8 znaków, użyj silnego hasła
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + Anuluj +
+ +
+ + + + +
+ + +
+
+
Jak to działa?
+
+
    +
  1. System pobiera najnowszą wersję WordPress
  2. +
  3. Generuje wp-config.php z danymi bazy
  4. +
  5. Wgrywa pliki na serwer przez FTP
  6. +
  7. Uruchamia instalację WordPress
  8. +
  9. Tworzy Application Password do API
  10. +
  11. Rejestruje stronę w BackPRO
  12. +
+
+
+ +
+
Wymagania
+
+
    +
  • Serwer z PHP 7.4+ i MySQL 5.7+
  • +
  • Konto FTP z uprawnieniami zapisu
  • +
  • Pusta baza danych (lub z unikalnym prefixem tabel)
  • +
  • Domena skierowana na serwer docelowy
  • +
  • Proces trwa 2-5 minut
  • +
+
+
+ +
+ + Uwaga: Instalacja może potrwać kilka minut ze względu na przesyłanie ~1500 plików przez FTP. + Nie zamykaj przeglądarki do momentu zakończenia procesu. +
+
+
+ + diff --git a/templates/layout/sidebar.php b/templates/layout/sidebar.php index a5e3e12..0163420 100644 --- a/templates/layout/sidebar.php +++ b/templates/layout/sidebar.php @@ -24,6 +24,11 @@ Artykuły +