├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── FUNDING.yml ├── .gitignore ├── contao ├── templates │ ├── modules │ │ ├── mod_recommendationsummary.html5 │ │ ├── mod_recommendationreader.html5 │ │ ├── mod_recommendationlist.html5 │ │ └── mod_recommendationform.html5 │ ├── summary │ │ └── recommendationsummary.html5 │ ├── js │ │ └── js_recommendation.html5 │ └── recommendation │ │ ├── recommendation_full.html5 │ │ ├── recommendation_latest.html5 │ │ └── recommendation_default.html5 ├── languages │ ├── en │ │ ├── default.xlf │ │ ├── tl_user.xlf │ │ ├── tl_user_group.xlf │ │ ├── tl_recommendation_settings.xlf │ │ ├── modules.xlf │ │ ├── tl_recommendation_list.xlf │ │ ├── tl_recommendation_notification.xlf │ │ ├── tl_recommendation_archive.xlf │ │ ├── tl_module.xlf │ │ └── tl_recommendation.xlf │ └── de │ │ ├── default.xlf │ │ ├── tl_user.xlf │ │ ├── tl_user_group.xlf │ │ ├── tl_recommendation_settings.xlf │ │ ├── modules.xlf │ │ ├── tl_recommendation_list.xlf │ │ ├── tl_recommendation_notification.xlf │ │ ├── tl_recommendation_archive.xlf │ │ └── tl_module.xlf ├── dca │ ├── tl_user_group.php │ ├── tl_user.php │ ├── tl_recommendation_settings.php │ ├── tl_recommendation_archive.php │ ├── tl_recommendation.php │ └── tl_module.php ├── config │ └── config.php └── modules │ ├── ModuleRecommendationReader.php │ ├── ModuleRecommendationSummary.php │ ├── ModuleRecommendationList.php │ └── ModuleRecommendation.php ├── config └── services.yaml ├── depcheck.php ├── src ├── Security │ └── ContaoRecommendationPermissions.php ├── Import │ └── Validator │ │ ├── RecommendationValidator.php │ │ └── RecommendationArchiveValidator.php ├── ContaoRecommendationBundle.php ├── ContaoManager │ └── Plugin.php ├── EventListener │ ├── Import │ │ └── AddRecommendationValidatorListener.php │ ├── DataContainer │ │ ├── ModuleListener.php │ │ ├── RecommendationArchiveListener.php │ │ └── RecommendationListener.php │ └── SitemapListener.php ├── Cron │ └── PurgeRecommendationsCron.php ├── Util │ └── Summary.php └── Model │ ├── RecommendationArchiveModel.php │ └── RecommendationModel.php ├── phpunit.xml.dist ├── tests ├── ContaoManager │ └── PluginTest.php ├── Cron │ └── PurgeRecommendationsCronTest.php └── EventListener │ └── SitemapListenerTest.php ├── LICENSE ├── translations ├── setup.en.yaml └── setup.de.yaml └── composer.json /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer 2 | /composer.lock 3 | /vendor/ 4 | 5 | # PhpUnit 6 | /.phpunit.result.cache 7 | /phpunit.xml 8 | 9 | # IDE 10 | /.idea 11 | -------------------------------------------------------------------------------- /contao/templates/modules/mod_recommendationsummary.html5: -------------------------------------------------------------------------------- 1 | extend('block_unsearchable'); ?> 2 | 3 | block('content'); ?> 4 | 5 | summary ?> 6 | 7 | endblock(); ?> 8 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: true 6 | 7 | Oveleon\ContaoRecommendationBundle\: 8 | resource: '../src/' 9 | exclude: '../src/{Model,DependencyInjection,Util}' 10 | -------------------------------------------------------------------------------- /contao/templates/modules/mod_recommendationreader.html5: -------------------------------------------------------------------------------- 1 | extend('block_searchable'); ?> 2 | 3 | block('content'); ?> 4 | 5 | recommendation ?> 6 | 7 | 8 |

back ?>

9 | 10 | 11 | endblock(); ?> 12 | -------------------------------------------------------------------------------- /depcheck.php: -------------------------------------------------------------------------------- 1 | ignoreUnknownClasses([ 10 | 'Oveleon\ProductInstaller\Import\Validator', 11 | 'Oveleon\ProductInstaller\ProductInstaller' 12 | ]) 13 | 14 | ->ignoreErrorsOnPackage('contao/manager-plugin', [ErrorType::DEV_DEPENDENCY_IN_PROD]) 15 | ; 16 | -------------------------------------------------------------------------------- /contao/templates/modules/mod_recommendationlist.html5: -------------------------------------------------------------------------------- 1 | extend('block_unsearchable'); ?> 2 | 3 | block('content'); ?> 4 | 5 | average . ' ' . $this->averageRounded . ' ' . $this->countLabel ?> 6 | 7 | recommendations)): ?> 8 |

empty ?>

9 | 10 | summary ?> 11 | recommendations) ?> 12 | pagination ?> 13 | 14 | 15 | endblock(); ?> 16 | -------------------------------------------------------------------------------- /src/Security/ContaoRecommendationPermissions.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class RecommendationValidator 15 | { 16 | static public function getTrigger(): string 17 | { 18 | return RecommendationModel::getTable(); 19 | } 20 | 21 | static public function getModel(): string 22 | { 23 | return RecommendationModel::class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ContaoRecommendationBundle.php: -------------------------------------------------------------------------------- 1 | import('../config/services.yaml'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Import/Validator/RecommendationArchiveValidator.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class RecommendationArchiveValidator 15 | { 16 | static public function getTrigger(): string 17 | { 18 | return RecommendationArchiveModel::getTable(); 19 | } 20 | 21 | static public function getModel(): string 22 | { 23 | return RecommendationArchiveModel::class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ./tests 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /contao/languages/en/default.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Currently there are no recommendations. 6 | 7 | 8 | Read the recommendation: %s 9 | 10 | 11 | Recommendation summary 12 | 13 | 14 | %s of 5 stars 15 | 16 | 17 | %s Reviews 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /contao/templates/summary/recommendationsummary.html5: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | average - floor($this->average); 7 | $isActive = ($i <= floor($this->average)) ? ' active' : ''; 8 | $isHalf = ($i == ceil($this->average) && $fraction >= 0.3 && $fraction < 0.7) ? ' half' : ''; 9 | $isNextFull = ($i == ceil($this->average) && $fraction >= 0.7) ? ' active' : ''; 10 | ?> 11 | rating && $this->styles) ? $this->styles : '' ?>>★ 12 | 13 | 14 | averageRoundedLabel ?> 15 | (countLabel ?>) 16 |
17 | 18 | -------------------------------------------------------------------------------- /contao/languages/en/tl_user.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Allowed archives 6 | 7 | 8 | Here you can grant access to one or more recommendation archives. 9 | 10 | 11 | Recommendation permissions 12 | 13 | 14 | Here you can define the recommendation permissions. 15 | 16 | 17 | Recommendation permissions 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /contao/templates/js/js_recommendation.html5: -------------------------------------------------------------------------------- 1 | 2 | window.addEventListener("load", () => { 3 | const initRecommendation = (i) => { 4 | i.forEach(element => { 5 | element.addEventListener("click", (el) => { 6 | document.querySelector(`.rec_dialog_${el.currentTarget.dataset?.id}`)?.showModal(); 7 | }) 8 | }); 9 | } 10 | initRecommendation(document.querySelectorAll(".rec_show-modal")) 11 | 12 | new MutationObserver((mutations) => { 13 | mutations.forEach((mutation) => { 14 | const elements = mutation.target.matches(".rec_show-modal") ? mutation.target : mutation.target.querySelectorAll(".rec_show-modal"); 15 | initRecommendation(elements); 16 | }); 17 | }).observe(document, { attributes: false, childList: true, subtree: true }); 18 | }) 19 | '; 20 | -------------------------------------------------------------------------------- /contao/templates/modules/mod_recommendationform.html5: -------------------------------------------------------------------------------- 1 | extend('block_unsearchable'); ?> 2 | 3 | block('content'); ?> 4 | 5 | 6 |
7 | confirm): ?> 8 |

confirm ?>

9 | 10 |
11 |
12 | 13 | 14 | fields as $field): ?> 15 | parse() ?> 16 | 17 | 18 |
19 | 20 |
21 |
22 |
23 | 24 |
25 | 26 | 27 | endblock(); ?> 28 | -------------------------------------------------------------------------------- /contao/languages/en/tl_user_group.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Recommendation permissions 6 | 7 | 8 | Allowed archives 9 | 10 | 11 | Here you can grant access to one or more recommendation archives. 12 | 13 | 14 | Recommendation permissions 15 | 16 | 17 | Here you can define the recommendation permissions. 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/ContaoManager/PluginTest.php: -------------------------------------------------------------------------------- 1 | createMock(ParserInterface::class); 19 | 20 | /** @var BundleConfig $config */ 21 | $config = (new Plugin())->getBundles($parser)[0]; 22 | 23 | $this->assertInstanceOf(BundleConfig::class, $config); 24 | $this->assertSame(ContaoRecommendationBundle::class, $config->getName()); 25 | $this->assertSame([ContaoCoreBundle::class], $config->getLoadAfter()); 26 | $this->assertSame(['recommendation'], $config->getReplace()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ContaoManager/Plugin.php: -------------------------------------------------------------------------------- 1 | setReplace(['recommendation']) 30 | ->setLoadAfter([ 31 | ContaoCoreBundle::class, 32 | ProductInstaller::class 33 | ]) 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /contao/languages/de/default.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Currently there are no recommendations. 6 | Zurzeit sind keine Bewertungen vorhanden. 7 | 8 | 9 | Read the recommendation: %s 10 | Die Bewertung lesen: %s 11 | 12 | 13 | Recommendation summary 14 | Bewertungenszusammenfassung 15 | 16 | 17 | %s of 5 stars 18 | %s von 5 Sternen 19 | 20 | 21 | %s Reviews 22 | %s Bewertungen 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest new features 3 | labels: [enhancement] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | description: Please take a couple of minutes to help our maintainers to work more efficiently. 9 | options: 10 | - label: I [checked](https://github.com/oveleon/contao-recommendation-bundle/issues?q=is%3Aissue) for duplicate feature requests (open and closed) 11 | required: true 12 | - label: I have read the [contributing guidelines](https://github.com/oveleon/contao-recommendation-bundle#bugs-and-feature-requests) 13 | required: true 14 | - type: textarea 15 | id: proposal 16 | attributes: 17 | label: Proposal 18 | description: Give us clear details about what to add, including relevant links and screenshots if available. 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: context 23 | attributes: 24 | label: Context 25 | description: Explain why this change is necessary or beneficial and what problems it can solve. 26 | validations: 27 | required: true -------------------------------------------------------------------------------- /tests/Cron/PurgeRecommendationsCronTest.php: -------------------------------------------------------------------------------- 1 | createMock(RecommendationModel::class); 17 | $recommendationModel 18 | ->expects($this->once()) 19 | ->method('delete') 20 | ; 21 | 22 | $recommendationModelAdapter = $this->mockAdapter(['findExpiredRecommendations']); 23 | $recommendationModelAdapter 24 | ->expects($this->once()) 25 | ->method('findExpiredRecommendations') 26 | ->willReturn(new Collection([$recommendationModel], RecommendationModel::getTable())) 27 | ; 28 | 29 | $framework = $this->mockContaoFramework([RecommendationModel::class => $recommendationModelAdapter]); 30 | 31 | (new PurgeRecommendationsCron($framework, null))(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /contao/languages/de/tl_user.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Allowed archives 6 | Erlaubte Archive 7 | 8 | 9 | Here you can grant access to one or more recommendation archives. 10 | Hier können Sie den Zugriff auf ein oder mehrere Bewertungs-Archive erlauben. 11 | 12 | 13 | Recommendation permissions 14 | Archivrechte 15 | 16 | 17 | Here you can define the recommendation permissions. 18 | Hier können Sie die Archivrechte festlegen. 19 | 20 | 21 | Recommendation permissions 22 | Bewertungsrechte 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /contao/languages/de/tl_user_group.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Recommendation permissions 6 | Bewertungsrechte 7 | 8 | 9 | Allowed archives 10 | Erlaubte Archive 11 | 12 | 13 | Here you can grant access to one or more recommendation archives. 14 | Hier können Sie den Zugriff auf ein oder mehrere Bewertungs-Archive erlauben. 15 | 16 | 17 | Recommendation permissions 18 | Archivrechte 19 | 20 | 21 | Here you can define the recommendation permissions. 22 | Hier können Sie die Archivrechte festlegen. 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /contao/dca/tl_user_group.php: -------------------------------------------------------------------------------- 1 | addLegend('recommendation_legend', 'amg_legend', PaletteManipulator::POSITION_BEFORE) 16 | ->addField(['recommendations', 'recommendationp'], 'recommendation_legend', PaletteManipulator::POSITION_APPEND) 17 | ->applyToPalette('default', 'tl_user_group') 18 | ; 19 | 20 | // Add fields to tl_user_group 21 | $GLOBALS['TL_DCA']['tl_user_group']['fields']['recommendations'] = [ 22 | 'exclude' => true, 23 | 'inputType' => 'checkbox', 24 | 'foreignKey' => 'tl_recommendation_archive.title', 25 | 'eval' => ['multiple'=>true], 26 | 'sql' => "blob NULL" 27 | ]; 28 | 29 | $GLOBALS['TL_DCA']['tl_user_group']['fields']['recommendationp'] = [ 30 | 'exclude' => true, 31 | 'inputType' => 'checkbox', 32 | 'options' => ['create', 'delete'], 33 | 'reference' => &$GLOBALS['TL_LANG']['MSC'], 34 | 'eval' => ['multiple'=>true], 35 | 'sql' => "blob NULL" 36 | ]; 37 | -------------------------------------------------------------------------------- /contao/dca/tl_user.php: -------------------------------------------------------------------------------- 1 | addLegend('recommendation_legend', 'amg_legend', PaletteManipulator::POSITION_BEFORE) 16 | ->addField(['recommendations', 'recommendationp'], 'recommendation_legend', PaletteManipulator::POSITION_APPEND) 17 | ->applyToPalette('extend', 'tl_user') 18 | ->applyToPalette('custom', 'tl_user') 19 | ; 20 | 21 | // Add fields to tl_user_group 22 | $GLOBALS['TL_DCA']['tl_user']['fields']['recommendations'] = [ 23 | 'exclude' => true, 24 | 'inputType' => 'checkbox', 25 | 'foreignKey' => 'tl_recommendation_archive.title', 26 | 'eval' => ['multiple'=>true], 27 | 'sql' => "blob NULL" 28 | ]; 29 | 30 | $GLOBALS['TL_DCA']['tl_user']['fields']['recommendationp'] = [ 31 | 'exclude' => true, 32 | 'inputType' => 'checkbox', 33 | 'options' => ['create', 'delete'], 34 | 'reference' => &$GLOBALS['TL_LANG']['MSC'], 35 | 'eval' => ['multiple'=>true], 36 | 'sql' => "blob NULL" 37 | ]; 38 | -------------------------------------------------------------------------------- /contao/languages/en/tl_recommendation_settings.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings 6 | 7 | 8 | Default image 9 | 10 | 11 | The default image is displayed for recommendations that have not set their own image. 12 | 13 | 14 | Color of active stars 15 | 16 | 17 | Color value will be set as inline css style in the html code. 18 | 19 | 20 | Alias prefix 21 | 22 | 23 | Here you can enter an alias prefix that will precede a recommendation alias if no title has been entered. 24 | 25 | 26 | recommendation 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/EventListener/Import/AddRecommendationValidatorListener.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright Oveleon 12 | */ 13 | 14 | namespace Oveleon\ContaoRecommendationBundle\EventListener\Import; 15 | 16 | use Oveleon\ContaoRecommendationBundle\Import\Validator\RecommendationArchiveValidator; 17 | use Oveleon\ContaoRecommendationBundle\Model\RecommendationArchiveModel; 18 | use Oveleon\ProductInstaller\Import\Validator; 19 | use Oveleon\ProductInstaller\Import\Validator\ModuleValidator; 20 | 21 | class AddRecommendationValidatorListener 22 | { 23 | public function addValidators(): void 24 | { 25 | // Connects jumpTo pages 26 | Validator::addValidatorCollection([RecommendationArchiveValidator::class], ['setJumpToPageConnection']); 27 | } 28 | 29 | public function setModuleArchiveConnections(array $row): array 30 | { 31 | return match ($row['type']) { 32 | 'recommendationlist', 'recommendationreader' => ['field' => 'recommendation_archives', 'table' => RecommendationArchiveModel::getTable()], 33 | 'recommendationform' => ['field' => 'recommendation_archive', 'table' => RecommendationArchiveModel::getTable()], 34 | default => [], 35 | }; 36 | } 37 | 38 | public function setUserGroupArchiveConnections(array &$connections): void 39 | { 40 | $connections['recommendations'] = RecommendationArchiveModel::getTable(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Cron/PurgeRecommendationsCron.php: -------------------------------------------------------------------------------- 1 | framework->initialize(); 36 | 37 | $schemaManager = $this->connection->createSchemaManager(); 38 | 39 | if (!$schemaManager->tablesExist('tl_recommendation')) 40 | { 41 | return; 42 | } 43 | 44 | $recommendations = $this->framework->getAdapter(RecommendationModel::class)->findExpiredRecommendations(); 45 | 46 | if (null === $recommendations) 47 | { 48 | return; 49 | } 50 | 51 | /** @var RecommendationModel $recommendation */ 52 | foreach ($recommendations as $recommendation) 53 | { 54 | $recommendation->delete(); 55 | } 56 | 57 | $this->logger?->info('Purged the unactivated recommendations'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/ModuleListener.php: -------------------------------------------------------------------------------- 1 | get('security.helper'); 27 | 28 | if ( 29 | !$security->isGranted(ContaoCorePermissions::USER_CAN_ACCESS_MODULE, 'themes') || 30 | !$security->isGranted(ContaoCorePermissions::USER_CAN_ACCESS_LAYOUTS) 31 | ) { 32 | return; 33 | } 34 | 35 | $objModule = ModuleModel::findByPk($dc->id); 36 | 37 | if (null !== $objModule && 'recommendationlist' === $objModule->type) 38 | { 39 | // Get module 40 | $objModule = Database::getInstance()->prepare("SELECT * FROM " . $dc->table . " WHERE id=?") 41 | ->limit(1) 42 | ->execute($dc->id); 43 | 44 | if (null !== $objModule && !!$objModule->recommendation_useDialog) 45 | { 46 | Message::addInfo(sprintf(($GLOBALS['TL_LANG']['tl_module']['includeRecTemplate'] ?? null), 'js_recommendation')); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /contao/templates/recommendation/recommendation_full.html5: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | headline): ?> 5 |

headline ?>

6 | 7 | 8 | addRecommendationImage): ?> 9 | addInternalImage): ?> 10 | insert('image', $this->arrData); ?> 11 | addExternalImage): ?> 12 |
13 | externalSize ?> itemprop="image"> 14 |
15 | 16 | 17 | 18 | addAuthor || $this->addDate || $this->addRating || $this->addLocation): ?> 19 |
20 | addAuthor): ?> 21 | author ?> 22 | 23 | addCustomField): ?> 24 | customField ?> 25 | 26 | addLocation): ?> 27 | location ?> 28 | 29 | 30 | addDate): ?> 31 | 32 | 33 | 34 | addRating): ?> 35 | 36 | 37 | rating && $this->styles) ? $this->styles : '' ?>>★ 38 | 39 | 40 | 41 |
42 | 43 | 44 |
45 | text ?> 46 |
47 | 48 |
49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Report a bug 2 | description: Report a bug, issue or problem you have identified using the contao-recommendation-bundle 3 | labels: [bug, unconfirmed] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | description: Please take a couple of minutes to help our maintainers to work more efficiently. 9 | options: 10 | - label: I [checked](https://github.com/oveleon/contao-recommendation-bundle/issues?q=is%3Aissue) for duplicate issues (open and closed) 11 | required: true 12 | - label: I am using the latest stable [version/release](https://packagist.org/packages/oveleon/contao-recommendation-bundle) of the Contao Recommendation Bundle 13 | required: true 14 | - label: I have read the [contributing guidelines](https://github.com/oveleon/contao-recommendation-bundle#bugs-and-feature-requests) 15 | required: true 16 | - type: dropdown 17 | id: type 18 | attributes: 19 | label: Please select the topic(s) that most closely match your concern 20 | options: 21 | - Template (HTML/Twig) 22 | - Backend (PHP) 23 | - Other (Specify within description) 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: description 28 | attributes: 29 | label: Description 30 | description: Please describe the issue and what you expected to happen, including detailed instructions on how to reproduce it in a fresh Contao installation without any third-party extensions installed. 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: logs 35 | attributes: 36 | label: Relevant log output 37 | description: Please copy and paste the relevant stacktrace within enabled debug-mode. This will be automatically formatted into code, so no need for backticks. 38 | render: shell -------------------------------------------------------------------------------- /contao/languages/en/modules.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Recommendations 6 | 7 | 8 | Manage recommendations 9 | 10 | 11 | Recommendation settings 12 | 13 | 14 | Configure recommendation settings 15 | 16 | 17 | Recommendations 18 | 19 | 20 | Recommendation list 21 | 22 | 23 | Adds a list of recommendations to the page 24 | 25 | 26 | Recommendation reader 27 | 28 | 29 | Shows the details of a recommendation 30 | 31 | 32 | Recommendation form 33 | 34 | 35 | Adds a recommendation form to the page 36 | 37 | 38 | Recommendation summary 39 | 40 | 41 | Adds a summary of all recommendations to the page 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /contao/languages/en/tl_recommendation_list.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Show all recommendations 6 | 7 | 8 | At least 2 stars 9 | 10 | 11 | At least 3 stars 12 | 13 | 14 | At least 4 stars 15 | 16 | 17 | At least 5 stars 18 | 19 | 20 | Show all recommendations 21 | 22 | 23 | Show featured recommendation only 24 | 25 | 26 | Skip featured recommendations 27 | 28 | 29 | Show featured recommendations first 30 | 31 | 32 | Date ascending 33 | 34 | 35 | Date descending 36 | 37 | 38 | Random order 39 | 40 | 41 | Rating descending 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /translations/setup.en.yaml: -------------------------------------------------------------------------------- 1 | setup: 2 | tables: 3 | tl_recommendation_archive: 'Recommendation archives' 4 | tl_recommendation: 'Recommendations' 5 | prompt: 6 | recommendation_archive: 7 | jumpTo: 8 | label: 'Recommendation-Archive → Redirect page' 9 | description: 'One or more recommendation archives refer to a forwarding page that cannot be resolved. Your selection is applied to all other rating archives that reference the same redirection page.' 10 | explanation: 'When importing one or more recommendation archives, the corresponding redirect page could not be found. Please select a page from your Contao instance to create a link between these archives and a page.

The following page was not imported and needs an alternative:' 11 | module: 12 | recommendation_archives: 13 | label: 'Module → Recommendation archives' 14 | description: 'One or more archives could not be assigned to the module. Your selection will be applied to the current module.' 15 | explanation: 'During the import of one or more modules, the corresponding archives could not be found. Please select an archive from your Contao instance to create a link between this module and an archive.' 16 | recommendation_archive: 17 | label: 'Module → Recommendation archive' 18 | description: 'No archive could not be assigned to the module. Your selection will be applied to the current module.' 19 | explanation: 'During the import of a module, no associated archive could be found. Please select an archive from your Contao instance to create a link between this module and an archive.' 20 | user_group: 21 | recommendations: 22 | label: 'User group "%userGroupName%" → Recommendation archives' 23 | description: 'One or more archives could not be assigned, please assign them manually.' 24 | -------------------------------------------------------------------------------- /contao/languages/de/tl_recommendation_settings.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings 6 | Einstellungen 7 | 8 | 9 | Default image 10 | Standard-Bild 11 | 12 | 13 | The default image is displayed for recommendations that have not set their own image. 14 | Das Standard-Bild wird bei Bewertungen angezeigt, die kein eigenes Bild gesetzt haben. 15 | 16 | 17 | Color of active stars 18 | Farbe für aktive Sterne 19 | 20 | 21 | Color value will be set as inline css style in the html code. 22 | Farbwert wird als inline css style im HTML code gesetzt. 23 | 24 | 25 | Alias prefix 26 | Alias-Präfix 27 | 28 | 29 | Here you can enter an alias prefix that will precede a recommendation alias if no title has been entered. 30 | Hier können Sie einen Alias-Präfix eingeben, der einem Bewertungsalias vorangestellt wird, wenn kein Titel eingegeben wurde. 31 | 32 | 33 | recommendation 34 | bewertung 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /contao/languages/en/tl_recommendation_notification.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Your recommendation has been added and is now pending for approval. 6 | 7 | 8 | Thank you for your recommendation. You will receive a confirmation e-mail. 9 | 10 | 11 | Your recommendation has been added. 12 | 13 | 14 | Your recommendation has been verified. 15 | 16 | 17 | New recommendation on %s 18 | 19 | 20 | %s has submitted a new recommendation on your website: 21 | 22 | --- 23 | 24 | Recommendation archive: %s 25 | 26 | Rating: %s 27 | 28 | Text: %s 29 | 30 | --- 31 | 32 | Click here to edit or publish the recommendation: 33 | 34 | %s 35 | 36 | 37 | 38 | This recommendation has to be published in the back end before it will appear on the website! 39 | 40 | 41 | Your recommendation on %s 42 | 43 | 44 | Thank you for your recommendation on ##domain##. 45 | 46 | Click here to confirm your recommendation: 47 | 48 | ##link## 49 | 50 | The confirmation link is valid for 24 hours. 51 | 52 | If you did not submit a recommendation, please ignore this e-mail. 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /contao/config/config.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'tables' => ['tl_recommendation_archive', 'tl_recommendation'] 18 | ] 19 | ]); 20 | 21 | ArrayUtil::arrayInsert($GLOBALS['BE_MOD']['system'], 3, [ 22 | 'recommendation_settings' => [ 23 | 'tables' => ['tl_recommendation_settings'], 24 | 'hideInNavigation' => true 25 | ] 26 | ]); 27 | 28 | // Front end modules 29 | ArrayUtil::arrayInsert($GLOBALS['FE_MOD'], 2, [ 30 | 'recommendation' => [ 31 | 'recommendationform' => ModuleRecommendationForm::class, 32 | 'recommendationlist' => ModuleRecommendationList::class, 33 | 'recommendationreader' => ModuleRecommendationReader::class, 34 | 'recommendationsummary' => ModuleRecommendationSummary::class 35 | ] 36 | ]); 37 | 38 | // Add permissions 39 | $GLOBALS['TL_PERMISSIONS'][] = 'recommendations'; 40 | $GLOBALS['TL_PERMISSIONS'][] = 'recommendationp'; 41 | 42 | // Models 43 | $GLOBALS['TL_MODELS']['tl_recommendation'] = RecommendationModel::class; 44 | $GLOBALS['TL_MODELS']['tl_recommendation_archive'] = RecommendationArchiveModel::class; 45 | 46 | // Add product installer validators 47 | $GLOBALS['PI_HOOKS']['addValidator'][] = [AddRecommendationValidatorListener::class, 'addValidators']; 48 | $GLOBALS['PI_HOOKS']['setModuleValidatorArchiveConnections'][] = [AddRecommendationValidatorListener::class, 'setModuleArchiveConnections']; 49 | $GLOBALS['PI_HOOKS']['setUserGroupValidatorArchiveConnections'][] = [AddRecommendationValidatorListener::class, 'setUserGroupArchiveConnections']; 50 | -------------------------------------------------------------------------------- /translations/setup.de.yaml: -------------------------------------------------------------------------------- 1 | setup: 2 | tables: 3 | tl_recommendation_archive: 'Bewertungen-Archive' 4 | tl_recommendation: 'Bewertungen' 5 | prompt: 6 | recommendation_archive: 7 | jumpTo: 8 | label: 'Bewertungen-Archiv → Weiterleitungsseite' 9 | description: 'Ein oder mehrere Bewertungen-Archive verweisen auf eine Weiterleitungsseite, welche nicht aufgelöst werden kann. Ihre Auswahl wird für alle weiteren Bewertungen-Archive, welche auf die selbe Weiterleitungsseite referenzieren, übernommen.' 10 | explanation: 'Beim Importieren eines oder mehrerer Bewertungen-Archive konnte die zugehörige Weiterleitungsseite nicht gefunden werden. Wählen Sie bitte eine Seite aus Ihrer Contao-Instanz, um eine Verknüpfung zwischen diesen Archiven und einer Seite herzustellen.

Folgende Seite wurde nicht importiert und benötigt eine Alternative:' 11 | module: 12 | recommendation_archives: 13 | label: 'Modul → Bewertungsarchive' 14 | description: 'Dem Modul konnten ein oder mehrere Archive nicht zugeordnet werden. Ihre Auswahl wird für das derzeitige Modul übernommen.' 15 | explanation: 'Beim Importieren eines oder mehrerer Module konnten zugehörige Archive nicht gefunden werden. Wählen Sie bitte ein Archiv aus Ihrer Contao-Instanz aus, um eine Verknüpfung zwischen diesem Modul und einem Archiv herzustellen.' 16 | recommendation_archive: 17 | label: 'Modul → Bewertungsarchiv' 18 | description: 'Dem Modul konnte kein Archiv nicht zugeordnet werden. Ihre Auswahl wird für das derzeitige Modul übernommen.' 19 | explanation: 'Beim Importieren eines Moduls konnte kein zugehöriges Archiv gefunden werden. Wählen Sie bitte ein Archiv aus Ihrer Contao-Instanz aus, um eine Verknüpfung zwischen diesem Modul und einem Archiv herzustellen.' 20 | user_group: 21 | recommendations: 22 | label: 'Benutzergruppe "%userGroupName%" → Bewertungsarchive' 23 | description: 'Ein oder mehrere Archive konnten nicht zugewiesen werden, bitte weisen Sie diese manuell zu.' 24 | -------------------------------------------------------------------------------- /contao/dca/tl_recommendation_settings.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'dataContainer' => DC_File::class, 20 | 'closed' => true 21 | ], 22 | 23 | // Palettes 24 | 'palettes' => [ 25 | 'default' => '{recommendation_legend},recommendationDefaultImage,recommendationActiveColor,recommendationAliasPrefix;' 26 | ], 27 | 28 | // Fields 29 | 'fields' => [ 30 | 'recommendationDefaultImage' => [ 31 | 'inputType' => 'fileTree', 32 | 'eval' => ['fieldType'=>'radio', 'filesOnly'=>true, 'isGallery'=>true, 'extensions'=> '%contao.image.valid_extensions%', 'tl_class'=>'clr'] 33 | ], 34 | 'recommendationActiveColor' => [ 35 | 'inputType' => 'text', 36 | 'eval' => ['maxlength'=>6, 'multiple'=>true, 'size'=>1, 'colorpicker'=>true, 'isHexColor'=>true, 'decodeEntities'=>true, 'tl_class'=>'w50 wizard'], 37 | 'save_callback' => [ 38 | // See contao/issues (#6105) 39 | static function ($value) 40 | { 41 | if (!\is_array($value)) 42 | { 43 | return StringUtil::restoreBasicEntities($value); 44 | } 45 | 46 | return serialize(array_map('\Contao\StringUtil::restoreBasicEntities', $value)); 47 | } 48 | ] 49 | ], 50 | 'recommendationAliasPrefix' => [ 51 | 'inputType' => 'text', 52 | 'eval' => ['rgxp'=>'alias', 'maxlength'=>255, 'tl_class'=>'w50 clr'], 53 | 'save_callback' => [ 54 | static function ($value) 55 | { 56 | if (!$value) 57 | { 58 | $value = &$GLOBALS['TL_LANG']['tl_recommendation_settings']['defaultPrefix']; 59 | } 60 | 61 | return $value; 62 | } 63 | ] 64 | ] 65 | ] 66 | ]; 67 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oveleon/contao-recommendation-bundle", 3 | "type": "contao-bundle", 4 | "description": "Recommendation integration for Contao Open Source CMS", 5 | "keywords": [ 6 | "contao", 7 | "recommendation-bundle", 8 | "recommendation", 9 | "reviews" 10 | ], 11 | "homepage": "https://www.oveleon.de/", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Oveleon", 16 | "homepage": "https://oveleon.de/", 17 | "role": "Developer" 18 | }, 19 | { 20 | "name": "Sebastian Zoglowek", 21 | "homepage": "https://github.com/zoglo", 22 | "role": "Developer" 23 | }, 24 | { 25 | "name": "Fabian Ekert", 26 | "homepage": "https://github.com/eki89", 27 | "role": "Developer" 28 | } 29 | ], 30 | "require": { 31 | "php": "^8.3", 32 | "contao/core-bundle": "^5.3", 33 | "doctrine/dbal": "^3.3", 34 | "psr/log": "^1.1 || 2.0 || ^3.0", 35 | "symfony/config": "^6.4 || ^7.0", 36 | "symfony/dependency-injection": "^6.4 || ^7.0", 37 | "symfony/filesystem": "^6.4 || ^7.0", 38 | "symfony/http-kernel": "^6.4 || ^7.0", 39 | "symfony/security-core": "^6.4 || ^7.0" 40 | }, 41 | "require-dev": { 42 | "contao/manager-plugin": "^2.3.1", 43 | "contao/test-case": "^5.1", 44 | "phpunit/phpunit": "^9.5", 45 | "symfony/http-client": "^6.4 || ^7.0", 46 | "symfony/phpunit-bridge": "^6.4 || ^7.0", 47 | "shipmonk/composer-dependency-analyser": "^1.8" 48 | }, 49 | "conflict": { 50 | "contao/core": "*", 51 | "contao/manager-plugin": "<2.0 || >=3.0" 52 | }, 53 | "suggest": { 54 | "oveleon/contao-google-recommendation-bundle": "This bundle imports Google reviews into the contao-recommendation-bundle" 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Oveleon\\ContaoRecommendationBundle\\": "src/" 59 | }, 60 | "classmap": [ 61 | "contao/" 62 | ], 63 | "exclude-from-classmap": [ 64 | "contao/config/", 65 | "contao/dca/", 66 | "contao/languages/", 67 | "contao/templates/" 68 | ] 69 | }, 70 | "extra": { 71 | "branch-alias": { 72 | "dev-main": "1.8.x-dev" 73 | }, 74 | "contao-manager-plugin": "Oveleon\\ContaoRecommendationBundle\\ContaoManager\\Plugin" 75 | }, 76 | "config": { 77 | "allow-plugins": { 78 | "php-http/discovery": true, 79 | "contao/manager-plugin": true 80 | } 81 | }, 82 | "scripts": { 83 | "depcheck": "@php vendor/bin/composer-dependency-analyser --config=depcheck.php" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /contao/templates/recommendation/recommendation_latest.html5: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | addInternalImage): ?> 5 | insert('image', $this->arrData); ?> 6 | addExternalImage): ?> 7 |
8 | externalSize ?> itemprop="image"> 9 |
10 | 11 | 12 |
13 | headlineLink): ?> 14 |

headlineLink ?>

15 | 16 | 17 | addAuthor || $this->addDate || $this->addRating || $this->addLocation): ?> 18 |
19 | addAuthor): ?> 20 | author ?> 21 | 22 | addLocation): ?> 23 | location ?> 24 | 25 | 26 | addDate): ?> 27 | 28 | 29 | 30 | addRating): ?> 31 | 32 | 33 | rating && $this->styles) ? $this->styles : '' ?>>★ 34 | 35 | 36 | 37 |
38 | 39 | 40 |
41 | teaser && ($this->allowRedirect || $this->dialog)): ?> 42 | cspInlineStyles($this->teaser) : $this->teaser ?> 43 | 44 | text ?> 45 | 46 |
47 | 48 | allowRedirect): ?> 49 |

more ?>

50 | dialog): ?> 51 | 52 |
53 | text ?> 54 |
55 | 56 |
57 |
58 |
59 |

60 | more ?> 61 |

62 | 63 |
64 | 65 |
66 | -------------------------------------------------------------------------------- /contao/languages/de/modules.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Recommendations 6 | Bewertungen 7 | 8 | 9 | Manage recommendations 10 | Bewertungen verwalten 11 | 12 | 13 | Recommendation settings 14 | Bewertungs-Einstellungen 15 | 16 | 17 | Configure recommendation settings 18 | Bewertungs-Einstellungen vornehmen 19 | 20 | 21 | Recommendations 22 | Bewertungen 23 | 24 | 25 | Recommendation list 26 | Bewertungsliste 27 | 28 | 29 | Adds a list of recommendations to the page 30 | Fügt der Seite eine Bewertungsliste hinzu 31 | 32 | 33 | Recommendation reader 34 | Bewertungsleser 35 | 36 | 37 | Shows the details of a recommendation 38 | Stellt eine einzelne Bewertung dar 39 | 40 | 41 | Recommendation form 42 | Bewertungsformular 43 | 44 | 45 | Adds a recommendation form to the page 46 | Fügt der Seite ein Formular für Bewertungen hinzu 47 | 48 | 49 | Recommendation summary 50 | Bewertungszusammenfassung 51 | 52 | 53 | Adds a summary of all recommendations to the page 54 | Fügt der Seite eine Bewertungszusammenfassung hinzu 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /contao/languages/de/tl_recommendation_list.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Show all recommendations 6 | Alle Bewertungen anzeigen 7 | 8 | 9 | At least 2 stars 10 | Mindestens 2 Sterne 11 | 12 | 13 | At least 3 stars 14 | Mindestens 3 Sterne 15 | 16 | 17 | At least 4 stars 18 | Mindestens 4 Sterne 19 | 20 | 21 | At least 5 stars 22 | Mindestens 5 Sterne 23 | 24 | 25 | Show all recommendations 26 | Alle Bewertungen anzeigen 27 | 28 | 29 | Show featured recommendation only 30 | Nur hervorgehobene Bewertungen anzeigen 31 | 32 | 33 | Skip featured recommendations 34 | Hervorgehobene Bewertungen überspringen 35 | 36 | 37 | Show featured recommendations first 38 | Hervorgehobene Bewertungen zuerst anzeigen 39 | 40 | 41 | Date ascending 42 | Datum aufsteigend 43 | 44 | 45 | Date descending 46 | Datum absteigend 47 | 48 | 49 | Random order 50 | Zufällige Reihenfolge 51 | 52 | 53 | Rating descending 54 | Bewertung absteigend 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /contao/templates/recommendation/recommendation_default.html5: -------------------------------------------------------------------------------- 1 |
2 | 3 | addRecommendationImage): ?> 4 | addInternalImage): ?> 5 | insert('image', $this->arrData); ?> 6 | addExternalImage): ?> 7 |
8 | externalSize ?> itemprop="image"> 9 |
10 | 11 | 12 | 13 |
14 | headlineLink): ?> 15 |

headlineLink ?>

16 | 17 | 18 | addAuthor || $this->addDate || $this->addRating || $this->addLocation): ?> 19 |
20 | addAuthor): ?> 21 | author ?> 22 | 23 | addCustomField): ?> 24 | customField ?> 25 | 26 | addLocation): ?> 27 | location ?> 28 | 29 | 30 | addDate): ?> 31 | 32 | 33 | 34 | addRating): ?> 35 | 36 | 37 | rating && $this->styles) ? $this->styles : '' ?>>★ 38 | 39 | 40 | 41 |
42 | 43 | 44 |
45 | teaser && ($this->allowRedirect || $this->dialog)): ?> 46 | cspInlineStyles($this->teaser) : $this->teaser ?> 47 | 48 | text ?> 49 | 50 |
51 | 52 | allowRedirect): ?> 53 |

more ?>

54 | dialog): ?> 55 | 56 |
57 | text ?> 58 |
59 | 60 |
61 |
62 |
63 |

64 | more ?> 65 |

66 | 67 |
68 | 69 |
70 | -------------------------------------------------------------------------------- /src/Util/Summary.php: -------------------------------------------------------------------------------- 1 | getAverageRating(); 34 | $averageRounded = round($average, $this->intPrecision); 35 | 36 | $objTemplate = new FrontendTemplate('recommendationsummary'); 37 | 38 | $objTemplate->average = $average; 39 | $objTemplate->averageRounded = $averageRounded; 40 | $objTemplate->averageLabel = sprintf(StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['recommendationAverageLabel']), $average); 41 | $objTemplate->averageRoundedLabel = sprintf(StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['recommendationAverageLabel']), $averageRounded); 42 | $objTemplate->countLabel = sprintf(StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['recommendationCount']), $this->intTotal); 43 | $objTemplate->summary = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['recommendationSummary']); 44 | 45 | return $objTemplate->parse(); 46 | } 47 | 48 | protected function getAverageRating(): float 49 | { 50 | if (empty($this->arrArchives) || !\is_array($this->arrArchives)) 51 | { 52 | return 0.0; 53 | } 54 | 55 | $t = 'tl_recommendation'; 56 | $objDatabase = Database::getInstance(); 57 | 58 | $arrWhere = ["$t.pid IN(" . implode(',', array_map('\intval', $this->arrArchives)) . ")", "$t.verified='1'"]; 59 | $arrValues = []; 60 | 61 | if ($this->blnFeatured !== null) 62 | { 63 | $arrWhere[] = "$t.featured=?"; 64 | $arrValues[] = $this->blnFeatured ? '1' : ''; 65 | } 66 | 67 | if ($this->minRating > 1) 68 | { 69 | $arrWhere[] = "$t.rating >= ?"; 70 | $arrValues[] = (int) $this->minRating; 71 | } 72 | 73 | if (!System::getContainer()->get('contao.security.token_checker')->isPreviewMode()) 74 | { 75 | $time = Date::floorToMinute(); 76 | $arrWhere[] = "($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "') AND $t.published='1'"; 77 | } 78 | 79 | $strWhere = implode(' AND ', $arrWhere); 80 | 81 | $objResult = $objDatabase 82 | ->prepare("SELECT AVG($t.rating) AS avgRating FROM $t WHERE $strWhere") 83 | ->execute(...$arrValues); 84 | 85 | return (float) $objResult->avgRating; 86 | } 87 | } 88 | 89 | class_alias(Summary::class, 'Summary'); 90 | -------------------------------------------------------------------------------- /contao/languages/de/tl_recommendation_notification.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Your recommendation has been added and is now pending for approval. 6 | Ihre Bewertung wurde hinzugefügt und wird nach redaktioneller Prüfung veröffentlicht. 7 | 8 | 9 | Thank you for your recommendation. You will receive a confirmation e-mail. 10 | Vielen Dank für Ihre Bewertung. Sie erhalten eine Bestätigungsmail. 11 | 12 | 13 | Your recommendation has been added. 14 | Ihre Bewertung wurde hinzugefügt. 15 | 16 | 17 | Your recommendation has been verified. 18 | Ihre Bewertung wurde verifiziert. 19 | 20 | 21 | New recommendation on %s 22 | Neue Bewertung auf %s 23 | 24 | 25 | %s has submitted a new recommendation on your website: 26 | 27 | --- 28 | 29 | Recommendation archive: %s 30 | 31 | Rating: %s 32 | 33 | Text: %s 34 | 35 | --- 36 | 37 | Click here to edit or publish the recommendation: 38 | 39 | %s 40 | 41 | %s hat eine neue Bewertung auf Ihrer Webseite abgegeben. 42 | 43 | --- 44 | 45 | Bewertungsarchiv: %s 46 | 47 | Bewertung: %s 48 | 49 | Text: %s 50 | 51 | --- 52 | 53 | Klicken Sie hier, um die Bewertung zu bearbeiten oder zu aktivieren: 54 | 55 | %s 56 | 57 | 58 | 59 | This recommendation has to be published in the back end before it will appear on the website! 60 | Diese Bewertung muss im Backend veröffentlicht werden, bevor sie auf der Webseite erscheint! 61 | 62 | 63 | Your recommendation on %s 64 | Ihre Bewertung auf %s 65 | 66 | 67 | Thank you for your recommendation on ##domain##. 68 | 69 | Click here to confirm your recommendation: 70 | 71 | ##link## 72 | 73 | The confirmation link is valid for 24 hours. 74 | 75 | If you did not submit a recommendation, please ignore this e-mail. 76 | 77 | Vielen Dank für Ihre Bewertung auf ##domain##. 78 | 79 | Bitte klicken Sie hier, um Ihre Bewertung zu bestätigen: 80 | 81 | ##link## 82 | 83 | Der Bestätigungslink ist 24 Stunden gültig. 84 | 85 | Wenn Sie keine Bewertung abgegeben haben, ignorieren Sie bitte diese E-Mail. 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /tests/EventListener/SitemapListenerTest.php: -------------------------------------------------------------------------------- 1 | $this->mockConfiguredAdapter(['findByProtected' => null]), 29 | ]; 30 | 31 | $sitemapEvent = $this->createSitemapEvent([]); 32 | $listener = $this->createListener([], $adapters); 33 | $listener($sitemapEvent); 34 | 35 | $this->assertStringNotContainsString('', (string) $sitemapEvent->getDocument()->saveXML()); 36 | } 37 | 38 | public function testRecommendationIsAdded(): void 39 | { 40 | $jumpToPage = $this->mockClassWithProperties(PageModel::class, [ 41 | 'published' => 1, 42 | 'protected' => 0, 43 | ]); 44 | 45 | $jumpToPage 46 | ->method('getAbsoluteUrl') 47 | ->willReturn('https://www.oveleon.de') 48 | ; 49 | 50 | $adapters = [ 51 | RecommendationArchiveModel::class => $this->mockConfiguredAdapter([ 52 | 'findByProtected' => [ 53 | $this->mockClassWithProperties(RecommendationArchiveModel::class, [ 54 | 'jumpTo' => 21, 55 | ]), 56 | ], 57 | ]), 58 | PageModel::class => $this->mockConfiguredAdapter([ 59 | 'findWithDetails' => $jumpToPage, 60 | ]), 61 | RecommendationModel::class => $this->mockConfiguredAdapter([ 62 | 'findPublishedByPid' => [ 63 | $this->mockClassWithProperties(RecommendationModel::class, [ 64 | 'jumpTo' => 21, 65 | ]), 66 | ], 67 | ]), 68 | ]; 69 | 70 | $sitemapEvent = $this->createSitemapEvent([1]); 71 | $listener = $this->createListener([1, 21], $adapters); 72 | $listener($sitemapEvent); 73 | 74 | $this->assertStringContainsString('https://www.oveleon.de', (string) $sitemapEvent->getDocument()->saveXML()); 75 | } 76 | 77 | private function createListener(array $allPages, array $adapters): SitemapListener 78 | { 79 | $database = $this->createMock(Database::class); 80 | $database 81 | ->method('getChildRecords') 82 | ->willReturn($allPages) 83 | ; 84 | 85 | $instances = [ 86 | Database::class => $database, 87 | ]; 88 | 89 | $framework = $this->mockContaoFramework($adapters, $instances); 90 | 91 | return new SitemapListener($framework); 92 | } 93 | 94 | private function createSitemapEvent(array $rootPages): SitemapEvent 95 | { 96 | $sitemap = new \DOMDocument('1.0', 'UTF-8'); 97 | $urlSet = $sitemap->createElementNS('https://www.sitemaps.org/schemas/sitemap/0.9', 'urlset'); 98 | $sitemap->appendChild($urlSet); 99 | 100 | return new SitemapEvent($sitemap, new Request(), $rootPages); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Model/RecommendationArchiveModel.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | Please enter a recommendation archive title. 9 | 10 | 11 | Redirect page 12 | 13 | 14 | Please choose the recommendation reader page to which visitors will be redirected when clicking a recommendation. 15 | 16 | 17 | Protect archive 18 | 19 | 20 | Show recommendations to certain member groups only. 21 | 22 | 23 | Use auto-item 24 | 25 | 26 | Activate auto-item parameter for this archive. 27 | 28 | 29 | Allowed member groups 30 | 31 | 32 | These groups will be able to see the recommendations in this archive. 33 | 34 | 35 | Revision date 36 | 37 | 38 | Date and time of the latest revision 39 | 40 | 41 | Title and redirect page 42 | 43 | 44 | Expert settings 45 | 46 | 47 | Access protection 48 | 49 | 50 | Settings 51 | 52 | 53 | Recommendation settings 54 | 55 | 56 | New archive 57 | 58 | 59 | Create a new archive 60 | 61 | 62 | Archive details 63 | 64 | 65 | Show the details of archive ID %s 66 | 67 | 68 | Edit archive 69 | 70 | 71 | Edit archive ID %s 72 | 73 | 74 | Edit archive 75 | 76 | 77 | Edit the archive settings 78 | 79 | 80 | Duplicate archive 81 | 82 | 83 | Duplicate archive ID %s 84 | 85 | 86 | Delete archive 87 | 88 | 89 | Delete archive ID %s 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /contao/modules/ModuleRecommendationReader.php: -------------------------------------------------------------------------------- 1 | get('request_stack')->getCurrentRequest(); 47 | 48 | if ($request && System::getContainer()->get('contao.routing.scope_matcher')->isBackendRequest($request)) 49 | { 50 | $objTemplate = new BackendTemplate('be_wildcard'); 51 | $objTemplate->wildcard = '### ' . $GLOBALS['TL_LANG']['FMD']['recommendationreader'][0] . ' ###'; 52 | $objTemplate->title = $this->headline; 53 | $objTemplate->id = $this->id; 54 | $objTemplate->link = $this->name; 55 | $objTemplate->href = StringUtil::specialcharsUrl(System::getContainer()->get('router')->generate('contao_backend', ['do'=>'themes', 'table'=>'tl_module', 'act'=>'edit', 'id'=>$this->id])); 56 | 57 | return $objTemplate->parse(); 58 | } 59 | 60 | $auto_item = Input::get('auto_item'); 61 | 62 | if (Input::get('auto_item')) 63 | { 64 | Input::setGet('items', Input::get('auto_item')); 65 | $auto_item = Input::get('items'); 66 | } 67 | 68 | // Return an empty string if "items" is not set (to combine list and reader on same page) 69 | if (null === $auto_item) 70 | { 71 | return ''; 72 | } 73 | 74 | $this->recommendation_archives = $this->sortOutProtected(StringUtil::deserialize($this->recommendation_archives)); 75 | 76 | if (empty($this->recommendation_archives)) 77 | { 78 | throw new InternalServerErrorException('The recommendation reader ID ' . $this->id . ' has no archives specified.'); 79 | } 80 | 81 | return parent::generate(); 82 | } 83 | 84 | /** 85 | * Generate the module 86 | */ 87 | protected function compile() 88 | { 89 | $this->Template->recommendation = ''; 90 | 91 | if ($this->overviewPage) 92 | { 93 | $this->Template->referer = PageModel::findById($this->overviewPage)->getFrontendUrl(); 94 | $this->Template->back = $this->customLabel ?: $GLOBALS['TL_LANG']['MSC']['goBack']; 95 | } 96 | else 97 | { 98 | $this->Template->referer = 'javascript:history.go(-1)'; 99 | $this->Template->back = $GLOBALS['TL_LANG']['MSC']['goBack']; 100 | } 101 | 102 | // Get the recommendation item 103 | $objRecommendation = RecommendationModel::findPublishedByParentAndIdOrAlias(Input::get('auto_item'), $this->recommendation_archives); 104 | 105 | if (null === $objRecommendation) 106 | { 107 | throw new PageNotFoundException('Page not found: ' . Environment::get('uri')); 108 | } 109 | 110 | /** @var RecommendationArchiveModel $objRecommendationArchive */ 111 | $objRecommendationArchive = $objRecommendation->getRelated('pid'); 112 | 113 | $arrRecommendation = $this->parseRecommendation($objRecommendation, $objRecommendationArchive); 114 | $this->Template->recommendation = $arrRecommendation; 115 | } 116 | } 117 | 118 | class_alias(ModuleRecommendationReader::class, 'ModuleRecommendationReader'); 119 | -------------------------------------------------------------------------------- /src/EventListener/SitemapListener.php: -------------------------------------------------------------------------------- 1 | framework->createInstance(Database::class)->getChildRecords($event->getRootPageIds(), 'tl_page'); 33 | 34 | // Early return here in the unlikely case that there are no pages 35 | if (empty($arrRoot)) 36 | { 37 | return; 38 | } 39 | 40 | $arrPages = []; 41 | $time = time(); 42 | 43 | // Get all recommendation archives 44 | $objArchives = $this->framework->getAdapter(RecommendationArchiveModel::class)->findByProtected(''); 45 | 46 | if (null === $objArchives) 47 | { 48 | return; 49 | } 50 | 51 | // Walk through each recommendation archive 52 | foreach ($objArchives as $objArchive) 53 | { 54 | // Skip recommendation archives without target page 55 | if (!$objArchive->jumpTo) 56 | { 57 | continue; 58 | } 59 | 60 | // Skip recommendation archives outside the root nodes 61 | if (!\in_array($objArchive->jumpTo, $arrRoot, true)) 62 | { 63 | continue; 64 | } 65 | 66 | $objParent = $this->framework->getAdapter(PageModel::class)->findWithDetails($objArchive->jumpTo); 67 | 68 | // The target page does not exist 69 | if (null === $objParent) 70 | { 71 | continue; 72 | } 73 | 74 | // The target page has not been published 75 | if (!$objParent->published || ($objParent->start && $objParent->start > $time) || ($objParent->stop && $objParent->stop <= $time)) 76 | { 77 | continue; 78 | } 79 | 80 | // The target page is protected 81 | if ($objParent->protected) 82 | { 83 | continue; 84 | } 85 | 86 | // The target page is exempt from the sitemap 87 | if ('noindex,nofollow' === $objParent->robots) 88 | { 89 | continue; 90 | } 91 | 92 | // Get the items 93 | $objRecommendations = $this->framework->getAdapter(RecommendationModel::class)->findPublishedByPid($objArchive->id); 94 | 95 | if (null === $objRecommendations) 96 | { 97 | continue; 98 | } 99 | 100 | foreach ($objRecommendations as $objRecommendation) 101 | { 102 | $arrPages[] = $objParent->getAbsoluteUrl('/' . ($objRecommendation->alias ?: $objRecommendation->id)); 103 | } 104 | } 105 | 106 | foreach ($arrPages as $strUrl) 107 | { 108 | $this->addUrlToDefaultUrlSet($strUrl, $event); 109 | } 110 | } 111 | 112 | private function addUrlToDefaultUrlSet(string $url, $event): self 113 | { 114 | $sitemap = $event->getDocument(); 115 | $urlSet = $sitemap->getElementsByTagNameNS('https://www.sitemaps.org/schemas/sitemap/0.9', 'urlset')->item(0); 116 | 117 | if (null === $urlSet) 118 | { 119 | return $this; 120 | } 121 | 122 | $loc = $sitemap->createElement('loc', $url); 123 | $urlEl = $sitemap->createElement('url'); 124 | $urlEl->appendChild($loc); 125 | $urlSet->appendChild($urlEl); 126 | 127 | $sitemap->appendChild($urlSet); 128 | 129 | return $this; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /contao/modules/ModuleRecommendationSummary.php: -------------------------------------------------------------------------------- 1 | get('request_stack')->getCurrentRequest(); 51 | 52 | if ($request && System::getContainer()->get('contao.routing.scope_matcher')->isBackendRequest($request)) 53 | { 54 | $objTemplate = new BackendTemplate('be_wildcard'); 55 | $objTemplate->wildcard = '### ' . $GLOBALS['TL_LANG']['FMD']['recommendationsummary'][0] . ' ###'; 56 | $objTemplate->title = $this->headline; 57 | $objTemplate->id = $this->id; 58 | $objTemplate->link = $this->name; 59 | $objTemplate->href = StringUtil::specialcharsUrl(System::getContainer()->get('router')->generate('contao_backend', ['do'=>'themes', 'table'=>'tl_module', 'act'=>'edit', 'id'=>$this->id])); 60 | 61 | return $objTemplate->parse(); 62 | } 63 | 64 | $this->recommendation_archives = $this->sortOutProtected(StringUtil::deserialize($this->recommendation_archives)); 65 | 66 | // Return if there are no archives 67 | if (empty($this->recommendation_archives) || !\is_array($this->recommendation_archives)) 68 | { 69 | return ''; 70 | } 71 | 72 | // Tag recommendation archives 73 | if (System::getContainer()->has('fos_http_cache.http.symfony_response_tagger')) 74 | { 75 | $responseTagger = System::getContainer()->get('fos_http_cache.http.symfony_response_tagger'); 76 | $responseTagger->addTags(array_map(static fn($id) => 'contao.db.tl_recommendation_archive.' . $id, $this->recommendation_archives)); 77 | } 78 | 79 | return parent::generate(); 80 | } 81 | 82 | /** 83 | * Generate the module 84 | */ 85 | protected function compile() 86 | { 87 | $minRating = (int) $this->recommendation_minRating; // Min rating has been saved as char in the db, this casts it into an int 88 | 89 | // Handle featured recommendations 90 | $blnFeatured = match ($this->recommendation_featured) 91 | { 92 | 'featured' => true, 93 | 'unfeatured' => false, 94 | default => null, 95 | }; 96 | 97 | // Get the total number of items 98 | $intTotal = $this->countItems($this->recommendation_archives, $blnFeatured, $minRating); 99 | 100 | // Add summary details 101 | $objSummary = new Summary($this->recommendation_archives, $intTotal, $blnFeatured, $minRating); 102 | $this->Template->summary = $objSummary->generate(); 103 | } 104 | 105 | /** 106 | * Count the total matching items 107 | * 108 | * @param array $recommendationArchives 109 | * @param boolean $blnFeatured 110 | * 111 | * @return integer 112 | */ 113 | protected function countItems($recommendationArchives, $blnFeatured, $minRating) 114 | { 115 | // HOOK: add custom logic 116 | if (isset($GLOBALS['TL_HOOKS']['recommendationSummaryCountItems']) && \is_array($GLOBALS['TL_HOOKS']['recommendationSummaryCountItems'])) 117 | { 118 | foreach ($GLOBALS['TL_HOOKS']['recommendationSummaryCountItems'] as $callback) 119 | { 120 | if (($intResult = System::importStatic($callback[0])->{$callback[1]}($recommendationArchives, $blnFeatured, $this)) === false) 121 | { 122 | continue; 123 | } 124 | 125 | if (\is_int($intResult)) 126 | { 127 | return $intResult; 128 | } 129 | } 130 | } 131 | 132 | return RecommendationModel::countPublishedByPids($recommendationArchives, $blnFeatured, $minRating); 133 | } 134 | } 135 | 136 | class_alias(ModuleRecommendationSummary::class, 'ModuleRecommendationSummary'); 137 | -------------------------------------------------------------------------------- /contao/dca/tl_recommendation_archive.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'dataContainer' => DC_Table::class, 20 | 'ctable' => ['tl_recommendation'], 21 | 'switchToEdit' => true, 22 | 'enableVersioning' => true, 23 | 'oncreate_callback' => [ 24 | [RecommendationArchiveListener::class, 'adjustPermissions'] 25 | ], 26 | 'oncopy_callback' => [ 27 | [RecommendationArchiveListener::class, 'adjustPermissions'] 28 | ], 29 | 'oninvalidate_cache_tags_callback' => [ 30 | [RecommendationArchiveListener::class, 'addSitemapCacheInvalidationTag'], 31 | ], 32 | 'sql' => [ 33 | 'keys' => [ 34 | 'id' => 'primary' 35 | ] 36 | ] 37 | ], 38 | 39 | // List 40 | 'list' => [ 41 | 'sorting' => [ 42 | 'mode' => DataContainer::MODE_SORTED, 43 | 'fields' => ['title'], 44 | 'flag' => DataContainer::SORT_INITIAL_LETTER_ASC, 45 | 'panelLayout' => 'filter;search,limit' 46 | ], 47 | 'label' => [ 48 | 'fields' => ['title'], 49 | 'format' => '%s' 50 | ], 51 | 'global_operations' => [ 52 | 'settings' => [ 53 | 'href' => 'do=recommendation_settings', 54 | 'class' => '', 55 | 'icon' => 'edit.svg', 56 | 'attributes' => 'data-action="contao--scroll-offset#store"' 57 | ], 58 | 'all', 59 | ], 60 | 'operations' => [ 61 | 'edit' => [ 62 | 'primary' => true, 63 | 'href' => 'act=edit', 64 | 'icon' => 'edit.svg', 65 | 'button_callback' => [RecommendationArchiveListener::class, 'edit'] 66 | ], 67 | 'children', 68 | 'copy' => [ 69 | 'href' => 'act=copy', 70 | 'icon' => 'copy.svg', 71 | 'button_callback' => [RecommendationArchiveListener::class, 'copyArchive'] 72 | ], 73 | 'delete' => [ 74 | 'href' => 'act=delete', 75 | 'icon' => 'delete.svg', 76 | 'attributes' => 'data-action="contao--scroll-offset#store" onclick="if(!confirm(\'' . ($GLOBALS['TL_LANG']['MSC']['deleteConfirmFile'] ?? null) . '\'))return false"', 77 | 'button_callback' => [RecommendationArchiveListener::class, 'deleteArchive'] 78 | ], 79 | 'show', 80 | ] 81 | ], 82 | 83 | // Palettes 84 | 'palettes' => [ 85 | '__selector__' => ['protected'], 86 | 'default' => '{title_legend},title,jumpTo;{protected_legend:collapsed},protected;{expert_legend:collapsed},useAutoItem' 87 | ], 88 | 89 | // Subpalettes 90 | 'subpalettes' => [ 91 | 'protected' => 'groups' 92 | ], 93 | 94 | // Fields 95 | 'fields' => [ 96 | 'id' => [ 97 | 'sql' => "int(10) unsigned NOT NULL auto_increment" 98 | ], 99 | 'tstamp' => [ 100 | 'sql' => "int(10) unsigned NOT NULL default '0'" 101 | ], 102 | 'title' => [ 103 | 'exclude' => true, 104 | 'search' => true, 105 | 'inputType' => 'text', 106 | 'eval' => ['mandatory'=>true, 'maxlength'=>255, 'tl_class'=>'w50'], 107 | 'sql' => "varchar(255) NOT NULL default ''" 108 | ], 109 | 'jumpTo' => [ 110 | 'exclude' => true, 111 | 'inputType' => 'pageTree', 112 | 'foreignKey' => 'tl_page.title', 113 | 'eval' => ['fieldType'=>'radio', 'tl_class'=>'clr'], 114 | 'sql' => "int(10) unsigned NOT NULL default '0'", 115 | 'relation' => ['type'=>'hasOne', 'load'=>'eager'] 116 | ], 117 | 'protected' => [ 118 | 'exclude' => true, 119 | 'filter' => true, 120 | 'inputType' => 'checkbox', 121 | 'eval' => ['submitOnChange'=>true], 122 | 'sql' => "char(1) NOT NULL default ''" 123 | ], 124 | 'useAutoItem' => [ 125 | 'exclude' => true, 126 | 'filter' => true, 127 | 'inputType' => 'checkbox', 128 | 'sql' => "char(1) NOT NULL default ''" 129 | ], 130 | 'groups' => [ 131 | 'exclude' => true, 132 | 'inputType' => 'checkbox', 133 | 'foreignKey' => 'tl_member_group.name', 134 | 'eval' => ['mandatory'=>true, 'multiple'=>true], 135 | 'sql' => "blob NULL", 136 | 'relation' => ['type'=>'hasMany', 'load'=>'lazy'] 137 | ] 138 | ] 139 | ]; 140 | 141 | -------------------------------------------------------------------------------- /contao/languages/de/tl_recommendation_archive.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | Titel 7 | 8 | 9 | Please enter a recommendation archive title. 10 | Bitte geben Sie den Archiv-Titel ein. 11 | 12 | 13 | Redirect page 14 | Weiterleitungsseite 15 | 16 | 17 | Please choose the recommendation reader page to which visitors will be redirected when clicking a recommendation. 18 | Bitte wählen Sie die Bewertungsleser-Seite aus, zu der Besucher weitergeleitet werden, wenn Sie eine Bewertung anklicken. 19 | 20 | 21 | Protect archive 22 | Archiv schützen 23 | 24 | 25 | Show recommendations to certain member groups only. 26 | Bewertungen nur bestimmten Frontend-Gruppen anzeigen. 27 | 28 | 29 | Use auto-item 30 | Auto-item nutzen 31 | 32 | 33 | Activate auto-item parameter for this archive. 34 | Auto-Item Parameter für dieses Archiv aktivieren. 35 | 36 | 37 | Allowed member groups 38 | Erlaubte Mitgliedergruppen 39 | 40 | 41 | These groups will be able to see the recommendations in this archive. 42 | Diese Mitgliedergruppen können die Bewertungen des Archivs sehen. 43 | 44 | 45 | Revision date 46 | Änderungsdatum 47 | 48 | 49 | Date and time of the latest revision 50 | Datum und Uhrzeit der letzten Änderung 51 | 52 | 53 | Title and redirect page 54 | Titel und Weiterleitung 55 | 56 | 57 | Access protection 58 | Zugriffsschutz 59 | 60 | 61 | Expert settings 62 | Experteneinstellungen 63 | 64 | 65 | Settings 66 | Einstellungen 67 | 68 | 69 | Recommendation settings 70 | Bewertungen-Einstellungen 71 | 72 | 73 | New archive 74 | Neues Archiv 75 | 76 | 77 | Create a new archive 78 | Ein neues Archiv erstellen 79 | 80 | 81 | Archive details 82 | Archivdetails 83 | 84 | 85 | Show the details of archive ID %s 86 | Die Details des Archivs ID %s anzeigen 87 | 88 | 89 | Edit archive 90 | Archiv bearbeiten 91 | 92 | 93 | Edit archive ID %s 94 | Archiv ID %s bearbeiten 95 | 96 | 97 | Edit archive 98 | Archiv bearbeiten 99 | 100 | 101 | Edit the archive settings 102 | Die Archiv-Einstellungen bearbeiten 103 | 104 | 105 | Duplicate archive 106 | Archiv duplizieren 107 | 108 | 109 | Duplicate archive ID %s 110 | Archiv ID %s duplizieren 111 | 112 | 113 | Delete archive 114 | Archiv löschen 115 | 116 | 117 | Delete archive ID %s 118 | Archiv ID %s löschen 119 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/RecommendationArchiveListener.php: -------------------------------------------------------------------------------- 1 | security->isGranted(ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE, 'tl_recommendation_archive') ? '' . Image::getHtml($icon, $label) . ' ' : Image::getHtml(preg_replace('/\.svg$/i', '_.svg', $icon)) . ' '; 40 | } 41 | 42 | /** 43 | * Return the copy archive button 44 | */ 45 | public function copyArchive(array $row, string $href, string $label, string $title, string $icon, string $attributes): string 46 | { 47 | return $this->security->isGranted(ContaoRecommendationPermissions::USER_CAN_CREATE_ARCHIVES) ? '' . Image::getHtml($icon, $label) . ' ' : Image::getHtml(preg_replace('/\.svg$/i', '_.svg', $icon)) . ' '; 48 | } 49 | 50 | /** 51 | * Return the delete archive button 52 | */ 53 | public function deleteArchive(array $row, string $href, string $label, string $title, string $icon, string $attributes): string 54 | { 55 | return $this->security->isGranted(ContaoRecommendationPermissions::USER_CAN_DELETE_ARCHIVES) ? '' . Image::getHtml($icon, $label) . ' ' : Image::getHtml(preg_replace('/\.svg$/i', '_.svg', $icon)) . ' '; 56 | } 57 | 58 | /** 59 | * Add the new archive to the permissions 60 | */ 61 | public function adjustPermissions(int|string $insertId): void 62 | { 63 | // The oncreate_callback passes $insertId as second argument 64 | if (func_num_args() == 4) 65 | { 66 | $insertId = func_get_arg(1); 67 | } 68 | 69 | $objUser = Controller::getContainer()->get('security.helper')->getUser(); 70 | 71 | if ($objUser->isAdmin) 72 | { 73 | return; 74 | } 75 | 76 | // Set root IDs 77 | if (empty($objUser->recommendations) || !is_array($objUser->recommendations)) 78 | { 79 | $root = [0]; 80 | } 81 | else 82 | { 83 | $root = $objUser->recommendations; 84 | } 85 | 86 | // The archive is enabled already 87 | if (in_array($insertId, $root)) 88 | { 89 | return; 90 | } 91 | 92 | /** @var AttributeBagInterface $objSessionBag */ 93 | $objSessionBag = System::getContainer()->get('request_stack')->getSession()->getBag('contao_backend'); 94 | 95 | $arrNew = $objSessionBag->get('new_records'); 96 | 97 | if (is_array($arrNew['tl_recommendation_archive']) && in_array($insertId, $arrNew['tl_recommendation_archive'])) 98 | { 99 | $db = Database::getInstance(); 100 | 101 | // Add the permissions on group level 102 | if ($objUser->inherit != 'custom') 103 | { 104 | $objGroup = $db->execute("SELECT id, recommendations, recommendationp FROM tl_user_group WHERE id IN(" . implode(',', array_map('\intval', $objUser->groups)) . ")"); 105 | 106 | while ($objGroup->next()) 107 | { 108 | $arrRecommendationp = StringUtil::deserialize($objGroup->recommendationp); 109 | 110 | if (is_array($arrRecommendationp) && in_array('create', $arrRecommendationp)) 111 | { 112 | $arrRecommendations = StringUtil::deserialize($objGroup->recommendations, true); 113 | $arrRecommendations[] = $insertId; 114 | 115 | $db->prepare("UPDATE tl_user_group SET recommendations=? WHERE id=?") 116 | ->execute(serialize($arrRecommendations), $objGroup->id); 117 | } 118 | } 119 | } 120 | 121 | // Add the permissions on user level 122 | if ($objUser->inherit != 'group') 123 | { 124 | $objUser = $db->prepare("SELECT recommendations, recommendationp FROM tl_user WHERE id=?") 125 | ->limit(1) 126 | ->execute($objUser->id); 127 | 128 | $arrRecommendationp = StringUtil::deserialize($objUser->recommendationp); 129 | 130 | if (is_array($arrRecommendationp) && in_array('create', $arrRecommendationp)) 131 | { 132 | $arrRecommendations = StringUtil::deserialize($objUser->recommendations, true); 133 | $arrRecommendations[] = $insertId; 134 | 135 | $db->prepare("UPDATE tl_user SET recommendations=? WHERE id=?") 136 | ->execute(serialize($arrRecommendations), $objUser->id); 137 | } 138 | } 139 | 140 | // Add the new element to the user object 141 | $root[] = $insertId; 142 | $objUser->recommendations = $root; 143 | } 144 | } 145 | 146 | public function addSitemapCacheInvalidationTag(DataContainer $dc, array $tags): array 147 | { 148 | $pageModel = PageModel::findWithDetails($dc->activeRecord->jumpTo); 149 | 150 | if ($pageModel === null) 151 | { 152 | return $tags; 153 | } 154 | 155 | return array_merge($tags, ['contao.sitemap.' . $pageModel->rootId]); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /contao/languages/en/tl_module.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Data security 6 | 7 | 8 | Recommendation archives 9 | 10 | 11 | Please select one or more recommendation archives. 12 | 13 | 14 | Recommendation archive 15 | 16 | 17 | Please select a recommendation archive. 18 | 19 | 20 | Featured items 21 | 22 | 23 | Here you can choose how featured items are handled. 24 | 25 | 26 | Recommendation reader module 27 | 28 | 29 | Automatically switch to the recommendation reader if an item has been selected. 30 | 31 | 32 | Meta fields 33 | 34 | 35 | Here you can select the meta fields. 36 | 37 | 38 | Add summary 39 | 40 | 41 | Adds the rating average and the total number of ratings. 42 | 43 | 44 | Use dialog popup 45 | 46 | 47 | Adds a dialog popup for the recommendation and disables the forwarding page. You don't need a reader module for this feature. 48 | 49 | 50 | Optional recommendation fields 51 | 52 | 53 | Here you can select one or more recommendation fields. Author, rating and text are mandatory. 54 | 55 | 56 | Label: Custom field 57 | 58 | 59 | Here you can add a label for the custom field. If the option is empty, the fallback from the tl_recommendation language file is used. 60 | 61 | 62 | E-mail notifications 63 | 64 | 65 | Notify me of new recommendations by e-mail. E-Mails will be sent to the system administrator. 66 | 67 | 68 | Moderate 69 | 70 | 71 | Approve recommendations before they are published on the website. 72 | 73 | 74 | Disable spam protection 75 | 76 | 77 | Here you can disable the spam protection (not recommended). 78 | 79 | 80 | Privacy text 81 | 82 | 83 | The label for a privacy checkbox. Here you can enter a privacy note to make the recommendation GDPR compliant. 84 | 85 | 86 | Send activation e-mail 87 | 88 | 89 | Send an activation e-mail to the user. 90 | 91 | 92 | Confirmation page 93 | 94 | 95 | Please choose the page to which users will be redirected after the request has been completed. 96 | 97 | 98 | Activation message 99 | 100 | 101 | You can use the wildcards <em>##domain##</em> (domain name) and <em>##link##</em> (activation link). 102 | 103 | 104 | Recommendation template 105 | 106 | 107 | Here you can select the recommendation template. 108 | 109 | 110 | External size 111 | 112 | 113 | Width and height of the external images in pixels (e.g. 128x128). 114 | 115 | 116 | Sort order 117 | 118 | 119 | Here you can choose the sort order. 120 | 121 | 122 | Minimum rating 123 | 124 | 125 | Show only recommendations with a minimum rating. 126 | 127 | 128 | To use the dialog-popup, the <em>%s</em> template needs to be included in the page layout. 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /contao/languages/de/tl_module.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Data security 6 | Datenschutz 7 | 8 | 9 | Recommendation archives 10 | Bewertungsarchive 11 | 12 | 13 | Please select one or more recommendation archives. 14 | Bitte wählen Sie ein oder mehrere Bewertungsarchive. 15 | 16 | 17 | Recommendation archive 18 | Bewertungsarchiv 19 | 20 | 21 | Please select a recommendation archive. 22 | Bitte wählen Sie ein Bewertungsarchiv aus. 23 | 24 | 25 | Featured items 26 | Hervorgehobene Bewertungen 27 | 28 | 29 | Here you can choose how featured items are handled. 30 | Hier legen Sie fest, wie hervorgehobene Bewertungen gehandhabt werden. 31 | 32 | 33 | Recommendation reader module 34 | Bewertungsleser 35 | 36 | 37 | Automatically switch to the recommendation reader if an item has been selected. 38 | Automatisch zum Bewertungsleser wechseln, wenn eine Bewertung ausgewählt wurde. 39 | 40 | 41 | Meta fields 42 | Meta-Felder 43 | 44 | 45 | Here you can select the meta fields. 46 | Hier können Sie die Meta-Felder auswählen. 47 | 48 | 49 | Add summary 50 | Zusammenfassung hinzufügen 51 | 52 | 53 | Adds the rating average and the total number of ratings. 54 | Fügt den Bewertungsdurchschnitt und die Gesamtanzahl der Bewertungen hinzu. 55 | 56 | 57 | Use dialog popup 58 | Dialog-Popup nutzen 59 | 60 | 61 | Adds a dialog popup for the recommendation and disables the forwarding page. You don't need a reader module for this feature. 62 | Fügt ein Dialog-Popup für die Bewertung hinzu und deaktiviert die Weiterleitungsseite. Für diese Funktion benötigen Sie kein Lesemodul. 63 | 64 | 65 | Optional recommendation fields 66 | Optionale Bewertungsfelder 67 | 68 | 69 | Here you can select one or more recommendation fields. Author, rating and text are mandatory. 70 | Bitte wählen Sie ein oder mehrere Bewertungsfelder. Autor, Bewertung und Text sind Pflichtfelder. 71 | 72 | 73 | Label: Custom field 74 | Label: Benutzerdefiniertes Feld 75 | 76 | 77 | Here you can add a label for the custom field. If the option is empty, the fallback from the tl_recommendation language file is used. 78 | Hier können Sie eine Beschriftung für das benutzerdefinierte Feld hinzufügen. Bei leerer Option wird der Fallback aus der Sprachdatei tl_recommendation genutzt. 79 | 80 | 81 | E-mail notifications 82 | E-Mail Benachrichtigungen 83 | 84 | 85 | Notify me of new recommendations by e-mail. E-Mails will be sent to the system administrator. 86 | Über neue Bewertungen per E-Mail benachrichtigen. E-Mails werden an den Webseiten-Administrator gesendet. 87 | 88 | 89 | Moderate 90 | Moderieren 91 | 92 | 93 | Approve recommendations before they are published on the website. 94 | Bewertungen erst nach Bestätigung auf der Webseite veröffentlichen. 95 | 96 | 97 | Disable spam protection 98 | Spam-Schutz deaktivieren 99 | 100 | 101 | Here you can disable the spam protection (not recommended). 102 | Hier können Sie den Spam-Schutz deaktivieren (nicht empfohlen). 103 | 104 | 105 | Privacy text 106 | Datenschutz Text 107 | 108 | 109 | The label for a privacy checkbox. Here you can enter a privacy note to make the recommendation GDPR compliant. 110 | Die Beschriftung für eine Datenschutz-Checkbox. Hier können Sie einen Datenschutzhinweis eingeben, um die Bewertung DSGVO-konform zu gestalten. 111 | 112 | 113 | Send activation e-mail 114 | Aktivierungsmail verschicken 115 | 116 | 117 | Send an activation e-mail to the user. 118 | Eine Aktivierungsmail an den Benutzer senden. 119 | 120 | 121 | Confirmation page 122 | Bestätigungsseite 123 | 124 | 125 | Please choose the page to which users will be redirected after the request has been completed. 126 | Bitte wählen Sie die Seite aus, zu der Benutzer nach Verarbeitung der Anfrage weitergeleitet werden. 127 | 128 | 129 | Activation message 130 | Aktivierungsmail 131 | 132 | 133 | You can use the wildcards <em>##domain##</em> (domain name) and <em>##link##</em> (activation link). 134 | Sie können die Platzhalter <em>##domain##</em> (Domainname) and <em>##link##</em> (Aktivierungslink) benutzen. 135 | 136 | 137 | Recommendation template 138 | Bewertungstemplate 139 | 140 | 141 | Here you can select the recommendation template. 142 | Hier können Sie das Bewertungstemplate auswählen. 143 | 144 | 145 | External size 146 | Externe Größe 147 | 148 | 149 | Width and height of the external images in pixels (e.g. 128x128). 150 | Breite und Höhe der externen Bilder in Pixeln (z. B. 128x128). 151 | 152 | 153 | Sort order 154 | Sortierreihenfolge 155 | 156 | 157 | Here you can choose the sort order. 158 | Hier können Sie die Sortierreihenfolge festlegen. 159 | 160 | 161 | Minimum rating 162 | Mindestbewertung 163 | 164 | 165 | Show only recommendations with a minimum rating. 166 | Nur Bewertungen mit einem Mindestrating anzeigen. 167 | 168 | 169 | To use the dialog-popup, the <em>%s</em> template needs to be included in the page layout. 170 | Um das Dialog-Popup zu nutzen muss das <em>%s</em>-Template im Seitenlayout eingebunden sein. 171 | 172 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /contao/modules/ModuleRecommendationList.php: -------------------------------------------------------------------------------- 1 | get('request_stack')->getCurrentRequest(); 51 | 52 | if ($request && System::getContainer()->get('contao.routing.scope_matcher')->isBackendRequest($request)) 53 | { 54 | $objTemplate = new BackendTemplate('be_wildcard'); 55 | $objTemplate->wildcard = '### ' . $GLOBALS['TL_LANG']['FMD']['recommendationlist'][0] . ' ###'; 56 | $objTemplate->title = $this->headline; 57 | $objTemplate->id = $this->id; 58 | $objTemplate->link = $this->name; 59 | $objTemplate->href = StringUtil::specialcharsUrl(System::getContainer()->get('router')->generate('contao_backend', ['do'=>'themes', 'table'=>'tl_module', 'act'=>'edit', 'id'=>$this->id])); 60 | 61 | return $objTemplate->parse(); 62 | } 63 | 64 | $this->recommendation_archives = $this->sortOutProtected(StringUtil::deserialize($this->recommendation_archives)); 65 | 66 | // Return if there are no archives 67 | if (empty($this->recommendation_archives) || !\is_array($this->recommendation_archives)) 68 | { 69 | return ''; 70 | } 71 | 72 | // Show the recommendation reader if an item has been selected 73 | if ($this->recommendation_readerModule > 0 && (isset($_GET['items']) || ($this->useAutoItem() && isset($_GET['auto_item'])))) 74 | { 75 | return $this->getFrontendModule($this->recommendation_readerModule, $this->strColumn); 76 | } 77 | 78 | // Tag recommendation archives 79 | if (System::getContainer()->has('fos_http_cache.http.symfony_response_tagger')) 80 | { 81 | $responseTagger = System::getContainer()->get('fos_http_cache.http.symfony_response_tagger'); 82 | $responseTagger->addTags(array_map(static fn($id) => 'contao.db.tl_recommendation_archive.' . $id, $this->recommendation_archives)); 83 | } 84 | 85 | return parent::generate(); 86 | } 87 | 88 | /** 89 | * Generate the module 90 | */ 91 | protected function compile() 92 | { 93 | System::loadLanguageFile('tl_recommendation'); 94 | 95 | $limit = null; 96 | $offset = 0; 97 | 98 | $minRating = (int) $this->recommendation_minRating; 99 | 100 | 101 | // Maximum number of items 102 | if ($this->numberOfItems > 0) 103 | { 104 | $limit = $this->numberOfItems; 105 | } 106 | 107 | // Handle featured recommendations 108 | $blnFeatured = match ($this->recommendation_featured) 109 | { 110 | 'featured' => true, 111 | 'unfeatured' => false, 112 | default => null, 113 | }; 114 | 115 | $this->Template->recommendations = []; 116 | $this->Template->empty = $GLOBALS['TL_LANG']['MSC']['emptyRecommendationList']; 117 | 118 | // Get the total number of items 119 | $intTotal = $this->countItems($this->recommendation_archives, $blnFeatured, $minRating); 120 | 121 | if ($intTotal < 1) 122 | { 123 | return; 124 | } 125 | 126 | $total = $intTotal - $offset; 127 | 128 | // Split the results 129 | if ($this->perPage > 0 && (!isset($limit) || $this->numberOfItems > $this->perPage)) 130 | { 131 | // Adjust the overall limit 132 | if (isset($limit)) 133 | { 134 | $total = min($limit, $total); 135 | } 136 | 137 | // Get the current page 138 | $id = 'page_r' . $this->id; 139 | $page = Input::get($id) ?? 1; 140 | 141 | // Do not index or cache the page if the page number is outside the range 142 | if ($page < 1 || $page > max(ceil($total/$this->perPage), 1)) 143 | { 144 | throw new PageNotFoundException('Page not found: ' . Environment::get('uri')); 145 | } 146 | 147 | // Set limit and offset 148 | $limit = $this->perPage; 149 | $offset += (max($page, 1) - 1) * $this->perPage; 150 | $skip = 0; 151 | 152 | // Overall limit 153 | if ($offset + $limit > $total + $skip) 154 | { 155 | $limit = $total + $skip - $offset; 156 | } 157 | 158 | // Add the pagination menu 159 | $objPagination = new Pagination($total, $this->perPage, Config::get('maxPaginationLinks'), $id); 160 | $this->Template->pagination = $objPagination->generate("\n "); 161 | } 162 | 163 | $objRecommendations = $this->fetchItems($this->recommendation_archives, $blnFeatured, ($limit ?: 0), $offset, $minRating); 164 | 165 | // Add summary details 166 | if ($this->recommendation_addSummary) 167 | { 168 | $objSummary = new Summary($this->recommendation_archives, $intTotal, $blnFeatured, $minRating); 169 | $this->Template->summary = $objSummary->generate(); 170 | } 171 | 172 | // Add recommendations 173 | if ($objRecommendations !== null) 174 | { 175 | $this->Template->recommendations = $this->parseRecommendations($objRecommendations); 176 | } 177 | } 178 | 179 | /** 180 | * Count the total matching items 181 | * 182 | * @param array $recommendationArchives 183 | * @param boolean $blnFeatured 184 | * 185 | * @return integer 186 | */ 187 | protected function countItems($recommendationArchives, $blnFeatured, $minRating) 188 | { 189 | // HOOK: add custom logic 190 | if (isset($GLOBALS['TL_HOOKS']['recommendationListCountItems']) && \is_array($GLOBALS['TL_HOOKS']['recommendationListCountItems'])) 191 | { 192 | foreach ($GLOBALS['TL_HOOKS']['recommendationListCountItems'] as $callback) 193 | { 194 | if (($intResult = System::importStatic($callback[0])->{$callback[1]}($recommendationArchives, $blnFeatured, $this)) === false) 195 | { 196 | continue; 197 | } 198 | 199 | if (\is_int($intResult)) 200 | { 201 | return $intResult; 202 | } 203 | } 204 | } 205 | 206 | //return RecommendationModel::countPublishedByPids($recommendationArchives, $blnFeatured, $minRating); 207 | return $this->fetchItems($recommendationArchives, $blnFeatured, 0, 0, $minRating)?->count() ?? 0; 208 | } 209 | 210 | /** 211 | * Fetch the matching items 212 | */ 213 | protected function fetchItems($recommendationArchives, $blnFeatured, $limit, $offset, $minRating) 214 | { 215 | // HOOK: add custom logic 216 | if (isset($GLOBALS['TL_HOOKS']['recommendationListFetchItems']) && \is_array($GLOBALS['TL_HOOKS']['recommendationListFetchItems'])) 217 | { 218 | foreach ($GLOBALS['TL_HOOKS']['recommendationListFetchItems'] as $callback) 219 | { 220 | if (($objCollection = System::importStatic($callback[0])->{$callback[1]}($recommendationArchives, $blnFeatured, $limit, $offset, $this)) === false) 221 | { 222 | continue; 223 | } 224 | 225 | if ($objCollection === null || $objCollection instanceof Collection) 226 | { 227 | return $objCollection; 228 | } 229 | } 230 | } 231 | 232 | $t = RecommendationModel::getTable(); 233 | $order = ''; 234 | 235 | if ($this->recommendation_featured == 'featured_first') 236 | { 237 | $order .= "$t.featured DESC, "; 238 | } 239 | 240 | match ($this->recommendation_order) { 241 | 'order_random' => $order .= "RAND()", 242 | 'order_date_asc' => $order .= "$t.date", 243 | 'order_rating_desc' => $order .= "$t.rating DESC", 244 | default => $order .= "$t.date DESC", 245 | }; 246 | 247 | // Get archives 248 | $archives = RecommendationArchiveModel::findMultipleByIds($recommendationArchives); 249 | $archives = array_combine( 250 | $archives->fetchEach('id'), 251 | $archives->fetchAll() 252 | ); 253 | 254 | // Fetch items 255 | $items = []; 256 | 257 | // Get auto item 258 | $autoItem = Input::get('auto_item', false, true); 259 | 260 | foreach (RecommendationModel::findPublishedByPids($recommendationArchives, $blnFeatured, $limit, $offset, $minRating, ['order'=>$order]) ?? [] as $item) 261 | { 262 | if($archives[$item->pid]['useAutoItem'] ?? null) 263 | { 264 | // Add only if the scope match to the current auto_item 265 | if($autoItem === $item->scope) 266 | { 267 | $items[] = $item; 268 | } 269 | }else{ 270 | $items[] = $item; 271 | } 272 | } 273 | 274 | return new Collection($items, $t); 275 | } 276 | } 277 | 278 | class_alias(ModuleRecommendationList::class, 'ModuleRecommendationList'); 279 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/RecommendationListener.php: -------------------------------------------------------------------------------- 1 | activeRecord) 57 | { 58 | return; 59 | } 60 | 61 | $arrSet['date'] = strtotime(date('Y-m-d', $dc->activeRecord->date) . ' ' . date('H:i:s', $dc->activeRecord->time)); 62 | $arrSet['time'] = $arrSet['date']; 63 | 64 | $db = Database::getInstance(); 65 | $db->prepare("UPDATE tl_recommendation %s WHERE id=?")->set($arrSet)->execute($dc->id); 66 | } 67 | 68 | /** 69 | * @throws Exception 70 | */ 71 | public function generateRecommendationAlias($varValue, DataContainer $dc) 72 | { 73 | $db = Database::getInstance(); 74 | 75 | $aliasExists = (fn(string $alias): bool => $db->prepare("SELECT id FROM tl_recommendation WHERE alias=? AND id!=?")->execute($alias, $dc->id)->numRows > 0); 76 | 77 | // Generate alias if there is none 78 | if (!$varValue) 79 | { 80 | // Use alias prefix if no title has been set 81 | if (!$title = $dc->activeRecord->title) 82 | { 83 | $title = Config::get('recommendationAliasPrefix') ?? 'recommendation'; 84 | } 85 | 86 | $varValue = System::getContainer()->get('contao.slug')->generate($title, RecommendationArchiveModel::findByPk($dc->activeRecord->pid)->jumpTo, $aliasExists); 87 | } 88 | elseif (preg_match('/^[1-9]\d*$/', (string) $varValue)) 89 | { 90 | throw new Exception(sprintf($GLOBALS['TL_LANG']['ERR']['aliasNumeric'], $varValue)); 91 | } 92 | elseif ($aliasExists($varValue)) 93 | { 94 | throw new Exception(sprintf($GLOBALS['TL_LANG']['ERR']['aliasExists'], $varValue)); 95 | } 96 | 97 | return $varValue; 98 | } 99 | 100 | public function checkRecommendationPermission(DataContainer $dc) 101 | { 102 | $objUser = Controller::getContainer()->get('security.helper')->getUser(); 103 | 104 | if ($objUser->isAdmin) 105 | { 106 | return; 107 | } 108 | 109 | // Set the root IDs 110 | if (empty($objUser->recommendations) || !is_array($objUser->recommendations)) 111 | { 112 | $root = [0]; 113 | } 114 | else 115 | { 116 | $root = $objUser->recommendations; 117 | } 118 | 119 | $id = strlen(Input::get('id')) ? Input::get('id') : $dc->currentPid; 120 | $db = Database::getInstance(); 121 | 122 | // Check current action 123 | switch (Input::get('act')) 124 | { 125 | case 'paste': 126 | case 'select': 127 | // Check currentPid 128 | if (!in_array($dc->currentPid, $root)) 129 | { 130 | throw new AccessDeniedException('Not enough permissions to access recommendation archive ID ' . $id . '.'); 131 | } 132 | break; 133 | 134 | case 'create': 135 | if (!Input::get('pid') || !in_array(Input::get('pid'), $root)) 136 | { 137 | throw new AccessDeniedException('Not enough permissions to create recommendation items in recommendation archive ID ' . Input::get('pid') . '.'); 138 | } 139 | break; 140 | 141 | case 'cut': 142 | case 'copy': 143 | if (Input::get('act') == 'cut' && Input::get('mode') == 1) 144 | { 145 | $objArchive = $db->prepare("SELECT pid FROM tl_recommendation WHERE id=?") 146 | ->limit(1) 147 | ->execute(Input::get('pid')); 148 | 149 | if ($objArchive->numRows < 1) 150 | { 151 | throw new AccessDeniedException('Invalid recommendation item ID ' . Input::get('pid') . '.'); 152 | } 153 | 154 | $pid = $objArchive->pid; 155 | } 156 | else 157 | { 158 | $pid = Input::get('pid'); 159 | } 160 | 161 | if (!in_array($pid, $root)) 162 | { 163 | throw new AccessDeniedException('Not enough permissions to ' . Input::get('act') . ' recommendation item ID ' . $id . ' to recommendation archive ID ' . $pid . '.'); 164 | } 165 | // no break 166 | 167 | case 'edit': 168 | case 'show': 169 | case 'delete': 170 | case 'toggle': 171 | $objArchive = $db->prepare("SELECT pid FROM tl_recommendation WHERE id=?") 172 | ->limit(1) 173 | ->execute($id); 174 | 175 | if ($objArchive->numRows < 1) 176 | { 177 | throw new AccessDeniedException('Invalid recommendation item ID ' . $id . '.'); 178 | } 179 | 180 | if (!in_array($objArchive->pid, $root)) 181 | { 182 | throw new AccessDeniedException('Not enough permissions to ' . Input::get('act') . ' recommendation item ID ' . $id . ' of recommendation archive ID ' . $objArchive->pid . '.'); 183 | } 184 | break; 185 | 186 | case 'editAll': 187 | case 'deleteAll': 188 | case 'overrideAll': 189 | case 'cutAll': 190 | case 'copyAll': 191 | if (!in_array($id, $root)) 192 | { 193 | throw new AccessDeniedException('Not enough permissions to access recommendation archive ID ' . $id . '.'); 194 | } 195 | 196 | $objArchive = $db->prepare("SELECT id FROM tl_recommendation WHERE pid=?") 197 | ->execute($id); 198 | 199 | $objSession = System::getContainer()->get('request_stack')->getSession(); 200 | 201 | $session = $objSession->all(); 202 | $session['CURRENT']['IDS'] = array_intersect((array) $session['CURRENT']['IDS'], $objArchive->fetchEach('id')); 203 | $objSession->replace($session); 204 | break; 205 | 206 | default: 207 | if (Input::get('act')) 208 | { 209 | throw new AccessDeniedException('Invalid command "' . Input::get('act') . '".'); 210 | } 211 | 212 | if (!in_array($id, $root)) 213 | { 214 | throw new AccessDeniedException('Not enough permissions to access recommendation archive ID ' . $id . '.'); 215 | } 216 | break; 217 | } 218 | } 219 | 220 | /** 221 | * List a recommendation record 222 | */ 223 | public function listRecommendations(array $arrRow): string 224 | { 225 | if(!$arrRow['verified']) 226 | { 227 | return '
' . $arrRow['author'] . ' [' . ($GLOBALS['TL_LANG']['tl_recommendation']['notVerified'] ?? null) . ']
'; 228 | } 229 | 230 | return '
' . $arrRow['author'] . ' [' . Date::parse(Config::get('datimFormat'), $arrRow['date']) . ']
'; 231 | } 232 | 233 | /** 234 | * Check for modified recommendation and update the XML files if necessary 235 | */ 236 | public function generateSitemap(): void 237 | { 238 | /** @var SessionInterface $objSession */ 239 | $objSession = System::getContainer()->get('request_stack')->getSession(); 240 | 241 | $session = $objSession->get('recommendation_updater'); 242 | 243 | if (empty($session) || !is_array($session)) 244 | { 245 | return; 246 | } 247 | 248 | $automator = new Automator(); 249 | $automator->generateSitemap(); 250 | 251 | $objSession->set('recommendation_updater', null); 252 | } 253 | 254 | /** 255 | * Schedule a recommendation update 256 | * 257 | * This method is triggered when a single recommendation or multiple recommendations 258 | * are modified (edit/editAll), moved (cut/cutAll) or deleted (delete/deleteAll). 259 | * Since duplicated items are unpublished by default, it is not necessary to 260 | * schedule updates on copyAll as well. 261 | */ 262 | public function scheduleUpdate(DataContainer $dc): void 263 | { 264 | // Return if there is no ID 265 | if (!$dc->activeRecord || !$dc->activeRecord->pid || Input::get('act') == 'copy') 266 | { 267 | return; 268 | } 269 | 270 | /** @var SessionInterface $objSession */ 271 | $objSession = System::getContainer()->get('request_stack')->getSession(); 272 | 273 | // Store the ID in the session 274 | $session = $objSession->get('recommendation_updater'); 275 | $session[] = $dc->activeRecord->pid; 276 | $objSession->set('recommendation_updater', array_unique($session)); 277 | } 278 | 279 | public function addSitemapCacheInvalidationTag(DataContainer $dc, array $tags): array 280 | { 281 | $archiveModel = RecommendationArchiveModel::findByPk($dc->activeRecord->pid); 282 | $pageModel = PageModel::findWithDetails($archiveModel->jumpTo); 283 | 284 | if ($pageModel === null) 285 | { 286 | return $tags; 287 | } 288 | 289 | return array_merge($tags, ['contao.sitemap.' . $pageModel->rootId]); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /contao/languages/en/tl_recommendation.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Not verified 6 | 7 | 8 | Title 9 | 10 | 11 | Please enter the recommendation title. 12 | 13 | 14 | Recommendation alias 15 | 16 | 17 | The recommendation alias is a unique reference to the recommendation which can be called instead of its numeric ID. 18 | 19 | 20 | Author 21 | 22 | 23 | Here you can change the author of the recommendation. 24 | 25 | 26 | Location 27 | 28 | 29 | Here you can enter the name of the location. 30 | 31 | 32 | Date 33 | 34 | 35 | Please enter the date according to the global date format. 36 | 37 | 38 | Time 39 | 40 | 41 | Please enter the time according to the global time format. 42 | 43 | 44 | Rating 45 | 46 | 47 | Here you can enter a recommendation. 48 | 49 | 50 | Custom field 51 | 52 | 53 | Here you can add additional text that should be displayed within the recommendation. 54 | 55 | 56 | Recommendation teaser 57 | 58 | 59 | The recommendation teaser can be shown in a recommendation list instead of the full text. A "read more …" link will be added automatically. 60 | 61 | 62 | Recommendation text 63 | 64 | 65 | Here you can enter the recommendation text. 66 | 67 | 68 | E-mail address 69 | 70 | 71 | The e-mail address will not be published. 72 | 73 | 74 | Image 75 | 76 | 77 | Image 78 | 79 | 80 | Here you can enter a recommendation image. 81 | 82 | 83 | CSS class 84 | 85 | 86 | Here you can enter one or more classes. 87 | 88 | 89 | Feature recommendation 90 | 91 | 92 | Show the recommendation in a featured recommendation list. 93 | 94 | 95 | Publish recommendation 96 | 97 | 98 | Make the recommendation publicly visible on the website. 99 | 100 | 101 | Verified 102 | 103 | 104 | Indicates if the rating is verified. 105 | 106 | 107 | Show from 108 | 109 | 110 | Do not show the recommendation on the website before this day. 111 | 112 | 113 | Show until 114 | 115 | 116 | Do not show the recommendation on the website on and after this day. 117 | 118 | 119 | Revision date 120 | 121 | 122 | Date and time of the latest revision 123 | 124 | 125 | Title and author 126 | 127 | 128 | Date and time 129 | 130 | 131 | Recommendation 132 | 133 | 134 | Teaser 135 | 136 | 137 | Expert settings 138 | 139 | 140 | Publish settings 141 | 142 | 143 | New recommendation 144 | 145 | 146 | Create a new recommendation 147 | 148 | 149 | Recommendation details 150 | 151 | 152 | Show the details of recommendation ID %s 153 | 154 | 155 | Edit recommendation 156 | 157 | 158 | Edit recommendation ID %s 159 | 160 | 161 | Duplicate recommendation 162 | 163 | 164 | Duplicate recommendation ID %s 165 | 166 | 167 | Move recommendation 168 | 169 | 170 | Move recommendation ID %s 171 | 172 | 173 | Delete recommendation 174 | 175 | 176 | Delete recommendation ID %s 177 | 178 | 179 | Publish/unpublish recommendation 180 | 181 | 182 | Publish/unpublish recommendation ID %s 183 | 184 | 185 | Feature/unfeature recommendation 186 | 187 | 188 | Feature/unfeature recommendation ID %s 189 | 190 | 191 | Edit recommendation settings 192 | 193 | 194 | Edit the recommendation settings 195 | 196 | 197 | Paste into this recommendation archive 198 | 199 | 200 | Paste after recommendation ID %s 201 | 202 | 203 | Submit recommendation 204 | 205 | 206 | Field 207 | 208 | 209 | %s years ago 210 | 211 | 212 | a year ago 213 | 214 | 215 | %s months ago 216 | 217 | 218 | a month ago 219 | 220 | 221 | %s weeks ago 222 | 223 | 224 | a week ago 225 | 226 | 227 | %s days ago 228 | 229 | 230 | 1 day ago 231 | 232 | 233 | %s hours ago 234 | 235 | 236 | 1 hour ago 237 | 238 | 239 | just now 240 | 241 | 242 | recommendation 243 | 244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /contao/dca/tl_recommendation.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'dataContainer' => DC_Table::class, 19 | 'ptable' => 'tl_recommendation_archive', 20 | 'switchToEdit' => true, 21 | 'enableVersioning' => true, 22 | 'onload_callback' => [ 23 | [RecommendationListener::class, 'checkRecommendationPermission'], 24 | [RecommendationListener::class, 'generateSitemap'] 25 | ], 26 | 'oncut_callback' => [ 27 | [RecommendationListener::class, 'scheduleUpdate'] 28 | ], 29 | 'ondelete_callback' => [ 30 | [RecommendationListener::class, 'scheduleUpdate'] 31 | ], 32 | 'onsubmit_callback' => [ 33 | [RecommendationListener::class, 'adjustTime'], 34 | [RecommendationListener::class, 'scheduleUpdate'] 35 | ], 36 | 'oninvalidate_cache_tags_callback' => [ 37 | [RecommendationListener::class, 'addSitemapCacheInvalidationTag'], 38 | ], 39 | 'sql' => [ 40 | 'keys' => [ 41 | 'id' => 'primary', 42 | 'alias' => 'index', 43 | 'pid,start,stop,published' => 'index' 44 | ] 45 | ] 46 | ], 47 | 48 | // List 49 | 'list' => [ 50 | 'sorting' => [ 51 | 'mode' => DataContainer::MODE_PARENT, 52 | 'fields' => ['date DESC'], 53 | 'headerFields' => ['title', 'jumpTo', 'tstamp', 'protected'], 54 | 'panelLayout' => 'filter;sort,search,limit', 55 | 'child_record_callback' => [RecommendationListener::class, 'listRecommendations'], 56 | 'child_record_class' => 'no_padding' 57 | ], 58 | 'global_operations' => [ 59 | 'all', 60 | ], 61 | 'operations' => [ 62 | 'edit', 63 | 'copy', 64 | 'cut', 65 | 'delete', 66 | 'toggle', 67 | 'feature' => [ 68 | 'href' => 'act=toggle&field=featured', 69 | 'icon' => 'featured.svg', 70 | ], 71 | 'show', 72 | ] 73 | ], 74 | 75 | // Palettes 76 | 'palettes' => [ 77 | 'default' => '{title_legend},author,title,alias,email,location;{date_legend},date,time;{recommendation_legend},text,imageUrl,rating,customField;{teaser_legend:collapsed},teaser;{expert_legend:collapsed},cssClass,featured;{publish_legend},published,start,stop' 78 | ], 79 | 80 | // Fields 81 | 'fields' => [ 82 | 'id' => [ 83 | 'sql' => "int(10) unsigned NOT NULL auto_increment" 84 | ], 85 | 'pid' => [ 86 | 'foreignKey' => 'tl_recommendation_archive.title', 87 | 'sql' => "int(10) unsigned NOT NULL default '0'", 88 | 'relation' => ['type'=>'belongsTo', 'load'=>'lazy'] 89 | ], 90 | 'tstamp' => [ 91 | 'sql' => "int(10) unsigned NOT NULL default '0'" 92 | ], 93 | 'title' => [ 94 | 'exclude' => true, 95 | 'search' => true, 96 | 'sorting' => true, 97 | 'flag' => DataContainer::SORT_INITIAL_LETTER_ASC, 98 | 'inputType' => 'text', 99 | 'eval' => ['maxlength'=>255, 'tl_class'=>'w50 clr'], 100 | 'sql' => "varchar(255) NOT NULL default ''" 101 | ], 102 | 'alias' => [ 103 | 'exclude' => true, 104 | 'search' => true, 105 | 'inputType' => 'text', 106 | 'eval' => ['rgxp'=>'alias', 'doNotCopy'=>true, 'unique'=>true, 'maxlength'=>128, 'tl_class'=>'w50'], 107 | 'save_callback' => [ 108 | [RecommendationListener::class, 'generateRecommendationAlias'] 109 | ], 110 | 'sql' => "varchar(255) BINARY NOT NULL default ''" 111 | ], 112 | 'author' => [ 113 | 'exclude' => true, 114 | 'search' => true, 115 | 'sorting' => true, 116 | 'flag' => DataContainer::SORT_INITIAL_LETTER_ASC, 117 | 'inputType' => 'text', 118 | 'eval' => ['doNotCopy'=>true, 'mandatory'=>true, 'maxlength'=>128, 'tl_class'=>'w50'], 119 | 'sql' => "varchar(128) NOT NULL default ''" 120 | ], 121 | 'email' => [ 122 | 'exclude' => true, 123 | 'search' => true, 124 | 'inputType' => 'text', 125 | 'eval' => ['doNotCopy'=>true, 'maxlength'=>255, 'rgxp'=>'email', 'decodeEntities'=>true, 'tl_class'=>'w50'], 126 | 'sql' => "varchar(255) NOT NULL default ''" 127 | ], 128 | 'location' => [ 129 | 'exclude' => true, 130 | 'search' => true, 131 | 'sorting' => true, 132 | 'flag' => DataContainer::SORT_INITIAL_LETTER_ASC, 133 | 'inputType' => 'text', 134 | 'eval' => ['doNotCopy'=>true, 'maxlength'=>128, 'tl_class'=>'w50'], 135 | 'sql' => "varchar(128) NOT NULL default ''" 136 | ], 137 | 'date' => [ 138 | 'default' => time(), 139 | 'exclude' => true, 140 | 'filter' => true, 141 | 'sorting' => true, 142 | 'flag' => DataContainer::SORT_MONTH_DESC, 143 | 'inputType' => 'text', 144 | 'eval' => ['rgxp'=>'date', 'mandatory'=>true, 'doNotCopy'=>true, 'datepicker'=>true, 'tl_class'=>'w50 wizard'], 145 | 'load_callback' => [ 146 | [RecommendationListener::class, 'loadDate'] 147 | ], 148 | 'sql' => "int(10) unsigned NOT NULL default 0" 149 | ], 150 | 'time' => [ 151 | 'default' => time(), 152 | 'exclude' => true, 153 | 'inputType' => 'text', 154 | 'eval' => ['rgxp'=>'time', 'mandatory'=>true, 'doNotCopy'=>true, 'tl_class'=>'w50'], 155 | 'load_callback' => [ 156 | [RecommendationListener::class, 'loadTime'] 157 | ], 158 | 'sql' => "int(10) NOT NULL default 0" 159 | ], 160 | 'teaser' => [ 161 | 'exclude' => true, 162 | 'search' => true, 163 | 'inputType' => 'textarea', 164 | 'eval' => ['rte'=>'tinyMCE', 'tl_class'=>'clr'], 165 | 'sql' => "mediumtext NULL" 166 | ], 167 | 'text' => [ 168 | 'exclude' => true, 169 | 'search' => true, 170 | 'inputType' => 'textarea', 171 | 'eval' => ['rte'=>'tinyMCE', 'tl_class'=>'clr'], 172 | 'sql' => "mediumtext NULL" 173 | ], 174 | 'imageUrl' => [ 175 | 'exclude' => true, 176 | 'search' => true, 177 | 'inputType' => 'text', 178 | 'eval' => ['rgxp'=>'url', 'decodeEntities'=>true, 'maxlength'=>255, 'dcaPicker'=>true, 'tl_class'=>'w50 wizard'], 179 | 'sql' => "varchar(255) NOT NULL default ''" 180 | ], 181 | 'rating' => [ 182 | 'default' => 5, 183 | 'exclude' => true, 184 | 'search' => true, 185 | 'filter' => true, 186 | 'sorting' => true, 187 | 'inputType' => 'select', 188 | 'options' => [1,2,3,4,5], 189 | 'eval' => ['mandatory'=>true, 'tl_class'=>'w50'], 190 | 'sql' => "char(1) NOT NULL default ''" 191 | ], 192 | 'customField' => [ 193 | 'exclude' => true, 194 | 'inputType' => 'text', 195 | 'eval' => ['doNotCopy'=>true, 'maxlength'=>255, 'tl_class'=>'w100 clr'], 196 | 'sql' => "varchar(255) NOT NULL default ''" 197 | ], 198 | 'scope' => [ 199 | 'inputType' => 'text', 200 | 'filter' => true, 201 | 'sql' => "varchar(255) NOT NULL default ''" 202 | ], 203 | 'cssClass' => [ 204 | 'exclude' => true, 205 | 'inputType' => 'text', 206 | 'eval' => ['tl_class'=>'w50'], 207 | 'sql' => "varchar(255) NOT NULL default ''" 208 | ], 209 | 'featured' => [ 210 | 'exclude' => true, 211 | 'toggle' => true, 212 | 'filter' => true, 213 | 'inputType' => 'checkbox', 214 | 'eval' => ['tl_class'=>'w50 m12'], 215 | 'sql' => "char(1) NOT NULL default ''" 216 | ], 217 | 'verified' => [ 218 | 'filter' => true, 219 | 'eval' => ['isBoolean'=>true], 220 | 'sql' => "char(1) NOT NULL default '1'" 221 | ], 222 | 'published' => [ 223 | 'exclude' => true, 224 | 'toggle' => true, 225 | 'filter' => true, 226 | 'flag' => DataContainer::SORT_INITIAL_LETTER_ASC, 227 | 'inputType' => 'checkbox', 228 | 'eval' => ['doNotCopy'=>true], 229 | 'sql' => "char(1) NOT NULL default ''" 230 | ], 231 | 'start' => [ 232 | 'exclude' => true, 233 | 'inputType' => 'text', 234 | 'eval' => ['rgxp'=>'datim', 'datepicker'=>true, 'tl_class'=>'w50 wizard'], 235 | 'sql' => "varchar(10) NOT NULL default ''" 236 | ], 237 | 'stop' => [ 238 | 'exclude' => true, 239 | 'inputType' => 'text', 240 | 'eval' => ['rgxp'=>'datim', 'datepicker'=>true, 'tl_class'=>'w50 wizard'], 241 | 'sql' => "varchar(10) NOT NULL default ''" 242 | ] 243 | ] 244 | ]; 245 | -------------------------------------------------------------------------------- /src/Model/RecommendationModel.php: -------------------------------------------------------------------------------- 1 | '" . ($time + 60) . "') AND $t.published='1'"; 136 | } 137 | 138 | return static::findOneBy($arrColumns, $varId, $arrOptions); 139 | } 140 | 141 | /** 142 | * Find published recommendations with the default redirect target by their parent ID 143 | */ 144 | public static function findPublishedByPid(int $intPid, array $arrOptions=[]): Collection|RecommendationModel|array|null 145 | { 146 | $t = static::$strTable; 147 | $arrColumns = ["$t.pid=? AND $t.verified='1'"]; 148 | 149 | if (!static::isPreviewMode($arrOptions)) 150 | { 151 | $time = Date::floorToMinute(); 152 | $arrColumns[] = "($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "') AND $t.published='1'"; 153 | } 154 | 155 | if (!isset($arrOptions['order'])) 156 | { 157 | $arrOptions['order'] = "$t.date DESC"; 158 | } 159 | 160 | return static::findBy($arrColumns, $intPid, $arrOptions); 161 | } 162 | 163 | /** 164 | * Find published recommendations by their parent ID 165 | */ 166 | public static function findPublishedByPids($arrPids, bool $blnFeatured=null, int $intLimit=0, int $intOffset=0, $minRating=null, array $arrOptions=[]): Collection|RecommendationModel|array|null 167 | { 168 | if (empty($arrPids) || !\is_array($arrPids)) 169 | { 170 | return null; 171 | } 172 | 173 | $t = static::$strTable; 174 | $arrColumns = ["$t.pid IN(" . implode(',', array_map('\intval', $arrPids)) . ") AND $t.verified='1'"]; 175 | 176 | if ($blnFeatured === true) 177 | { 178 | $arrColumns[] = "$t.featured='1'"; 179 | } 180 | elseif ($blnFeatured === false) 181 | { 182 | $arrColumns[] = "$t.featured=''"; 183 | } 184 | 185 | if ($minRating > 1) 186 | { 187 | $arrColumns[] = "$t.rating >= $minRating"; 188 | } 189 | 190 | if (!static::isPreviewMode($arrOptions)) 191 | { 192 | $time = Date::floorToMinute(); 193 | $arrColumns[] = "($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "') AND $t.published='1'"; 194 | } 195 | 196 | if (!isset($arrOptions['order'])) 197 | { 198 | $arrOptions['order'] = "$t.date DESC"; 199 | } 200 | 201 | $arrOptions['limit'] = $intLimit; 202 | $arrOptions['offset'] = $intOffset; 203 | 204 | return static::findBy($arrColumns, null, $arrOptions); 205 | } 206 | 207 | /** 208 | * Count published recommendations by their parent ID 209 | */ 210 | public static function countPublishedByPids($arrPids, bool $blnFeatured=null, $minRating=null, array $arrOptions=[]): int 211 | { 212 | if (empty($arrPids) || !\is_array($arrPids)) 213 | { 214 | return 0; 215 | } 216 | 217 | $t = static::$strTable; 218 | $arrColumns = ["$t.pid IN(" . implode(',', array_map('\intval', $arrPids)) . ") AND $t.verified='1'"]; 219 | 220 | if ($blnFeatured === true) 221 | { 222 | $arrColumns[] = "$t.featured='1'"; 223 | } 224 | elseif ($blnFeatured === false) 225 | { 226 | $arrColumns[] = "$t.featured=''"; 227 | } 228 | 229 | if ($minRating > 1) 230 | { 231 | $arrColumns[] = "$t.rating >= $minRating"; 232 | } 233 | 234 | if (!static::isPreviewMode($arrOptions)) 235 | { 236 | $time = Date::floorToMinute(); 237 | $arrColumns[] = "($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "') AND $t.published='1'"; 238 | } 239 | 240 | return static::countBy($arrColumns, null, $arrOptions); 241 | } 242 | 243 | /** 244 | * Find registrations that have not been activated for more than 24 hours 245 | */ 246 | public static function findExpiredRecommendations(array $arrOptions=[]): Collection|RecommendationModel|array|null 247 | { 248 | $t = static::$strTable; 249 | $objDatabase = Database::getInstance(); 250 | 251 | $objResult = $objDatabase->prepare("SELECT * FROM $t WHERE verified='0' AND EXISTS (SELECT * FROM tl_opt_in_related r LEFT JOIN tl_opt_in o ON r.pid=o.id WHERE r.relTable='$t' AND r.relId=$t.id AND o.createdOn<=? AND o.confirmedOn=0)") 252 | ->execute(strtotime('-24 hours')); 253 | 254 | if ($objResult->numRows < 1) 255 | { 256 | return null; 257 | } 258 | 259 | return static::createCollectionFromDbResult($objResult, $t); 260 | } 261 | } 262 | 263 | class_alias(RecommendationModel::class, 'RecommendationModel'); 264 | -------------------------------------------------------------------------------- /contao/dca/tl_module.php: -------------------------------------------------------------------------------- 1 | true, 36 | 'inputType' => 'checkbox', 37 | 'options_callback' => ['tl_module_recommendation', 'getRecommendationArchives'], 38 | 'eval' => ['multiple'=>true, 'mandatory'=>true], 39 | 'sql' => "blob NULL" 40 | ]; 41 | 42 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_archive'] = [ 43 | 'exclude' => true, 44 | 'inputType' => 'select', 45 | 'options_callback' => ['tl_module_recommendation', 'getRecommendationArchives'], 46 | 'eval' => ['mandatory'=>true, 'includeBlankOption'=>true, 'tl_class'=>'w50 clr'], 47 | 'sql' => "int(10) unsigned NOT NULL default 0" 48 | ]; 49 | 50 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_featured'] = [ 51 | 'default' => 'all_items', 52 | 'exclude' => true, 53 | 'inputType' => 'select', 54 | 'options' => ['all_items', 'featured', 'unfeatured', 'featured_first'], 55 | 'reference' => &$GLOBALS['TL_LANG']['tl_recommendation_list'], 56 | 'eval' => ['tl_class'=>'w50'], 57 | 'sql' => "varchar(16) NOT NULL default ''" 58 | ]; 59 | 60 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_readerModule'] = [ 61 | 'exclude' => true, 62 | 'inputType' => 'select', 63 | 'options_callback' => ['tl_module_recommendation', 'getReaderModules'], 64 | 'reference' => &$GLOBALS['TL_LANG']['tl_module'], 65 | 'eval' => ['includeBlankOption'=>true, 'tl_class'=>'w50'], 66 | 'sql' => "int(10) unsigned NOT NULL default 0" 67 | ]; 68 | 69 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_metaFields'] = [ 70 | 'default' => ['date', 'author'], 71 | 'exclude' => true, 72 | 'inputType' => 'checkbox', 73 | 'options' => ['image', 'date', 'author', 'rating', 'location', 'customField'], 74 | 'reference' => &$GLOBALS['TL_LANG']['tl_recommendation'], 75 | 'eval' => ['multiple'=>true], 76 | 'sql' => "varchar(255) NOT NULL default ''" 77 | ]; 78 | 79 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_addSummary'] = [ 80 | 'exclude' => true, 81 | 'inputType' => 'checkbox', 82 | 'eval' => ['tl_class'=>'clr'], 83 | 'sql' => "char(1) NOT NULL default ''" 84 | ]; 85 | 86 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_useDialog'] = [ 87 | 'exclude' => true, 88 | 'inputType' => 'checkbox', 89 | 'eval' => ['tl_class'=>'clr'], 90 | 'sql' => "char(1) NOT NULL default ''" 91 | ]; 92 | 93 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_order'] = [ 94 | 'exclude' => true, 95 | 'inputType' => 'select', 96 | 'options' => ['order_date_asc', 'order_date_desc', 'order_random', 'order_rating_desc'], 97 | 'reference' => &$GLOBALS['TL_LANG']['tl_recommendation_list'], 98 | 'eval' => ['tl_class'=>'w50'], 99 | 'sql' => "varchar(32) NOT NULL default 'order_date_desc'" 100 | ]; 101 | 102 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_minRating'] = [ 103 | 'exclude' => true, 104 | 'inputType' => 'select', 105 | 'options' => [1=> 'minOne', 2=>'minTwo', 3=>'minThree', 4=>'minFour', 5=>'minFive'], 106 | 'reference' => &$GLOBALS['TL_LANG']['tl_recommendation_list'], 107 | 'eval' => ['tl_class'=>'w50'], 108 | 'sql' => "char(1) NOT NULL default '1'" 109 | ]; 110 | 111 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_optionalFormFields'] = [ 112 | 'exclude' => true, 113 | 'inputType' => 'checkbox', 114 | 'options' => ['title', 'location', 'email', 'customField'], 115 | 'reference' => &$GLOBALS['TL_LANG']['tl_recommendation'], 116 | 'eval' => ['multiple'=>true, 'tl_class'=>'w50 clr'], 117 | 'sql' => "varchar(255) NOT NULL default ''" 118 | ]; 119 | 120 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_customFieldLabel'] = [ 121 | 'exclude' => true, 122 | 'inputType' => 'text', 123 | 'eval' => ['maxlength'=>64, 'tl_class'=>'w50 clr'], 124 | 'sql' => "varchar(64) NOT NULL default ''" 125 | ]; 126 | 127 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_notify'] = [ 128 | 'default' => true, 129 | 'exclude' => true, 130 | 'inputType' => 'checkbox', 131 | 'eval' => ['tl_class'=>'w50 clr'], 132 | 'sql' => "char(1) NOT NULL default '1'" 133 | ]; 134 | 135 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_moderate'] = [ 136 | 'default' => true, 137 | 'exclude' => true, 138 | 'inputType' => 'checkbox', 139 | 'eval' => ['tl_class'=>'w50'], 140 | 'sql' => "char(1) NOT NULL default '1'" 141 | ]; 142 | 143 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_disableCaptcha'] = [ 144 | 'exclude' => true, 145 | 'inputType' => 'checkbox', 146 | 'eval' => ['tl_class'=>'w50'], 147 | 'sql' => "char(1) NOT NULL default ''" 148 | ]; 149 | 150 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_disableCaptcha'] = [ 151 | 'exclude' => true, 152 | 'inputType' => 'checkbox', 153 | 'eval' => ['tl_class'=>'w50'], 154 | 'sql' => "char(1) NOT NULL default ''" 155 | ]; 156 | 157 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_privacyText'] = [ 158 | 'exclude' => true, 159 | 'inputType' => 'textarea', 160 | 'eval' => ['style'=>'height:100px', 'allowHtml'=>true], 161 | 'sql' => "text NULL" 162 | ]; 163 | 164 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_activate'] = [ 165 | 'exclude' => true, 166 | 'inputType' => 'checkbox', 167 | 'eval' => ['submitOnChange'=>true], 168 | 'sql' => "char(1) NOT NULL default ''" 169 | ]; 170 | 171 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_activateJumpTo'] = [ 172 | 'exclude' => true, 173 | 'inputType' => 'pageTree', 174 | 'foreignKey' => 'tl_page.title', 175 | 'eval' => ['fieldType'=>'radio'], 176 | 'sql' => "int(10) unsigned NOT NULL default 0", 177 | 'relation' => ['type'=>'hasOne', 'load'=>'lazy'] 178 | ]; 179 | 180 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_activateText'] = [ 181 | 'exclude' => true, 182 | 'inputType' => 'textarea', 183 | 'eval' => ['style'=>'height:120px', 'decodeEntities'=>true, 'alwaysSave'=>true], 184 | 'load_callback' => [['tl_module_recommendation', 'getRecommendationActivationDefault']], 185 | 'sql' => "text NULL" 186 | ]; 187 | 188 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_template'] = [ 189 | 'exclude' => true, 190 | 'inputType' => 'select', 191 | 'options_callback' => static fn () => Controller::getTemplateGroup('recommendation_'), 192 | 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class'=>'w50'], 193 | 'sql' => "varchar(64) NOT NULL default ''" 194 | ]; 195 | 196 | $GLOBALS['TL_DCA']['tl_module']['fields']['recommendation_externalSize'] = [ 197 | 'inputType' => 'text', 198 | 'eval' => ['multiple'=>true, 'size'=>2, 'rgxp'=>'natural', 'nospace'=>true, 'tl_class'=>'w50'], 199 | 'sql' => "varchar(64) COLLATE ascii_bin NOT NULL default ''" 200 | ]; 201 | 202 | class tl_module_recommendation extends Backend 203 | { 204 | /** 205 | * Import the back end user object 206 | */ 207 | public function __construct() 208 | { 209 | parent::__construct(); 210 | $this->import(BackendUser::class, 'User'); 211 | } 212 | 213 | /** 214 | * Get all recommendation archives and return them as array 215 | */ 216 | public function getRecommendationArchives(): array 217 | { 218 | if (!$this->User->isAdmin && !is_array($this->User->recommendations)) 219 | { 220 | return []; 221 | } 222 | 223 | $arrArchives = []; 224 | $objArchives = $this->Database->execute("SELECT id, title FROM tl_recommendation_archive ORDER BY title"); 225 | $security = System::getContainer()->get('security.helper'); 226 | 227 | while ($objArchives->next()) 228 | { 229 | if ($security->isGranted(ContaoRecommendationPermissions::USER_CAN_EDIT_ARCHIVE, $objArchives->id)) 230 | { 231 | $arrArchives[$objArchives->id] = $objArchives->title; 232 | } 233 | } 234 | 235 | return $arrArchives; 236 | } 237 | 238 | /** 239 | * Get all recommendation reader modules and return them as array 240 | */ 241 | public function getReaderModules(): array 242 | { 243 | $arrModules = []; 244 | $objModules = $this->Database->execute("SELECT m.id, m.name, t.name AS theme FROM tl_module m LEFT JOIN tl_theme t ON m.pid=t.id WHERE m.type='recommendationreader' ORDER BY t.name, m.name"); 245 | 246 | while ($objModules->next()) 247 | { 248 | $arrModules[$objModules->theme][$objModules->id] = $objModules->name . ' (ID ' . $objModules->id . ')'; 249 | } 250 | 251 | return $arrModules; 252 | } 253 | 254 | /** 255 | * Load the default recommendation activation text 256 | */ 257 | public function getRecommendationActivationDefault(mixed $varValue): mixed 258 | { 259 | if (trim((string) $varValue) === '') 260 | { 261 | $varValue = (is_array($GLOBALS['TL_LANG']['tl_recommendation_notification']['email_activation'] ?? null) ? $GLOBALS['TL_LANG']['tl_recommendation_notification']['email_activation'][1] : ($GLOBALS['TL_LANG']['tl_recommendation_notification']['email_activation'] ?? null)); 262 | } 263 | 264 | return $varValue; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /contao/modules/ModuleRecommendation.php: -------------------------------------------------------------------------------- 1 | get('security.helper'); 55 | 56 | while ($objArchive->next()) 57 | { 58 | if ($objArchive->protected && !$security->isGranted(ContaoCorePermissions::MEMBER_IN_GROUPS, StringUtil::deserialize($objArchive->groups, true))) 59 | { 60 | continue; 61 | } 62 | 63 | $arrArchives[] = $objArchive->id; 64 | } 65 | } 66 | 67 | return $arrArchives; 68 | } 69 | 70 | /** 71 | * Parse an item and return it as string 72 | */ 73 | protected function parseRecommendation(RecommendationModel $objRecommendation, RecommendationArchiveModel $objRecommendationArchive, string $strClass='', int $intCount=0): string 74 | { 75 | $objTemplate = new FrontendTemplate($this->recommendation_template ?: 'recommendation_default'); 76 | $objTemplate->setData($objRecommendation->row()); 77 | 78 | if ($objRecommendation->cssClass) 79 | { 80 | $strClass = ' ' . $objRecommendation->cssClass . $strClass; 81 | } 82 | 83 | if ($objRecommendation->featured) 84 | { 85 | $strClass = ' featured' . $strClass; 86 | } 87 | 88 | $objTemplate->class = $strClass; 89 | $objTemplate->archiveId = $objRecommendationArchive->id; 90 | 91 | $moreLabel = $this->customLabel ?: $GLOBALS['TL_LANG']['MSC']['more']; 92 | 93 | if ($this->recommendation_useDialog) 94 | { 95 | $objTemplate->dialog = true; 96 | $objTemplate->more = $moreLabel; 97 | $objRecommendationArchive->jumpTo = null; 98 | } 99 | elseif ($objRecommendationArchive->jumpTo) 100 | { 101 | $objTemplate->allowRedirect = true; 102 | $objTemplate->more = $this->generateLink($moreLabel, $objRecommendation, $objRecommendation->title ?: $objRecommendation->author, true); 103 | } 104 | 105 | if ($objRecommendation->title) 106 | { 107 | $objTemplate->headlineLink = $objRecommendationArchive->jumpTo ? $this->generateLink($objRecommendation->title, $objRecommendation, $objRecommendation->title) : $objRecommendation->title; 108 | $objTemplate->headline = $objRecommendation->title; 109 | } 110 | 111 | $arrMeta = $this->getMetaFields($objRecommendation); 112 | 113 | // Add the meta information 114 | $objTemplate->addRating = array_key_exists('rating', $arrMeta); 115 | $objTemplate->addDate = array_key_exists('date', $arrMeta); 116 | $objTemplate->datetime = $strDateTime = date('Y-m-d\TH:i:sP', $objRecommendation->date); 117 | $objTemplate->date = $arrMeta['date'] ?? null; 118 | $objTemplate->elapsedTime = $this->getElapsedTime($strDateTime); 119 | $objTemplate->addAuthor = array_key_exists('author', $arrMeta); 120 | $objTemplate->author = $arrMeta['author'] ?? null; 121 | $objTemplate->addCustomField = array_key_exists('customField', $arrMeta); 122 | $objTemplate->customField = $arrMeta['customField'] ?? null; 123 | $objTemplate->addLocation = array_key_exists('location', $arrMeta); 124 | $objTemplate->location = $arrMeta['location'] ?? null; 125 | 126 | // Add styles 127 | $color = unserialize(Config::get('recommendationActiveColor') ?? '')[0] ?? null; 128 | $objTemplate->styles = $color ? ' style="color:#'.$color.'"' : ''; 129 | 130 | $objTemplate->addExternalImage = false; 131 | $objTemplate->addInternalImage = false; 132 | 133 | // Parsing image meta field to template for backwards compatibility // Works for recommendation_default.html5 134 | $objTemplate->addRecommendationImage = array_key_exists('image', $arrMeta); 135 | 136 | $container = System::getContainer(); 137 | 138 | // Add an image 139 | if ($objRecommendation->imageUrl != '') 140 | { 141 | $objRecommendation->imageUrl = $container->get('contao.insert_tag.parser')->replace($objRecommendation->imageUrl); 142 | 143 | // Insert tag parser on contao ^5 returns a leading slash whilst contao 4.13 does not 144 | if (Path::isAbsolute($objRecommendation->imageUrl)) 145 | { 146 | $objRecommendation->imageUrl = substr($objRecommendation->imageUrl,1); 147 | } 148 | 149 | if ($this->isExternal($objRecommendation->imageUrl)) 150 | { 151 | $objTemplate->addExternalImage = true; 152 | $objTemplate->imageUrl = $objRecommendation->imageUrl; 153 | } 154 | else 155 | { 156 | $objModel = FilesModel::findByPath($objRecommendation->imageUrl); 157 | $this->addInternalImage($objModel, $objTemplate); 158 | } 159 | } 160 | elseif (Config::get('recommendationDefaultImage')) 161 | { 162 | $objModel = FilesModel::findByUuid(Config::get('recommendationDefaultImage')); 163 | $this->addInternalImage($objModel, $objTemplate); 164 | } 165 | 166 | $size = StringUtil::deserialize($this->recommendation_externalSize); 167 | $width = $height = 128; 168 | 169 | if (\is_array($size) && !empty($size[0]) && !empty($size[1])) 170 | { 171 | $width = $size[0]; 172 | $height = $size[1]; 173 | } 174 | 175 | $objTemplate->externalSize = ' width="' . $width . '" height="' . $height . '"'; 176 | 177 | // HOOK: add custom logic 178 | if (isset($GLOBALS['TL_HOOKS']['parseRecommendation']) && \is_array($GLOBALS['TL_HOOKS']['parseRecommendation'])) 179 | { 180 | foreach ($GLOBALS['TL_HOOKS']['parseRecommendation'] as $callback) 181 | { 182 | $this->import($callback[0]); 183 | $this->{$callback[0]}->{$callback[1]}($objTemplate, $objRecommendation->row(), $this); 184 | } 185 | } 186 | 187 | // Tag recommendations 188 | if ($container->has('fos_http_cache.http.symfony_response_tagger')) 189 | { 190 | $responseTagger = $container->get('fos_http_cache.http.symfony_response_tagger'); 191 | $responseTagger->addTags(['contao.db.tl_recommendation.' . $objRecommendation->id]); 192 | } 193 | 194 | return $objTemplate->parse(); 195 | } 196 | 197 | /** 198 | * Parse one or more items and return them as array 199 | */ 200 | protected function parseRecommendations(Collection $objRecommendations): array 201 | { 202 | $limit = $objRecommendations->count(); 203 | 204 | if ($limit < 1) 205 | { 206 | return []; 207 | } 208 | 209 | $count = 0; 210 | $arrRecommendations = []; 211 | 212 | foreach ($objRecommendations as $recommendation) 213 | { 214 | /** @var RecommendationArchiveModel $objRecommendationArchive */ 215 | $objRecommendationArchive = $recommendation->getRelated('pid'); 216 | 217 | $arrRecommendations[] = $this->parseRecommendation( 218 | $recommendation, 219 | $objRecommendationArchive, 220 | ((++$count == 1) ? ' first' : '') . (($count == $limit) ? ' last' : '') . ((($count % 2) == 0) ? ' odd' : ' even'), 221 | $count 222 | ); 223 | } 224 | 225 | return $arrRecommendations; 226 | } 227 | 228 | /** 229 | * Return the meta fields of a recommendation as array 230 | */ 231 | protected function getMetaFields(RecommendationModel $objRecommendation): array 232 | { 233 | $meta = StringUtil::deserialize($this->recommendation_metaFields); 234 | 235 | if (!\is_array($meta)) 236 | { 237 | return []; 238 | } 239 | 240 | /** @var PageModel $objPage */ 241 | global $objPage; 242 | 243 | $return = []; 244 | 245 | foreach ($meta as $field) 246 | { 247 | switch ($field) 248 | { 249 | case 'date': 250 | $return['date'] = Date::parse($objPage->datimFormat, $objRecommendation->date); 251 | break; 252 | 253 | case 'image': 254 | $return['image'] = true; 255 | break; 256 | 257 | default: 258 | $return[ $field ] = $objRecommendation->{$field}; 259 | } 260 | } 261 | 262 | return $return; 263 | } 264 | 265 | /** 266 | * Generate a link and return it as string 267 | */ 268 | protected function generateLink(string $strLink, RecommendationModel $objRecommendation, string $strTitle, bool $blnIsReadMore=false): string 269 | { 270 | return sprintf('', 271 | $this->generateRecommendationUrl($objRecommendation), 272 | StringUtil::specialchars(sprintf($GLOBALS['TL_LANG']['MSC']['readRecommendation'], $strTitle), true), 273 | $strLink, 274 | ($blnIsReadMore ? '' : '')); 275 | } 276 | 277 | /** 278 | * Generate a URL and return it as string 279 | */ 280 | protected function generateRecommendationUrl(RecommendationModel $objRecommendation): string 281 | { 282 | $objPage = PageModel::findByPk($objRecommendation->getRelated('pid')->jumpTo); 283 | 284 | return StringUtil::ampersand($objPage->getFrontendUrl(($this->useAutoItem() ? '/' : '/items/') . ($objRecommendation->alias ?: $objRecommendation->id))); 285 | } 286 | 287 | /** 288 | * Check whether path is external 289 | */ 290 | protected function isExternal(string $strPath): bool 291 | { 292 | if (str_starts_with($strPath, 'http://') || str_starts_with($strPath, 'https://')) 293 | { 294 | return true; 295 | } 296 | 297 | return false; 298 | } 299 | 300 | /** 301 | * Add an internal image to template 302 | */ 303 | protected function addInternalImage($objModel, &$objTemplate): void 304 | { 305 | if (null !== $objModel) 306 | { 307 | $imgSize = $this->imgSize ?: null; 308 | $objTemplate->addInternalImage = true; 309 | 310 | // Override the default image size 311 | if ($this->imgSize) 312 | { 313 | $size = StringUtil::deserialize($this->imgSize); 314 | 315 | if ($size[0] > 0 || $size[1] > 0 || is_numeric($size[2]) || ($size[2][0] ?? null) === '_') 316 | { 317 | $imgSize = $this->imgSize; 318 | } 319 | } 320 | 321 | $figureBuilder = System::getContainer() 322 | ->get('contao.image.studio') 323 | ->createFigureBuilder() 324 | ->from($objModel->path) 325 | ->setSize($imgSize); 326 | 327 | if (null !== ($figure = $figureBuilder->buildIfResourceExists())) 328 | { 329 | $figure->applyLegacyTemplateData($objTemplate); 330 | } 331 | } 332 | } 333 | 334 | /** 335 | * Parses a timestamp into a human-readable string 336 | * @throws Exception 337 | */ 338 | protected function getElapsedTime(string $strDateTime): string 339 | { 340 | $objElapsedTime = (new \DateTime($strDateTime))->diff(new \DateTime(date("Y-m-d\TH:i:sP",time()))); 341 | 342 | if (($years = $objElapsedTime->y) > 0) 343 | { 344 | return $this->translateElapsedTime($years, 'year'); 345 | } 346 | elseif (($months = $objElapsedTime->m) > 0) 347 | { 348 | return $this->translateElapsedTime($months, 'month'); 349 | } 350 | elseif (($weeks = $objElapsedTime->d) > 6) 351 | { 352 | return $this->translateElapsedTime((int) round($weeks / 7), 'week'); 353 | } 354 | elseif (($days = $objElapsedTime->d) > 0) 355 | { 356 | return $this->translateElapsedTime($days, 'day'); 357 | } 358 | elseif (($hours = $objElapsedTime->h) > 0) 359 | { 360 | return $this->translateElapsedTime($hours, 'hour'); 361 | } 362 | else 363 | { 364 | return $GLOBALS['TL_LANG']['tl_recommendation']['justNow'] ?? 'just now'; 365 | } 366 | } 367 | 368 | /** 369 | * Translates elapsed time 370 | */ 371 | protected function translateElapsedTime(int $value, string $strUnit = 'justNow'): string 372 | { 373 | if (isset($GLOBALS['TL_LANG']['tl_recommendation'][$strUnit][!($value>1)])) 374 | { 375 | return sprintf($GLOBALS['TL_LANG']['tl_recommendation'][$strUnit][!($value>1)], $value); 376 | } 377 | 378 | return ''; 379 | } 380 | 381 | /** 382 | * Checks weather auto_item should be used to provide BC 383 | * 384 | * @deprecated - To be removed when contao 4.13 support ends 385 | * @internal 386 | */ 387 | protected function useAutoItem(): bool 388 | { 389 | return !str_starts_with(ContaoCoreBundle::getVersion(), '5.') ? Config::get('useAutoItem') : true; 390 | } 391 | } 392 | --------------------------------------------------------------------------------