├── .gitattributes
├── uninstall.php
├── pages
├── index.php
├── config.php
├── main.php
└── direct.php
├── .gitignore
├── .github
└── workflows
│ ├── publish-to-redaxo.yml
│ └── code_style.yml
├── package.yml
├── LICENSE
├── update.php
├── install.php
├── lib
├── Provider
│ ├── ProviderInterface.php
│ ├── PixabayProvider.php
│ ├── PexelsProvider.php
│ └── WikimediaProvider.php
├── AssetImporter.php
├── Asset
│ └── AbstractProvider.php
└── DirectImporter.php
├── lang
├── en_gb.lang
└── de_de.lang
├── boot.php
├── assets
├── direct_import.js
├── asset_import.css
└── asset_import.js
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | vendor/ export-ignore
2 | .php-cs-fixer.dist.php export-ignore
3 | composer.json export-ignore
4 |
--------------------------------------------------------------------------------
/uninstall.php:
--------------------------------------------------------------------------------
1 | drop();
6 |
--------------------------------------------------------------------------------
/pages/index.php:
--------------------------------------------------------------------------------
1 | i18n('title'));
11 | rex_be_controller::includeCurrentPageSubPath();
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .definition
3 | .dislap
4 | .idea/**/workspace.xml
5 | .idea/**/tasks.xml
6 | .idea/**/usage.statistics.xml
7 | .idea/**/dictionaries
8 | .idea/**/shelf
9 | *.iml
10 | modules.xml
11 | .idea/misc.xml
12 | .idea/vcs.xml
13 | .idea/encodings.xml
14 | *.ipr
15 | fragments/.DS_Store
16 | /node_modules/
17 | /tests_output/
18 | /.env
19 | /test-results/
20 | /playwright-report/
21 | /playwright/.cache/
22 | /screens/
23 | /.idea
24 | /composer.lock
25 | /.php-cs-fixer.cache
26 | /vendor
27 |
--------------------------------------------------------------------------------
/.github/workflows/publish-to-redaxo.yml:
--------------------------------------------------------------------------------
1 | name: Publish release
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 |
8 | jobs:
9 | redaxo_publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: FriendsOfREDAXO/installer-action@v1
14 | with:
15 | myredaxo-username: ${{ secrets.MYREDAXO_USERNAME }}
16 | myredaxo-api-key: ${{ secrets.MYREDAXO_API_KEY }}
17 | description: ${{ github.event.release.body }}
18 |
19 |
--------------------------------------------------------------------------------
/package.yml:
--------------------------------------------------------------------------------
1 | package: asset_import
2 | version: '1.3.0'
3 | author: Friends Of REDAXO
4 | supportpage: https://github.com/FriendsOfREDAXO/asset_import
5 |
6 |
7 | requires:
8 | redaxo: '^5.18.0'
9 | php:
10 | version: '>=8.2'
11 |
12 | page:
13 | perm: asset_import[]
14 | title: 'translate:asset_import_title'
15 | icon: rex-icon fa-cloud-download
16 | subpages:
17 | main:
18 | title: 'translate:asset_import_media'
19 | direct:
20 | title: 'translate:asset_import_direct_title'
21 | perm: asset_import[direct]
22 | config:
23 | title: 'translate:asset_import_settings'
24 | perm: admin[]
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Friends Of REDAXO
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 |
--------------------------------------------------------------------------------
/.github/workflows/code_style.yml:
--------------------------------------------------------------------------------
1 | name: PHP-CS-Fixer
2 |
3 | on:
4 | push:
5 | branches: [ master, main ]
6 | pull_request:
7 | branches: [ master, main ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | code-style:
14 |
15 | runs-on: ubuntu-latest
16 | permissions:
17 | contents: write # for Git to git apply
18 |
19 | steps:
20 | - uses: actions/checkout@v3
21 |
22 | - name: Setup PHP
23 | uses: shivammathur/setup-php@v2
24 | with:
25 | php-version: '8.3'
26 | extensions: gd, intl, pdo_mysql
27 | coverage: none # disable xdebug, pcov
28 |
29 | # install dependencies from composer.json
30 | - name: Install test dependencies
31 | env:
32 | COMPOSER: composer.json
33 | run: composer install --prefer-dist --no-progress
34 |
35 | # run php-cs-fixer, fix code styles
36 | - name: Run PHP CS Fixer
37 | run: composer cs-fix
38 |
39 | # commit and push fixed files
40 | - uses: stefanzweifel/git-auto-commit-action@v4
41 | with:
42 | commit_message: Apply php-cs-fixer changes
43 |
--------------------------------------------------------------------------------
/update.php:
--------------------------------------------------------------------------------
1 | hasColumn('med_copyright')) {
9 | $mediaTable->addColumn(new rex_sql_column('med_copyright', 'text', true));
10 | // Führe die Änderungen aus
11 | $mediaTable->ensure();
12 | }
13 |
14 | // Prüfe ob die Metainfo-Tabelle existiert
15 | $sql = rex_sql::factory();
16 | $sql->setQuery('SHOW TABLES LIKE "' . rex::getTable('metainfo_field') . '"');
17 |
18 | if ($sql->getRows() > 0) {
19 | // Prüfe ob das Metainfo-Feld bereits existiert
20 | $sql->setQuery('SELECT * FROM ' . rex::getTable('metainfo_field') . ' WHERE name = :name', [':name' => 'med_copyright']);
21 |
22 | if (0 == $sql->getRows()) {
23 | // Erstelle Metainfo Feld für med_copyright
24 | $metaField = [
25 | 'title' => 'Copyright',
26 | 'name' => 'med_copyright',
27 | 'priority' => 3,
28 | 'attributes' => '',
29 | 'type_id' => 1, // Text Input
30 | 'params' => '',
31 | 'validate' => '',
32 | 'restrictions' => '',
33 | 'createuser' => rex::getUser()->getLogin(),
34 | 'createdate' => date('Y-m-d H:i:s'),
35 | 'updateuser' => rex::getUser()->getLogin(),
36 | 'updatedate' => date('Y-m-d H:i:s'),
37 | ];
38 |
39 | $insert = rex_sql::factory();
40 | $insert->setTable(rex::getTable('metainfo_field'));
41 | $insert->setValues($metaField);
42 | $insert->insert();
43 | }
44 | }
45 | } catch (rex_sql_exception $e) {
46 | rex_logger::factory()->log('error', 'med_copyright field creation error - ' . $e->getMessage());
47 | throw new rex_functional_exception($e->getMessage());
48 | }
49 |
--------------------------------------------------------------------------------
/install.php:
--------------------------------------------------------------------------------
1 | ensureColumn(new rex_sql_column('id', 'int(10) unsigned', false, null, 'auto_increment'))
10 | ->ensureColumn(new rex_sql_column('provider', 'varchar(191)'))
11 | ->ensureColumn(new rex_sql_column('cache_key', 'varchar(32)'))
12 | ->ensureColumn(new rex_sql_column('response', 'longtext'))
13 | ->ensureColumn(new rex_sql_column('created', 'datetime'))
14 | ->ensureColumn(new rex_sql_column('valid_until', 'datetime'))
15 |
16 | // Primary Key
17 | ->setPrimaryKey('id')
18 |
19 | // Index für schnellere Suche
20 | ->ensureIndex(new rex_sql_index('provider_cache', ['provider', 'cache_key']));
21 |
22 | // Erstelle oder aktualisiere die Tabelle
23 | $table->ensure();
24 |
25 | try {
26 | // Hole Medientabelle
27 | $mediaTable = rex_sql_table::get(rex::getTable('media'));
28 |
29 | // Prüfe und füge med_copyright Spalte hinzu, wenn sie nicht existiert
30 | if (!$mediaTable->hasColumn('med_copyright')) {
31 | $mediaTable->addColumn(new rex_sql_column('med_copyright', 'text', true));
32 | // Führe die Änderungen aus
33 | $mediaTable->ensure();
34 | }
35 |
36 | // Prüfe ob die Metainfo-Tabelle existiert
37 | $sql = rex_sql::factory();
38 | $sql->setQuery('SHOW TABLES LIKE "' . rex::getTable('metainfo_field') . '"');
39 |
40 | if ($sql->getRows() > 0) {
41 | // Prüfe ob das Metainfo-Feld bereits existiert
42 | $sql->setQuery('SELECT * FROM ' . rex::getTable('metainfo_field') . ' WHERE name = :name', [':name' => 'med_copyright']);
43 |
44 | if (0 == $sql->getRows()) {
45 | // Erstelle Metainfo Feld für med_copyright
46 | $metaField = [
47 | 'title' => 'Copyright',
48 | 'name' => 'med_copyright',
49 | 'priority' => 3,
50 | 'attributes' => '',
51 | 'type_id' => 1, // Text Input
52 | 'params' => '',
53 | 'validate' => '',
54 | 'restrictions' => '',
55 | 'createuser' => rex::getUser()->getLogin(),
56 | 'createdate' => date('Y-m-d H:i:s'),
57 | 'updateuser' => rex::getUser()->getLogin(),
58 | 'updatedate' => date('Y-m-d H:i:s'),
59 | ];
60 |
61 | $insert = rex_sql::factory();
62 | $insert->setTable(rex::getTable('metainfo_field'));
63 | $insert->setValues($metaField);
64 | $insert->insert();
65 | }
66 | }
67 | } catch (rex_sql_exception $e) {
68 | rex_logger::factory()->log('error', 'med_copyright field creation error - ' . $e->getMessage());
69 | throw new rex_functional_exception($e->getMessage());
70 | }
71 |
--------------------------------------------------------------------------------
/lib/Provider/ProviderInterface.php:
--------------------------------------------------------------------------------
1 | string, // Übersetzungsschlüssel für Label
36 | * 'name' => string, // Feldname
37 | * 'type' => string, // Feldtyp (text, password, select)
38 | * 'notice' => string|null, // Optional: Übersetzungsschlüssel für Hinweistext
39 | * 'options' => array|null // Optional: Optionen für Select-Felder
40 | * ]
41 | */
42 | public function getConfigFields(): array;
43 |
44 | /**
45 | * Sucht Assets anhand der übergebenen Parameter.
46 | *
47 | * @param string $query Suchanfrage oder Asset-URL
48 | * @param int $page Aktuelle Seite
49 | * @param array $options Zusätzliche Suchoptionen
50 | *
51 | * @return array Array mit folgender Struktur:
52 | * [
53 | * 'items' => [
54 | * [
55 | * 'id' => string, // Asset-ID
56 | * 'preview_url' => string, // URL des Vorschaubilds
57 | * 'title' => string, // Asset-Titel
58 | * 'author' => string, // Asset-Autor
59 | * 'copyright' => string, // Copyright-Information
60 | * 'type' => string, // Asset-Typ (image/video)
61 | * 'size' => [ // Verfügbare Größen
62 | * 'tiny' => ['url' => string],
63 | * 'small' => ['url' => string],
64 | * 'medium' => ['url' => string],
65 | * 'large' => ['url' => string]
66 | * ]
67 | * ]
68 | * ],
69 | * 'total' => int, // Gesamtanzahl der Ergebnisse
70 | * 'page' => int, // Aktuelle Seite
71 | * 'total_pages' => int // Gesamtanzahl der Seiten
72 | * ]
73 | */
74 | public function search(string $query, int $page = 1, array $options = []): array;
75 |
76 | /**
77 | * Importiert ein Asset in den Medienpool.
78 | *
79 | * @param string $url Download-URL des Assets
80 | * @param string $filename Zieldateiname
81 | * @param string|null $copyright Optional: Copyright-Information
82 | *
83 | * @return bool True bei erfolgreichem Import, sonst false
84 | */
85 | public function import(string $url, string $filename, ?string $copyright = null): bool;
86 |
87 | /**
88 | * Gibt die Standard-Suchoptionen des Providers zurück.
89 | *
90 | * @return array Array von Standard-Optionen
91 | */
92 | public function getDefaultOptions(): array;
93 | }
94 |
--------------------------------------------------------------------------------
/pages/config.php:
--------------------------------------------------------------------------------
1 | $class) {
26 | $provider = new $class();
27 | if (isset($_POST['config'][$id])) {
28 | $config = $_POST['config'][$id];
29 | $addon->setConfig($id, $config);
30 | }
31 | }
32 |
33 | if (!$error) {
34 | echo rex_view::success(rex_i18n::msg('asset_import_config_saved'));
35 | }
36 | }
37 |
38 | $content = '';
39 |
40 | // Für jeden Provider Einstellungen anzeigen
41 | foreach ($providers as $id => $class) {
42 | $provider = new $class();
43 | $fields = $provider->getConfigFields();
44 |
45 | if (empty($fields)) {
46 | continue;
47 | }
48 |
49 | $content .= '
';
95 | }
96 |
97 | if (!empty($content)) {
98 | $content = '
99 | ';
112 |
113 | $fragment = new rex_fragment();
114 | $fragment->setVar('class', 'edit', false);
115 | $fragment->setVar('title', rex_i18n::msg('asset_import_settings'));
116 | $fragment->setVar('body', $content, false);
117 | echo $fragment->parse('core/page/section.php');
118 | }
119 |
--------------------------------------------------------------------------------
/lang/en_gb.lang:
--------------------------------------------------------------------------------
1 | asset_import_title = Asset Import
2 | asset_import_media = Media
3 | asset_import_settings = Settings
4 |
5 | # Permissions
6 | perm_general_asset_import[] = Asset Import
7 | perm_general_asset_import[direct] = Direct URL Import
8 |
9 | asset_import_search = Search
10 | asset_import_search_placeholder = Enter search term...
11 | asset_import_type = Type
12 | asset_import_type_image = Images
13 | asset_import_type_video = Videos
14 | asset_import_type_all = All
15 |
16 | asset_import_results = Results
17 | asset_import_no_results = No results found
18 | asset_import_loading = Loading...
19 | asset_import_load_more = Load more
20 |
21 | asset_import_preview = Preview
22 | asset_import_import = Import
23 | asset_import_import_success = File has been imported successfully
24 | asset_import_import_error = Error importing file
25 | asset_import_error_loading = No results could be retrieved. API key correct?
26 | asset_import_file_exists = The file "{0}" already exists in the media pool
27 |
28 | asset_import_target_category = Target category
29 | asset_import_category_notice = Choose the category to import files into
30 |
31 | asset_import_provider_error = Provider not configured
32 | asset_import_provider_missing = Provider not found
33 |
34 |
35 | asset_import_save = Save settings
36 | asset_import_config_saved = Settings saved
37 |
38 | # Pixabay Provider
39 | asset_import_provider_pixabay_apikey = Pixabay API key
40 | asset_import_provider_pixabay_apikey_notice = API key from Pixabay.com | https://pixabay.com/api/docs/
41 | asset_import_provider_pixabay_copyright_format = Copyright Format
42 | asset_import_provider_pixabay_copyright_format_notice = Choose the format for copyright information
43 |
44 | # Pexels Provider
45 | asset_import_provider_pexels_apikey = Pexels API key
46 | asset_import_provider_pexels_apikey_notice = API key from Pexels.com | https://www.pexels.com/api/key/
47 | asset_import_provider_pexels_copyright_format = Copyright Format
48 | asset_import_provider_pexels_copyright_format_notice = Choose the format for copyright information
49 |
50 | # Wikimedia Commons Provider
51 | asset_import_provider_wikimedia_useragent = User-Agent
52 | asset_import_provider_wikimedia_useragent_notice = User-Agent for API requests (e.g. "MyWebsite.com AssetImport/1.0 (contact@mywebsite.com)"). Wikimedia recommends a unique identifier with contact details for better API performance.
53 | asset_import_provider_wikimedia_file_types = File types
54 | asset_import_provider_wikimedia_file_types_notice = Which file types should be searched?
55 | asset_import_provider_set_copyright = Set copyright info
56 | asset_import_provider_set_copyright_notice = Should copyright information be saved in the media description?
57 |
58 | # Copyright Formats
59 | asset_import_copyright_format_extended = Extended (Author, License & Source)
60 | asset_import_copyright_format_simple = Simple (Source only)
61 |
62 | # Copyright Info
63 | asset_import_copyright_field = Copyright Info
64 | asset_import_copyright_photographer = Photographer
65 | asset_import_copyright_author = Author
66 | asset_import_copyright_license = License
67 | asset_import_copyright_source = Source
68 |
69 | # Lightbox
70 | asset_import_lightbox_close = Close
71 | asset_import_lightbox_size = Size
72 |
73 | # Direct Import
74 | asset_import_direct_title = Direct URL Import
75 | asset_import_direct_info_title = Direct Import from URLs
76 | asset_import_direct_info_text = Import media directly via URL. This feature is ideal for quick imports without provider configuration.
77 | asset_import_direct_info_formats = Supported formats: JPG, PNG, GIF, WebP, MP4, WebM, OGG
78 | asset_import_direct_info_copyright = Copyright information can be entered freely
79 | asset_import_direct_info_filename = Filename is automatically suggested but can be customized
80 | asset_import_direct_url_import = URL Import
81 | asset_import_direct_url = URL to media file
82 | asset_import_direct_preview = Preview
83 | asset_import_direct_filename = Filename
84 | asset_import_direct_filename_placeholder = e.g. my-image.jpg
85 | asset_import_direct_copyright = Copyright Information
86 | asset_import_direct_copyright_placeholder = e.g. © 2024 Photographer Name
87 | asset_import_direct_import_btn = Import
88 | asset_import_direct_reset = Reset
89 | asset_import_direct_file_type = File Type
90 | asset_import_direct_file_size = File Size
91 | asset_import_direct_url_required = Please enter a URL
92 | asset_import_direct_fields_required = URL and filename are required
93 | asset_import_direct_preview_success = Preview loaded successfully
94 | asset_import_direct_import_success = File imported successfully
95 |
--------------------------------------------------------------------------------
/lang/de_de.lang:
--------------------------------------------------------------------------------
1 | asset_import_title = Asset Import
2 | asset_import_media = Medien
3 | asset_import_settings = Einstellungen
4 |
5 | # Berechtigungen
6 | perm_general_asset_import[] = Asset Import
7 | perm_general_asset_import[direct] = Direct URL Import
8 |
9 | asset_import_search = Suchen
10 | asset_import_search_placeholder = Suchbegriff eingeben...
11 | asset_import_type = Typ
12 | asset_import_type_image = Bilder
13 | asset_import_type_video = Videos
14 | asset_import_type_all = Alle
15 |
16 | asset_import_results = Ergebnisse
17 | asset_import_no_results = Keine Ergebnisse gefunden
18 | asset_import_loading = Lädt...
19 | asset_import_load_more = Mehr laden
20 | asset_import_importing = Importiere…
21 |
22 | asset_import_preview = Vorschau
23 | asset_import_import = Importieren
24 | asset_import_import_success = Die Datei wurde erfolgreich importiert
25 | asset_import_error_loading = Es konnten keine Ergebnisse ermittelt werden, ist der API-Key korrekt?
26 | asset_import_import_error = Fehler beim Import der Datei
27 | asset_import_file_exists = Die Datei "{0}" existiert bereits im Medienpool
28 |
29 |
30 | asset_import_target_category = Zielkategorie
31 | asset_import_category_notice = In welche Kategorie sollen die Dateien importiert werden?
32 |
33 | asset_import_provider_error = Provider nicht konfiguriert
34 | asset_import_provider_missing = Provider nicht gefunden
35 |
36 | asset_import_save = Einstellungen speichern
37 | asset_import_config_saved = Einstellungen wurden gespeichert
38 |
39 | # Pixabay Provider
40 | asset_import_provider_pixabay_apikey = Pixabay API-Key
41 | asset_import_provider_pixabay_apikey_notice = API-Key von Pixabay.com | https://pixabay.com/api/docs/
42 | asset_import_provider_pixabay_copyright_format = Copyright-Format
43 | asset_import_provider_pixabay_copyright_format_notice = Wähle das Format für die Copyright-Informationen
44 |
45 | # Pexels Provider
46 | asset_import_provider_pexels_apikey = Pexels API-Key
47 | asset_import_provider_pexels_apikey_notice = API-Key von Pexels.com | https://www.pexels.com/api/key/
48 | asset_import_provider_pexels_copyright_format = Copyright-Format
49 | asset_import_provider_pexels_copyright_format_notice = Wähle das Format für die Copyright-Informationen
50 |
51 | # Wikimedia Commons Provider
52 | asset_import_provider_wikimedia_useragent = User-Agent
53 | asset_import_provider_wikimedia_useragent_notice = User-Agent für API-Anfragen (z.B. "MeineWebsite.de AssetImport/1.0 (kontakt@meinewebsite.de)"). Wikimedia empfiehlt eine eindeutige Kennung mit Kontaktdaten für bessere API-Performance.
54 | asset_import_provider_wikimedia_file_types = Dateitypen
55 | asset_import_provider_wikimedia_file_types_notice = Welche Dateitypen sollen durchsucht werden?
56 | asset_import_provider_set_copyright = Copyright-Info setzen
57 | asset_import_provider_set_copyright_notice = Sollen Copyright-Informationen in der Medienbeschreibung gespeichert werden?
58 |
59 | # Copyright Formate
60 | asset_import_provider_copyright_fields = Copyright-Felder
61 | asset_import_provider_copyright_notice = In welchem Format sollen die Copyrights gespeichert werden
62 |
63 |
64 | # Copyright Info
65 | asset_import_copyright_field = Copyright Info
66 | asset_import_copyright_photographer = Fotograf
67 | asset_import_copyright_author = Autor
68 | asset_import_copyright_license = Lizenz
69 | asset_import_copyright_source = Quelle
70 |
71 | # Lightbox
72 | asset_import_lightbox_close = Schließen
73 | asset_import_lightbox_size = Größe
74 |
75 | # Direct Import
76 | asset_import_direct_title = Direkter URL-Import
77 | asset_import_direct_info_title = Direkter Import von URLs
78 | asset_import_direct_info_text = Importieren Sie Medien direkt über eine URL. Diese Funktion ist ideal für schnelle Imports ohne Provider-Konfiguration.
79 | asset_import_direct_info_formats = Unterstützte Formate: JPG, PNG, GIF, WebP, MP4, WebM, OGG
80 | asset_import_direct_info_copyright = Copyright-Informationen können frei eingegeben werden
81 | asset_import_direct_info_filename = Dateiname wird automatisch vorgeschlagen, kann aber angepasst werden
82 | asset_import_direct_url_import = URL-Import
83 | asset_import_direct_url = URL zur Mediendatei
84 | asset_import_direct_preview = Vorschau
85 | asset_import_direct_filename = Dateiname
86 | asset_import_direct_filename_placeholder = z.B. mein-bild.jpg
87 | asset_import_direct_copyright = Copyright-Information
88 | asset_import_direct_copyright_placeholder = z.B. © 2024 Fotograf Name
89 | asset_import_direct_import_btn = Importieren
90 | asset_import_direct_reset = Zurücksetzen
91 | asset_import_direct_file_type = Dateityp
92 | asset_import_direct_file_size = Dateigröße
93 | asset_import_direct_url_required = Bitte geben Sie eine URL ein
94 | asset_import_direct_fields_required = URL und Dateiname sind erforderlich
95 | asset_import_direct_preview_success = Vorschau erfolgreich geladen
96 | asset_import_direct_import_success = Datei erfolgreich importiert
97 |
--------------------------------------------------------------------------------
/pages/main.php:
--------------------------------------------------------------------------------
1 | setStyle('class="form-control selectpicker"');
21 | $cats_sel->setName('category_id');
22 | $cats_sel->setId('rex-mediapool-category');
23 | $cats_sel->setSize(1);
24 | $cats_sel->setAttribute('class', 'form-control selectpicker');
25 | $cats_sel->setAttribute('data-live-search', 'true');
26 |
27 | $user = rex::requireUser();
28 |
29 | if ($user->getComplexPerm('media')->hasAll()) {
30 | $cats_sel->addOption(rex_i18n::msg('pool_kats_no'), '0');
31 | }
32 |
33 | $content = '
34 |
35 |
36 |
37 |
38 |
39 |
42 |
43 | ' . $cats_sel->get() . '
44 |
45 |
46 |
47 |
48 |
49 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
114 |
115 |
116 |
117 |
118 |
119 | ';
120 |
121 | $fragment = new rex_fragment();
122 | $fragment->setVar('class', 'edit', false);
123 | $fragment->setVar('title', rex_i18n::msg('asset_import_media'));
124 | $fragment->setVar('body', $content, false);
125 | echo $fragment->parse('core/page/section.php');
126 |
--------------------------------------------------------------------------------
/lib/AssetImporter.php:
--------------------------------------------------------------------------------
1 | getConfigFields();
39 | foreach ($configFields as $field) {
40 | if (!isset($field['name']) || !isset($field['type']) || !isset($field['label'])) {
41 | throw new rex_exception('Invalid config field format in provider: ' . $class);
42 | }
43 | }
44 |
45 | self::$providers[$provider->getName()] = $class;
46 | /*
47 | // Log erfolgreiche Provider-Registrierung
48 | if (class_exists('\rex_logger')) {
49 | rex_logger::factory()->log(LogLevel::INFO,
50 | 'Provider registered successfully',
51 | ['provider' => $provider->getName(), 'class' => $class],
52 | );
53 | }*/
54 | }
55 |
56 | /**
57 | * Registriert alle Provider aus einem bestimmten Namespace.
58 | *
59 | * @param string $namespace Namespace der Provider-Klassen
60 | */
61 | public static function registerProvidersFromNamespace(string $namespace): void
62 | {
63 | foreach (rex_autoload::getClasses() as $class) {
64 | if (str_starts_with($class, $namespace)
65 | && is_subclass_of($class, Provider\ProviderInterface::class)) {
66 | try {
67 | self::registerProvider($class);
68 | } catch (Exception $e) {
69 | if (class_exists('\rex_logger')) {
70 | rex_logger::factory()->log(LogLevel::ERROR,
71 | 'Failed to register provider from namespace',
72 | [
73 | 'namespace' => $namespace,
74 | 'class' => $class,
75 | 'error' => $e->getMessage(),
76 | ],
77 | );
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
84 | /**
85 | * Gibt eine Provider-Instanz zurück.
86 | *
87 | * @param string $id Provider-ID
88 | * @return Provider\ProviderInterface|null Provider-Instanz oder null wenn nicht gefunden
89 | */
90 | public static function getProvider(string $id): ?Provider\ProviderInterface
91 | {
92 | if (!isset(self::$providers[$id])) {
93 | if (class_exists('\rex_logger')) {
94 | rex_logger::factory()->log(LogLevel::WARNING,
95 | 'Provider not found',
96 | ['provider_id' => $id],
97 | );
98 | }
99 | return null;
100 | }
101 |
102 | try {
103 | $class = self::$providers[$id];
104 | $provider = new $class();
105 |
106 | // Überprüfe ob die Konfiguration gültig ist
107 | if (!$provider->isConfigured()) {
108 | if (class_exists('\rex_logger')) {
109 | rex_logger::factory()->log(LogLevel::WARNING,
110 | 'Provider not configured properly',
111 | ['provider_id' => $id],
112 | );
113 | }
114 | }
115 |
116 | return $provider;
117 | } catch (Exception $e) {
118 | if (class_exists('\rex_logger')) {
119 | rex_logger::factory()->log(LogLevel::ERROR,
120 | 'Failed to instantiate provider',
121 | [
122 | 'provider_id' => $id,
123 | 'error' => $e->getMessage(),
124 | ],
125 | );
126 | }
127 | return null;
128 | }
129 | }
130 |
131 | /**
132 | * Gibt alle registrierten Provider zurück.
133 | *
134 | * @return array Array von Provider-Klassen
135 | */
136 | public static function getProviders(): array
137 | {
138 | return self::$providers;
139 | }
140 |
141 | /**
142 | * Überprüft ob ein Provider registriert ist.
143 | *
144 | * @param string $id Provider-ID
145 | */
146 | public static function hasProvider(string $id): bool
147 | {
148 | return isset(self::$providers[$id]);
149 | }
150 |
151 | /**
152 | * Entfernt einen Provider.
153 | *
154 | * @param string $id Provider-ID
155 | * @return bool true wenn der Provider entfernt wurde
156 | */
157 | public static function removeProvider(string $id): bool
158 | {
159 | if (isset(self::$providers[$id])) {
160 | unset(self::$providers[$id]);
161 |
162 | if (class_exists('\rex_logger')) {
163 | rex_logger::factory()->log(LogLevel::INFO,
164 | 'Provider removed',
165 | ['provider_id' => $id],
166 | );
167 | }
168 |
169 | return true;
170 | }
171 | return false;
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/boot.php:
--------------------------------------------------------------------------------
1 | getAssetsUrl('asset_import.css'));
41 | rex_view::addJsFile(rex_addon::get('asset_import')->getAssetsUrl('asset_import.js'));
42 |
43 | // Add Direct Import JS for direct page
44 | if ('asset_import/direct' === rex_be_controller::getCurrentPage()) {
45 | rex_view::addJsFile(rex_addon::get('asset_import')->getAssetsUrl('direct_import.js'));
46 | }
47 |
48 | // Add Javascript translations
49 | $translations = [
50 | 'error_unknown' => rex_i18n::msg('asset_import_error_unknown'),
51 | 'error_loading' => rex_i18n::msg('asset_import_error_loading'),
52 | 'error_import' => rex_i18n::msg('asset_import_error_import'),
53 | 'importing' => rex_i18n::msg('asset_import_importing'),
54 | 'import' => rex_i18n::msg('asset_import_import'),
55 | 'success' => rex_i18n::msg('asset_import_import_success'),
56 | 'loading' => rex_i18n::msg('asset_import_loading'),
57 | 'results_found' => rex_i18n::msg('asset_import_results'),
58 | 'no_results' => rex_i18n::msg('asset_import_no_results'),
59 | // Direct Import translations
60 | 'direct_url_required' => rex_i18n::msg('asset_import_direct_url_required'),
61 | 'direct_fields_required' => rex_i18n::msg('asset_import_direct_fields_required'),
62 | 'direct_preview_success' => rex_i18n::msg('asset_import_direct_preview_success'),
63 | 'direct_import_success' => rex_i18n::msg('asset_import_direct_import_success'),
64 | 'direct_file_type' => rex_i18n::msg('asset_import_direct_file_type'),
65 | 'direct_file_size' => rex_i18n::msg('asset_import_direct_file_size'),
66 | ];
67 |
68 | // Add translations to Javascript
69 | rex_view::setJsProperty('asset_import', $translations);
70 | }
71 |
72 | // Handle AJAX requests
73 | if (rex_request('asset_import_api', 'bool', false)) {
74 | try {
75 | $action = rex_request('action', 'string');
76 | $provider = rex_request('provider', 'string');
77 |
78 | // Get provider instance
79 | $providerInstance = AssetImporter::getProvider($provider);
80 | if (!$providerInstance) {
81 | throw new rex_exception('Invalid provider');
82 | }
83 |
84 | // Check if provider is configured
85 | if (!$providerInstance->isConfigured()) {
86 | throw new rex_exception(rex_i18n::msg('asset_import_provider_error'));
87 | }
88 |
89 | switch ($action) {
90 | case 'search':
91 | // Handle search request
92 | $query = rex_request('query', 'string', '');
93 | $page = rex_request('page', 'integer', 1);
94 | $options = rex_request('options', 'array', []);
95 |
96 | $results = $providerInstance->search($query, $page, $options);
97 |
98 | rex_response::sendJson(['success' => true, 'data' => $results]);
99 | break;
100 |
101 | case 'import':
102 | // Handle import request
103 | $url = rex_request('url', 'string');
104 | $filename = rex_request('filename', 'string');
105 | $copyright = rex_request('copyright', 'string', '');
106 |
107 | rex_logger::factory()->log(LogLevel::INFO,
108 | 'Starting import request',
109 | [
110 | 'provider' => $provider,
111 | 'url' => $url,
112 | 'filename' => $filename,
113 | 'copyright' => $copyright,
114 | ],
115 | );
116 |
117 | // Validate input
118 | if (empty($url) || empty($filename)) {
119 | throw new rex_exception('Invalid import parameters');
120 | }
121 |
122 | // Import file
123 | $result = $providerInstance->import($url, $filename, $copyright);
124 |
125 | rex_logger::factory()->log(LogLevel::INFO,
126 | 'Import request completed',
127 | [
128 | 'provider' => $provider,
129 | 'success' => $result,
130 | 'filename' => $filename,
131 | ],
132 | );
133 |
134 | // Verify media and copyright after import
135 | if ($result) {
136 | $media = rex_media::get($filename);
137 | }
138 |
139 | // Send response
140 | rex_response::sendJson(['success' => $result]);
141 | break;
142 |
143 | default:
144 | throw new rex_exception('Invalid action');
145 | }
146 | } catch (Exception $e) {
147 | rex_logger::factory()->log(LogLevel::ERROR,
148 | 'API request failed: ' . $e->getMessage(),
149 | [
150 | 'provider' => $provider ?? 'unknown',
151 | 'action' => $action ?? 'unknown',
152 | 'file' => $e->getFile(),
153 | 'line' => $e->getLine(),
154 | ],
155 | );
156 |
157 | // Send error response
158 | rex_response::sendJson([
159 | 'success' => false,
160 | 'error' => $e->getMessage(),
161 | ]);
162 | }
163 | exit;
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/pages/direct.php:
--------------------------------------------------------------------------------
1 | hasPerm('asset_import[direct]')) {
17 | rex_response::sendJson([
18 | 'success' => false,
19 | 'error' => 'Keine Berechtigung für Direct URL Import',
20 | ]);
21 | exit;
22 | }
23 |
24 | try {
25 | $action = rex_request('action', 'string', '');
26 |
27 | switch ($action) {
28 | case 'preview':
29 | $url = rex_request('url', 'string', '');
30 | $result = DirectImporter::preview($url);
31 | rex_response::sendJson(['success' => true, 'data' => $result]);
32 | break;
33 |
34 | case 'import':
35 | $url = rex_request('url', 'string', '');
36 | $filename = rex_request('filename', 'string', '');
37 | $copyright = rex_request('copyright', 'string', '');
38 | $categoryId = rex_request('category_id', 'int', 0);
39 |
40 | $result = DirectImporter::import($url, $filename, $copyright, $categoryId);
41 | rex_response::sendJson(['success' => $result]);
42 | break;
43 |
44 | default:
45 | throw new Exception('Invalid action');
46 | }
47 | } catch (Exception $e) {
48 | rex_response::sendJson([
49 | 'success' => false,
50 | 'error' => $e->getMessage(),
51 | ]);
52 | }
53 | exit;
54 | }
55 |
56 | // Medienpool Kategorien laden
57 | $cats_sel = new rex_media_category_select();
58 | $cats_sel->setStyle('class="form-control selectpicker"');
59 | $cats_sel->setName('category_id');
60 | $cats_sel->setId('rex-mediapool-category-direct');
61 | $cats_sel->setSize(1);
62 | $cats_sel->setAttribute('class', 'form-control selectpicker');
63 | $cats_sel->setAttribute('data-live-search', 'true');
64 |
65 | $user = rex::requireUser();
66 |
67 | if ($user->getComplexPerm('media')->hasAll()) {
68 | $cats_sel->addOption(rex_i18n::msg('pool_kats_no'), '0');
69 | }
70 |
71 | $content = '
72 |
73 |
74 |
75 |
76 |
77 |
82 |
83 |
' . rex_i18n::msg('asset_import_direct_info_text') . '
84 |
85 | - ' . rex_i18n::msg('asset_import_direct_info_formats') . '
86 | - ' . rex_i18n::msg('asset_import_direct_info_copyright') . '
87 | - ' . rex_i18n::msg('asset_import_direct_info_filename') . '
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
101 |
102 | ' . $cats_sel->get() . '
103 |
104 |
105 |
106 |
107 |
108 |
177 |
178 |
179 |
180 |
181 |
';
182 |
183 | $fragment = new rex_fragment();
184 | $fragment->setVar('class', 'edit', false);
185 | $fragment->setVar('title', rex_i18n::msg('asset_import_direct_title'));
186 | $fragment->setVar('body', $content, false);
187 | echo $fragment->parse('core/page/section.php');
188 |
--------------------------------------------------------------------------------
/assets/direct_import.js:
--------------------------------------------------------------------------------
1 | $(document).on('rex:ready', function() {
2 | const DirectImport = {
3 | loading: false,
4 |
5 | init: function() {
6 | this.bindEvents();
7 | },
8 |
9 | bindEvents: function() {
10 | $('#direct-import-preview').on('click', (e) => {
11 | e.preventDefault();
12 | this.preview();
13 | });
14 |
15 | $('#direct-import-form').on('submit', (e) => {
16 | e.preventDefault();
17 | this.import();
18 | });
19 |
20 | $('#direct-import-reset').on('click', (e) => {
21 | e.preventDefault();
22 | this.reset();
23 | });
24 |
25 | // URL-Eingabe bei Enter
26 | $('#direct-import-url').on('keypress', (e) => {
27 | if (e.which === 13) {
28 | e.preventDefault();
29 | this.preview();
30 | }
31 | });
32 | },
33 |
34 | preview: function() {
35 | if (this.loading) return;
36 |
37 | const url = $('#direct-import-url').val().trim();
38 | if (!url) {
39 | this.showError(rex.asset_import.direct_url_required);
40 | return;
41 | }
42 |
43 | this.loading = true;
44 | this.showStatus('loading');
45 |
46 | $.ajax({
47 | url: window.location.href,
48 | data: {
49 | direct_import_api: 1,
50 | action: 'preview',
51 | url: url
52 | },
53 | success: (response) => {
54 | if (response.success) {
55 | this.showPreview(response.data);
56 | } else {
57 | this.showError(response.error || rex.asset_import.error_unknown);
58 | }
59 | },
60 | error: (xhr, status, error) => {
61 | this.showError(rex.asset_import.error_loading + ': ' + error);
62 | },
63 | complete: () => {
64 | this.loading = false;
65 | this.hideStatus();
66 | }
67 | });
68 | },
69 |
70 | showPreview: function(data) {
71 | // Dateiname vorausfüllen
72 | $('#direct-import-filename').val(data.suggested_filename);
73 |
74 | // Preview HTML erstellen
75 | let previewHtml = '';
76 |
77 | if (data.is_image) {
78 | previewHtml += `
79 |
80 |

81 |
82 | `;
83 | } else if (data.is_video) {
84 | previewHtml += `
85 |
86 |
89 |
90 | `;
91 | }
92 |
93 | previewHtml += `
94 |
95 |
96 |
97 | ${rex.asset_import.direct_file_type}: ${data.content_type}
98 |
99 |
100 | ${rex.asset_import.direct_file_size}: ${data.file_size_formatted}
101 |
102 |
103 |
104 | `;
105 |
106 | previewHtml += '
';
107 |
108 | $('#direct-import-preview-area').html(previewHtml);
109 | $('#direct-import-preview-container').slideDown();
110 |
111 | this.showSuccess(rex.asset_import.direct_preview_success);
112 | },
113 |
114 | import: function() {
115 | if (this.loading) return;
116 |
117 | const url = $('#direct-import-url').val().trim();
118 | const filename = $('#direct-import-filename').val().trim();
119 | const copyright = $('#direct-import-copyright').val().trim();
120 | const categoryId = $('#rex-mediapool-category-direct').val();
121 |
122 | if (!url || !filename) {
123 | this.showError(rex.asset_import.direct_fields_required);
124 | return;
125 | }
126 |
127 | this.loading = true;
128 | $('#direct-import-submit').hide();
129 | $('#direct-import-progress').show();
130 |
131 | $.ajax({
132 | url: window.location.href,
133 | method: 'POST',
134 | data: {
135 | direct_import_api: 1,
136 | action: 'import',
137 | url: url,
138 | filename: filename,
139 | copyright: copyright,
140 | category_id: categoryId
141 | },
142 | success: (response) => {
143 | if (response.success) {
144 | this.showSuccess(rex.asset_import.direct_import_success);
145 | setTimeout(() => {
146 | this.reset();
147 | }, 2000);
148 | } else {
149 | this.showError(response.error || rex.asset_import.error_import);
150 | $('#direct-import-progress').hide();
151 | $('#direct-import-submit').show();
152 | }
153 | },
154 | error: (xhr, status, error) => {
155 | this.showError(rex.asset_import.error_loading + ': ' + error);
156 | $('#direct-import-progress').hide();
157 | $('#direct-import-submit').show();
158 | },
159 | complete: () => {
160 | this.loading = false;
161 | }
162 | });
163 | },
164 |
165 | reset: function() {
166 | $('#direct-import-form')[0].reset();
167 | $('#direct-import-preview-container').slideUp();
168 | $('#direct-import-preview-area').empty();
169 | $('#direct-import-progress').hide();
170 | $('#direct-import-submit').show();
171 | this.hideStatus();
172 | },
173 |
174 | showStatus: function(type) {
175 | const status = $('#direct-import-status');
176 |
177 | switch(type) {
178 | case 'loading':
179 | status.removeClass('alert-danger alert-success')
180 | .addClass('alert-info')
181 | .html(` ${rex.asset_import.loading}`)
182 | .show();
183 | break;
184 | }
185 | },
186 |
187 | hideStatus: function() {
188 | $('#direct-import-status').hide();
189 | },
190 |
191 | showError: function(message) {
192 | $('#direct-import-status')
193 | .removeClass('alert-info alert-success')
194 | .addClass('alert-danger')
195 | .text(message)
196 | .show();
197 |
198 | setTimeout(() => {
199 | $('#direct-import-status').fadeOut();
200 | }, 5000);
201 | },
202 |
203 | showSuccess: function(message) {
204 | $('#direct-import-status')
205 | .removeClass('alert-danger alert-info')
206 | .addClass('alert-success')
207 | .text(message)
208 | .show();
209 |
210 | setTimeout(() => {
211 | $('#direct-import-status').fadeOut();
212 | }, 3000);
213 | }
214 | };
215 |
216 | // Nur initialisieren wenn auf der Direct Import Seite
217 | if ($('.direct-import-container').length) {
218 | DirectImport.init();
219 | }
220 | });
221 |
--------------------------------------------------------------------------------
/lib/Asset/AbstractProvider.php:
--------------------------------------------------------------------------------
1 | 'all', // Default Copyright-Einstellung
33 | ];
34 |
35 | public function __construct()
36 | {
37 | $this->loadConfig();
38 | }
39 |
40 | protected function loadConfig(): void
41 | {
42 | $this->config = array_merge(
43 | $this->defaultConfig,
44 | rex_addon::get('asset_import')->getConfig($this->getName()) ?? [],
45 | );
46 | }
47 |
48 | protected function saveConfig(array $config): void
49 | {
50 | $this->config = array_merge($this->defaultConfig, $config);
51 | rex_addon::get('asset_import')->setConfig($this->getName(), $this->config);
52 | }
53 |
54 | public function getDefaultOptions(): array
55 | {
56 | return [
57 | 'type' => 'all',
58 | 'safesearch' => true,
59 | 'lang' => rex::getUser()->getLanguage(),
60 | ];
61 | }
62 |
63 | /**
64 | * Führt die Suche durch und handhabt das Caching.
65 | */
66 | public function search(string $query, int $page = 1, array $options = []): array
67 | {
68 | try {
69 | $cacheKey = $this->buildCacheKey($query, $page, $options);
70 |
71 | // Füge die aktuelle Copyright-Einstellung zum Cache-Key hinzu
72 | $cacheKey .= '_' . ($this->config['copyright_fields'] ?? 'default');
73 |
74 | $cachedResult = $this->getCachedResponse($cacheKey);
75 |
76 | if (null !== $cachedResult) {
77 | return $cachedResult;
78 | }
79 |
80 | $result = $this->searchApi($query, $page, $options);
81 | $this->cacheResponse($cacheKey, $result);
82 |
83 | return $result;
84 | } catch (Exception $e) {
85 | rex_logger::logException($e);
86 | return [
87 | 'items' => [],
88 | 'total' => 0,
89 | 'page' => $page,
90 | 'total_pages' => 0,
91 | ];
92 | }
93 | }
94 |
95 | /**
96 | * API-Suche - muss von konkreten Provider-Klassen implementiert werden.
97 | */
98 | abstract protected function searchApi(string $query, int $page = 1, array $options = []): array;
99 |
100 | /**
101 | * Generiert einen eindeutigen Cache-Key.
102 | */
103 | protected function buildCacheKey(string $query, int $page, array $options): string
104 | {
105 | $data = [
106 | 'provider' => $this->getName(),
107 | 'query' => $query,
108 | 'page' => $page,
109 | 'options' => $options,
110 | 'lang' => rex::getUser()->getLanguage(),
111 | 'copyright_fields' => $this->config['copyright_fields'] ?? 'default', // Wichtig: Copyright-Einstellung mit in Cache-Key
112 | ];
113 |
114 | return md5(serialize($data));
115 | }
116 |
117 | /**
118 | * Liest gecachte Antwort.
119 | */
120 | protected function getCachedResponse(string $cacheKey): ?array
121 | {
122 | $sql = rex_sql::factory();
123 | $sql->setQuery('
124 | SELECT response
125 | FROM ' . rex::getTable('asset_import_cache') . '
126 | WHERE provider = :provider
127 | AND cache_key = :cache_key
128 | AND valid_until > NOW()',
129 | [
130 | 'provider' => $this->getName(),
131 | 'cache_key' => $cacheKey,
132 | ],
133 | );
134 |
135 | if ($sql->getRows() > 0) {
136 | return json_decode($sql->getValue('response'), true);
137 | }
138 |
139 | return null;
140 | }
141 |
142 | /**
143 | * Speichert API-Antwort im Cache.
144 | */
145 | protected function cacheResponse(string $cacheKey, array $response): void
146 | {
147 | // Alte Cache-Einträge löschen
148 | $sql = rex_sql::factory();
149 | $sql->setQuery('
150 | DELETE FROM ' . rex::getTable('asset_import_cache') . '
151 | WHERE provider = :provider
152 | AND (valid_until < NOW() OR cache_key = :cache_key)',
153 | [
154 | 'provider' => $this->getName(),
155 | 'cache_key' => $cacheKey,
156 | ],
157 | );
158 |
159 | // Neuen Cache-Eintrag erstellen
160 | $sql = rex_sql::factory();
161 | $sql->setTable(rex::getTable('asset_import_cache'));
162 | $sql->setValue('provider', $this->getName());
163 | $sql->setValue('cache_key', $cacheKey);
164 | $sql->setValue('response', json_encode($response));
165 | $sql->setValue('created', date('Y-m-d H:i:s'));
166 | $sql->setValue('valid_until', date('Y-m-d H:i:s', time() + $this->getCacheLifetime()));
167 | $sql->insert();
168 | }
169 |
170 | /**
171 | * Bereinigt Dateinamen.
172 | */
173 | protected function sanitizeFilename(string $filename): string
174 | {
175 | $filename = mb_convert_encoding($filename, 'UTF-8', 'auto');
176 | $filename = rex_string::normalize($filename);
177 |
178 | // Entferne alle nicht erlaubten Zeichen
179 | $filename = preg_replace('/[^a-zA-Z0-9_\-.]/', '_', $filename);
180 |
181 | // Entferne mehrfache Unterstriche
182 | $filename = preg_replace('/_+/', '_', $filename);
183 |
184 | // Kürze zu lange Dateinamen
185 | $maxLength = 100;
186 | $extension = pathinfo($filename, PATHINFO_EXTENSION);
187 | $name = pathinfo($filename, PATHINFO_FILENAME);
188 |
189 | if (strlen($name) > $maxLength) {
190 | $name = substr($name, 0, $maxLength);
191 | $filename = $name . '.' . $extension;
192 | }
193 |
194 | return trim($filename, '_');
195 | }
196 |
197 | /**
198 | * Lädt eine Datei herunter.
199 | */
200 | protected function downloadFile(string $url, string $filename): bool
201 | {
202 | try {
203 | $tmpFile = rex_path::cache('asset_import_' . uniqid() . '_' . $filename);
204 |
205 | $ch = curl_init($url);
206 | $fp = fopen($tmpFile, 'w');
207 |
208 | if (false === $fp) {
209 | throw new Exception('Could not open temporary file for writing');
210 | }
211 |
212 | curl_setopt_array($ch, [
213 | CURLOPT_FILE => $fp,
214 | CURLOPT_HEADER => 0,
215 | CURLOPT_FOLLOWLOCATION => true,
216 | CURLOPT_TIMEOUT => 60,
217 | CURLOPT_SSL_VERIFYPEER => true,
218 | CURLOPT_USERAGENT => 'REDAXO Asset Import',
219 | ]);
220 |
221 | $success = curl_exec($ch);
222 |
223 | if (curl_errno($ch)) {
224 | throw new Exception(curl_error($ch));
225 | }
226 |
227 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
228 | if (200 !== $httpCode) {
229 | throw new Exception('HTTP error: ' . $httpCode);
230 | }
231 |
232 | curl_close($ch);
233 | fclose($fp);
234 |
235 | if ($success) {
236 | // Prüfe Dateigröße
237 | $fileSize = filesize($tmpFile);
238 | if (0 === $fileSize) {
239 | throw new Exception('Downloaded file is empty');
240 | }
241 |
242 | // Prüfe Dateiformat
243 | $mimeType = mime_content_type($tmpFile);
244 | if (!$this->isAllowedMimeType($mimeType)) {
245 | throw new Exception('Invalid file type: ' . $mimeType);
246 | }
247 |
248 | $media = [
249 | 'title' => pathinfo($filename, PATHINFO_FILENAME),
250 | 'file' => [
251 | 'name' => $filename,
252 | 'path' => $tmpFile,
253 | 'tmp_name' => $tmpFile,
254 | ],
255 | 'category_id' => rex_post('category_id', 'int', 0),
256 | ];
257 |
258 | $result = rex_media_service::addMedia($media, true);
259 |
260 | // Lösche temporäre Datei
261 | if (file_exists($tmpFile)) {
262 | unlink($tmpFile);
263 | }
264 |
265 | return false !== $result;
266 | }
267 |
268 | return false;
269 | } catch (Exception $e) {
270 | rex_logger::logException($e);
271 | if (file_exists($tmpFile)) {
272 | unlink($tmpFile);
273 | }
274 | return false;
275 | }
276 | }
277 |
278 | /**
279 | * Prüft, ob der MIME-Type erlaubt ist.
280 | */
281 | protected function isAllowedMimeType(string $mimeType): bool
282 | {
283 | $allowedTypes = [
284 | // Bilder
285 | 'image/jpeg',
286 | 'image/png',
287 | 'image/gif',
288 | 'image/webp',
289 | 'image/svg+xml',
290 | // Videos
291 | 'video/mp4',
292 | 'video/webm',
293 | 'video/ogg',
294 | ];
295 |
296 | return in_array($mimeType, $allowedTypes);
297 | }
298 |
299 | /**
300 | * Import-Methode mit Copyright-Unterstützung.
301 | */
302 | abstract public function import(string $url, string $filename, ?string $copyright = null): bool;
303 |
304 | /**
305 | * Gibt die Cache-Lebensdauer zurück.
306 | */
307 | protected function getCacheLifetime(): int
308 | {
309 | return 86400; // 24 Stunden
310 | }
311 | }
312 |
--------------------------------------------------------------------------------
/lib/DirectImporter.php:
--------------------------------------------------------------------------------
1 | = 300) {
47 | throw new Exception('URL ist nicht verfügbar (HTTP ' . $statusCode . ')');
48 | }
49 |
50 | // Content-Type prüfen
51 | $contentType = self::getContentType($headers);
52 | $supportedTypes = [
53 | 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
54 | 'video/mp4', 'video/webm', 'video/ogg',
55 | ];
56 |
57 | if (!in_array($contentType, $supportedTypes)) {
58 | throw new Exception('Nicht unterstützter Dateityp: ' . $contentType);
59 | }
60 |
61 | // Dateiname aus URL extrahieren
62 | $urlPath = parse_url($url, PHP_URL_PATH);
63 | $suggestedFilename = basename($urlPath);
64 |
65 | // Falls kein Dateiname in URL, einen generieren
66 | if (empty($suggestedFilename) || !pathinfo($suggestedFilename, PATHINFO_EXTENSION)) {
67 | $extension = self::getExtensionFromContentType($contentType);
68 | $suggestedFilename = 'import_' . date('Y-m-d_H-i-s') . '.' . $extension;
69 | }
70 |
71 | // Dateigröße
72 | $fileSize = self::getContentLength($headers);
73 |
74 | return [
75 | 'url' => $url,
76 | 'content_type' => $contentType,
77 | 'file_size' => $fileSize,
78 | 'file_size_formatted' => $fileSize ? self::formatBytes($fileSize) : 'Unbekannt',
79 | 'suggested_filename' => $suggestedFilename,
80 | 'is_image' => str_starts_with($contentType, 'image/'),
81 | 'is_video' => str_starts_with($contentType, 'video/'),
82 | 'preview_url' => $url, // Für Bilder kann die Original-URL als Vorschau verwendet werden
83 | ];
84 | }
85 |
86 | /**
87 | * Datei von URL importieren.
88 | */
89 | public static function import(string $url, string $filename, string $copyright = '', int $categoryId = 0): bool
90 | {
91 | if (empty($url) || empty($filename)) {
92 | throw new Exception('URL und Dateiname sind erforderlich');
93 | }
94 |
95 | // Dateiname bereinigen
96 | $filename = self::sanitizeFilename($filename);
97 |
98 | // Prüfen ob Datei bereits existiert
99 | if (rex_media::get($filename)) {
100 | throw new Exception('Eine Datei mit dem Namen "' . $filename . '" existiert bereits');
101 | }
102 |
103 | // Temporäre Datei herunterladen
104 | $tempFile = self::downloadFile($url);
105 |
106 | try {
107 | // Verwende REDAXO's Media-Upload Mechanismus
108 | $success = self::addMediaToPool($tempFile, $filename, $copyright, $categoryId);
109 |
110 | if (!$success) {
111 | throw new Exception('Fehler beim Hinzufügen zur Mediendatenbank');
112 | }
113 |
114 | // Prüfen ob die Datei wirklich im Medienpool ist
115 | $media = rex_media::get($filename);
116 | if (!$media) {
117 | throw new Exception('Datei wurde nicht korrekt im Medienpool erstellt');
118 | }
119 |
120 | return true;
121 | } catch (Exception $e) {
122 | // Temporäre Datei aufräumen
123 | if (file_exists($tempFile)) {
124 | @unlink($tempFile);
125 | }
126 | throw $e;
127 | }
128 | }
129 |
130 | /**
131 | * Datei zum Medienpool hinzufügen (verwendet REDAXO's rex_media_service).
132 | */
133 | private static function addMediaToPool(string $tempFile, string $filename, string $copyright, int $categoryId): bool
134 | {
135 | try {
136 | // Media-Array für rex_media_service erstellen
137 | $media = [
138 | 'title' => pathinfo($filename, PATHINFO_FILENAME),
139 | 'file' => [
140 | 'name' => $filename,
141 | 'path' => $tempFile,
142 | 'tmp_name' => $tempFile,
143 | ],
144 | 'category_id' => $categoryId,
145 | ];
146 |
147 | // REDAXO's Standard-Media-Service verwenden
148 | $result = rex_media_service::addMedia($media, true);
149 |
150 | if (false === $result) {
151 | throw new Exception('rex_media_service::addMedia() hat false zurückgegeben');
152 | }
153 |
154 | // Copyright nachträglich setzen (wie bei den Providern)
155 | if (!empty($copyright)) {
156 | $mediaObject = rex_media::get($filename);
157 | if ($mediaObject) {
158 | $sql = rex_sql::factory();
159 | $sql->setTable(rex::getTable('media'));
160 | $sql->setWhere(['filename' => $filename]);
161 | $sql->setValue('med_copyright', $copyright);
162 | $sql->update();
163 | }
164 | }
165 |
166 | return true;
167 | } catch (Exception $e) {
168 | throw new Exception('Media-Pool-Fehler: ' . $e->getMessage());
169 | }
170 | }
171 |
172 | /**
173 | * Datei von URL herunterladen.
174 | */
175 | private static function downloadFile(string $url): string
176 | {
177 | $tempFile = rex_path::cache('asset_import_' . uniqid() . '.tmp');
178 |
179 | $context = stream_context_create([
180 | 'http' => [
181 | 'method' => 'GET',
182 | 'header' => [
183 | 'User-Agent: REDAXO Asset Import/1.0',
184 | 'Accept: */*',
185 | ],
186 | 'timeout' => 30,
187 | ],
188 | ]);
189 |
190 | $content = @file_get_contents($url, false, $context);
191 |
192 | if (false === $content) {
193 | throw new Exception('Fehler beim Herunterladen der Datei');
194 | }
195 |
196 | if (false === rex_file::put($tempFile, $content)) {
197 | throw new Exception('Fehler beim Speichern der temporären Datei');
198 | }
199 |
200 | return $tempFile;
201 | }
202 |
203 | /**
204 | * Status Code aus Headers extrahieren.
205 | */
206 | private static function getStatusCode(array $headers): int
207 | {
208 | $statusLine = $headers[0];
209 | if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $statusLine, $matches)) {
210 | return (int) $matches[1];
211 | }
212 | return 0;
213 | }
214 |
215 | /**
216 | * Content-Type aus Headers extrahieren.
217 | */
218 | private static function getContentType(array $headers): string
219 | {
220 | $contentType = $headers['Content-Type'] ?? $headers['content-type'] ?? '';
221 |
222 | if (is_array($contentType)) {
223 | $contentType = $contentType[0];
224 | }
225 |
226 | // Nur den Haupttyp nehmen (ohne charset etc.)
227 | return strtok($contentType, ';');
228 | }
229 |
230 | /**
231 | * Content-Length aus Headers extrahieren.
232 | */
233 | private static function getContentLength(array $headers): ?int
234 | {
235 | $contentLength = $headers['Content-Length'] ?? $headers['content-length'] ?? null;
236 |
237 | if (is_array($contentLength)) {
238 | $contentLength = $contentLength[0];
239 | }
240 |
241 | return $contentLength ? (int) $contentLength : null;
242 | }
243 |
244 | /**
245 | * Dateiendung basierend auf Content-Type ermitteln.
246 | */
247 | private static function getExtensionFromContentType(string $contentType): string
248 | {
249 | $extensions = [
250 | 'image/jpeg' => 'jpg',
251 | 'image/jpg' => 'jpg',
252 | 'image/png' => 'png',
253 | 'image/gif' => 'gif',
254 | 'image/webp' => 'webp',
255 | 'video/mp4' => 'mp4',
256 | 'video/webm' => 'webm',
257 | 'video/ogg' => 'ogg',
258 | ];
259 |
260 | return $extensions[$contentType] ?? 'bin';
261 | }
262 |
263 | /**
264 | * Dateiname bereinigen.
265 | */
266 | private static function sanitizeFilename(string $filename): string
267 | {
268 | // Gefährliche Zeichen entfernen
269 | $filename = preg_replace('/[^\w\-_\.]/', '_', $filename);
270 |
271 | // Mehrfache Unterstriche reduzieren
272 | $filename = preg_replace('/_+/', '_', $filename);
273 |
274 | // Führende/Nachfolgende Unterstriche entfernen
275 | $filename = trim($filename, '_');
276 |
277 | return $filename;
278 | }
279 |
280 | /**
281 | * Prüfen ob es sich um ein Bild handelt.
282 | */
283 | private static function isImage(string $filename): bool
284 | {
285 | $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
286 | return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
287 | }
288 |
289 | /**
290 | * Bytes formatieren.
291 | */
292 | private static function formatBytes(int $bytes): string
293 | {
294 | $units = ['B', 'KB', 'MB', 'GB'];
295 | $bytes = max($bytes, 0);
296 | $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
297 | $pow = min($pow, count($units) - 1);
298 |
299 | $bytes /= 1024 ** $pow;
300 |
301 | return round($bytes, 2) . ' ' . $units[$pow];
302 | }
303 | }
304 |
--------------------------------------------------------------------------------
/assets/asset_import.css:
--------------------------------------------------------------------------------
1 | /* CSS-Variablen für Light/Dark Mode */
2 | :root,
3 | .rex-theme-light {
4 | --ai-bg-color: #fff;
5 | --ai-border-color: #e9ecef;
6 | --ai-shadow-color: rgba(0, 0, 0, 0.1);
7 | --ai-preview-bg: #f8f9fa;
8 | --ai-text-color: #666;
9 | --ai-hover-bg: rgba(0, 0, 0, 0.03);
10 | --ai-card-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
11 | --ai-transition: 0.3s ease;
12 | --ai-preview-overlay: rgba(0, 0, 0, 0.1);
13 | --ai-btn-hover: rgba(0, 0, 0, 0.1);
14 | }
15 |
16 | /* Dark Mode Variablen */
17 | @media (prefers-color-scheme: dark) {
18 | :root:not(.rex-theme-light) {
19 | --ai-bg-color: #32373c;
20 | --ai-border-color: #404850;
21 | --ai-shadow-color: rgba(0, 0, 0, 0.25);
22 | --ai-preview-bg: #282c34;
23 | --ai-text-color: #b4b4b4;
24 | --ai-hover-bg: rgba(255, 255, 255, 0.05);
25 | --ai-card-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
26 | --ai-preview-overlay: rgba(0, 0, 0, 0.2);
27 | --ai-btn-hover: rgba(255, 255, 255, 0.1);
28 | }
29 | }
30 |
31 | /* REDAXO Dark Mode */
32 | .rex-theme-dark {
33 | --ai-bg-color: #32373c;
34 | --ai-border-color: #404850;
35 | --ai-shadow-color: rgba(0, 0, 0, 0.25);
36 | --ai-preview-bg: #282c34;
37 | --ai-text-color: #b4b4b4;
38 | --ai-hover-bg: rgba(255, 255, 255, 0.05);
39 | --ai-card-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
40 | --ai-preview-overlay: rgba(0, 0, 0, 0.2);
41 | --ai-btn-hover: rgba(255, 255, 255, 0.1);
42 | }
43 |
44 | .asset-import-container {
45 | padding: 20px 0;
46 | }
47 |
48 | /* Verbesserte Touch-Bereiche */
49 | .asset-import-search {
50 | margin-bottom: 20px;
51 | }
52 |
53 | .asset-import-search .input-group {
54 | margin-bottom: 10px;
55 | }
56 |
57 | .asset-import-search .btn {
58 | display: flex;
59 | align-items: center;
60 | justify-content: center;
61 | }
62 |
63 | /* Responsive Grid mit besserer Touch-Unterstützung */
64 | .asset-import-results {
65 | display: grid;
66 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
67 | gap: 20px;
68 | margin-top: 20px;
69 | }
70 |
71 | /* Verbesserte Karten-Darstellung */
72 | .asset-import-item {
73 | background: var(--ai-bg-color);
74 | border: 1px solid var(--ai-border-color);
75 | border-radius: 8px;
76 | overflow: hidden;
77 | transition: box-shadow var(--ai-transition), transform var(--ai-transition);
78 | box-shadow: var(--ai-card-shadow);
79 | position: relative;
80 | }
81 |
82 | .asset-import-item:hover,
83 | .asset-import-item:focus-within {
84 | box-shadow: 0 4px 8px var(--ai-shadow-color);
85 | transform: translateY(-2px);
86 | }
87 |
88 | /* Optimierte Vorschau-Darstellung */
89 | .asset-import-preview {
90 | position: relative;
91 | padding-bottom: 75%;
92 | background: var(--ai-preview-bg);
93 | overflow: hidden;
94 | }
95 |
96 | .asset-import-preview img,
97 | .asset-import-preview video {
98 | position: absolute;
99 | top: 0;
100 | left: 0;
101 | width: 100%;
102 | height: 100%;
103 | object-fit: cover;
104 | transition: transform var(--ai-transition);
105 | }
106 |
107 | .asset-import-item:hover {
108 | opacity: 1;
109 | }
110 |
111 | /* Verbesserte Info-Darstellung */
112 | .asset-import-info {
113 | padding: 15px;
114 | }
115 |
116 | .asset-import-title {
117 | margin: 0 0 15px 0;
118 | font-size: 14px;
119 | color: var(--ai-text-color);
120 | word-break: break-word;
121 | }
122 |
123 | /* Touch-optimierte Aktionen */
124 | .asset-import-actions {
125 | display: grid;
126 | gap: 10px;
127 | margin-top: 15px;
128 | }
129 |
130 | .asset-import-actions .btn {
131 | display: flex;
132 | align-items: center;
133 | justify-content: center;
134 | width: 100%;
135 | padding: 10px 20px;
136 | transition: background-color var(--ai-transition);
137 | }
138 |
139 | .asset-import-actions .btn:hover {
140 | background-color: var(--ai-btn-hover);
141 | }
142 |
143 | /* Verbesserte Select-Darstellung */
144 | .asset-import-size-select {
145 | width: 100%;
146 | background-color: var(--ai-bg-color);
147 | color: var(--ai-text-color);
148 | border-color: var(--ai-border-color);
149 | padding: 10px;
150 | border-radius: 4px;
151 | }
152 |
153 | .asset-import-size-select option {
154 | background-color: var(--ai-bg-color);
155 | color: var(--ai-text-color);
156 | padding: 10px;
157 | }
158 |
159 | /* Status und Progress */
160 | #asset-import-status {
161 | padding: 15px;
162 | margin: 20px 0;
163 | border-radius: 20px;
164 | position: fixed; /* Beibehalten */
165 |
166 | /* Zentrierung */
167 | top: 80px;
168 | left: 50%;
169 | transform: translate(-50%, -50%);
170 |
171 | z-index: 1000;
172 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* Box-Shadow hinzugefügt */
173 | min-width: 300px; /* Minimum Width für den Inhalt*/
174 | max-width: 80%; /* Maximale Breite, um bei kleinen Bildschirmen nicht über den Rand zu gehen */
175 | text-align: center; /* Falls Inhalt zentriert sein soll */
176 |
177 | }
178 |
179 | .progress {
180 | margin: 10px 0;
181 | height: 44px;
182 | border-radius: 4px;
183 | background-color: var(--ai-preview-bg);
184 | }
185 |
186 | .progress-bar {
187 | display: flex;
188 | align-items: center;
189 | justify-content: center;
190 | font-size: 14px;
191 | transition: width 0.3s ease;
192 | }
193 |
194 | /* Load More Button */
195 | .asset-import-load-more {
196 | margin-top: 30px;
197 | text-align: center;
198 | }
199 |
200 | .asset-import-load-more .btn {
201 | min-height: 44px;
202 | min-width: 200px;
203 | padding: 10px 30px;
204 | }
205 |
206 | /* Lightbox für Medien-Vorschau */
207 | .asset-import-lightbox {
208 | position: fixed;
209 | top: 0;
210 | left: 0;
211 | width: 100%;
212 | height: 100%;
213 | background: rgba(0, 0, 0, 0);
214 | z-index: 9999;
215 | display: flex;
216 | align-items: center;
217 | justify-content: center;
218 | padding: 20px;
219 | box-sizing: border-box;
220 | opacity: 0;
221 | visibility: hidden;
222 | transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
223 | cursor: pointer;
224 | -webkit-backdrop-filter: blur(0px);
225 | backdrop-filter: blur(0px);
226 | }
227 |
228 | .asset-import-lightbox.active {
229 | opacity: 1;
230 | visibility: visible;
231 | background: rgba(0, 0, 0, 0.9);
232 | -webkit-backdrop-filter: blur(4px);
233 | backdrop-filter: blur(4px);
234 | }
235 |
236 | .asset-import-lightbox-content {
237 | position: relative;
238 | max-width: 90vw;
239 | max-height: 90vh;
240 | cursor: default;
241 | display: flex;
242 | align-items: center;
243 | justify-content: center;
244 | transform: scale(0.7) translateY(50px);
245 | transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
246 | opacity: 0;
247 | will-change: transform, opacity;
248 | }
249 |
250 | .asset-import-lightbox.active .asset-import-lightbox-content {
251 | transform: scale(1) translateY(0);
252 | opacity: 1;
253 | }
254 |
255 | .asset-import-lightbox-media {
256 | max-width: 100%;
257 | max-height: 100%;
258 | width: auto;
259 | height: auto;
260 | object-fit: contain;
261 | border-radius: 4px;
262 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
263 | transform: scale(0.9);
264 | transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s;
265 | will-change: transform;
266 | }
267 |
268 | .asset-import-lightbox.active .asset-import-lightbox-media {
269 | transform: scale(1);
270 | }
271 |
272 | .asset-import-lightbox-close {
273 | position: absolute;
274 | top: -50px;
275 | right: -50px;
276 | background: rgba(255, 255, 255, 0.1);
277 | border: none;
278 | color: white;
279 | font-size: 24px;
280 | width: 40px;
281 | height: 40px;
282 | border-radius: 50%;
283 | cursor: pointer;
284 | display: flex;
285 | align-items: center;
286 | justify-content: center;
287 | transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
288 | z-index: 10000;
289 | transform: scale(0.8) rotate(-90deg);
290 | opacity: 0;
291 | will-change: transform, opacity;
292 | }
293 |
294 | .asset-import-lightbox.active .asset-import-lightbox-close {
295 | transform: scale(1) rotate(0deg);
296 | opacity: 1;
297 | transition-delay: 0.2s;
298 | }
299 |
300 | .asset-import-lightbox-close:hover {
301 | background: rgba(255, 255, 255, 0.2);
302 | transform: scale(1.1) rotate(0deg) !important;
303 | }
304 |
305 | .asset-import-lightbox-info {
306 | position: absolute;
307 | bottom: -60px;
308 | left: 0;
309 | right: 0;
310 | color: white;
311 | text-align: center;
312 | padding: 10px;
313 | background: rgba(0, 0, 0, 0.7);
314 | border-radius: 4px;
315 | transform: translateY(20px);
316 | opacity: 0;
317 | transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
318 | will-change: transform, opacity;
319 | }
320 |
321 | .asset-import-lightbox.active .asset-import-lightbox-info {
322 | transform: translateY(0);
323 | opacity: 1;
324 | transition-delay: 0.3s;
325 | }
326 |
327 | .asset-import-lightbox-title {
328 | font-size: 16px;
329 | margin-bottom: 5px;
330 | word-break: break-word;
331 | }
332 |
333 | .asset-import-lightbox-meta {
334 | font-size: 14px;
335 | opacity: 0.8;
336 | }
337 |
338 | /* Cursor-Pointer für klickbare Vorschau-Bilder */
339 | .asset-import-preview {
340 | cursor: pointer;
341 | transition: opacity var(--ai-transition);
342 | }
343 |
344 | .asset-import-preview:hover {
345 | opacity: 0.9;
346 | }
347 |
348 | .asset-import-preview::after {
349 | content: '\f002'; /* FontAwesome Lupe Icon */
350 | font-family: 'FontAwesome';
351 | position: absolute;
352 | top: 10px;
353 | right: 10px;
354 | background: rgba(0, 0, 0, 0.7);
355 | color: white;
356 | width: 30px;
357 | height: 30px;
358 | border-radius: 50%;
359 | display: flex;
360 | align-items: center;
361 | justify-content: center;
362 | font-size: 12px;
363 | opacity: 0;
364 | transition: opacity var(--ai-transition);
365 | }
366 |
367 | .asset-import-preview:hover::after {
368 | opacity: 1;
369 | }
370 |
371 | /* Mobile Optimierungen */
372 | @media (max-width: 768px) {
373 | .asset-import-results {
374 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
375 | }
376 |
377 | .asset-import-search .input-group {
378 | flex-direction: column;
379 | }
380 |
381 | .asset-import-search .input-group-btn {
382 | margin-top: 10px;
383 | width: 100%;
384 | }
385 |
386 | .asset-import-search .btn {
387 | width: 100%;
388 | }
389 | }
390 |
391 | /* Mobile Optimierungen für Lightbox */
392 | @media (max-width: 768px) {
393 | .asset-import-lightbox-content {
394 | max-width: 95vw;
395 | max-height: 95vh;
396 | padding: 10px;
397 | }
398 |
399 | .asset-import-lightbox-close {
400 | top: -40px;
401 | right: -40px;
402 | width: 35px;
403 | height: 35px;
404 | font-size: 20px;
405 | }
406 |
407 | .asset-import-lightbox-info {
408 | bottom: -50px;
409 | font-size: 14px;
410 | }
411 |
412 | .asset-import-lightbox-title {
413 | font-size: 14px;
414 | }
415 |
416 | .asset-import-lightbox-meta {
417 | font-size: 12px;
418 | }
419 | }
420 |
421 | /* Verhindert Scrolling wenn Lightbox aktiv ist */
422 | body.lightbox-active {
423 | overflow: hidden;
424 | }
425 |
426 | /* Direct Import Styles */
427 | .direct-import-container {
428 | padding: 20px 0;
429 | }
430 |
431 | .direct-import-preview {
432 | background: var(--ai-preview-bg);
433 | border: 1px solid var(--ai-border-color);
434 | border-radius: 8px;
435 | padding: 20px;
436 | margin-top: 15px;
437 | text-align: center;
438 | }
439 |
440 | .direct-import-preview img,
441 | .direct-import-preview video {
442 | border-radius: 4px;
443 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
444 | }
445 |
446 | .preview-info {
447 | background: rgba(0, 0, 0, 0.02);
448 | border-radius: 4px;
449 | padding: 10px;
450 | margin-top: 15px;
451 | font-size: 14px;
452 | }
453 |
454 | .rex-theme-dark .preview-info {
455 | background: rgba(255, 255, 255, 0.05);
456 | }
457 |
458 | .form-actions {
459 | margin-top: 20px;
460 | padding-top: 15px;
461 | border-top: 1px solid var(--ai-border-color);
462 | }
463 |
464 | .form-actions .btn {
465 | margin-right: 10px;
466 | }
467 |
468 | #direct-import-status {
469 | margin-top: 20px;
470 | border-radius: 4px;
471 | }
472 |
473 | #direct-import-progress {
474 | margin-top: 15px;
475 | height: 40px;
476 | }
477 |
478 | #direct-import-progress .progress-bar {
479 | display: flex;
480 | align-items: center;
481 | justify-content: center;
482 | font-size: 14px;
483 | }
484 |
485 | /* Info Panel Styling */
486 | .panel-info {
487 | border-color: #bce8f1;
488 | }
489 |
490 | .panel-info > .panel-heading {
491 | background-color: #d9edf7;
492 | border-color: #bce8f1;
493 | color: #31708f;
494 | }
495 |
496 | .rex-theme-dark .panel-info > .panel-heading {
497 | background-color: #1f4e5a;
498 | color: #9acfea;
499 | }
500 |
501 | .panel-info ul {
502 | margin-bottom: 0;
503 | padding-left: 20px;
504 | }
505 |
506 | .panel-info ul li {
507 | margin-bottom: 5px;
508 | }
509 |
--------------------------------------------------------------------------------
/assets/asset_import.js:
--------------------------------------------------------------------------------
1 | $(document).on('rex:ready', function() {
2 | const AssetImport = {
3 | currentPage: 1,
4 | loading: false,
5 | hasMore: true,
6 | currentQuery: '',
7 |
8 | init: function() {
9 | this.bindEvents();
10 | this.resetResults();
11 | },
12 |
13 | bindEvents: function() {
14 | $('#asset-import-search-form').on('submit', (e) => {
15 | e.preventDefault();
16 | this.currentQuery = $('#asset-import-query').val();
17 | this.currentPage = 1;
18 | this.hasMore = true;
19 | this.resetResults();
20 | this.search();
21 | });
22 |
23 | $('#asset-import-load-more button').on('click', (e) => {
24 | e.preventDefault();
25 | if (!this.loading && this.hasMore) {
26 | this.currentPage++;
27 | this.search();
28 | }
29 | });
30 |
31 | $(document).on('click', '.asset-import-import-btn', (e) => {
32 | e.preventDefault();
33 | const btn = $(e.currentTarget);
34 | const item = btn.closest('.asset-import-item');
35 | const selectSize = item.find('.asset-import-size-select');
36 | const selectedOption = selectSize.find('option:selected');
37 | const url = selectedOption.data('url');
38 | const filename = item.find('.asset-import-title').text();
39 | const copyright = item.data('copyright') || '';
40 |
41 | this.import(url, filename, copyright, btn);
42 | });
43 |
44 | // Lightbox Events
45 | $(document).on('click', '.asset-import-preview', (e) => {
46 | e.preventDefault();
47 | const item = $(e.currentTarget).closest('.asset-import-item');
48 | this.openLightbox(item);
49 | });
50 |
51 | // Lightbox schließen
52 | $(document).on('click', '.asset-import-lightbox', (e) => {
53 | if (e.target === e.currentTarget) {
54 | this.closeLightbox();
55 | }
56 | });
57 |
58 | $(document).on('click', '.asset-import-lightbox-close', (e) => {
59 | e.preventDefault();
60 | this.closeLightbox();
61 | });
62 |
63 | // ESC-Taste zum Schließen der Lightbox
64 | $(document).on('keyup', (e) => {
65 | if (e.keyCode === 27) { // ESC
66 | this.closeLightbox();
67 | }
68 | });
69 | },
70 |
71 | resetResults: function() {
72 | $('#asset-import-results').empty();
73 | $('#asset-import-load-more').hide();
74 | $('#asset-import-status').hide();
75 | },
76 |
77 | search: function() {
78 | if (this.loading) return;
79 |
80 | this.loading = true;
81 | this.showStatus('loading');
82 |
83 | const data = {
84 | asset_import_api: 1,
85 | action: 'search',
86 | provider: $('#asset-import-provider').val(),
87 | query: this.currentQuery,
88 | page: this.currentPage,
89 | options: {
90 | type: $('#asset-import-type').val()
91 | }
92 | };
93 |
94 | $.ajax({
95 | url: window.location.href,
96 | data: data,
97 | success: (response) => {
98 | if (response.success) {
99 | this.renderResults(response.data);
100 | } else {
101 | this.showError(response.error || rex.asset_import.error_unknown);
102 | }
103 | },
104 | error: (xhr, status, error) => {
105 | this.showError(rex.asset_import.error_loading + ': ' + error);
106 | },
107 | complete: () => {
108 | this.loading = false;
109 | }
110 | });
111 | },
112 |
113 | renderResults: function(data) {
114 | const container = $('#asset-import-results');
115 | let html = '';
116 |
117 | if (!data.items || !data.items.length) {
118 | this.showStatus('no-results');
119 | return;
120 | }
121 |
122 | data.items.forEach(item => {
123 | const copyright = item.copyright || '';
124 | html += `
125 |
126 |
127 | ${item.type === 'video' ? `
128 |
131 | ` : `
132 |

133 | `}
134 |
135 |
136 |
${this.escapeHtml(item.title)}
137 |
144 |
145 |
148 |
149 |
150 |
151 | ${rex.asset_import.importing}
152 |
153 |
154 |
155 |
156 | `;
157 | });
158 |
159 | if (this.currentPage === 1) {
160 | container.html(html);
161 | } else {
162 | container.append(html);
163 | }
164 |
165 | this.hasMore = data.page < data.total_pages;
166 | $('#asset-import-load-more').toggle(this.hasMore);
167 |
168 | this.showStatus('results', data.total);
169 |
170 | // Initialize bootstrap-select for newly added selects
171 | $('.selectpicker').selectpicker('refresh');
172 | },
173 |
174 | import: function(url, filename, copyright, btn) {
175 | const item = btn.closest('.asset-import-item');
176 | const progress = item.find('.progress');
177 | const categoryId = $('#rex-mediapool-category').val();
178 |
179 | btn.hide();
180 | progress.show();
181 |
182 | $.ajax({
183 | url: window.location.href,
184 | method: 'POST',
185 | data: {
186 | asset_import_api: 1,
187 | action: 'import',
188 | provider: $('#asset-import-provider').val(),
189 | url: url,
190 | filename: filename,
191 | category_id: categoryId,
192 | copyright: copyright
193 | },
194 | success: (response) => {
195 | if (response.success) {
196 | this.showSuccess(rex.asset_import.success);
197 | setTimeout(() => {
198 | progress.hide();
199 | btn.show();
200 | }, 1000);
201 | } else {
202 | this.showError(response.error || rex.asset_import.error_import);
203 | progress.hide();
204 | btn.show();
205 | }
206 | },
207 | error: (xhr, status, error) => {
208 | this.showError(rex.asset_import.error_loading + ': ' + error);
209 | progress.hide();
210 | btn.show();
211 | }
212 | });
213 | },
214 |
215 | showStatus: function(type, total = 0) {
216 | const status = $('#asset-import-status');
217 |
218 | switch(type) {
219 | case 'loading':
220 | status.removeClass('alert-danger alert-info')
221 | .addClass('alert-info')
222 | .html(` ${rex.asset_import.loading}`)
223 | .show();
224 | break;
225 |
226 | case 'results':
227 | status.removeClass('alert-danger alert-info')
228 | .addClass('alert-info')
229 | .text(total + ' ' + rex.asset_import.results_found)
230 | .show();
231 | break;
232 |
233 | case 'no-results':
234 | status.removeClass('alert-danger alert-info')
235 | .addClass('alert-info')
236 | .text(rex.asset_import.no_results)
237 | .show();
238 | break;
239 |
240 | default:
241 | status.hide();
242 | }
243 | },
244 |
245 | showError: function(message) {
246 | $('#asset-import-status')
247 | .removeClass('alert-info')
248 | .addClass('alert-danger')
249 | .text(message)
250 | .show();
251 |
252 | setTimeout(() => {
253 | $('#asset-import-status').fadeOut();
254 | }, 5000);
255 | },
256 |
257 | showSuccess: function(message) {
258 | $('#asset-import-status')
259 | .removeClass('alert-danger')
260 | .addClass('alert-info')
261 | .text(message)
262 | .show();
263 |
264 | setTimeout(() => {
265 | $('#asset-import-status').fadeOut();
266 | }, 3000);
267 | },
268 |
269 | escapeHtml: function(unsafe) {
270 | return unsafe
271 | .replace(/&/g, "&")
272 | .replace(//g, ">")
274 | .replace(/"/g, """)
275 | .replace(/'/g, "'");
276 | },
277 |
278 | openLightbox: function(item) {
279 | const title = item.find('.asset-import-title').text();
280 | const selectSize = item.find('.asset-import-size-select');
281 | const selectedOption = selectSize.find('option:selected');
282 | const copyright = item.data('copyright') || '';
283 |
284 | // Versuche die beste verfügbare Auflösung zu finden
285 | let bestSize = this.getBestAvailableSize(item);
286 | let mediaUrl = bestSize.url;
287 | let mediaType = bestSize.type;
288 |
289 | // Bestimme den Media-Typ basierend auf der Dateiendung falls nicht verfügbar
290 | if (!mediaType) {
291 | const extension = mediaUrl.split('.').pop().toLowerCase();
292 | mediaType = ['mp4', 'webm', 'ogg', 'mov', 'avi'].includes(extension) ? 'video' : 'image';
293 | }
294 |
295 | // Erstelle Lightbox HTML
296 | const lightboxHtml = `
297 |
298 |
299 |
300 | ${mediaType === 'video' ? `
301 |
305 | ` : `
306 |

307 | `}
308 |
309 |
${this.escapeHtml(title)}
310 |
311 | Größe: ${bestSize.label}
312 | ${copyright ? ' | © ' + this.escapeHtml(copyright) : ''}
313 |
314 |
315 |
316 |
317 | `;
318 |
319 | // Entferne vorhandene Lightbox falls vorhanden
320 | $('.asset-import-lightbox').remove();
321 |
322 | // Füge neue Lightbox hinzu
323 | $('body').append(lightboxHtml).addClass('lightbox-active');
324 |
325 | // Trigger reflow um sicherzustellen, dass CSS angewendet wird
326 | const lightbox = $('.asset-import-lightbox')[0];
327 | lightbox.offsetHeight; // Force reflow
328 |
329 | // Zeige Lightbox mit Animation mit kleiner Verzögerung
330 | requestAnimationFrame(() => {
331 | $('.asset-import-lightbox').addClass('active');
332 | });
333 | },
334 |
335 | closeLightbox: function() {
336 | $('.asset-import-lightbox').removeClass('active');
337 | $('body').removeClass('lightbox-active');
338 |
339 | setTimeout(() => {
340 | $('.asset-import-lightbox').remove();
341 | }, 400); // Angepasst an die längere Animationsdauer
342 | },
343 |
344 | getBestAvailableSize: function(item) {
345 | const sizeSelect = item.find('.asset-import-size-select');
346 | const options = sizeSelect.find('option');
347 |
348 | // Prioritätsliste für die beste Qualität
349 | const sizePriority = ['large', 'original', 'medium', 'small', 'tiny'];
350 |
351 | let bestOption = null;
352 | let bestPriority = -1;
353 |
354 | options.each(function() {
355 | const option = $(this);
356 | const sizeKey = option.val();
357 | const priority = sizePriority.indexOf(sizeKey);
358 |
359 | if (priority !== -1 && (bestPriority === -1 || priority < bestPriority)) {
360 | bestOption = option;
361 | bestPriority = priority;
362 | }
363 | });
364 |
365 | // Falls keine priorisierte Größe gefunden wurde, nimm die erste verfügbare
366 | if (!bestOption) {
367 | bestOption = options.first();
368 | }
369 |
370 | // Bestimme den Typ basierend auf dem ursprünglichen Item
371 | const preview = item.find('.asset-import-preview');
372 | const isVideo = preview.find('video').length > 0;
373 |
374 | return {
375 | url: bestOption.data('url'),
376 | label: bestOption.text(),
377 | type: isVideo ? 'video' : 'image'
378 | };
379 | }
380 | };
381 |
382 | AssetImport.init();
383 | });
384 |
--------------------------------------------------------------------------------
/lib/Provider/PixabayProvider.php:
--------------------------------------------------------------------------------
1 | 'asset_import_provider_pixabay_apikey',
52 | 'name' => 'apikey',
53 | 'type' => 'text',
54 | 'notice' => 'asset_import_provider_pixabay_apikey_notice',
55 | ],
56 | [
57 | 'label' => 'asset_import_provider_copyright_fields',
58 | 'name' => 'copyright_fields',
59 | 'type' => 'select',
60 | 'options' => [
61 | ['label' => 'Username + Pixabay', 'value' => 'user_pixabay'],
62 | ['label' => 'Only Username', 'value' => 'user'],
63 | ['label' => 'Only Pixabay', 'value' => 'pixabay'],
64 | ],
65 | 'notice' => 'asset_import_provider_copyright_notice',
66 | ],
67 | ];
68 | }
69 |
70 | public function isConfigured(): bool
71 | {
72 | $isConfigured = isset($this->config['apikey']) && !empty($this->config['apikey']);
73 |
74 | if (!$isConfigured) {
75 | rex_logger::factory()->log(LogLevel::WARNING, 'Pixabay provider not configured correctly.', [], __FILE__, __LINE__);
76 | }
77 |
78 | return $isConfigured;
79 | }
80 |
81 | protected function searchApi(string $query, int $page = 1, array $options = []): array
82 | {
83 | try {
84 | if (!$this->isConfigured()) {
85 | throw new rex_exception('Pixabay API key not configured');
86 | }
87 |
88 | if ($this->isPixabayUrl($query)) {
89 | return $this->handlePixabayUrl($query);
90 | }
91 |
92 | $type = $options['type'] ?? 'all';
93 | $results = ['items' => [], 'total' => 0];
94 |
95 | // Setze itemsPerPage basierend auf dem Typ
96 | $currentItemsPerPage = ('all' === $type) ?
97 | (int) ($this->itemsPerPage / 2) :
98 | $this->itemsPerPage;
99 |
100 | if ('all' === $type || 'image' === $type) {
101 | $imageResults = $this->searchImages($query, $page, $currentItemsPerPage);
102 | $results['items'] = array_merge($results['items'], $imageResults['items'] ?? []);
103 | $results['total'] += $imageResults['total'] ?? 0;
104 | }
105 |
106 | if ('all' === $type || 'video' === $type) {
107 | $videoResults = $this->searchVideos($query, $page, $currentItemsPerPage);
108 | $results['items'] = array_merge($results['items'], $videoResults['items'] ?? []);
109 | if ('video' === $type) {
110 | $results['total'] = $videoResults['total'] ?? 0;
111 | } else {
112 | $results['total'] = (int) (($results['total'] + ($videoResults['total'] ?? 0)) / 2);
113 | }
114 | }
115 |
116 | // Sortiere und begrenze Ergebnisse
117 | if ('all' === $type) {
118 | usort($results['items'], static function ($a, $b) {
119 | return $b['id'] - $a['id'];
120 | });
121 | $results['items'] = array_slice($results['items'], 0, $this->itemsPerPage);
122 | }
123 |
124 | return [
125 | 'items' => $results['items'],
126 | 'total' => $results['total'],
127 | 'page' => $page,
128 | 'total_pages' => ceil($results['total'] / $this->itemsPerPage),
129 | ];
130 | } catch (Exception $e) {
131 | rex_logger::factory()->log(LogLevel::ERROR, $e->getMessage(), [], __FILE__, __LINE__);
132 | return ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1];
133 | }
134 | }
135 |
136 | protected function searchImages(string $query, int $page, int $perPage): array
137 | {
138 | $params = [
139 | 'key' => $this->config['apikey'],
140 | 'q' => $query,
141 | 'page' => $page,
142 | 'per_page' => $perPage,
143 | 'safesearch' => 'true',
144 | 'lang' => rex::getUser()->getLanguage(),
145 | 'image_type' => 'all',
146 | 'orientation' => 'horizontal',
147 | ];
148 |
149 | $response = $this->makeApiRequest($this->apiUrl, $params);
150 |
151 | if (!$response || !isset($response['hits'])) {
152 | rex_logger::factory()->log(LogLevel::WARNING, 'No image results from Pixabay API', [
153 | 'query' => $query,
154 | 'page' => $page,
155 | ]);
156 | return ['items' => [], 'total' => 0];
157 | }
158 |
159 | return [
160 | 'items' => array_map(
161 | fn ($item) => $this->formatItem($item, 'image'),
162 | $response['hits'],
163 | ),
164 | 'total' => $response['totalHits'],
165 | ];
166 | }
167 |
168 | protected function searchVideos(string $query, int $page, int $perPage): array
169 | {
170 | $params = [
171 | 'key' => $this->config['apikey'],
172 | 'q' => $query,
173 | 'page' => $page,
174 | 'per_page' => $perPage,
175 | 'safesearch' => 'true',
176 | 'lang' => rex::getUser()->getLanguage(),
177 | ];
178 |
179 | $response = $this->makeApiRequest($this->apiUrlVideos, $params);
180 |
181 | if (!$response || !isset($response['hits'])) {
182 | rex_logger::factory()->log(LogLevel::WARNING, 'No video results from Pixabay API', [
183 | 'query' => $query,
184 | 'page' => $page,
185 | ]);
186 | return ['items' => [], 'total' => 0];
187 | }
188 |
189 | return [
190 | 'items' => array_map(
191 | fn ($item) => $this->formatItem($item, 'video'),
192 | $response['hits'],
193 | ),
194 | 'total' => $response['totalHits'],
195 | ];
196 | }
197 |
198 | protected function formatItem(array $item, string $type): array
199 | {
200 | $copyright = $this->formatCopyright($item);
201 |
202 | if ('video' === $type) {
203 | return [
204 | 'id' => $item['id'],
205 | 'preview_url' => !empty($item['picture_id'])
206 | ? "https://i.vimeocdn.com/video/{$item['picture_id']}_640x360.jpg"
207 | : ($item['userImageURL'] ?? $item['previewURL'] ?? ''),
208 | 'title' => $this->formatTitle($item),
209 | 'author' => $item['user'] ?? '',
210 | 'copyright' => $copyright,
211 | 'type' => 'video',
212 | 'size' => $this->formatVideoSizes($item),
213 | ];
214 | }
215 |
216 | return [
217 | 'id' => $item['id'],
218 | 'preview_url' => $item['previewURL'],
219 | 'title' => $this->formatTitle($item),
220 | 'author' => $item['user'] ?? '',
221 | 'copyright' => $copyright,
222 | 'type' => 'image',
223 | 'size' => [
224 | 'medium' => ['url' => $item['largeImageURL']],
225 | 'tiny' => ['url' => $item['previewURL']],
226 | 'small' => ['url' => $item['webformatURL']],
227 | 'large' => ['url' => $item['imageURL'] ?? $item['largeImageURL']],
228 | ],
229 | ];
230 | }
231 |
232 | protected function formatTitle(array $item): string
233 | {
234 | $title = '';
235 |
236 | if (!empty($item['tags'])) {
237 | $tags = explode(',', $item['tags']);
238 | $title = trim($tags[0]);
239 | }
240 |
241 | if (empty($title)) {
242 | $type = isset($item['videos']) ? 'Video' : 'Image';
243 | $title = $type . ' ' . $item['id'];
244 | }
245 |
246 | return $title;
247 | }
248 |
249 | protected function formatCopyright(array $item): string
250 | {
251 | $copyrightFields = $this->config['copyright_fields'] ?? 'user_pixabay';
252 | $parts = [];
253 |
254 | switch ($copyrightFields) {
255 | case 'user':
256 | if (!empty($item['user'])) {
257 | $parts[] = $item['user'];
258 | }
259 | break;
260 | case 'pixabay':
261 | $parts[] = 'Pixabay.com';
262 | break;
263 | case 'user_pixabay':
264 | default:
265 | if (!empty($item['user'])) {
266 | $parts[] = $item['user'];
267 | }
268 | $parts[] = 'Pixabay.com';
269 | break;
270 | }
271 |
272 | $copyright = implode(' / ', array_filter($parts));
273 |
274 | return $copyright;
275 | }
276 |
277 | protected function formatVideoSizes(array $item): array
278 | {
279 | $sizes = [
280 | 'medium' => ['url' => ''],
281 | 'tiny' => ['url' => ''],
282 | 'small' => ['url' => ''],
283 | 'large' => ['url' => ''],
284 | ];
285 |
286 | if (!empty($item['videos'])) {
287 | $fallbackUrl = '';
288 |
289 | $qualityMap = [
290 | 'large' => ['large', 'medium'],
291 | 'medium' => ['medium', 'large', 'small'],
292 | 'small' => ['small', 'medium', 'tiny'],
293 | 'tiny' => ['tiny', 'small'],
294 | ];
295 |
296 | // Finde zuerst einen Fallback
297 | foreach (['large', 'medium', 'small', 'tiny'] as $quality) {
298 | if (!empty($item['videos'][$quality]['url'])) {
299 | $fallbackUrl = $item['videos'][$quality]['url'];
300 | break;
301 | }
302 | }
303 |
304 | foreach ($qualityMap as $targetSize => $possibleSources) {
305 | foreach ($possibleSources as $source) {
306 | if (!empty($item['videos'][$source]['url'])) {
307 | $sizes[$targetSize]['url'] = $item['videos'][$source]['url'];
308 | break;
309 | }
310 | }
311 | if (empty($sizes[$targetSize]['url']) && $fallbackUrl) {
312 | $sizes[$targetSize]['url'] = $fallbackUrl;
313 | }
314 | }
315 | }
316 |
317 | return $sizes;
318 | }
319 |
320 | public function import(string $url, string $filename, ?string $copyright = null): bool
321 | {
322 | if (!$this->isConfigured()) {
323 | throw new rex_exception('Pixabay API key not configured');
324 | }
325 |
326 | try {
327 | $filename = $this->sanitizeFilename($filename);
328 | $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
329 |
330 | if (!$extension) {
331 | $extension = str_contains($url, 'vimeocdn.com') ? 'mp4' : 'jpg';
332 | }
333 |
334 | $filename = $filename . '.' . $extension;
335 |
336 | // Prüfe ob die Datei bereits existiert
337 | if (rex_media::get($filename)) {
338 | throw new rex_exception(
339 | rex_i18n::msg('asset_import_file_exists', $filename),
340 | );
341 | }
342 |
343 | if ($this->downloadFile($url, $filename)) {
344 | if ($copyright) {
345 | $media = rex_media::get($filename);
346 | if ($media) {
347 | $sql = rex_sql::factory();
348 | $sql->setTable(rex::getTable('media'));
349 | $sql->setWhere(['filename' => $filename]);
350 | $sql->setValue('med_copyright', $copyright);
351 | $sql->update();
352 | }
353 | }
354 | return true;
355 | }
356 |
357 | return false;
358 | } catch (Exception $e) {
359 | rex_logger::factory()->log(LogLevel::ERROR, 'Import error: ' . $e->getMessage());
360 | throw $e;
361 | }
362 | }
363 |
364 | protected function isPixabayUrl(string $query): bool
365 | {
366 | return (bool) preg_match('#^https?://(?:www\.)?pixabay\.com/(?:photos|videos)/#i', $query);
367 | }
368 |
369 | protected function handlePixabayUrl(string $query): array
370 | {
371 | $id = $this->extractImageIdFromUrl($query);
372 | if (!$id) {
373 | return ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1];
374 | }
375 |
376 | $item = $this->getById($id, 'image');
377 | $type = 'image';
378 |
379 | if (!$item) {
380 | $item = $this->getById($id, 'video');
381 | $type = 'video';
382 | }
383 |
384 | if ($item) {
385 | $formattedItem = $this->formatItem($item, $type);
386 | return [
387 | 'items' => [$formattedItem],
388 | 'total' => 1,
389 | 'page' => 1,
390 | 'total_pages' => 1,
391 | ];
392 | }
393 |
394 | return ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1];
395 | }
396 |
397 | protected function extractImageIdFromUrl(string $url): ?int
398 | {
399 | if (preg_match('#pixabay\.com/(?:photos|videos)/[^/]+-(\d+)/?$#i', $url, $matches)) {
400 | return (int) $matches[1];
401 | }
402 | return null;
403 | }
404 |
405 | protected function getById(int $id, string $type = 'image'): ?array
406 | {
407 | $params = [
408 | 'key' => $this->config['apikey'],
409 | 'id' => $id,
410 | 'lang' => rex::getUser()->getLanguage(),
411 | ];
412 |
413 | $url = 'video' === $type ? $this->apiUrlVideos : $this->apiUrl;
414 | $response = $this->makeApiRequest($url, $params);
415 |
416 | return $response['hits'][0] ?? null;
417 | }
418 |
419 | protected function makeApiRequest(string $url, array $params): ?array
420 | {
421 | $url = $url . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
422 |
423 | $ch = curl_init();
424 | curl_setopt_array($ch, [
425 | CURLOPT_URL => $url,
426 | CURLOPT_RETURNTRANSFER => true,
427 | CURLOPT_SSL_VERIFYPEER => true,
428 | CURLOPT_TIMEOUT => 20,
429 | ]);
430 |
431 | $response = curl_exec($ch);
432 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
433 | curl_close($ch);
434 |
435 | if (false === $response || 200 !== $httpCode) {
436 | rex_logger::factory()->log(LogLevel::ERROR, 'API request failed: {url}', ['url' => $url, 'http_code' => $httpCode], __FILE__, __LINE__);
437 | return null;
438 | }
439 |
440 | $data = json_decode($response, true);
441 | if (!isset($data['hits'])) {
442 | rex_logger::factory()->log(LogLevel::ERROR, 'Invalid API response: {url}', ['url' => $url], __FILE__, __LINE__);
443 | return null;
444 | }
445 |
446 | return $data;
447 | }
448 |
449 | public function getDefaultOptions(): array
450 | {
451 | return [
452 | 'type' => 'image',
453 | 'orientation' => 'horizontal',
454 | 'safesearch' => true,
455 | 'lang' => rex::getUser()->getLanguage(),
456 | ];
457 | }
458 |
459 | protected function getCacheLifetime(): int
460 | {
461 | return 86400; // 24 Stunden
462 | }
463 | }
464 |
--------------------------------------------------------------------------------
/lib/Provider/PexelsProvider.php:
--------------------------------------------------------------------------------
1 | 'asset_import_provider_pexels_apikey',
57 | 'name' => 'apikey',
58 | 'type' => 'text',
59 | 'notice' => 'asset_import_provider_pexels_apikey_notice',
60 | ],
61 | [
62 | 'label' => 'asset_import_provider_copyright_fields',
63 | 'name' => 'copyright_fields',
64 | 'type' => 'select',
65 | 'options' => [
66 | ['label' => 'Photographer + Pexels', 'value' => 'photographer_pexels'],
67 | ['label' => 'Only Photographer', 'value' => 'photographer'],
68 | ['label' => 'Only Pexels', 'value' => 'pexels'],
69 | ],
70 | 'notice' => 'asset_import_provider_copyright_notice',
71 | ],
72 | ];
73 | }
74 |
75 | public function isConfigured(): bool
76 | {
77 | $isConfigured = isset($this->config)
78 | && is_array($this->config)
79 | && isset($this->config['apikey'])
80 | && !empty($this->config['apikey']);
81 |
82 | if (!$isConfigured) {
83 | rex_logger::factory()->log(LogLevel::WARNING, 'Pexels provider not configured correctly.', [], __FILE__, __LINE__);
84 | }
85 |
86 | return $isConfigured;
87 | }
88 |
89 | protected function searchApi(string $query, int $page = 1, array $options = []): array
90 | {
91 | try {
92 | if (!$this->isConfigured()) {
93 | throw new rex_exception('Pexels API key not configured');
94 | }
95 |
96 | if ($this->isPexelsUrl($query)) {
97 | return $this->handlePexelsUrl($query);
98 | }
99 |
100 | $type = $options['type'] ?? 'all';
101 | $results = ['items' => [], 'total' => 0];
102 |
103 | // Setze itemsPerPage basierend auf dem Typ
104 | $currentItemsPerPage = ('all' === $type) ?
105 | (int) ($this->itemsPerPage / 2) :
106 | $this->itemsPerPage;
107 |
108 | if ('all' === $type || 'image' === $type) {
109 | $imageResults = $this->searchImages($query, $page, $currentItemsPerPage);
110 | $results['items'] = array_merge($results['items'], $imageResults['items'] ?? []);
111 | $results['total'] += $imageResults['total'] ?? 0;
112 | }
113 |
114 | if ('all' === $type || 'video' === $type) {
115 | $videoResults = $this->searchVideos($query, $page, $currentItemsPerPage);
116 | $results['items'] = array_merge($results['items'], $videoResults['items'] ?? []);
117 | if ('video' === $type) {
118 | $results['total'] = $videoResults['total'] ?? 0;
119 | } else {
120 | $results['total'] = (int) (($results['total'] + ($videoResults['total'] ?? 0)) / 2);
121 | }
122 | }
123 |
124 | // Sortiere und begrenze Ergebnisse
125 | if ('all' === $type) {
126 | usort($results['items'], static function ($a, $b) {
127 | return $b['id'] - $a['id'];
128 | });
129 | $results['items'] = array_slice($results['items'], 0, $this->itemsPerPage);
130 | }
131 |
132 | return [
133 | 'items' => $results['items'],
134 | 'total' => $results['total'],
135 | 'page' => $page,
136 | 'total_pages' => ceil($results['total'] / $this->itemsPerPage),
137 | ];
138 | } catch (Exception $e) {
139 | rex_logger::factory()->log(LogLevel::ERROR, $e->getMessage(), [], __FILE__, __LINE__);
140 | return ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1];
141 | }
142 | }
143 |
144 | protected function searchImages(string $query, int $page, int $perPage): array
145 | {
146 | $results = [];
147 |
148 | if (!empty($query)) {
149 | $searchResults = $this->makeApiRequest(
150 | $this->apiUrl . 'search',
151 | [
152 | 'query' => $query,
153 | 'page' => $page,
154 | 'per_page' => $perPage,
155 | 'orientation' => 'landscape',
156 | ],
157 | );
158 |
159 | if ($searchResults && isset($searchResults['photos'])) {
160 | $results = [
161 | 'items' => array_map(
162 | fn ($item) => $this->formatItem($item, 'image'),
163 | $searchResults['photos'],
164 | ),
165 | 'total' => $searchResults['total_results'],
166 | ];
167 | }
168 | } else {
169 | $curatedResults = $this->makeApiRequest(
170 | $this->apiUrl . 'curated',
171 | [
172 | 'page' => $page,
173 | 'per_page' => $perPage,
174 | ],
175 | );
176 |
177 | if ($curatedResults && isset($curatedResults['photos'])) {
178 | $results = [
179 | 'items' => array_map(
180 | fn ($item) => $this->formatItem($item, 'image'),
181 | $curatedResults['photos'],
182 | ),
183 | 'total' => count($curatedResults['photos']) * 10,
184 | ];
185 | }
186 | }
187 |
188 | return $results;
189 | }
190 |
191 | protected function searchVideos(string $query, int $page, int $perPage): array
192 | {
193 | $videoParams = [
194 | 'page' => $page,
195 | 'per_page' => $perPage,
196 | ];
197 |
198 | if (!empty($query)) {
199 | $videoParams['query'] = $query;
200 | $endpoint = 'search';
201 | } else {
202 | $endpoint = 'popular';
203 | }
204 |
205 | $videoResults = $this->makeApiRequest(
206 | $this->apiUrlVideos . $endpoint,
207 | $videoParams,
208 | );
209 |
210 | if ($videoResults && isset($videoResults['videos'])) {
211 | return [
212 | 'items' => array_map(
213 | fn ($item) => $this->formatItem($item, 'video'),
214 | $videoResults['videos'],
215 | ),
216 | 'total' => $videoResults['total_results'] ?? count($videoResults['videos']) * 10,
217 | ];
218 | }
219 |
220 | return ['items' => [], 'total' => 0];
221 | }
222 |
223 | protected function formatItem(array $item, string $type): array
224 | {
225 | $copyright = $this->formatCopyright($item);
226 |
227 | if ('video' === $type) {
228 | $sizes = $this->formatVideoSizes($item);
229 |
230 | return [
231 | 'id' => $item['id'],
232 | 'preview_url' => $item['image'] ?? '',
233 | 'title' => $item['duration'] ? sprintf('Video (%ds)', $item['duration']) : 'Video',
234 | 'author' => $item['user']['name'] ?? '',
235 | 'copyright' => $copyright,
236 | 'type' => 'video',
237 | 'size' => $sizes,
238 | ];
239 | }
240 |
241 | return [
242 | 'id' => $item['id'],
243 | 'preview_url' => $item['src']['medium'] ?? $item['src']['small'] ?? '',
244 | 'title' => $item['alt'] ?? $item['photographer'] ?? 'Image',
245 | 'author' => $item['photographer'] ?? '',
246 | 'copyright' => $copyright,
247 | 'type' => 'image',
248 | 'size' => [
249 | 'medium' => ['url' => $item['src']['medium'] ?? $item['src']['large'] ?? ''],
250 | 'tiny' => ['url' => $item['src']['tiny'] ?? $item['src']['small'] ?? ''],
251 | 'small' => ['url' => $item['src']['small'] ?? $item['src']['medium'] ?? ''],
252 | 'large' => ['url' => $item['src']['original'] ?? $item['src']['large2x'] ?? $item['src']['large'] ?? ''],
253 | ],
254 | ];
255 | }
256 |
257 | protected function formatCopyright(array $item): string
258 | {
259 | $copyrightFields = $this->config['copyright_fields'] ?? 'photographer_pexels';
260 | $parts = [];
261 |
262 | switch ($copyrightFields) {
263 | case 'photographer':
264 | if (isset($item['photographer']) || isset($item['user']['name'])) {
265 | $parts[] = $item['photographer'] ?? $item['user']['name'];
266 | }
267 | break;
268 | case 'pexels':
269 | $parts[] = 'Pexels.com';
270 | break;
271 | case 'photographer_pexels':
272 | default:
273 | if (isset($item['photographer']) || isset($item['user']['name'])) {
274 | $parts[] = $item['photographer'] ?? $item['user']['name'];
275 | }
276 | $parts[] = 'Pexels.com';
277 | break;
278 | }
279 |
280 | $copyright = implode(' / ', array_filter($parts));
281 |
282 | return $copyright;
283 | }
284 |
285 | protected function formatVideoSizes(array $item): array
286 | {
287 | $videoFiles = $item['video_files'] ?? [];
288 | $sizes = [];
289 |
290 | foreach ($videoFiles as $file) {
291 | $height = $file['height'] ?? 0;
292 | $link = $file['link'] ?? '';
293 |
294 | if (empty($link)) {
295 | continue;
296 | }
297 |
298 | if ($height >= 1080) {
299 | $sizes['large'] = ['url' => $link];
300 | } elseif ($height >= 720) {
301 | $sizes['medium'] = ['url' => $link];
302 | } elseif ($height >= 480) {
303 | $sizes['small'] = ['url' => $link];
304 | } else {
305 | $sizes['tiny'] = ['url' => $link];
306 | }
307 | }
308 |
309 | // Fallback für fehlende Größen
310 | if (!empty($videoFiles)) {
311 | $fallbackUrl = '';
312 | foreach ($videoFiles as $file) {
313 | if (!empty($file['link'])) {
314 | $fallbackUrl = $file['link'];
315 | break;
316 | }
317 | }
318 |
319 | $requiredSizes = ['tiny', 'small', 'medium', 'large'];
320 | foreach ($requiredSizes as $size) {
321 | if (!isset($sizes[$size])) {
322 | $sizes[$size] = ['url' => $fallbackUrl];
323 | }
324 | }
325 | }
326 |
327 | return $sizes;
328 | }
329 |
330 | public function import(string $url, string $filename, ?string $copyright = null): bool
331 | {
332 | if (!$this->isConfigured()) {
333 | throw new rex_exception('Pexels API key not configured');
334 | }
335 |
336 | try {
337 | $filename = $this->sanitizeFilename($filename);
338 | $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
339 |
340 | if (!$extension) {
341 | // Versuche Content-Type zu erhalten
342 | $ch = curl_init($url);
343 | curl_setopt_array($ch, [
344 | CURLOPT_RETURNTRANSFER => true,
345 | CURLOPT_NOBODY => true,
346 | CURLOPT_HEADER => true,
347 | ]);
348 | $header = curl_exec($ch);
349 | curl_close($ch);
350 |
351 | if (preg_match('/Content-Type: image\/(\w+)/i', $header, $matches)) {
352 | $extension = $matches[1];
353 | } else {
354 | $extension = 'jpg';
355 | }
356 | }
357 |
358 | $filename = $filename . '.' . $extension;
359 |
360 | // Prüfe ob die Datei bereits existiert
361 | if (rex_media::get($filename)) {
362 | throw new rex_exception(
363 | rex_i18n::msg('asset_import_file_exists', $filename),
364 | );
365 | }
366 |
367 | if ($this->downloadFile($url, $filename)) {
368 | if ($copyright) {
369 | $media = rex_media::get($filename);
370 | if ($media) {
371 | $sql = rex_sql::factory();
372 | $sql->setTable(rex::getTable('media'));
373 | $sql->setWhere(['filename' => $filename]);
374 | $sql->setValue('med_copyright', $copyright);
375 | $sql->update();
376 | }
377 | }
378 | return true;
379 | }
380 |
381 | return false;
382 | } catch (Exception $e) {
383 | rex_logger::factory()->log(LogLevel::ERROR, 'Import error: ' . $e->getMessage());
384 | throw $e;
385 | }
386 | }
387 |
388 | protected function isPexelsUrl(string $query): bool
389 | {
390 | return (bool) preg_match('#^https?://(?:www\.)?pexels\.com/(?:photo|video)/#i', $query);
391 | }
392 |
393 | protected function handlePexelsUrl(string $query): array
394 | {
395 | $id = $this->extractIdFromUrl($query);
396 | if (!$id) {
397 | return ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1];
398 | }
399 |
400 | // Try photo first
401 | $item = $this->getById($id, 'image');
402 | $type = 'image';
403 |
404 | // If not found, try video
405 | if (!$item) {
406 | $item = $this->getById($id, 'video');
407 | $type = 'video';
408 | }
409 |
410 | if ($item) {
411 | $formattedItem = $this->formatItem($item, $type);
412 | return [
413 | 'items' => [$formattedItem],
414 | 'total' => 1,
415 | 'page' => 1,
416 | 'total_pages' => 1,
417 | ];
418 | }
419 |
420 | return ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1];
421 | }
422 |
423 | protected function makeApiRequest(string $url, array $params = []): ?array
424 | {
425 | if (!empty($params)) {
426 | $url .= '?' . http_build_query($params);
427 | }
428 |
429 | $ch = curl_init();
430 | curl_setopt_array($ch, [
431 | CURLOPT_URL => $url,
432 | CURLOPT_RETURNTRANSFER => true,
433 | CURLOPT_SSL_VERIFYPEER => true,
434 | CURLOPT_TIMEOUT => 20,
435 | CURLOPT_HTTPHEADER => [
436 | 'Authorization: ' . $this->config['apikey'],
437 | ],
438 | ]);
439 |
440 | $response = curl_exec($ch);
441 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
442 | curl_close($ch);
443 |
444 | if (false === $response || 200 !== $httpCode) {
445 | rex_logger::factory()->log(LogLevel::ERROR, 'API request failed: ' . $url, ['http_code' => $httpCode], __FILE__, __LINE__);
446 | return null;
447 | }
448 |
449 | $data = json_decode($response, true);
450 | if (null === $data) {
451 | rex_logger::factory()->log(LogLevel::ERROR, 'Invalid JSON response from API: ' . $url, [], __FILE__, __LINE__);
452 | return null;
453 | }
454 |
455 | return $data;
456 | }
457 |
458 | protected function getById(int $id, string $type = 'image'): ?array
459 | {
460 | $endpoint = 'video' === $type ? 'videos/' . $id : 'photos/' . $id;
461 | $baseUrl = 'video' === $type ? $this->apiUrlVideos : $this->apiUrl;
462 |
463 | return $this->makeApiRequest($baseUrl . $endpoint);
464 | }
465 |
466 | protected function extractIdFromUrl(string $url): ?int
467 | {
468 | // Match URLs like:
469 | // https://www.pexels.com/photo/brown-rocks-during-golden-hour-2014422/
470 | // https://www.pexels.com/video/drone-view-of-a-city-3129957/
471 | if (preg_match('#pexels\.com/(?:photo|video)/[^/]+-(\d+)/?$#i', $url, $matches)) {
472 | return (int) $matches[1];
473 | }
474 | return null;
475 | }
476 |
477 | public function getDefaultOptions(): array
478 | {
479 | return [
480 | 'type' => 'image',
481 | 'orientation' => 'landscape',
482 | 'size' => 'medium',
483 | ];
484 | }
485 |
486 | protected function getCacheLifetime(): int
487 | {
488 | // 24 Stunden Cache
489 | return 86400;
490 | }
491 | }
492 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Asset Import für REDAXO
2 |
3 | Ein AddOn zum Importieren von Medien aus verschiedenen Quellen (Pixabay, Pexels etc.) direkt in den REDAXO Medienpool.
4 |
5 | 
6 |
7 | ## Features
8 |
9 | - Bildsuche über verschiedene Provider
10 | - Direkte Suche in **Wikimedia Commons** (Wikipedia Medien)
11 | - Vorschau der Assets mit Metadaten
12 | - Direkter Import in den Medienpool
13 | - **Copyright-Informationen** automatisch übernehmen
14 | - Kategoriezuweisung
15 | - 24h API-Cache für bessere Performance
16 | - Erweiterbar durch weitere Provider
17 |
18 | ## Verfügbare Provider
19 |
20 | ### Pixabay
21 | - Kostenlose Stock-Fotos und -Videos
22 | - API-Key erforderlich
23 | - Kommerzielle Nutzung möglich
24 |
25 | ### Pexels
26 | - Hochqualitative Stock-Fotos
27 | - API-Key erforderlich
28 | - Alle Bilder kostenlos nutzbar
29 |
30 | ### **Wikimedia Commons** ⭐ **NEU**
31 | - Freie Medien der Wikipedia-Projekte
32 | - **Keine API-Key erforderlich**
33 | - Millionen von freien Bildern, SVGs und Dokumenten
34 | - Automatische Copyright- und Lizenz-Übernahme
35 | - Unterstützt JPG, PNG, SVG, WebP und PDF
36 |
37 | ## Installation
38 |
39 | 1. Im REDAXO Installer das AddOn `asset_import` herunterladen
40 | 2. Installation durchführen
41 | 3. Provider konfigurieren unter "Asset Import > Einstellungen"
42 |
43 | ## Konfiguration
44 |
45 | ### Wikimedia Commons (empfohlen)
46 |
47 | **Wikimedia Commons** ist sofort einsatzbereit - kein API-Key erforderlich!
48 |
49 | 1. **Gehe zu:** Asset Import > Einstellungen > Wikimedia Commons
50 | 2. **Konfiguriere:**
51 | - **User-Agent:** `DeineWebsite.de Import/1.0 (deine@email.de)`
52 | - **Copyright-Felder:** Wähle das gewünschte Format:
53 | - `Author + Wikimedia Commons` → "Max Mustermann / Wikimedia Commons"
54 | - `Only Author` → "Max Mustermann"
55 | - `License Info` → "CC BY-SA 4.0"
56 | - **Copyright-Info setzen:** `Ja` (für automatische Copyright-Übernahme)
57 | - **Dateitypen:** `Images only` oder `All file types`
58 | 3. **Speichere die Einstellungen**
59 | 4. **Fertig!** Du kannst sofort loslegen
60 |
61 | ## Quick-Start: Erstes Bild importieren
62 |
63 | ### Mit Wikimedia Commons (empfohlen für Einsteiger)
64 |
65 | 1. **Gehe zu:** AddOns > Asset Import
66 | 2. **Wähle:** Wikimedia Commons
67 | 3. **Suche nach:** `cat` oder `Berlin`
68 | 4. **Klicke auf:** "Importieren" bei einem Bild deiner Wahl
69 | 5. **Prüfe:** Medienpool - dein Bild ist da mit Copyright-Info! 🎉
70 |
71 | Das wars! Kein API-Key, keine komplizierte Einrichtung.
72 |
73 | ### Mit Pixabay/Pexels
74 |
75 | 1. **Erstelle API-Key** (siehe Links oben)
76 | 2. **Gehe zu:** Asset Import > Einstellungen > Pixabay/Pexels
77 | 3. **Trage API-Key ein** und speichere
78 | 4. **Gehe zu:** Asset Import und wähle den Provider
79 | 5. **Suche und importiere** wie bei Wikimedia
80 |
81 | ## FAQ
82 |
83 | ### Warum Wikimedia Commons wählen?
84 |
85 | - ✅ **Kostenlos:** Keine API-Limits oder Kosten
86 | - ✅ **Rechtssicher:** Alle Medien sind frei nutzbar
87 | - ✅ **Vielfältig:** Millionen professioneller Bilder und Grafiken
88 | - ✅ **Qualität:** Oft bessere Qualität als Stock-Foto-Seiten
89 | - ✅ **Einzigartig:** Historische und wissenschaftliche Inhalte
90 |
91 | ### Was bedeuten die Copyright-Optionen?
92 |
93 | - **Author + Wikimedia Commons:** `"Max Mustermann / Wikimedia Commons"`
94 | - **Only Author:** `"Max Mustermann"`
95 | - **Only Wikimedia Commons:** `"Wikimedia Commons"`
96 | - **License Info:** `"CC BY-SA 4.0"`
97 |
98 | ### Welche Dateiformate werden unterstützt?
99 |
100 | **Wikimedia Commons:**
101 | - **Bilder:** JPG, PNG, SVG, WebP
102 | - **Dokumente:** PDF
103 |
104 | **Pixabay/Pexels:**
105 | - **Bilder:** JPG, PNG, WebP
106 | - **Videos:** MP4, WebM (je nach Provider)
107 |
108 | ### Wo finde ich die Copyright-Informationen?
109 |
110 | Nach dem Import findest du die Copyright-Informationen im **Medienpool**:
111 | 1. **Gehe zu:** Medienpool
112 | 2. **Klicke** auf dein importiertes Bild
113 | 3. **Schaue** ins Feld "**Copyright**" (nicht Beschreibung!)
114 |
115 | ### Kann ich auch Videos importieren?
116 |
117 | - **Wikimedia Commons:** Nein, nur Bilder und PDFs
118 | - **Pixabay/Pexels:** Ja, Videos werden unterstützt
119 |
120 | ### Pixabay & Pexels
121 |
122 | Für Pixabay und Pexels benötigst du einen kostenlosen API-Key:
123 | - [Pixabay API-Key erstellen](https://pixabay.com/api/docs/)
124 | - [Pexels API-Key erstellen](https://www.pexels.com/api/key/)
125 |
126 | ## Berechtigungen
127 |
128 | Das AddOn bringt eine eigene Berechtigung mit:
129 |
130 | - **`asset_import[]`** - Berechtigt zum Zugriff auf das gesamte Asset Import AddOn
131 |
132 | Diese Berechtigung kann in der Benutzerverwaltung (System > Benutzer) einzelnen Benutzern oder Rollen zugewiesen werden. Ohne diese Berechtigung ist das AddOn für den Benutzer nicht sichtbar.
133 |
134 | Die Einstellungsseite erfordert zusätzlich Administratorrechte (`admin[]`).
135 |
136 | ## Provider registrieren
137 |
138 | Provider können in der boot.php eines anderen AddOns registriert werden:
139 |
140 | ```php
141 | // Provider-Klasse implementieren
142 | namespace MyAddon\Provider;
143 |
144 | class MyProvider extends \FriendsOfRedaxo\AssetImport\Asset\AbstractProvider
145 | {
146 | // Provider Implementation
147 | }
148 |
149 | // Provider im Asset Import registrieren
150 | if (\rex_addon::get('asset_import')->isAvailable()) {
151 | \FriendsOfRedaxo\AssetImport\AssetImporter::registerProvider(MyProvider::class);
152 | }
153 | ```
154 |
155 | ## Provider implementieren
156 |
157 | Ein Provider muss das ProviderInterface implementieren:
158 |
159 | ```php
160 | public function getName(): string; // Eindeutiger Name
161 | public function getTitle(): string; // Anzeigename
162 | public function getIcon(): string; // FontAwesome Icon
163 | public function isConfigured(): bool; // Prüft Konfiguration
164 | public function getConfigFields(): array; // Konfigurationsfelder
165 | public function search(): array; // Suchmethode
166 | public function import(): bool; // Import Methode
167 | public function getDefaultOptions(): array; // Standard-Optionen
168 | ```
169 |
170 | Die abstrakte Klasse `AbstractProvider` bietet bereits:
171 | - API Caching (24h)
172 | - Medienpool Import
173 | - Konfigurationsverwaltung
174 |
175 | ## Wikimedia Commons Provider
176 |
177 | ### Überblick
178 |
179 | Der **Wikimedia Commons Provider** ermöglicht den direkten Import von freien Medien aus der größten Sammlung freier Inhalte der Welt. Wikimedia Commons ist die zentrale Mediendatenbank aller Wikipedia-Projekte und enthält Millionen von Bildern, SVGs, Audio- und Videodateien unter freien Lizenzen.
180 |
181 | ### Besondere Features
182 |
183 | - ✅ **Kein API-Key erforderlich** - sofort einsatzbereit
184 | - ✅ **Millionen freie Medien** - Fotos, Grafiken, historische Bilder
185 | - ✅ **Automatische Copyright-Übernahme** - Autor und Lizenzinfo werden automatisch gesetzt
186 | - ✅ **Verschiedene Formate** - JPG, PNG, SVG, WebP, PDF
187 | - ✅ **Direkte URL-Eingabe** - Wikimedia-Links direkt importieren
188 | - ✅ **Erweiterte Suche** - mit Dateityp-Filtern
189 |
190 | ### Verwendung
191 |
192 | 1. **Textsuche:** Gib Suchbegriffe ein (z.B. "Berlin", "cat", "nature")
193 | 2. **URL-Import:** Kopiere Wikimedia-URLs direkt in das Suchfeld
194 | 3. **Dateityp-Filter:** Wähle zwischen "Alle Dateien" oder "Nur Bilder"
195 | 4. **Copyright-Übernahme:** Aktiviere die automatische Übernahme von Autoren- und Lizenzinformationen
196 |
197 | ### Rechtliche Sicherheit
198 |
199 | Alle Dateien auf Wikimedia Commons stehen unter **freien Lizenzen**:
200 | - **Creative Commons** (CC BY, CC BY-SA, CC0)
201 | - **Public Domain** (gemeinfrei)
202 | - **GNU Free Documentation License**
203 |
204 | Das AddOn übernimmt automatisch die korrekte **Quellenangabe** und **Lizenzinformation**, um rechtliche Anforderungen zu erfüllen.
205 |
206 | ### Beispiele für verfügbare Inhalte
207 |
208 | - **Fotos:** Natur, Städte, Architektur, Personen, Tiere
209 | - **Historische Bilder:** Gemälde, historische Fotos, Karten
210 | - **SVG-Grafiken:** Logos, Icons, Diagramme, Flaggen
211 | - **Dokumente:** Bücher, Karten, wissenschaftliche Arbeiten
212 |
213 | ### User-Agent Konfiguration
214 |
215 | Wikimedia empfiehlt die Angabe eines **User-Agent** für bessere API-Performance:
216 |
217 | **Format:** `[Website/Projekt] [Tool]/[Version] ([Kontakt-Email])`
218 |
219 | **Beispiele:**
220 | ```
221 | MeineWebsite.de AssetImport/1.0 (kontakt@meinewebsite.de)
222 | Firma-XY REDAXO-Import/1.0 (admin@firma-xy.de)
223 | MyProject.com MediaBot/1.0 (support@myproject.com)
224 | ```
225 |
226 |
227 | ## Beispiel Provider für File import aus lokalem Ordner
228 |
229 | ### Was macht der Provider?
230 |
231 | Der FTP Upload Provider ermöglicht es, Dateien aus einem definierten Upload-Verzeichnis in den REDAXO Medienpool zu importieren. Er ist ein gutes Beispiel dafür, wie ein eigener Provider für das Asset Import AddOn implementiert werden kann.
232 |
233 | ### Features
234 |
235 | - Durchsucht das `ftpupload`-Verzeichnis im REDAXO-Root rekursiv
236 | - Unterstützt Bilder (jpg, jpeg, png, gif, webp) und Videos (mp4, webm)
237 | - Sortiert Dateien nach Änderungsdatum (neueste zuerst)
238 | - Bietet Suche nach Dateinamen
239 | - Paginierte Ergebnisse (20 pro Seite)
240 |
241 | ### Installation
242 |
243 | 1. Erstelle Provider-Klasse in deinem AddOn:
244 |
245 | ```php
246 | // in /redaxo/src/addons/project/lib/Provider/FtpUploadProvider.php
247 |
248 | 'image'
286 | ];
287 | }
288 |
289 | protected function searchApi(string $query, int $page = 1, array $options = []): array
290 | {
291 | $items = [];
292 | $type = $options['type'] ?? 'image';
293 | $uploadPath = rex_path::base('ftpupload');
294 |
295 | if (is_dir($uploadPath)) {
296 | $files = new \RecursiveIteratorIterator(
297 | new \RecursiveDirectoryIterator($uploadPath, \RecursiveDirectoryIterator::SKIP_DOTS),
298 | \RecursiveIteratorIterator::SELF_FIRST
299 | );
300 |
301 | foreach ($files as $file) {
302 | if ($file->isFile()) {
303 | $fileType = $this->getFileType($file->getFilename());
304 |
305 | // Nur Bilder und Videos berücksichtigen
306 | if ($fileType && ($type === 'all' || $type === $fileType)) {
307 | if (empty($query) || stripos($file->getFilename(), $query) !== false) {
308 | $relativePath = str_replace($uploadPath, '', $file->getPathname());
309 | $relativePath = ltrim($relativePath, '/\\');
310 | $filename = $file->getFilename();
311 |
312 | $items[] = [
313 | 'id' => md5($relativePath),
314 | 'preview_url' => rex_url::base('ftpupload/' . $relativePath),
315 | 'title' => $filename,
316 | 'author' => 'FTP Upload',
317 | 'type' => $fileType,
318 | 'size' => [
319 | 'original' => ['url' => rex_url::base('ftpupload/' . $relativePath)]
320 | ]
321 | ];
322 | }
323 | }
324 | }
325 | }
326 |
327 | // Sortiere nach Datum absteigend
328 | usort($items, function($a, $b) use ($uploadPath) {
329 | $timeA = filemtime($uploadPath . '/' . $a['title']);
330 | $timeB = filemtime($uploadPath . '/' . $b['title']);
331 | return $timeB - $timeA;
332 | });
333 |
334 | // Paginierung
335 | $itemsPerPage = 20;
336 | $offset = ($page - 1) * $itemsPerPage;
337 | $items = array_slice($items, $offset, $itemsPerPage);
338 | }
339 |
340 | $total = count($items);
341 |
342 | return [
343 | 'items' => $items,
344 | 'total' => $total,
345 | 'page' => $page,
346 | 'total_pages' => ceil($total / 20)
347 | ];
348 | }
349 |
350 | private function getFileType(string $filename): ?string
351 | {
352 | $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
353 |
354 | $types = [
355 | 'image' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
356 | 'video' => ['mp4', 'webm']
357 | ];
358 |
359 | foreach ($types as $type => $extensions) {
360 | if (in_array($ext, $extensions)) {
361 | return $type;
362 | }
363 | }
364 |
365 | return null;
366 | }
367 | }
368 | ```
369 |
370 | 2. Provider in deinem AddOn registrieren:
371 |
372 | ```php
373 | // in /redaxo/src/addons/project/boot.php
374 |
375 | if (\rex_addon::get('asset_import')->isAvailable()) {
376 | \FriendsOfRedaxo\AssetImport\AssetImporter::registerProvider(\Project\Provider\FtpUploadProvider::class);
377 | }
378 | ```
379 |
380 | 3. `ftpupload`-Verzeichnis im REDAXO-Root erstellen und Schreibrechte setzen
381 |
382 | ### Verzeichnisstruktur
383 |
384 | ```
385 | redaxo/
386 | ├── src/
387 | │ └── addons/
388 | │ └── project/
389 | │ ├── lib/
390 | │ │ └── Provider/
391 | │ │ └── FtpUploadProvider.php
392 | │ └── boot.php
393 | └── ftpupload/
394 | ├── bilder/
395 | └── videos/
396 | ```
397 |
398 | ### Funktionsweise
399 |
400 | 1. **Verzeichnis-Scan:**
401 | - Durchsucht das `ftpupload`-Verzeichnis rekursiv
402 | - Filtert nach unterstützten Dateitypen
403 | - Berücksichtigt nur Bilder und Videos
404 |
405 | 2. **Suche:**
406 | - Filtert Dateien nach Suchbegriff im Dateinamen
407 | - Typ-Filter für Bilder oder Videos
408 |
409 | 3. **Sortierung & Paginierung:**
410 | - Sortiert nach Änderungsdatum (neueste zuerst)
411 | - 20 Einträge pro Seite
412 | - Unterstützt Blättern durch die Ergebnisse
413 |
414 | 4. **Import:**
415 | - Nutzt den Standard-Import des AbstractProvider
416 | - Importiert direkt in den Medienpool
417 |
418 | ## API Referenz
419 |
420 | ### AbstractProvider
421 |
422 | Die Basisklasse, von der alle Provider erben müssen. Stellt grundlegende Funktionalitäten und Schnittstellen bereit.
423 |
424 | #### Hauptmethoden
425 |
426 | ```php
427 | public function getName(): string
428 | ```
429 | Gibt einen eindeutigen Bezeichner für den Provider zurück.
430 |
431 | ```php
432 | public function getTitle(): string
433 | ```
434 | Gibt den Anzeigenamen zurück, der in der Benutzeroberfläche angezeigt wird.
435 |
436 | ```php
437 | public function getIcon(): string
438 | ```
439 | Gibt einen FontAwesome-Icon-Bezeichner zurück (z.B. 'fa-cloud').
440 |
441 | ```php
442 | public function isConfigured(): bool
443 | ```
444 | Prüft, ob der Provider alle erforderlichen Konfigurationseinstellungen hat.
445 |
446 | ```php
447 | public function getConfigFields(): array
448 | ```
449 | Gibt Konfigurationsfelder für die Provider-Einstellungsseite zurück. Jedes Feld sollte ein Array mit folgenden Elementen sein:
450 | - `name`: Feldbezeichner
451 | - `type`: Eingabetyp ('text', 'password', 'select')
452 | - `label`: Übersetzungsschlüssel für das Label
453 | - `notice`: Optionaler Übersetzungsschlüssel für Hilfetext
454 | - `options`: Array von Optionen für Select-Felder
455 |
456 | ```php
457 | public function search(string $query, int $page = 1, array $options = []): array
458 | ```
459 | Führt die Suche durch und gibt Ergebnisse in folgendem Format zurück:
460 | ```php
461 | [
462 | 'items' => [
463 | [
464 | 'id' => string, // Eindeutige ID
465 | 'preview_url' => string, // Vorschaubild-URL
466 | 'title' => string, // Asset-Titel
467 | 'author' => string, // Ersteller/Autor
468 | 'type' => string, // 'image' oder 'video'
469 | 'size' => [ // Verfügbare Größen
470 | 'tiny' => ['url' => string],
471 | 'small' => ['url' => string],
472 | 'medium' => ['url' => string],
473 | 'large' => ['url' => string],
474 | 'original' => ['url' => string]
475 | ]
476 | ]
477 | ],
478 | 'total' => int, // Gesamtanzahl der Ergebnisse
479 | 'page' => int, // Aktuelle Seitennummer
480 | 'total_pages' => int // Gesamtanzahl der Seiten
481 | ]
482 | ```
483 |
484 | ```php
485 | public function import(string $url, string $filename): bool
486 | ```
487 | Importiert ein Asset in den REDAXO-Medienpool. Gibt bei Erfolg true zurück.
488 |
489 | #### Geschützte Methoden
490 |
491 | ```php
492 | protected function searchApi(string $query, int $page = 1, array $options = []): array
493 | ```
494 | Implementierung der eigentlichen API-Suche. Muss von Provider-Klassen implementiert werden.
495 |
496 | ```php
497 | protected function getCacheLifetime(): int
498 | ```
499 | Gibt die Cache-Lebensdauer in Sekunden zurück. Standard: 86400 (24 Stunden)
500 |
501 | ```php
502 | protected function getDefaultOptions(): array
503 | ```
504 | Gibt Standard-Suchoptionen zurück. Standard: `['type' => 'all']`
505 |
506 | ### AssetImporter
507 |
508 | Statische Klasse zur Verwaltung von Providern.
509 |
510 | ```php
511 | public static function registerProvider(string $providerClass): void
512 | ```
513 | Registriert eine neue Provider-Klasse.
514 |
515 | ```php
516 | public static function getProviders(): array
517 | ```
518 | Gibt alle registrierten Provider-Klassen zurück.
519 |
520 | ```php
521 | public static function getProvider(string $name): ?AbstractProvider
522 | ```
523 | Gibt Provider-Instanz anhand des Namens zurück oder null, wenn nicht gefunden.
524 |
525 | ### Cache
526 |
527 | Das AddOn verwendet das eingebaute Caching-System von REDAXO, um API-Antworten zu speichern. Cache-Einträge werden in der Tabelle `rex_asset_import_cache` gespeichert mit:
528 |
529 | - `provider`: Provider-Bezeichner
530 | - `cache_key`: MD5-Hash der Abfrageparameter
531 | - `response`: JSON-kodierte API-Antwort
532 | - `created`: Erstellungszeitpunkt
533 | - `valid_until`: Ablaufzeitpunkt
534 |
535 | Die Standard-Cache-Lebensdauer beträgt 24 Stunden und kann pro Provider durch Überschreiben von `getCacheLifetime()` angepasst werden.
536 |
537 | ### Lizenz
538 |
539 | MIT
540 |
541 |
542 | ## Lizenz
543 |
544 | MIT Lizenz, siehe [LICENSE](LICENSE)
545 |
546 | ## Autoren
547 |
548 | **Friends Of REDAXO**
549 |
550 | * http://www.redaxo.org
551 | * https://github.com/FriendsOfREDAXO
552 |
553 |
554 | **Project Lead**
555 |
556 | [Thomas Skerbis](https://github.com/skerbis)
557 |
--------------------------------------------------------------------------------
/lib/Provider/WikimediaProvider.php:
--------------------------------------------------------------------------------
1 | 'asset_import_provider_wikimedia_useragent',
65 | 'name' => 'useragent',
66 | 'type' => 'text',
67 | 'notice' => 'asset_import_provider_wikimedia_useragent_notice',
68 | ],
69 | [
70 | 'label' => 'asset_import_provider_copyright_fields',
71 | 'name' => 'copyright_fields',
72 | 'type' => 'select',
73 | 'options' => [
74 | ['label' => 'Author + Wikimedia Commons', 'value' => 'author_wikimedia'],
75 | ['label' => 'Only Author', 'value' => 'author'],
76 | ['label' => 'Only Wikimedia Commons', 'value' => 'wikimedia'],
77 | ['label' => 'License Info', 'value' => 'license'],
78 | ],
79 | 'notice' => 'asset_import_provider_copyright_notice',
80 | ],
81 | [
82 | 'label' => 'asset_import_provider_set_copyright',
83 | 'name' => 'set_copyright',
84 | 'type' => 'select',
85 | 'options' => [
86 | ['label' => 'Nein', 'value' => '0'],
87 | ['label' => 'Ja', 'value' => '1'],
88 | ],
89 | 'notice' => 'asset_import_provider_set_copyright_notice',
90 | ],
91 | [
92 | 'label' => 'asset_import_provider_wikimedia_file_types',
93 | 'name' => 'file_types',
94 | 'type' => 'select',
95 | 'options' => [
96 | ['label' => 'All file types', 'value' => 'all'],
97 | ['label' => 'Images only', 'value' => 'images'],
98 | ],
99 | 'notice' => 'asset_import_provider_wikimedia_file_types_notice',
100 | ],
101 | ];
102 | }
103 |
104 | public function isConfigured(): bool
105 | {
106 | // Wikimedia Commons API ist öffentlich, keine Konfiguration erforderlich
107 | // Nur User-Agent wird empfohlen
108 | return true;
109 | }
110 |
111 | protected function searchApi(string $query, int $page = 1, array $options = []): array
112 | {
113 | try {
114 | // Prüfe, ob es sich um eine direkte Wikimedia-URL handelt
115 | if ($this->isWikimediaUrl($query)) {
116 | return $this->handleWikimediaUrl($query);
117 | }
118 |
119 | $fileType = $options['file_type'] ?? 'all';
120 | $limit = $this->itemsPerPage;
121 | $offset = ($page - 1) * $limit;
122 |
123 | // Search-Parameter für MediaWiki API
124 | $searchParams = [
125 | 'action' => 'query',
126 | 'format' => 'json',
127 | 'list' => 'search',
128 | 'srsearch' => $this->buildSearchQuery($query, $fileType),
129 | 'srnamespace' => '6', // File namespace
130 | 'srlimit' => $limit,
131 | 'sroffset' => $offset,
132 | 'srprop' => 'size|wordcount|timestamp|snippet',
133 | 'srinfo' => 'totalhits',
134 | ];
135 |
136 | $searchUrl = $this->apiUrl . '?' . http_build_query($searchParams);
137 | // Fix: Verhindere HTML-Encoding von & Zeichen
138 | $searchUrl = str_replace('&', '&', $searchUrl);
139 | $searchResponse = $this->makeApiRequest($searchUrl);
140 |
141 | if (!$searchResponse || !isset($searchResponse['query']['search'])) {
142 | return $this->getEmptyResult($page);
143 | }
144 |
145 | $searchResults = $searchResponse['query']['search'];
146 | $totalHits = $searchResponse['query']['searchinfo']['totalhits'] ?? 0;
147 |
148 | if (empty($searchResults)) {
149 | return $this->getEmptyResult($page);
150 | }
151 |
152 | // Hole detaillierte Informationen für die gefundenen Dateien
153 | $titles = array_map(static function ($result) {
154 | return $result['title'];
155 | }, $searchResults);
156 |
157 | $detailsParams = [
158 | 'action' => 'query',
159 | 'format' => 'json',
160 | 'titles' => implode('|', $titles),
161 | 'prop' => 'imageinfo',
162 | 'iiprop' => 'url|size|mime|extmetadata|user|timestamp',
163 | 'iiurlwidth' => '300',
164 | 'iiurlheight' => '300',
165 | 'iilimit' => '50',
166 | ];
167 |
168 | $detailsUrl = $this->apiUrl . '?' . http_build_query($detailsParams);
169 | // Fix: Verhindere HTML-Encoding von & Zeichen
170 | $detailsUrl = str_replace('&', '&', $detailsUrl);
171 | $detailsResponse = $this->makeApiRequest($detailsUrl);
172 |
173 | if (!$detailsResponse || !isset($detailsResponse['query']['pages'])) {
174 | return $this->getEmptyResult($page);
175 | }
176 |
177 | $items = $this->processSearchResults($detailsResponse['query']['pages']);
178 |
179 | return [
180 | 'items' => $items,
181 | 'total' => $totalHits,
182 | 'page' => $page,
183 | 'total_pages' => ceil($totalHits / $limit),
184 | ];
185 | } catch (Exception $e) {
186 | rex_logger::logException($e);
187 | return $this->getEmptyResult($page);
188 | }
189 | }
190 |
191 | protected function isWikimediaUrl(string $url): bool
192 | {
193 | return str_contains($url, 'commons.wikimedia.org')
194 | || str_contains($url, 'upload.wikimedia.org');
195 | }
196 |
197 | protected function handleWikimediaUrl(string $url): array
198 | {
199 | try {
200 | // Extrahiere Dateinamen aus URL
201 | $filename = $this->extractFilenameFromUrl($url);
202 | if (!$filename) {
203 | return $this->getEmptyResult(1);
204 | }
205 |
206 | // Hole Informationen zur spezifischen Datei
207 | $params = [
208 | 'action' => 'query',
209 | 'format' => 'json',
210 | 'titles' => 'File:' . $filename,
211 | 'prop' => 'imageinfo',
212 | 'iiprop' => 'url|size|mime|extmetadata|user|timestamp',
213 | 'iiurlwidth' => '300',
214 | 'iiurlheight' => '300',
215 | ];
216 |
217 | $apiUrl = $this->apiUrl . '?' . http_build_query($params);
218 | // Fix: Verhindere HTML-Encoding von & Zeichen
219 | $apiUrl = str_replace('&', '&', $apiUrl);
220 | $response = $this->makeApiRequest($apiUrl);
221 |
222 | if (!$response || !isset($response['query']['pages'])) {
223 | return $this->getEmptyResult(1);
224 | }
225 |
226 | $items = $this->processSearchResults($response['query']['pages']);
227 |
228 | return [
229 | 'items' => $items,
230 | 'total' => count($items),
231 | 'page' => 1,
232 | 'total_pages' => 1,
233 | ];
234 | } catch (Exception $e) {
235 | rex_logger::logException($e);
236 | return $this->getEmptyResult(1);
237 | }
238 | }
239 |
240 | protected function extractFilenameFromUrl(string $url): ?string
241 | {
242 | // Verschiedene URL-Formate unterstützen
243 | if (preg_match('/File:([^&\?]+)/', $url, $matches)) {
244 | return urldecode($matches[1]);
245 | }
246 |
247 | if (preg_match('/\/([^\/]+\.(jpg|jpeg|png|gif|svg|webp|pdf|mp4|ogv|webm|ogg|mp3|wav))$/i', $url, $matches)) {
248 | return $matches[1];
249 | }
250 |
251 | return null;
252 | }
253 |
254 | protected function buildSearchQuery(string $query, string $fileType): string
255 | {
256 | $searchQuery = $query;
257 |
258 | // Filetype-Filter für spezifische Bildformate
259 | if ('images' === $fileType) {
260 | // Nur die gewünschten Formate: JPG, PNG, SVG, WebP
261 | $searchQuery .= ' (filetype:jpg OR filetype:jpeg OR filetype:png OR filetype:svg OR filetype:webp)';
262 | }
263 |
264 | return $searchQuery;
265 | }
266 |
267 | protected function processSearchResults(array $pages): array
268 | {
269 | $items = [];
270 |
271 | foreach ($pages as $page) {
272 | if (!isset($page['imageinfo']) || empty($page['imageinfo'])) {
273 | continue;
274 | }
275 |
276 | $imageInfo = $page['imageinfo'][0];
277 | $metadata = $imageInfo['extmetadata'] ?? [];
278 |
279 | // MIME-Type prüfen - nur erlaubte Typen verarbeiten
280 | $mimeType = $imageInfo['mime'] ?? '';
281 | if (!$this->isAllowedMimeType($mimeType)) {
282 | continue; // Überspringe nicht erlaubte Dateitypen
283 | }
284 |
285 | // Basis-Informationen
286 | $title = str_replace('File:', '', $page['title']);
287 | $author = $this->extractAuthor($metadata, $imageInfo);
288 | $copyright = $this->buildCopyright($metadata, $author);
289 | $description = $this->extractDescription($metadata);
290 |
291 | // URLs und Größen
292 | $previewUrl = $imageInfo['thumburl'] ?? $imageInfo['url'];
293 | $sizes = $this->buildSizeVariants($imageInfo);
294 |
295 | // Asset-Typ bestimmen
296 | $type = $this->determineAssetType($mimeType);
297 |
298 | $items[] = [
299 | 'id' => md5($imageInfo['url']),
300 | 'preview_url' => $previewUrl,
301 | 'title' => $title,
302 | 'description' => $description,
303 | 'author' => $author,
304 | 'copyright' => $copyright,
305 | 'type' => $type,
306 | 'size' => $sizes,
307 | 'license' => $this->extractLicense($metadata),
308 | 'original_url' => $imageInfo['url'],
309 | 'file_size' => $imageInfo['size'] ?? 0,
310 | 'mime_type' => $mimeType,
311 | 'timestamp' => $imageInfo['timestamp'] ?? '',
312 | ];
313 | }
314 |
315 | return $items;
316 | }
317 |
318 | protected function extractAuthor(array $metadata, array $imageInfo): string
319 | {
320 | // Versuche verschiedene Metadaten-Felder für den Autor
321 | if (isset($metadata['Artist']['value'])) {
322 | return strip_tags($metadata['Artist']['value']);
323 | }
324 |
325 | if (isset($metadata['Credit']['value'])) {
326 | return strip_tags($metadata['Credit']['value']);
327 | }
328 |
329 | if (isset($imageInfo['user'])) {
330 | return $imageInfo['user'];
331 | }
332 |
333 | return 'Unknown';
334 | }
335 |
336 | protected function extractDescription(array $metadata): string
337 | {
338 | if (isset($metadata['ImageDescription']['value'])) {
339 | return strip_tags($metadata['ImageDescription']['value']);
340 | }
341 |
342 | if (isset($metadata['ObjectName']['value'])) {
343 | return strip_tags($metadata['ObjectName']['value']);
344 | }
345 |
346 | return '';
347 | }
348 |
349 | protected function extractLicense(array $metadata): string
350 | {
351 | if (isset($metadata['LicenseShortName']['value'])) {
352 | return $metadata['LicenseShortName']['value'];
353 | }
354 |
355 | if (isset($metadata['License']['value'])) {
356 | return strip_tags($metadata['License']['value']);
357 | }
358 |
359 | return 'Unknown License';
360 | }
361 |
362 | protected function buildCopyright(array $metadata, string $author): string
363 | {
364 | $copyrightType = $this->config['copyright_fields'] ?? 'author_wikimedia';
365 |
366 | switch ($copyrightType) {
367 | case 'author':
368 | return $author;
369 |
370 | case 'wikimedia':
371 | return 'Wikimedia Commons';
372 |
373 | case 'license':
374 | return $this->extractLicense($metadata);
375 |
376 | case 'author_wikimedia':
377 | default:
378 | if ($author && 'Unknown' !== $author) {
379 | return $author . ' / Wikimedia Commons';
380 | }
381 | return 'Wikimedia Commons';
382 | }
383 | }
384 |
385 | protected function buildSizeVariants(array $imageInfo): array
386 | {
387 | $originalUrl = $imageInfo['url'];
388 | $width = $imageInfo['width'] ?? 0;
389 | $height = $imageInfo['height'] ?? 0;
390 |
391 | // Basis-URL für Thumbnails (MediaWiki thumb.php)
392 | $thumbBaseUrl = str_replace('/commons/', '/commons/thumb/', $originalUrl);
393 |
394 | $sizes = [
395 | 'original' => ['url' => $originalUrl, 'width' => $width, 'height' => $height],
396 | ];
397 |
398 | // Generiere verschiedene Thumbnail-Größen
399 | $thumbnailSizes = [
400 | 'tiny' => 150,
401 | 'small' => 300,
402 | 'medium' => 600,
403 | 'large' => 1200,
404 | ];
405 |
406 | foreach ($thumbnailSizes as $sizeName => $maxWidth) {
407 | if ($width > $maxWidth) {
408 | $thumbHeight = (int) (($height * $maxWidth) / $width);
409 | $thumbUrl = $thumbBaseUrl . '/' . $maxWidth . 'px-' . basename($originalUrl);
410 | $sizes[$sizeName] = [
411 | 'url' => $thumbUrl,
412 | 'width' => $maxWidth,
413 | 'height' => $thumbHeight,
414 | ];
415 | } else {
416 | // Wenn das Original kleiner ist, verwende das Original
417 | $sizes[$sizeName] = $sizes['original'];
418 | }
419 | }
420 |
421 | return $sizes;
422 | }
423 |
424 | protected function determineAssetType(string $mimeType): string
425 | {
426 | if (str_starts_with($mimeType, 'image/')) {
427 | return 'image';
428 | }
429 |
430 | return 'file';
431 | }
432 |
433 | protected function makeApiRequest(string $url): ?array
434 | {
435 | $curl = curl_init();
436 |
437 | $userAgent = $this->config['useragent'] ?? 'REDAXO Asset Import Bot/1.0';
438 |
439 | curl_setopt($curl, CURLOPT_URL, $url);
440 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
441 | curl_setopt($curl, CURLOPT_TIMEOUT, 30);
442 | curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
443 | curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
444 | curl_setopt($curl, CURLOPT_USERAGENT, $userAgent);
445 | curl_setopt($curl, CURLOPT_HEADER, false);
446 |
447 | $response = curl_exec($curl);
448 | $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
449 |
450 | if (curl_errno($curl)) {
451 | $error = curl_error($curl);
452 | curl_close($curl);
453 | throw new rex_exception('cURL Error: ' . $error);
454 | }
455 |
456 | curl_close($curl);
457 |
458 | if (200 !== $httpCode) {
459 | throw new rex_exception('HTTP Error: ' . $httpCode);
460 | }
461 |
462 | // Dekodiere JSON-Antwort
463 | $decodedResponse = json_decode($response, true);
464 |
465 | if (JSON_ERROR_NONE !== json_last_error()) {
466 | rex_logger::factory()->log(LogLevel::ERROR, 'Wikimedia API JSON Decode Error: ' . json_last_error_msg() . ' - URL: ' . $url);
467 | throw new rex_exception('JSON Decode Error: ' . json_last_error_msg());
468 | }
469 |
470 | return $decodedResponse;
471 | }
472 |
473 | protected function getEmptyResult(int $page): array
474 | {
475 | return [
476 | 'items' => [],
477 | 'total' => 0,
478 | 'page' => $page,
479 | 'total_pages' => 0,
480 | ];
481 | }
482 |
483 | public function import(string $url, string $filename, ?string $copyright = null): bool
484 | {
485 | try {
486 | // Bereinige den Dateinamen für REDAXO Media
487 | $cleanFilename = $this->sanitizeMediaFilename($filename);
488 |
489 | // Nutze die download-Methode der Parent-Klasse mit bereinigtem Dateinamen
490 | $success = $this->downloadFile($url, $cleanFilename);
491 |
492 | // Copyright setzen, wenn gewünscht und vorhanden
493 | if ($success && $copyright && $this->shouldSetCopyright()) {
494 | // Warte kurz, damit REDAXO die Datei verarbeiten kann
495 | usleep(100000); // 0.1 Sekunden
496 |
497 | $media = rex_media::get($cleanFilename);
498 | if ($media) {
499 | $sql = rex_sql::factory();
500 | $sql->setTable(rex::getTable('media'));
501 | $sql->setWhere(['filename' => $cleanFilename]);
502 | $sql->setValue('med_copyright', $copyright);
503 | $sql->update();
504 | }
505 | }
506 |
507 | return $success;
508 | } catch (Exception $e) {
509 | rex_logger::logException($e);
510 | return false;
511 | }
512 | }
513 |
514 | /**
515 | * Bereinigt Dateinamen für REDAXO Media.
516 | */
517 | protected function sanitizeMediaFilename(string $filename): string
518 | {
519 | // Entferne problematische Zeichen
520 | $cleaned = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
521 | // Mehrfache Unterstriche reduzieren
522 | $cleaned = preg_replace('/_+/', '_', $cleaned);
523 | // Führende/abschließende Unterstriche entfernen
524 | $cleaned = trim($cleaned, '_');
525 |
526 | return $cleaned;
527 | }
528 |
529 | /**
530 | * Prüft, ob Copyright-Informationen gesetzt werden sollen.
531 | */
532 | protected function shouldSetCopyright(): bool
533 | {
534 | return ($this->config['set_copyright'] ?? '0') === '1';
535 | }
536 |
537 | public function getDefaultOptions(): array
538 | {
539 | return array_merge(parent::getDefaultOptions(), [
540 | 'file_type' => 'all',
541 | ]);
542 | }
543 |
544 | /**
545 | * Prüft, ob der MIME-Type erlaubt ist.
546 | */
547 | protected function isAllowedMimeType(string $mimeType): bool
548 | {
549 | $allowedTypes = [
550 | // Nur die gewünschten Bildformate
551 | 'image/jpeg',
552 | 'image/png',
553 | 'image/svg+xml',
554 | 'image/webp',
555 | // Optional: PDF-Dokumente
556 | 'application/pdf',
557 | ];
558 |
559 | return in_array($mimeType, $allowedTypes);
560 | }
561 | }
562 |
--------------------------------------------------------------------------------