Compare commits

...

6 commits
v1.2.0 ... main

Author SHA1 Message Date
noschmarrn
087b93a82b release: v1.3.0 2026-04-17 18:00:27 +00:00
noschmarrn
a6043fe28a release: v1.3.0 2026-04-17 17:57:39 +00:00
noschmarrn
5139e5ad29 release: v1.3.0 2026-04-17 17:19:51 +00:00
noschmarrn
673d131a7c release: v1.2.2 2026-04-13 11:14:12 +00:00
noschmarrn
5e59e5fa6e fix: remove stray messages.mo from plugin package
Generic gettext artifact that doesn't belong in the plugin.
Correct translation files are in languages/brezngeo-*.mo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 11:08:36 +00:00
noschmarrn
a1e0ce612f release: v1.2.1 2026-03-28 10:54:01 +00:00
27 changed files with 3361 additions and 239 deletions

View file

@ -3,8 +3,8 @@
![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue) ![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue)
![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b) ![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b)
![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green) ![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green)
![Version](https://img.shields.io/badge/Version-1.2.0-orange) ![Version](https://img.shields.io/badge/Version-1.3.0-orange)
![Tests](https://img.shields.io/badge/Tests-158%20passing-brightgreen) ![Tests](https://img.shields.io/badge/Tests-163%20passing-brightgreen)
🇬🇧 [English version → README.md](README.md) 🇬🇧 [English version → README.md](README.md)
@ -26,7 +26,7 @@ Die KI-Welle hat es schlimmer gemacht. Plugins fingen an, „KI-gestützte" Feat
BreznGEO verfolgt einen anderen Ansatz: BreznGEO verfolgt einen anderen Ansatz:
- **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google oder xAI. BreznGEO ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter. - **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google, xAI oder OpenRouter (600+ Modelle über einen Key). BreznGEO ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter.
- **Klarer Output, kein Lärm.** Metabeschreibungen, Strukturdaten, KI-Inhaltsblöcke für GEO, Bot-Steuerung. Keine Lesbarkeits-Scores, keine Keyword-Dichte-Meter, keine Upsell-Banner. - **Klarer Output, kein Lärm.** Metabeschreibungen, Strukturdaten, KI-Inhaltsblöcke für GEO, Bot-Steuerung. Keine Lesbarkeits-Scores, keine Keyword-Dichte-Meter, keine Upsell-Banner.
- **Keine Subscription.** GPL-2.0. Kostenlos auf beliebig vielen Sites nutzbar. Die einzigen Kosten sind die API-Nutzung — typischerweise Bruchteile eines Cents pro Beitrag. - **Keine Subscription.** GPL-2.0. Kostenlos auf beliebig vielen Sites nutzbar. Die einzigen Kosten sind die API-Nutzung — typischerweise Bruchteile eines Cents pro Beitrag.
- **Keine Telemetrie.** BreznGEO sendet keine Daten nach Hause. Kein Usage-Tracking, kein Remote-Logging, keine Analytics, die den eigenen Server verlassen. - **Keine Telemetrie.** BreznGEO sendet keine Daten nach Hause. Kein Usage-Tracking, kein Remote-Logging, keine Analytics, die den eigenen Server verlassen.
@ -104,12 +104,13 @@ brezngeo/
│ │ ├── KeywordVariants.php # Locale-basierte Keyword-Varianten (EN/DE) │ │ ├── KeywordVariants.php # Locale-basierte Keyword-Varianten (EN/DE)
│ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk │ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk
│ └── Providers/ │ └── Providers/
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText │ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
│ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen │ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen
│ ├── AnthropicProvider.php # Claude API (Messages API) │ ├── AnthropicProvider.php # Claude API (Messages API)
│ ├── GeminiProvider.php # Google Gemini (generateContent API) │ ├── GeminiProvider.php # Google Gemini (generateContent API)
│ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt) │ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt)
│ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API) │ ├── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
│ └── OpenRouterProvider.php # OpenRouter (600+ Modelle über eine OpenAI-kompatible API)
└── vendor/ # Composer-Abhängigkeiten (nur Produktionsstand) └── vendor/ # Composer-Abhängigkeiten (nur Produktionsstand)
``` ```
@ -299,10 +300,11 @@ Kein `openssl_*` oder externe Extension nötig — läuft auf jeder PHP 8.0+ Ins
**Sicherheitsgrenzen:** XOR mit statischem Salt ist Verschleierung, keine kryptografische Verschlüsselung. Für maximale Sicherheit können Keys als `wp-config.php`-Konstanten definiert werden: **Sicherheitsgrenzen:** XOR mit statischem Salt ist Verschleierung, keine kryptografische Verschlüsselung. Für maximale Sicherheit können Keys als `wp-config.php`-Konstanten definiert werden:
```php ```php
define( 'BREZNGEO_OPENAI_KEY', 'sk-...' ); define( 'BREZNGEO_OPENAI_KEY', 'sk-...' );
define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' ); define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' );
define( 'BREZNGEO_GEMINI_KEY', 'AI...' ); define( 'BREZNGEO_GEMINI_KEY', 'AI...' );
define( 'BREZNGEO_GROK_KEY', 'xai-...' ); define( 'BREZNGEO_GROK_KEY', 'xai-...' );
define( 'BREZNGEO_OPENROUTER_KEY', 'sk-or-...' );
``` ```
### CSRF-Schutz und Capability Checks ### CSRF-Schutz und Capability Checks
@ -336,6 +338,9 @@ CrawlerLog speichert IPs ausschließlich als SHA-256-Hash. Originalwert wird nie
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` | | Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` | | Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` | | xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
| OpenRouter | `OpenRouterProvider` | `https://openrouter.ai/api/v1/chat/completions` |
**Zu OpenRouter:** Ein einziger API-Key öffnet den Zugang zu 600+ Modellen von OpenAI, Anthropic, Google, Meta, Mistral, xAI, DeepSeek u.v.m. Die kuratierte Marketing/SEO-Auswahl wird on demand geladen ("Modelle laden"-Button) und 12 Stunden im Transient gecached. Preise werden automatisch aus OpenRouter übernommen. Eigene Modell-IDs (z. B. `anthropic/claude-opus-4.7`) werden unterstützt.
Neuen Provider hinzufügen: `ProviderInterface` implementieren, in `Core.php` via `$registry->register()` eintragen — erscheint automatisch in allen Dropdowns. Neuen Provider hinzufügen: `ProviderInterface` implementieren, in `Core.php` via `$registry->register()` eintragen — erscheint automatisch in allen Dropdowns.
@ -371,6 +376,7 @@ Alle Endpunkte erfordern `manage_options` (kein `nopriv`).
| `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Meta-Beschreibung für einzelnen Post neu generieren | | `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Meta-Beschreibung für einzelnen Post neu generieren |
| `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | API-Key und Verbindung testen | | `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | API-Key und Verbindung testen |
| `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Standard-Prompt zurücksetzen | | `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Standard-Prompt zurücksetzen |
| `brezngeo_openrouter_load_models` | `ProviderPage::ajax_openrouter_load_models` | Kuratierte OpenRouter-Marketing/SEO-Modell-Liste laden (12 h Transient-Cache) |
| `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Link-Analyse ausführen | | `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Link-Analyse ausführen |
| `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Top-10 interne Link-Vorschläge für aktuellen Beitrag zurückgeben | | `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Top-10 interne Link-Vorschläge für aktuellen Beitrag zurückgeben |
| `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | GEO Block generieren | | `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | GEO Block generieren |
@ -421,7 +427,7 @@ Kein JavaScript-Build-Step. Alle Assets unter `assets/` sind direkte JS/CSS-Date
| Caching | WordPress Transients | | Caching | WordPress Transients |
| Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step | | Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step |
| I18n | `.pot`-File, Text-Domain `brezngeo` | | I18n | `.pot`-File, Text-Domain `brezngeo` |
| Tests | PHPUnit (158 Tests, 301 Assertions) | | Tests | PHPUnit (163 Tests, 311 Assertions) |
| Coding Standard | WordPress PHPCS | | Coding Standard | WordPress PHPCS |
| Lizenz | GPL-2.0-or-later | | Lizenz | GPL-2.0-or-later |

View file

@ -3,8 +3,8 @@
![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue) ![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue)
![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b) ![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b)
![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green) ![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green)
![Version](https://img.shields.io/badge/Version-1.2.0-orange) ![Version](https://img.shields.io/badge/Version-1.3.0-orange)
![Tests](https://img.shields.io/badge/Tests-158%20passing-brightgreen) ![Tests](https://img.shields.io/badge/Tests-163%20passing-brightgreen)
🇩🇪 [Deutsche Version → README.de.md](README.de.md) 🇩🇪 [Deutsche Version → README.de.md](README.de.md)
@ -26,7 +26,7 @@ The AI wave made it worse. Plugins started offering "AI-powered" features — bu
BreznGEO takes a different approach: BreznGEO takes a different approach:
- **Direct API access.** You store your own key from OpenAI, Anthropic, Google, or xAI. BreznGEO calls the API directly. No middleman, no margin, no data passing through third-party servers. - **Direct API access.** You store your own key from OpenAI, Anthropic, Google, xAI, or OpenRouter (600+ models through one key). BreznGEO calls the API directly. No middleman, no margin, no data passing through third-party servers.
- **Clear output, not noise.** Meta descriptions, structured data, GEO content blocks, bot management. No readability scores, no keyword density meters, no upsell banners. - **Clear output, not noise.** Meta descriptions, structured data, GEO content blocks, bot management. No readability scores, no keyword density meters, no upsell banners.
- **No subscription.** GPL-2.0. Free to use on any number of sites. The only costs are API usage — typically fractions of a cent per post. - **No subscription.** GPL-2.0. Free to use on any number of sites. The only costs are API usage — typically fractions of a cent per post.
- **No telemetry.** BreznGEO sends no data home. No usage tracking, no remote logging, no analytics leaving your server. - **No telemetry.** BreznGEO sends no data home. No usage tracking, no remote logging, no analytics leaving your server.
@ -104,12 +104,13 @@ brezngeo/
│ │ ├── KeywordVariants.php # Locale-aware keyword variant generation (EN/DE) │ │ ├── KeywordVariants.php # Locale-aware keyword variant generation (EN/DE)
│ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk │ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk
│ └── Providers/ │ └── Providers/
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText │ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
│ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers │ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers
│ ├── AnthropicProvider.php # Claude API (Messages API) │ ├── AnthropicProvider.php # Claude API (Messages API)
│ ├── GeminiProvider.php # Google Gemini (generateContent API) │ ├── GeminiProvider.php # Google Gemini (generateContent API)
│ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint) │ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint)
│ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API) │ ├── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
│ └── OpenRouterProvider.php # OpenRouter (600+ models via one OpenAI-compatible API)
└── vendor/ # Composer dependencies (production only) └── vendor/ # Composer dependencies (production only)
``` ```
@ -408,10 +409,11 @@ Plaintext key → XOR(key, sha256(AUTH_KEY . SECURE_AUTH_KEY)) → base64
**Security boundary:** XOR with a static salt is obfuscation, not cryptographic encryption. An attacker with access to **both** the database **and** `wp-config.php` can reconstruct the key. For maximum security, keys can be defined as `wp-config.php` constants — these take precedence over the database version: **Security boundary:** XOR with a static salt is obfuscation, not cryptographic encryption. An attacker with access to **both** the database **and** `wp-config.php` can reconstruct the key. For maximum security, keys can be defined as `wp-config.php` constants — these take precedence over the database version:
```php ```php
define( 'BREZNGEO_OPENAI_KEY', 'sk-...' ); define( 'BREZNGEO_OPENAI_KEY', 'sk-...' );
define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' ); define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' );
define( 'BREZNGEO_GEMINI_KEY', 'AI...' ); define( 'BREZNGEO_GEMINI_KEY', 'AI...' );
define( 'BREZNGEO_GROK_KEY', 'xai-...' ); define( 'BREZNGEO_GROK_KEY', 'xai-...' );
define( 'BREZNGEO_OPENROUTER_KEY', 'sk-or-...' );
``` ```
In the admin UI, keys are always displayed masked: `••••••Ab3c9` (only the last 5 characters visible). In the admin UI, keys are always displayed masked: `••••••Ab3c9` (only the last 5 characters visible).
@ -444,7 +446,7 @@ The crawler log stores IP addresses exclusively as SHA-256 hashes. The original
## AI Providers ## AI Providers
BreznGEO supports four providers, all implementing the same `ProviderInterface`: BreznGEO supports five providers, all implementing the same `ProviderInterface`:
| Provider | Class | API Base URL | | Provider | Class | API Base URL |
|---|---|---| |---|---|---|
@ -452,6 +454,9 @@ BreznGEO supports four providers, all implementing the same `ProviderInterface`:
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` | | Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` | | Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` | | xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
| OpenRouter | `OpenRouterProvider` | `https://openrouter.ai/api/v1/chat/completions` |
**About OpenRouter:** A single API key opens access to 600+ models from OpenAI, Anthropic, Google, Meta, Mistral, xAI, DeepSeek and others. The curated Marketing/SEO selection is fetched on demand ("Load models" button) and cached for 12 hours. Pricing is read from OpenRouter automatically. Custom model IDs (e.g. `anthropic/claude-opus-4.7`) are supported.
### Adding a New Provider ### Adding a New Provider
@ -525,6 +530,7 @@ All endpoints are exclusively accessible to logged-in users with `manage_options
| `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Regenerate meta description for a single post | | `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Regenerate meta description for a single post |
| `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | Test API key and connection | | `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | Test API key and connection |
| `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Reset to default prompt | | `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Reset to default prompt |
| `brezngeo_openrouter_load_models` | `ProviderPage::ajax_openrouter_load_models` | Fetch curated OpenRouter marketing/SEO model list (12 h transient cache) |
| `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Run link analysis for the dashboard | | `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Run link analysis for the dashboard |
| `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Return top-10 internal link suggestions for current post | | `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Return top-10 internal link suggestions for current post |
| `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | Generate GEO block for a single post | | `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | Generate GEO block for a single post |
@ -576,7 +582,7 @@ The plugin has no JavaScript build step. All assets under `assets/` are direct J
| Caching | WordPress transients (llms.txt, link analysis, bulk lock) | | Caching | WordPress transients (llms.txt, link analysis, bulk lock) |
| Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step | | Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step |
| i18n | `.pot` file, text domain `brezngeo` | | i18n | `.pot` file, text domain `brezngeo` |
| Tests | PHPUnit (158 tests, 301 assertions) | | Tests | PHPUnit (163 tests, 311 assertions) |
| Coding standard | WordPress PHPCS | | Coding standard | WordPress PHPCS |
| License | GPL-2.0-or-later | | License | GPL-2.0-or-later |

View file

@ -33,6 +33,68 @@ jQuery( function ( $ ) {
} ); } );
} ); } );
// OpenRouter: toggle custom-model field when dropdown value is __custom__
function updateOpenRouterCustom() {
var val = $( '#brezngeo-openrouter-model' ).val();
$( '.brezngeo-openrouter-custom-wrap' ).toggle( val === '__custom__' );
}
$( document ).on( 'change', '#brezngeo-openrouter-model', function () {
updateOpenRouterCustom();
var $opt = $( this ).find( 'option:selected' );
var inVal = $opt.data( 'input' );
var outVal = $opt.data( 'output' );
if ( inVal !== undefined && inVal !== '' ) {
$( '#brezngeo-openrouter-pricing' ).html(
'Input $<span class="or-price-input">' + Number( inVal ).toFixed( 4 ) +
'</span> / 1M \u00b7 Output $<span class="or-price-output">' + Number( outVal ).toFixed( 4 ) +
'</span> / 1M'
);
}
} );
if ( $( '#brezngeo-openrouter-model' ).length ) updateOpenRouterCustom();
// OpenRouter: "Load models" button — fetches curated Marketing/SEO list from OpenRouter
$( document ).on( 'click', '.brezngeo-openrouter-load-btn', function () {
var btn = $( this );
var status = $( '.brezngeo-openrouter-load-status' );
var select = $( '#brezngeo-openrouter-model' );
btn.prop( 'disabled', true );
status.removeClass( 'success error' ).text( brezngeoAdmin.testing );
$.post( brezngeoAdmin.ajaxUrl, {
action: 'brezngeo_openrouter_load_models',
nonce: brezngeoAdmin.nonce,
} ).done( function ( res ) {
if ( ! res.success ) {
status.addClass( 'error' ).text( '\u2717 ' + res.data );
return;
}
var models = res.data;
var previous = select.val();
// Preserve the __custom__ option at the bottom
select.find( 'option' ).not( '[value="__custom__"]' ).remove();
select.find( 'option[value=""]' ).remove();
$.each( models, function ( id, meta ) {
var opt = $( '<option></option>' )
.val( id )
.text( meta.label || id )
.attr( 'data-input', meta.input_cost )
.attr( 'data-output', meta.output_cost );
select.find( 'option[value="__custom__"]' ).before( opt );
} );
if ( previous && select.find( 'option[value="' + previous.replace( /"/g, '\\"' ) + '"]' ).length ) {
select.val( previous );
}
select.trigger( 'change' );
status.addClass( 'success' ).text( '\u2713 ' + Object.keys( models ).length );
} ).fail( function () {
status.addClass( 'error' ).text( '\u2717 ' + brezngeoAdmin.networkError );
} ).always( function () {
btn.prop( 'disabled', false );
} );
} );
$( '#brezngeo-reset-prompt' ).on( 'click', function () { $( '#brezngeo-reset-prompt' ).on( 'click', function () {
if ( ! confirm( brezngeoAdmin.resetConfirm ) ) return; if ( ! confirm( brezngeoAdmin.resetConfirm ) ) return;
$.post( brezngeoAdmin.ajaxUrl, { $.post( brezngeoAdmin.ajaxUrl, {

View file

@ -3,7 +3,7 @@
* Plugin Name: BreznGEO * Plugin Name: BreznGEO
* Plugin URI: https://brezngeo.com/ * Plugin URI: https://brezngeo.com/
* Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress. * Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress.
* Version: 1.2.0 * Version: 1.3.0
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 8.0 * Requires PHP: 8.0
* Author: NoSchmarrn.dev * Author: NoSchmarrn.dev
@ -18,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
define( 'BREZNGEO_VERSION', '1.2.0' ); define( 'BREZNGEO_VERSION', '1.3.0' );
define( 'BREZNGEO_FILE', __FILE__ ); define( 'BREZNGEO_FILE', __FILE__ );
define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) ); define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) );
define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) ); define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) );

View file

@ -51,7 +51,7 @@ class AdminMenu {
wp_safe_redirect( wp_safe_redirect(
add_query_arg( add_query_arg(
array( array(
'page' => 'brezngeo', 'page' => 'brezngeo',
'brezngeo-saved' => '1', 'brezngeo-saved' => '1',
), ),
admin_url( 'admin.php' ) admin_url( 'admin.php' )
@ -207,8 +207,8 @@ class AdminMenu {
$provider = $prov_obj ? $prov_obj->getName() : $provider_key; $provider = $prov_obj ? $prov_obj->getName() : $provider_key;
} }
$post_types = $settings['meta_post_types'] ?? array( 'post', 'page' ); $post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
$meta_stats = $this->get_meta_stats( $post_types ); $meta_stats = $this->get_meta_stats( $post_types );
$brezngeo_compat = $this->get_compat_info(); $brezngeo_compat = $this->get_compat_info();
$brezngeo_show_welcome = $this->should_show_welcome(); $brezngeo_show_welcome = $this->should_show_welcome();

View file

@ -7,13 +7,15 @@ if ( ! defined( 'ABSPATH' ) ) {
use BreznGEO\ProviderRegistry; use BreznGEO\ProviderRegistry;
use BreznGEO\Helpers\KeyVault; use BreznGEO\Helpers\KeyVault;
use BreznGEO\Providers\OpenRouterProvider;
class ProviderPage { class ProviderPage {
private const PRICING_URLS = array( private const PRICING_URLS = array(
'openai' => 'https://openai.com/de-DE/api/pricing', 'openai' => 'https://openai.com/de-DE/api/pricing',
'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing', 'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing',
'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de', 'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de',
'grok' => 'https://docs.x.ai/developers/models', 'grok' => 'https://docs.x.ai/developers/models',
'openrouter' => 'https://openrouter.ai/models',
); );
public function register(): void { public function register(): void {
@ -21,6 +23,7 @@ class ProviderPage {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_ajax_brezngeo_test_connection', array( $this, 'ajax_test_connection' ) ); add_action( 'wp_ajax_brezngeo_test_connection', array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_brezngeo_get_default_prompt', array( $this, 'ajax_get_default_prompt' ) ); add_action( 'wp_ajax_brezngeo_get_default_prompt', array( $this, 'ajax_get_default_prompt' ) );
add_action( 'wp_ajax_brezngeo_openrouter_load_models', array( $this, 'ajax_openrouter_load_models' ) );
} }
public function register_settings(): void { public function register_settings(): void {
@ -71,10 +74,23 @@ class ProviderPage {
$clean['api_keys'][ $provider_id ] = $existing['api_keys'][ $provider_id ]; $clean['api_keys'][ $provider_id ] = $existing['api_keys'][ $provider_id ];
} }
} }
// Preserve DB-stored keys for providers whose UI field was disabled
// (wp-config.php constant override) and therefore never submitted.
foreach ( ( $existing['api_keys'] ?? array() ) as $provider_id => $stored ) {
if ( ! isset( $clean['api_keys'][ $provider_id ] ) ) {
$clean['api_keys'][ $provider_id ] = $stored;
}
}
$clean['models'] = array(); $clean['models'] = array();
foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) { foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) {
$clean['models'][ sanitize_key( $provider_id ) ] = sanitize_text_field( $model ); $pid = sanitize_key( $provider_id );
$value = sanitize_text_field( $model );
if ( $pid === 'openrouter' && $value === '__custom__' ) {
$custom_raw = (string) ( $input['openrouter_custom_model'] ?? '' );
$value = sanitize_text_field( $custom_raw );
}
$clean['models'][ $pid ] = $value;
} }
$clean['costs'] = array(); $clean['costs'] = array();
@ -92,6 +108,18 @@ class ProviderPage {
} }
} }
$selected_openrouter = $clean['models']['openrouter'] ?? '';
if ( $selected_openrouter !== '' ) {
$cached = get_transient( \BreznGEO\Providers\OpenRouterProvider::MODELS_CACHE );
if ( is_array( $cached ) && isset( $cached[ $selected_openrouter ] ) ) {
$meta = $cached[ $selected_openrouter ];
$clean['costs']['openrouter'][ $selected_openrouter ] = array(
'input' => (float) ( $meta['input_cost'] ?? 0 ),
'output' => (float) ( $meta['output_cost'] ?? 0 ),
);
}
}
return $clean; return $clean;
} }
@ -126,6 +154,59 @@ class ProviderPage {
wp_send_json_success( SettingsPage::getDefaultPrompt() ); wp_send_json_success( SettingsPage::getDefaultPrompt() );
} }
public function ajax_openrouter_load_models(): void {
check_ajax_referer( 'brezngeo_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
}
$response = wp_remote_get(
OpenRouterProvider::MODELS_URL . '?category=marketing',
array(
'timeout' => 15,
'headers' => array(
'Accept' => 'application/json',
'HTTP-Referer' => home_url( '/' ),
'X-Title' => 'BreznGEO',
),
)
);
if ( is_wp_error( $response ) ) {
wp_send_json_error( $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code !== 200 || ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
$msg = $body['error']['message'] ?? "HTTP $code";
wp_send_json_error( $msg );
}
$normalized = array();
foreach ( $body['data'] as $model ) {
if ( ! is_array( $model ) || empty( $model['id'] ) ) {
continue;
}
$id = (string) $model['id'];
$label = isset( $model['name'] ) && is_string( $model['name'] ) && $model['name'] !== '' ? (string) $model['name'] : $id;
$input_per_token = isset( $model['pricing']['prompt'] ) ? (float) $model['pricing']['prompt'] : 0.0;
$output_per_token = isset( $model['pricing']['completion'] ) ? (float) $model['pricing']['completion'] : 0.0;
$normalized[ $id ] = array(
'label' => $label,
'input_cost' => round( $input_per_token * 1_000_000, 4 ),
'output_cost' => round( $output_per_token * 1_000_000, 4 ),
);
}
if ( empty( $normalized ) ) {
wp_send_json_error( __( 'No models returned by OpenRouter.', 'brezngeo' ) );
}
set_transient( OpenRouterProvider::MODELS_CACHE, $normalized, 12 * HOUR_IN_SECONDS );
wp_send_json_success( $normalized );
}
public function render(): void { public function render(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
return; return;

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Admin; namespace BreznGEO\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznGEO\Helpers\KeyVault; use BreznGEO\Helpers\KeyVault;
class SettingsPage { class SettingsPage {
@ -19,6 +23,12 @@ class SettingsPage {
*/ */
public const OPTION_KEY_SCHEMA = 'brezngeo_schema_settings'; public const OPTION_KEY_SCHEMA = 'brezngeo_schema_settings';
/**
* Provider IDs that support a wp-config.php constant override.
* A defined constant wins over the DB value and locks the UI field.
*/
private const CONSTANT_KEY_PROVIDERS = array( 'openai', 'anthropic', 'gemini', 'grok', 'openrouter' );
/** /**
* Returns merged settings from both option keys with defaults applied. * Returns merged settings from both option keys with defaults applied.
* Called by MetaGenerator, SchemaEnhancer, BulkPage, and admin pages. * Called by MetaGenerator, SchemaEnhancer, BulkPage, and admin pages.
@ -58,9 +68,37 @@ class SettingsPage {
$settings['api_keys'][ $id ] = $decrypted !== '' ? $decrypted : $stored; $settings['api_keys'][ $id ] = $decrypted !== '' ? $decrypted : $stored;
} }
// wp-config.php constants override the DB value and lock the admin field.
$settings['api_keys_locked'] = array();
foreach ( self::CONSTANT_KEY_PROVIDERS as $provider_id ) {
$constant = self::constantNameForProvider( $provider_id );
if ( $constant !== '' && defined( $constant ) ) {
$settings['api_keys'][ $provider_id ] = (string) constant( $constant );
$settings['api_keys_locked'][ $provider_id ] = true;
}
}
return $settings; return $settings;
} }
/**
* Returns the wp-config.php constant name for a given provider ID,
* or empty string if the provider has no constant override.
*
* Must be pure no WP hooks, no option reads. Called once per entry
* in CONSTANT_KEY_PROVIDERS during getSettings().
*
* The website (de/index.html, howto.html) already promises the names
* BREZNGEO_OPENAI_KEY, BREZNGEO_ANTHROPIC_KEY, BREZNGEO_GEMINI_KEY,
* BREZNGEO_GROK_KEY and BREZNGEO_OPENROUTER_KEY those are the contract.
*/
private static function constantNameForProvider( string $provider_id ): string {
if ( ! in_array( $provider_id, self::CONSTANT_KEY_PROVIDERS, true ) ) {
return '';
}
return 'BREZNGEO_' . strtoupper( $provider_id ) . '_KEY';
}
public static function getDefaultPrompt(): string { public static function getDefaultPrompt(): string {
$locale = get_locale(); $locale = get_locale();
$is_german = str_starts_with( $locale, 'de_' ); $is_german = str_starts_with( $locale, 'de_' );

View file

@ -0,0 +1,81 @@
<?php
/**
* OpenRouter-specific model field. Rendered from provider.php.
*
* Expected in scope:
* $provider (OpenRouterProvider), $settings (array), $pricing_urls (array)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$brezngeo_or_models = $provider->getModels();
$brezngeo_or_saved_model = $settings['models']['openrouter'] ?? '';
$brezngeo_or_is_custom = $brezngeo_or_saved_model !== '' && ! array_key_exists( $brezngeo_or_saved_model, $brezngeo_or_models );
$brezngeo_or_cached_pricing = get_transient( \BreznGEO\Providers\OpenRouterProvider::MODELS_CACHE );
$brezngeo_or_cache_is_array = is_array( $brezngeo_or_cached_pricing );
$brezngeo_or_selected_pricing = ( $brezngeo_or_cache_is_array && isset( $brezngeo_or_cached_pricing[ $brezngeo_or_saved_model ] ) )
? $brezngeo_or_cached_pricing[ $brezngeo_or_saved_model ]
: null;
?>
<br><br>
<label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label>
<select name="brezngeo_settings[models][openrouter]" class="brezngeo-openrouter-model-select" id="brezngeo-openrouter-model">
<?php if ( empty( $brezngeo_or_models ) ) : ?>
<option value=""><?php esc_html_e( 'No models loaded yet — click "Load models"', 'brezngeo' ); ?></option>
<?php else : ?>
<?php foreach ( $brezngeo_or_models as $brezngeo_or_mid => $brezngeo_or_label ) : ?>
<option value="<?php echo esc_attr( $brezngeo_or_mid ); ?>"
<?php selected( $brezngeo_or_saved_model, $brezngeo_or_mid ); ?>
data-input="<?php echo esc_attr( isset( $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['input_cost'] ) ? $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['input_cost'] : '' ); ?>"
data-output="<?php echo esc_attr( isset( $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['output_cost'] ) ? $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['output_cost'] : '' ); ?>">
<?php echo esc_html( $brezngeo_or_label ); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
<option value="__custom__" <?php selected( $brezngeo_or_is_custom ); ?>>
<?php esc_html_e( 'Custom model ID…', 'brezngeo' ); ?>
</option>
</select>
<button type="button" class="button brezngeo-openrouter-load-btn">
<?php esc_html_e( 'Load models', 'brezngeo' ); ?>
</button>
<span class="brezngeo-openrouter-load-status" aria-live="polite"></span>
<div class="brezngeo-openrouter-custom-wrap" style="<?php echo $brezngeo_or_is_custom ? '' : 'display:none;'; ?>margin-top:10px;">
<label for="brezngeo-openrouter-custom">
<?php esc_html_e( 'Custom model ID:', 'brezngeo' ); ?>
</label>
<input type="text"
id="brezngeo-openrouter-custom"
name="brezngeo_settings[openrouter_custom_model]"
value="<?php echo esc_attr( $brezngeo_or_is_custom ? $brezngeo_or_saved_model : '' ); ?>"
placeholder="<?php esc_attr_e( 'e.g. anthropic/claude-opus-4.7', 'brezngeo' ); ?>"
class="regular-text">
<p class="description">
<a href="https://brezngeo.com/faq.html#openrouter" target="_blank" rel="noopener">
<?php esc_html_e( 'Learn how to find OpenRouter model IDs →', 'brezngeo' ); ?>
</a>
</p>
</div>
<?php if ( ! empty( $pricing_urls['openrouter'] ) ) : ?>
<p style="margin-top:8px;">
<a href="<?php echo esc_url( $pricing_urls['openrouter'] ); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'Browse all OpenRouter models →', 'brezngeo' ); ?>
</a>
</p>
<?php endif; ?>
<p style="margin-top:12px;"><strong><?php esc_html_e( 'Pricing (automatically from OpenRouter, per 1M tokens):', 'brezngeo' ); ?></strong></p>
<div class="brezngeo-openrouter-pricing-display" id="brezngeo-openrouter-pricing" style="font-size:12px;color:#555;">
<?php if ( $brezngeo_or_selected_pricing ) : ?>
Input $<span class="or-price-input"><?php echo esc_html( number_format( (float) $brezngeo_or_selected_pricing['input_cost'], 4 ) ); ?></span>
/ 1M · Output $<span class="or-price-output"><?php echo esc_html( number_format( (float) $brezngeo_or_selected_pricing['output_cost'], 4 ) ); ?></span>
/ 1M
<?php elseif ( $brezngeo_or_is_custom ) : ?>
<em><?php esc_html_e( 'Pricing unknown for custom models — will be populated after you click "Load models".', 'brezngeo' ); ?></em>
<?php else : ?>
<em><?php esc_html_e( 'Click "Load models" to fetch pricing from OpenRouter.', 'brezngeo' ); ?></em>
<?php endif; ?>
</div>

View file

@ -36,9 +36,26 @@
</td> </td>
</tr> </tr>
<?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?> <?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
<?php $brezngeo_locked = ! empty( $settings['api_keys_locked'][ $id ] ); ?>
<tr class="brezngeo-provider-row" data-provider="<?php echo esc_attr( $id ); ?>"> <tr class="brezngeo-provider-row" data-provider="<?php echo esc_attr( $id ); ?>">
<th scope="row"><?php echo esc_html( $provider->getName() ); ?> <?php esc_html_e( 'API Key', 'brezngeo' ); ?></th> <th scope="row"><?php echo esc_html( $provider->getName() ); ?> <?php esc_html_e( 'API Key', 'brezngeo' ); ?></th>
<td> <td>
<?php if ( $brezngeo_locked ) : ?>
<span class="brezngeo-key-saved">
<?php
printf(
/* translators: %s: wp-config.php constant name */
esc_html__( 'Loaded from wp-config.php: %s', 'brezngeo' ),
'<code>BREZNGEO_' . esc_html( strtoupper( $id ) ) . '_KEY</code>'
);
?>
</span><br>
<input type="password" value="" placeholder="&#8212;" class="regular-text" disabled>
<button type="button" class="button brezngeo-test-btn" data-provider="<?php echo esc_attr( $id ); ?>">
<?php esc_html_e( 'Test connection', 'brezngeo' ); ?>
</button>
<span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span>
<?php else : ?>
<?php if ( ! empty( $masked_keys[ $id ] ) ) : ?> <?php if ( ! empty( $masked_keys[ $id ] ) ) : ?>
<span class="brezngeo-key-saved"> <span class="brezngeo-key-saved">
<?php esc_html_e( 'Saved:', 'brezngeo' ); ?> <code><?php echo esc_html( $masked_keys[ $id ] ); ?></code> <?php esc_html_e( 'Saved:', 'brezngeo' ); ?> <code><?php echo esc_html( $masked_keys[ $id ] ); ?></code>
@ -54,6 +71,10 @@
<?php esc_html_e( 'Test connection', 'brezngeo' ); ?> <?php esc_html_e( 'Test connection', 'brezngeo' ); ?>
</button> </button>
<span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span> <span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span>
<?php endif; ?>
<?php if ( $id === 'openrouter' ) : ?>
<?php include BREZNGEO_DIR . 'includes/Admin/views/partials/openrouter-model-field.php'; ?>
<?php else : ?>
<br><br> <br><br>
<label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label> <label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label>
<select name="brezngeo_settings[models][<?php echo esc_attr( $id ); ?>]"> <select name="brezngeo_settings[models][<?php echo esc_attr( $id ); ?>]">
@ -68,23 +89,23 @@
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<?php <?php
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
$pricing_url = $pricing_urls[ $id ] ?? ''; $pricing_url = $pricing_urls[ $id ] ?? '';
if ( $pricing_url ) : if ( $pricing_url ) :
?> ?>
<p style="margin-top:8px;"> <p style="margin-top:8px;">
<a href="<?php echo esc_url( $pricing_url ); ?>" target="_blank" rel="noopener noreferrer"> <a href="<?php echo esc_url( $pricing_url ); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View current pricing →', 'brezngeo' ); ?> <?php esc_html_e( 'View current pricing →', 'brezngeo' ); ?>
</a> </a>
</p> </p>
<?php endif; ?> <?php endif; ?>
<p style="margin-top:12px;"><strong><?php esc_html_e( 'Cost per 1 million tokens (for the Bulk cost overview):', 'brezngeo' ); ?></strong></p> <p style="margin-top:12px;"><strong><?php esc_html_e( 'Cost per 1 million tokens (for the Bulk cost overview):', 'brezngeo' ); ?></strong></p>
<?php <?php
foreach ( $provider->getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound foreach ( $provider->getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
$saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array(); $saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array();
?> ?>
<div style="margin-bottom:6px;display:flex;align-items:center;gap:12px;"> <div style="margin-bottom:6px;display:flex;align-items:center;gap:12px;">
<label style="min-width:180px;font-size:12px;"><?php echo esc_html( $model_label ); ?>:</label> <label style="min-width:180px;font-size:12px;"><?php echo esc_html( $model_label ); ?>:</label>
<span>Input $<input type="number" step="0.0001" min="0" <span>Input $<input type="number" step="0.0001" min="0"
@ -96,7 +117,8 @@
value="<?php echo esc_attr( $saved_costs['output'] ?? '' ); ?>" value="<?php echo esc_attr( $saved_costs['output'] ?? '' ); ?>"
placeholder="z.B. 0.60" style="width:75px;"> / 1M</span> placeholder="z.B. 0.60" style="width:75px;"> / 1M</span>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO; namespace BreznGEO;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Core { class Core {
private static ?Core $instance = null; private static ?Core $instance = null;
@ -12,6 +16,7 @@ class Core {
} }
public function init(): void { public function init(): void {
load_plugin_textdomain( 'brezngeo', false, dirname( plugin_basename( BREZNGEO_FILE ) ) . '/languages' );
$this->load_dependencies(); $this->load_dependencies();
$this->register_hooks(); $this->register_hooks();
} }
@ -23,6 +28,7 @@ class Core {
require_once BREZNGEO_DIR . 'includes/Providers/AnthropicProvider.php'; require_once BREZNGEO_DIR . 'includes/Providers/AnthropicProvider.php';
require_once BREZNGEO_DIR . 'includes/Providers/GeminiProvider.php'; require_once BREZNGEO_DIR . 'includes/Providers/GeminiProvider.php';
require_once BREZNGEO_DIR . 'includes/Providers/GrokProvider.php'; require_once BREZNGEO_DIR . 'includes/Providers/GrokProvider.php';
require_once BREZNGEO_DIR . 'includes/Providers/OpenRouterProvider.php';
require_once BREZNGEO_DIR . 'includes/Helpers/KeyVault.php'; require_once BREZNGEO_DIR . 'includes/Helpers/KeyVault.php';
require_once BREZNGEO_DIR . 'includes/Helpers/TokenEstimator.php'; require_once BREZNGEO_DIR . 'includes/Helpers/TokenEstimator.php';
require_once BREZNGEO_DIR . 'includes/Helpers/FallbackMeta.php'; require_once BREZNGEO_DIR . 'includes/Helpers/FallbackMeta.php';
@ -60,6 +66,7 @@ class Core {
$registry->register( new Providers\AnthropicProvider() ); $registry->register( new Providers\AnthropicProvider() );
$registry->register( new Providers\GeminiProvider() ); $registry->register( new Providers\GeminiProvider() );
$registry->register( new Providers\GrokProvider() ); $registry->register( new Providers\GrokProvider() );
$registry->register( new Providers\OpenRouterProvider() );
( new Features\MetaGenerator() )->register(); ( new Features\MetaGenerator() )->register();
( new Features\SchemaEnhancer() )->register(); ( new Features\SchemaEnhancer() )->register();

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class BulkQueue { class BulkQueue {
private const LOCK_KEY = 'brezngeo_bulk_running'; private const LOCK_KEY = 'brezngeo_bulk_running';
private const LOCK_TTL = 900; // 15 minutes private const LOCK_TTL = 900; // 15 minutes

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class FallbackMeta { class FallbackMeta {
private const MIN = 150; private const MIN = 150;
private const MAX = 160; private const MAX = 160;

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/** /**
* Obfuscates API keys for database storage using XOR with a derived WP-salt key. * Obfuscates API keys for database storage using XOR with a derived WP-salt key.
* *

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class TokenEstimator { class TokenEstimator {
/** /**
* Pricing per 1k tokens [provider][model][input|output] * Pricing per 1k tokens [provider][model][input|output]

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class AnthropicProvider implements ProviderInterface { class AnthropicProvider implements ProviderInterface {
private const API_URL = 'https://api.anthropic.com/v1/messages'; private const API_URL = 'https://api.anthropic.com/v1/messages';

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class GeminiProvider implements ProviderInterface { class GeminiProvider implements ProviderInterface {
private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/'; private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/';

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class GrokProvider implements ProviderInterface { class GrokProvider implements ProviderInterface {
private const API_URL = 'https://api.x.ai/v1/chat/completions'; private const API_URL = 'https://api.x.ai/v1/chat/completions';

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class OpenAIProvider implements ProviderInterface { class OpenAIProvider implements ProviderInterface {
private const API_URL = 'https://api.openai.com/v1/chat/completions'; private const API_URL = 'https://api.openai.com/v1/chat/completions';

View file

@ -0,0 +1,106 @@
<?php
namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class OpenRouterProvider implements ProviderInterface {
public const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
public const MODELS_URL = 'https://openrouter.ai/api/v1/models';
public const MODELS_CACHE = 'brezngeo_openrouter_models';
public const FALLBACK_TEST = 'openai/gpt-4o-mini';
public function getId(): string {
return 'openrouter'; }
public function getName(): string {
return 'OpenRouter'; }
/**
* Returns cached curated Marketing/SEO models plus any saved custom model.
* When the cache is empty, returns an empty array the admin view shows a "Load models" hint.
*/
public function getModels(): array {
$cached = get_transient( self::MODELS_CACHE );
$models = array();
if ( is_array( $cached ) ) {
foreach ( $cached as $id => $meta ) {
if ( is_array( $meta ) && isset( $meta['label'] ) ) {
$models[ (string) $id ] = (string) $meta['label'];
}
}
}
if ( class_exists( '\BreznGEO\Admin\SettingsPage' ) ) {
$settings = \BreznGEO\Admin\SettingsPage::getSettings();
$custom = $settings['models'][ $this->getId() ] ?? '';
if ( is_string( $custom ) && $custom !== '' && ! isset( $models[ $custom ] ) ) {
$models[ $custom ] = $custom . ' (' . __( 'custom', 'brezngeo' ) . ')';
}
}
return $models;
}
public function testConnection( string $api_key ): array {
try {
$this->generateText( 'Say "ok"', $api_key, self::FALLBACK_TEST, 5 );
return array(
'success' => true,
'message' => __( 'Connection successful', 'brezngeo' ),
);
} catch ( \RuntimeException $e ) {
return array(
'success' => false,
'message' => $e->getMessage(),
);
}
}
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
$response = wp_remote_post(
self::API_URL,
array(
'timeout' => 30,
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url( '/' ),
'X-Title' => 'BreznGEO',
),
'body' => wp_json_encode(
array(
'model' => $model,
'messages' => array(
array(
'role' => 'user',
'content' => $prompt,
),
),
'max_tokens' => $max_tokens,
)
),
)
);
return $this->parseResponse( $response );
}
private function parseResponse( $response ): string {
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code !== 200 ) {
$msg = $body['error']['message'] ?? "HTTP $code";
throw new \RuntimeException( esc_html( $msg ) );
}
return trim( $body['choices'][0]['message']['content'] ?? '' );
}
}

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
interface ProviderInterface { interface ProviderInterface {
/** Unique machine-readable ID, e.g. 'openai' */ /** Unique machine-readable ID, e.g. 'openai' */
public function getId(): string; public function getId(): string;

View file

@ -1,6 +1,10 @@
<?php <?php
namespace BreznGEO; namespace BreznGEO;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznGEO\Providers\ProviderInterface; use BreznGEO\Providers\ProviderInterface;
class ProviderRegistry { class ProviderRegistry {

Binary file not shown.

View file

@ -18,9 +18,6 @@ msgstr ""
"X-Domain: brezngeo\n" "X-Domain: brezngeo\n"
#: includes/Admin/AdminMenu.php:11 #: includes/Admin/AdminMenu.php:11
msgid "BreznGEO"
msgstr "BreznGEO"
#: includes/Admin/AdminMenu.php:12 #: includes/Admin/AdminMenu.php:12
msgid "BreznGEO" msgid "BreznGEO"
msgstr "BreznGEO" msgstr "BreznGEO"
@ -1089,3 +1086,582 @@ msgstr "Generiert GEO-optimierte Inhaltsblöcke mit KI für bessere LLM-Sichtbar
#: includes/Admin/views/dashboard.php #: includes/Admin/views/dashboard.php
msgid "Save" msgid "Save"
msgstr "Speichern" msgstr "Speichern"
# --- v1.3.3 i18n pass ---
#: includes/Admin/views/dashboard.php
#: includes/Admin/AdminMenu.php
#: includes/Admin/views/keyword-settings.php
msgid "Keyword Analysis"
msgstr "Keyword-Analyse"
#: includes/Admin/views/dashboard.php
msgid "AI-powered keyword suggestions, optimization tips, and semantic analysis."
msgstr "KI-basierte Keyword-Vorschläge, Optimierungstipps und semantische Analyse."
#: includes/Admin/views/meta.php
msgid "SEO Widget"
msgstr "SEO-Widget"
#: includes/Admin/views/meta.php
msgid "Theme outputs post title as H1 (suppresses \"no H1\" warning in editor)"
msgstr "Theme gibt Beitragstitel als H1 aus (unterdrückt \"kein H1\"-Warnung im Editor)"
#: includes/Admin/views/meta.php
msgid "Most themes render the post title as an H1 tag on the front end. Enable this to avoid false warnings in the SEO Widget when the content itself contains no H1."
msgstr "Die meisten Themes geben den Beitragstitel als H1 im Frontend aus. Aktivieren, um falsche Warnungen im SEO-Widget zu vermeiden, wenn der Inhalt selbst kein H1 enthält."
#: includes/Admin/views/meta.php
msgid "No AI provider active."
msgstr "Kein KI-Anbieter aktiv."
#: includes/Admin/views/meta.php
msgid "Meta descriptions will use the fallback method (first paragraph of the post) until an API key is configured and AI generation is enabled."
msgstr "Meta-Beschreibungen verwenden die Fallback-Methode (erster Absatz des Beitrags), bis ein API-Schlüssel konfiguriert und die KI-Generierung aktiviert ist."
#: includes/Admin/views/meta.php
msgid "Configure AI Provider →"
msgstr "KI-Anbieter konfigurieren →"
#: includes/Admin/views/meta.php
msgid "Fallback mode active — configure an AI provider to enable AI generation."
msgstr "Fallback-Modus aktiv — einen KI-Anbieter konfigurieren, um KI-Generierung zu aktivieren."
#: includes/Admin/views/txt.php
msgid "Clear Cache"
msgstr "Cache leeren"
#: includes/Admin/views/txt.php
msgid "URL:"
msgstr "URL:"
#: includes/Admin/KeywordMetaBox.php
msgid "Keyword Analysis (BreznGEO)"
msgstr "Keyword-Analyse (BreznGEO)"
#: includes/Admin/views/keyword-meta-box.php
msgid "Main Keyword"
msgstr "Haupt-Keyword"
#: includes/Admin/views/keyword-meta-box.php
msgid "e.g. Passau travel guide"
msgstr "z. B. Passau Reiseführer"
#: includes/Admin/views/keyword-meta-box.php
msgid "Secondary Keywords"
msgstr "Neben-Keywords"
#: includes/Admin/views/keyword-meta-box.php
msgid "Add Keyword"
msgstr "Keyword hinzufügen"
#: includes/Admin/views/keyword-meta-box.php
msgid "Analyze"
msgstr "Analysieren"
#: includes/Admin/views/keyword-meta-box.php
msgid "Suggest Keywords"
msgstr "Keywords vorschlagen"
#: includes/Admin/views/keyword-meta-box.php
msgid "Showing cached results. Click \"Analyze\" to refresh."
msgstr "Gecachte Ergebnisse. Klicke \"Analysieren\" zum Aktualisieren."
#: includes/Admin/views/keyword-meta-box.php
msgid "Optimization Tips"
msgstr "Optimierungstipps"
#: includes/Admin/views/keyword-meta-box.php
msgid "Semantic Analysis"
msgstr "Semantische Analyse"
#: includes/Admin/KeywordMetaBox.php (JS i18n)
msgid "Analyzing…"
msgstr "Analysiere…"
#: includes/Admin/KeywordMetaBox.php
msgid "Please enter a main keyword."
msgstr "Bitte ein Haupt-Keyword eingeben."
#: includes/Admin/KeywordMetaBox.php
msgid "Getting suggestions…"
msgstr "Hole Vorschläge…"
#: includes/Admin/KeywordMetaBox.php
msgid "Getting optimization tips…"
msgstr "Hole Optimierungstipps…"
#: includes/Admin/KeywordMetaBox.php
msgid "Running semantic analysis…"
msgstr "Semantische Analyse läuft…"
#: includes/Admin/KeywordMetaBox.php
msgid "AI keyword features are not activated."
msgstr "KI-Keyword-Funktionen sind nicht aktiviert."
#: includes/Admin/KeywordMetaBox.php
msgid "AI generation failed. Check provider settings."
msgstr "KI-Generierung fehlgeschlagen. Provider-Einstellungen prüfen."
#: includes/Admin/KeywordMetaBox.php
msgid "Could not parse AI response."
msgstr "KI-Antwort konnte nicht verarbeitet werden."
#: includes/Admin/KeywordMetaBox.php
msgid "No keyword provided."
msgstr "Kein Keyword angegeben."
#: includes/Admin/SeoWidget.php
msgid "No H1 heading"
msgstr "Keine H1-Überschrift"
#: includes/Admin/SeoWidget.php
msgid "Multiple H1 headings"
msgstr "Mehrere H1-Überschriften"
#: includes/Admin/SeoWidget.php
msgid "No internal links"
msgstr "Keine internen Links"
#: includes/Admin/SeoWidget.php
msgid "internal"
msgstr "intern"
#: includes/Admin/SeoWidget.php
msgid "external"
msgstr "extern"
#: includes/Admin/SeoWidget.php
msgid "min"
msgstr "Min."
#: includes/Admin/views/keyword-settings.php
msgid "Analysis Settings"
msgstr "Analyse-Einstellungen"
#: includes/Admin/views/keyword-settings.php
msgid "Update Mode"
msgstr "Aktualisierungsmodus"
#: includes/Admin/views/keyword-settings.php
msgid "Manual — click \"Analyze\" button"
msgstr "Manuell — Button \"Analysieren\" klicken"
#: includes/Admin/views/keyword-settings.php
msgid "Live — auto-analyze while typing"
msgstr "Live — automatisch beim Tippen analysieren"
#: includes/Admin/views/keyword-settings.php
msgid "On Save — analyze when post is saved"
msgstr "Beim Speichern — analysieren wenn Beitrag gespeichert wird"
#: includes/Admin/views/keyword-settings.php
msgid "Target Keyword Density (%)"
msgstr "Ziel-Keyword-Dichte (%)"
#: includes/Admin/views/keyword-settings.php
msgid "Recommended: 1.02.0%. Pass range is ±0.5% around the target."
msgstr "Empfohlen: 1,02,0 %. Toleranzbereich ist ±0,5 % um den Zielwert."
#: includes/Admin/views/keyword-settings.php
msgid "Min. Occurrences (Primary)"
msgstr "Mind. Vorkommen (Primär)"
#: includes/Admin/views/keyword-settings.php
msgid "Min. Occurrences (Secondary)"
msgstr "Mind. Vorkommen (Sekundär)"
#: includes/Admin/views/keyword-settings.php
msgid "Live Mode Debounce (ms)"
msgstr "Live-Modus Verzögerung (ms)"
#: includes/Admin/views/keyword-settings.php
msgid "Delay in milliseconds before live analysis triggers after typing stops."
msgstr "Verzögerung in Millisekunden bevor die Live-Analyse nach dem Tippen startet."
#: includes/Admin/views/keyword-settings.php
msgid "Show keyword meta box on"
msgstr "Keyword-Meta-Box anzeigen bei"
#: includes/Admin/views/bulk.php
msgid "No AI provider connected — descriptions will be generated from content without AI (fallback mode)."
msgstr "Kein KI-Anbieter verbunden — Beschreibungen werden ohne KI aus dem Inhalt generiert (Fallback-Modus)."
#: includes/Admin/views/geo.php
msgid "Labels"
msgstr "Beschriftungen"
#: includes/Admin/views/geo.php
msgid "Styling"
msgstr "Darstellung"
#: includes/Admin/views/geo.php
msgid "Position"
msgstr "Position"
#: includes/Admin/views/geo.php
msgid "Theme"
msgstr "Theme"
#: includes/Admin/views/geo.php
msgid "Light"
msgstr "Hell"
#: includes/Admin/views/geo.php
msgid "Dark"
msgstr "Dunkel"
#: includes/Admin/views/geo.php
msgid "Minimal"
msgstr "Minimal"
#: includes/Admin/views/geo.php
msgid "Brezn"
msgstr "Brezn"
# --- Schema.org ---
#: includes/Admin/AdminMenu.php
msgid "Schema.org"
msgstr "Schema.org"
#: includes/Admin/AdminMenu.php
msgid "AI disabled"
msgstr "KI deaktiviert"
#: includes/Admin/AdminMenu.php
msgid "\xe2\x80\x94 Not configured \xe2\x80\x94"
msgstr "\xe2\x80\x94 Nicht konfiguriert \xe2\x80\x94"
#: includes/Admin/views/schema.php
msgid "BreznGEO Schema"
msgstr "BreznGEO Schema"
#: includes/Admin/views/schema-meta-box.php
msgid "Schema Type"
msgstr "Schema-Typ"
#: includes/Admin/views/schema-meta-box.php
msgid "\xe2\x80\x94 No Schema \xe2\x80\x94"
msgstr "\xe2\x80\x94 Kein Schema \xe2\x80\x94"
#: includes/Admin/views/schema.php
msgid "BlogPosting / Article (with embedded Author + Image)"
msgstr "BlogPosting / Artikel (mit eingebettetem Autor + Bild)"
#: includes/Admin/views/schema.php
msgid "FAQPage (from GEO Quick Overview \xe2\x80\x94 automatic)"
msgstr "FAQPage (aus GEO Schnell\xc3\xbcberblick \xe2\x80\x94 automatisch)"
#: includes/Admin/views/schema.php
msgid "ImageObject (Featured Image)"
msgstr "ImageObject (Beitragsbild)"
#: includes/Admin/views/schema.php
msgid "VideoObject (auto-detect YouTube/Vimeo)"
msgstr "VideoObject (YouTube/Vimeo automatisch erkennen)"
#: includes/Admin/views/schema-meta-box.php
msgid "Recipe"
msgstr "Rezept"
#: includes/Admin/views/schema-meta-box.php
msgid "Recipe Name"
msgstr "Rezeptname"
#: includes/Admin/views/schema-meta-box.php
msgid "Recipe (Metabox in Post Editor)"
msgstr "Rezept (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "Event"
msgstr "Veranstaltung"
#: includes/Admin/views/schema-meta-box.php
msgid "Event Name"
msgstr "Veranstaltungsname"
#: includes/Admin/views/schema-meta-box.php
msgid "Event (Metabox in Post Editor)"
msgstr "Veranstaltung (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "HowTo Guide"
msgstr "HowTo-Anleitung"
#: includes/Admin/views/schema-meta-box.php
msgid "HowTo (Metabox in Post Editor)"
msgstr "HowTo (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "Review / Rating"
msgstr "Bewertung / Rating"
#: includes/Admin/views/schema-meta-box.php
msgid "Review with Rating (Metabox in Post Editor)"
msgstr "Bewertung mit Rating (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "Guide Name"
msgstr "Anleitung Name"
#: includes/Admin/views/schema-meta-box.php
msgid "Steps (one line = one step)"
msgstr "Schritte (eine Zeile = ein Schritt)"
#: includes/Admin/views/schema-meta-box.php
msgid "Ingredients (one per line)"
msgstr "Zutaten (eine pro Zeile)"
#: includes/Admin/views/schema-meta-box.php
msgid "Instructions (one step per line)"
msgstr "Anleitung (ein Schritt pro Zeile)"
#: includes/Admin/views/schema-meta-box.php
msgid "Prep Time (min)"
msgstr "Vorbereitungszeit (Min.)"
#: includes/Admin/views/schema-meta-box.php
msgid "Cook Time (min)"
msgstr "Kochzeit (Min.)"
#: includes/Admin/views/schema-meta-box.php
msgid "Servings"
msgstr "Portionen"
#: includes/Admin/views/schema-meta-box.php
msgid "Rating (1\xe2\x80\x935)"
msgstr "Bewertung (1\xe2\x80\x935)"
#: includes/Admin/views/schema-meta-box.php
msgid "Reviewed Product / Service"
msgstr "Bewertetes Produkt / Dienstleistung"
#: includes/Admin/views/schema-meta-box.php
msgid "Start Date"
msgstr "Startdatum"
#: includes/Admin/views/schema-meta-box.php
msgid "End Date (optional)"
msgstr "Enddatum (optional)"
#: includes/Admin/views/schema-meta-box.php
msgid "Location or URL"
msgstr "Ort oder URL"
#: includes/Admin/views/schema-meta-box.php
msgid "Online Event"
msgstr "Online-Veranstaltung"
#: includes/Admin/views/schema-meta-box.php
msgid "Post not found."
msgstr "Beitrag nicht gefunden."
# --- Keyword Analysis Checks ---
#: includes/Features/KeywordAnalysis.php
msgid "Keyword Density"
msgstr "Keyword-Dichte"
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in title."
msgstr "Keyword im Titel gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in title."
msgstr "Keyword nicht im Titel gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in subheading."
msgstr "Keyword in Zwischenüberschrift gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in any H2-H6."
msgstr "Keyword in keiner H2-H6 gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in URL."
msgstr "Keyword in URL gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in URL."
msgstr "Keyword nicht in URL gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in meta description."
msgstr "Keyword in Meta-Beschreibung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in meta description."
msgstr "Keyword nicht in Meta-Beschreibung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Meta description is empty."
msgstr "Meta-Beschreibung ist leer."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in first paragraph."
msgstr "Keyword im ersten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in first paragraph."
msgstr "Keyword nicht im ersten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in last paragraph."
msgstr "Keyword im letzten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in last paragraph."
msgstr "Keyword nicht im letzten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in excerpt."
msgstr "Keyword im Textauszug gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in excerpt."
msgstr "Keyword nicht im Textauszug gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Excerpt is empty."
msgstr "Textauszug ist leer."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in image alt text."
msgstr "Keyword im Bild-Alt-Text gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "No image contains keyword in alt text."
msgstr "Kein Bild enthält das Keyword im Alt-Text."
#: includes/Features/KeywordAnalysis.php
msgid "No images found."
msgstr "Keine Bilder gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in image title or caption."
msgstr "Keyword im Bildtitel oder Beschriftung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in any image title or caption."
msgstr "Keyword in keinem Bildtitel oder Beschriftung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "No paragraphs found."
msgstr "Keine Absätze gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in content."
msgstr "Keyword nicht im Inhalt gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "URL / Slug"
msgstr "URL / Slug"
#: includes/Features/KeywordAnalysis.php
msgid "First Paragraph"
msgstr "Erster Absatz"
#: includes/Features/KeywordAnalysis.php
msgid "Last Paragraph"
msgstr "Letzter Absatz"
#: includes/Features/KeywordAnalysis.php
msgid "Image Alt Texts"
msgstr "Bild-Alt-Texte"
#: includes/Features/KeywordAnalysis.php
msgid "Image Title/Caption"
msgstr "Bildtitel / Beschriftung"
#: includes/Features/KeywordAnalysis.php
msgid "Excerpt"
msgstr "Textauszug"
#: includes/Features/KeywordAnalysis.php
msgid "Meta Description"
msgstr "Meta-Beschreibung"
#: includes/Features/KeywordAnalysis.php
msgid "FAQ"
msgstr "FAQ"
#: includes/Features/KeywordAnalysis.php
#, php-format
msgid "%1$.1f%% (target: %2$.1f%%)"
msgstr "%1$.1f %% (Ziel: %2$.1f %%)"
#: includes/Admin/views/geo.php
msgid "The GEO block will not be generated automatically until an API key is configured and AI generation is enabled."
msgstr "Der GEO-Block wird erst automatisch generiert, wenn ein API-Schlüssel konfiguriert und die KI-Generierung aktiviert ist."
#: includes/Admin/views/geo.php
msgid "Variables: {title}, {content}, {language}"
msgstr "Variablen: {title}, {content}, {language}"
#: includes/Admin/views/geo.php
#, php-format
msgid "Want to customise further? <a href=\"%s\" target=\"_blank\" rel=\"noopener\">Learn how to style the block via your theme &rarr;</a>"
msgstr "Mehr anpassen? <a href=\"%s\" target=\"_blank\" rel=\"noopener\">Erfahre, wie du den Block per Theme stylen kannst &rarr;</a>"
#: includes/Admin/views/geo.php
msgid "Left border stripe and expand arrow colour. Leave empty for the default blue. Not used by the Minimal theme."
msgstr "Farbe des linken Rands und Expand-Pfeils. Leer lassen für Standard-Blau. Wird vom Minimal-Theme nicht verwendet."
#: includes/Admin/views/geo.php
msgid "Light \xe2\x80\x94 clean card with a blue accent. Dark \xe2\x80\x94 same for dark-mode sites. Minimal \xe2\x80\x94 borderless, left stripe only. Brezn \xe2\x80\x94 Brezn blue with diamond header pattern."
msgstr "Hell \xe2\x80\x94 klare Karte mit blauem Akzent. Dunkel \xe2\x80\x94 dasselbe f\xc3\xbcr Dark-Mode-Seiten. Minimal \xe2\x80\x94 rahmenlos, nur linker Streifen. Brezn \xe2\x80\x94 Brezn-Blau mit Rautenmuster."
#: includes/Admin/views/bulk.php
#, php-format
msgid "No AI provider connected \xe2\x80\x94 descriptions will be generated from content without AI (fallback mode). <a href=\"%s\">Configure a provider \xe2\x86\x92</a>"
msgstr "Kein KI-Anbieter verbunden \xe2\x80\x94 Beschreibungen werden ohne KI aus dem Inhalt generiert (Fallback-Modus). <a href=\"%s\">Anbieter konfigurieren \xe2\x86\x92</a>"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Load models"
msgstr "Modelle laden"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Custom model ID\xe2\x80\xa6"
msgstr "Eigene Modell-ID\xe2\x80\xa6"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Custom model ID:"
msgstr "Eigene Modell-ID:"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "e.g. anthropic/claude-opus-4.7"
msgstr "z. B. anthropic/claude-opus-4.7"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Learn how to find OpenRouter model IDs \xe2\x86\x92"
msgstr "So findest du OpenRouter-Modell-IDs \xe2\x86\x92"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Browse all OpenRouter models \xe2\x86\x92"
msgstr "Alle OpenRouter-Modelle ansehen \xe2\x86\x92"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "No models loaded yet \xe2\x80\x94 click \"Load models\""
msgstr "Noch keine Modelle geladen \xe2\x80\x94 auf \"Modelle laden\" klicken"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Pricing (automatically from OpenRouter, per 1M tokens):"
msgstr "Preise (automatisch von OpenRouter, pro 1M Tokens):"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Pricing unknown for custom models \xe2\x80\x94 will be populated after you click \"Load models\"."
msgstr "Preise f\xc3\xbcr eigene Modelle unbekannt \xe2\x80\x94 werden nach Klick auf \"Modelle laden\" bef\xc3\xbcllt."
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Click \"Load models\" to fetch pricing from OpenRouter."
msgstr "Klick auf \"Modelle laden\", um Preise von OpenRouter zu holen."
#: includes/Providers/OpenRouterProvider.php
msgid "custom"
msgstr "benutzerdefiniert"
#: includes/Admin/ProviderPage.php
msgid "No models returned by OpenRouter."
msgstr "OpenRouter hat keine Modelle geliefert."

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ Contributors: mifupadev
Tags: seo, ai, meta description, schema, llms.txt Tags: seo, ai, meta description, schema, llms.txt
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.9 Tested up to: 6.9
Stable tag: 1.2.0 Stable tag: 1.3.0
Requires PHP: 8.0 Requires PHP: 8.0
License: GPL-2.0-or-later License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@ -69,12 +69,13 @@ Finds all published posts without a meta description (including descriptions set
= Multi-Provider AI Support = = Multi-Provider AI Support =
Choose from four AI providers and switch at any time without losing your settings: Choose from five AI providers — or access 600+ models through a single OpenRouter API key. Switch at any time without losing your settings:
* OpenAI (GPT-4.1, GPT-4o, GPT-4o mini, and more) * OpenAI (GPT-4.1, GPT-4o, GPT-4o mini, and more)
* Anthropic Claude (Claude 3.5 Sonnet, Claude 3 Haiku, and more) * Anthropic Claude (Claude 3.5 Sonnet, Claude 3 Haiku, and more)
* Google Gemini (Gemini 2.0 Flash, Gemini 1.5 Pro, and more) * Google Gemini (Gemini 2.0 Flash, Gemini 1.5 Pro, and more)
* xAI Grok (Grok 3, Grok 3 mini, and more) * xAI Grok (Grok 3, Grok 3 mini, and more)
* OpenRouter (access to 600+ models including Claude, GPT, Gemini, Llama, Mistral, DeepSeek, and more through a single API key)
= Schema.org Enhancer (GEO) = = Schema.org Enhancer (GEO) =
@ -140,7 +141,7 @@ An API key is required for AI-generated meta descriptions. Without one, the plug
= How much does it cost to generate meta descriptions? = = How much does it cost to generate meta descriptions? =
Cost depends on the AI provider and model you choose. A single meta description typically uses fewer than 1,500 tokens (input + output combined). As a rough reference, 1,000 descriptions with GPT-4o mini has cost around $0.50$1.00 at recent rates — but AI provider pricing changes over time. The AI Provider settings page links directly to the current pricing page for each supported provider. Cost depends on the AI provider and model you choose. A single meta description typically uses fewer than 1,500 tokens (input + output combined). As a rough reference, 1,000 descriptions with GPT-4o mini has cost around $0.50$1.00 at recent rates — but AI provider pricing changes over time. The AI Provider settings page links directly to the current pricing page for each supported provider. For OpenRouter, per-model pricing is fetched directly from the API and displayed in-plugin after you click "Load models".
= Are my API keys stored securely? = = Are my API keys stored securely? =
@ -222,8 +223,30 @@ No data is transmitted during normal page loads or to visitors.
* Privacy policy: https://x.ai/privacy-policy * Privacy policy: https://x.ai/privacy-policy
* Terms of use: https://x.ai/legal/terms-of-service * Terms of use: https://x.ai/legal/terms-of-service
= OpenRouter =
* Data sent (only when selected as active provider): Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis).
* Additional request (only when you click "Load models" in the provider settings): a list of available models is fetched from the OpenRouter models API. No user data is sent with this request.
* API endpoints: `https://openrouter.ai/api/v1/chat/completions` (text generation), `https://openrouter.ai/api/v1/models` (model list, on demand).
* Note: OpenRouter is a routing aggregator. The actual AI model selected by the user may be served by OpenAI, Anthropic, Google, Meta, xAI, Mistral, DeepSeek or another upstream provider. See OpenRouter's privacy policy for details on upstream routing.
* Privacy policy: https://openrouter.ai/privacy
* Terms of use: https://openrouter.ai/terms
== Changelog == == Changelog ==
= 1.3.0 =
* New: OpenRouter as a fifth AI provider — access to 600+ models (Claude, GPT, Gemini, Llama, Mistral, DeepSeek, and more) through a single API key.
* New: On-demand model loader fetches OpenRouter's curated Marketing/SEO model list with live per-model pricing.
* New: Custom model ID field lets you route to any OpenRouter model (e.g. anthropic/claude-opus-4.7) without waiting for a plugin update.
* New: OpenRouter joins the wp-config.php key-mapping (BREZNGEO_OPENROUTER_KEY) — define any provider key as a constant to keep it out of the database entirely; the admin field becomes read-only.
= 1.2.2 =
* i18n: Added explicit load_plugin_textdomain() call for reliable translation loading on ClassicPress and other WordPress derivatives.
= 1.2.1 =
* Security: Added ABSPATH direct access guards to all PHP class files.
* i18n: Complete German translation — all 394 UI strings now translated.
* i18n: Regenerated .po/.mo/.pot translation files.
= 1.2.0 = = 1.2.0 =
* New: Keyword Analysis meta box in the post editor — checks keyword usage across title, headings, density, image alts, meta description, slug, first/last paragraph, image title/caption, and excerpt. * New: Keyword Analysis meta box in the post editor — checks keyword usage across title, headings, density, image alts, meta description, slug, first/last paragraph, image title/caption, and excerpt.
* New: Primary and secondary keyword support with configurable minimum occurrences. * New: Primary and secondary keyword support with configurable minimum occurrences.
@ -260,6 +283,12 @@ No data is transmitted during normal page loads or to visitors.
== Upgrade Notice == == Upgrade Notice ==
= 1.2.2 =
Fixes translation loading on ClassicPress and other WordPress derivatives.
= 1.2.1 =
Adds ABSPATH security guards to all files and completes German translation.
= 1.2.0 = = 1.2.0 =
Adds Keyword Analysis: real-time keyword checks in the post editor with optional AI-powered suggestions. Adds Keyword Analysis: real-time keyword checks in the post editor with optional AI-powered suggestions.