├── .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 | = $this->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 | = $this->recommendation ?>
6 |
7 |
8 |
= $this->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 | = $this->average . ' ' . $this->averageRounded . ' ' . $this->countLabel ?>
6 |
7 | recommendations)): ?>
8 | = $this->empty ?>
9 |
10 | = $this->summary ?>
11 | = implode('', $this->recommendations) ?>
12 | = $this->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 | = $this->averageRoundedLabel ?>
15 | (= $this->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 |
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 |
= $this->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 | = $this->author ?>
22 |
23 | addCustomField): ?>
24 | = $this->customField ?>
25 |
26 | addLocation): ?>
27 | = $this->location ?>
28 |
29 |
30 | addDate): ?>
31 | = $this->date ?>
32 |
33 |
34 | addRating): ?>
35 |
36 |
37 | rating && $this->styles) ? $this->styles : '' ?>>★
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | = $this->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 |
= $this->headlineLink ?>
15 |
16 |
17 | addAuthor || $this->addDate || $this->addRating || $this->addLocation): ?>
18 |
19 | addAuthor): ?>
20 | = $this->author ?>
21 |
22 | addLocation): ?>
23 | = $this->location ?>
24 |
25 |
26 | addDate): ?>
27 | = $this->date ?>
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 | = method_exists($this, 'cspInlineStyles') ? $this->cspInlineStyles($this->teaser) : $this->teaser ?>
43 |
44 | = $this->text ?>
45 |
46 |
47 |
48 | allowRedirect): ?>
49 |
= $this->more ?>
50 | dialog): ?>
51 |
52 |
53 | = $this->text ?>
54 |
57 |
58 |
59 |
60 | = $this->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 |
= $this->headlineLink ?>
16 |
17 |
18 | addAuthor || $this->addDate || $this->addRating || $this->addLocation): ?>
19 |
20 | addAuthor): ?>
21 | = $this->author ?>
22 |
23 | addCustomField): ?>
24 | = $this->customField ?>
25 |
26 | addLocation): ?>
27 | = $this->location ?>
28 |
29 |
30 | addDate): ?>
31 | = $this->elapsedTime ?>
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 | = method_exists($this, 'cspInlineStyles') ? $this->cspInlineStyles($this->teaser) : $this->teaser ?>
47 |
48 | = $this->text ?>
49 |
50 |
51 |
52 | allowRedirect): ?>
53 |
= $this->more ?>
54 | dialog): ?>
55 |
56 |
57 | = $this->text ?>
58 |
61 |
62 |
63 |
64 | = $this->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('%s%s ',
271 | $this->generateRecommendationUrl($objRecommendation),
272 | StringUtil::specialchars(sprintf($GLOBALS['TL_LANG']['MSC']['readRecommendation'], $strTitle), true),
273 | $strLink,
274 | ($blnIsReadMore ? ' '.$strTitle.' ' : ''));
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 |
--------------------------------------------------------------------------------