├── .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 .= '
' . $provider->getTitle() . ''; 50 | $fragment = new rex_fragment(); 51 | 52 | $formElements = []; 53 | 54 | foreach ($fields as $field) { 55 | $n = []; 56 | $value = $addon->getConfig($id)[$field['name']] ?? ''; 57 | 58 | $n['label'] = ''; 61 | 62 | switch ($field['type']) { 63 | case 'text': 64 | case 'password': 65 | $n['field'] = ''; 70 | break; 71 | 72 | case 'select': 73 | $select = new rex_select(); 74 | $select->setId($id . '-' . $field['name']); 75 | $select->setName('config[' . $id . '][' . $field['name'] . ']'); 76 | $select->setSelected($value); 77 | $select->setAttribute('class', 'form-control'); 78 | foreach ($field['options'] as $option) { 79 | $select->addOption($option['label'], $option['value']); 80 | } 81 | $n['field'] = $select->get(); 82 | break; 83 | } 84 | 85 | if (isset($field['notice'])) { 86 | $n['note'] = '

' . rex_i18n::msg($field['notice']) . '

'; 87 | } 88 | 89 | $formElements[] = $n; 90 | } 91 | 92 | $fragment->setVar('elements', $formElements, false); 93 | $content .= $fragment->parse('core/form/form.php'); 94 | $content .= '
'; 95 | } 96 | 97 | if (!empty($content)) { 98 | $content = ' 99 |
100 | ' . $content; 101 | 102 | $formElements = []; 103 | $n = []; 104 | $n['field'] = ''; 107 | $formElements[] = $n; 108 | 109 | $fragment = new rex_fragment(); 110 | $fragment->setVar('elements', $formElements, false); 111 | $content .= $fragment->parse('core/form/submit.php') . '
'; 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 |
40 |
' . rex_i18n::msg('asset_import_target_category') . '
41 |
42 |
43 | ' . $cats_sel->get() . ' 44 |
45 |
46 |
47 | 48 | 49 |
50 |
51 |
52 |
53 | ' . rex_i18n::msg('asset_import_search') . ' 54 |
55 |
56 |
57 | 97 |
98 |
99 |
100 |
101 | 102 | 103 | 104 | 105 | 106 |
107 |
108 |
109 | 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 |
78 |
79 | ' . rex_i18n::msg('asset_import_direct_info_title') . ' 80 |
81 |
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 |
99 |
' . rex_i18n::msg('asset_import_target_category') . '
100 |
101 |
102 | ' . $cats_sel->get() . ' 103 |
104 |
105 |
106 | 107 | 108 |
109 |
110 |
111 |
112 | ' . rex_i18n::msg('asset_import_direct_url_import') . ' 113 |
114 |
115 |
116 |
117 |
118 | 119 |
120 | 126 | 127 | 131 | 132 |
133 |
134 | 135 | 173 |
174 |
175 |
176 |
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 | Preview 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 | ${this.escapeHtml(item.title)} 133 | `} 134 |
135 |
136 |
${this.escapeHtml(item.title)}
137 | 144 |
145 | 148 |
149 | 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 | ${this.escapeHtml(title)} 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 | ![Screenshot](https://github.com/FriendsOfREDAXO/asset_import/blob/assets/screen.png?raw=true) 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 | --------------------------------------------------------------------------------