<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Post;
use WP_REST_Request;
final class AdminEditor
{
private const NONCE = 'meinweimar_editor_nonce';
/** @var list<string> */
private static array $pending_required_fields = [];
public static function hooks(): void
{
add_action('add_meta_boxes', [self::class, 'register_meta_boxes']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_assets']);
add_filter('wp_insert_post_data', [self::class, 'validate_required_fields_before_insert'], 10, 4);
foreach (['mw_classified', 'mw_job', 'mw_property'] as $post_type) {
add_filter(
'rest_pre_insert_' . $post_type,
[self::class, 'validate_required_fields_before_rest_insert'],
10,
2
);
}
add_filter('redirect_post_location', [self::class, 'redirect_validation_notice']);
add_action('admin_notices', [self::class, 'render_validation_notice']);
foreach (ContentTypes::public_post_types() as $post_type) {
add_filter('manage_' . $post_type . '_posts_columns', [self::class, 'admin_columns']);
add_action('manage_' . $post_type . '_posts_custom_column', [self::class, 'render_admin_column'], 10, 2);
}
foreach (ContentTypes::geo_post_types() as $post_type) {
add_action(
'save_post_' . $post_type,
'mw_place' === $post_type
? [self::class, 'save_place']
: ('mw_event' === $post_type ? [self::class, 'save_event'] : [self::class, 'save_geo']),
10,
2
);
}
foreach (['mw_news', 'mw_guide'] as $post_type) {
add_action('save_post_' . $post_type, [self::class, 'save_editorial'], 10, 2);
}
}
public static function register_meta_boxes(): void
{
add_meta_box(
'meinweimar_place_details',
__('MeinWeimar Place-Daten', 'meinweimar-core'),
[self::class, 'render_place_box'],
'mw_place',
'normal',
'high'
);
add_meta_box(
'meinweimar_event_details',
__('MeinWeimar Event-Daten', 'meinweimar-core'),
[self::class, 'render_event_box'],
'mw_event',
'normal',
'high'
);
foreach (['mw_service', 'mw_classified', 'mw_job', 'mw_property'] as $post_type) {
add_meta_box(
'meinweimar_' . $post_type . '_details',
__('MeinWeimar Geo- und Fachdaten', 'meinweimar-core'),
[self::class, 'render_geo_box'],
$post_type,
'normal',
'high'
);
}
foreach (['mw_news', 'mw_guide'] as $post_type) {
add_meta_box(
'meinweimar_' . $post_type . '_editorial',
__('MeinWeimar Redaktionsdaten', 'meinweimar-core'),
[self::class, 'render_editorial_box'],
$post_type,
'side',
'default'
);
}
}
public static function render_place_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
self::render_common_fields($post, 'places');
self::render_place_fields($post, 'places');
}
private static function render_place_fields(WP_Post $post, string $route): void
{
echo '<hr><h4>' . esc_html__('Place- und Einrichtungsdaten', 'meinweimar-core') . '</h4>';
self::textarea_field($post, 'mw_special_offers', __('Sonderangebote / Hinweise', 'meinweimar-core'));
self::field($post, 'mw_neighbourhood', __('Nachbarschaft / Umgebung', 'meinweimar-core'));
self::select_field($post, 'mw_featured', __('Hervorgehoben', 'meinweimar-core'), ['0' => __('Nein', 'meinweimar-core'), '1' => __('Ja', 'meinweimar-core')]);
self::field($post, 'mw_rating_average', __('Bewertung (Durchschnitt)', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '5', 'step' => '0.1']);
self::field($post, 'mw_rating_count', __('Anzahl Bewertungen', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '1']);
self::select_field($post, 'mw_media_rights_status', __('Bildrechte-Status', 'meinweimar-core'), ['unknown' => __('Ungeprüft', 'meinweimar-core'), 'legacy-confirmed' => __('Legacy-Angabe vorhanden', 'meinweimar-core'), 'verified' => __('Geprüft', 'meinweimar-core')]);
self::select_field($post, 'mw_claim_status', __('Claim-Status', 'meinweimar-core'), ['unclaimed' => __('Nicht beansprucht', 'meinweimar-core'), 'claimed' => __('Beansprucht', 'meinweimar-core'), 'verified' => __('Verifiziert', 'meinweimar-core')]);
self::field($post, 'mw_map_style', __('Legacy-Kartenstil', 'meinweimar-core'));
self::field($post, 'mw_map_zoom', __('Kartenzoom', 'meinweimar-core'), 'number', ['min' => '1', 'max' => '20']);
if (FieldSchemas::has($route, 'mw_expire_date')) {
self::field($post, 'mw_expire_date', __('Ablaufdatum', 'meinweimar-core'), 'date');
}
}
public static function render_event_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
self::render_common_fields($post, 'events');
echo '<hr><h4>' . esc_html__('Event', 'meinweimar-core') . '</h4>';
self::field($post, 'mw_event_start', __('Beginn', 'meinweimar-core'), 'datetime-local');
self::field($post, 'mw_event_end', __('Ende', 'meinweimar-core'), 'datetime-local');
self::field($post, 'mw_venue_place_id', __('Venue-Place-ID', 'meinweimar-core'), 'number', ['min' => '0']);
self::select_field(
$post,
'mw_event_status',
__('Status', 'meinweimar-core'),
[
'' => __('Nicht gesetzt', 'meinweimar-core'),
'scheduled' => __('Geplant', 'meinweimar-core'),
'cancelled' => __('Abgesagt', 'meinweimar-core'),
'postponed' => __('Verschoben', 'meinweimar-core'),
'expired' => __('Abgelaufen', 'meinweimar-core'),
]
);
self::render_event_occurrences($post);
echo '<hr><h4>' . esc_html__('Tickets und Veranstalter', 'meinweimar-core') . '</h4><div class="mw-admin-grid">';
self::select_field($post, 'mw_event_ticket_mode', __('Ticketing', 'meinweimar-core'), [
'free' => __('Kostenlos / kein Ticket', 'meinweimar-core'),
'external' => __('Externer Ticketverkauf', 'meinweimar-core'),
'internal' => __('Ticketverkauf über MeinWeimar', 'meinweimar-core'),
'registration' => __('Anmeldung erforderlich', 'meinweimar-core'),
]);
self::field($post, 'mw_event_ticket_url', __('Ticket- oder Anmeldelink', 'meinweimar-core'), 'url');
self::field($post, 'mw_event_ticket_price_from', __('Ticketpreis ab', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_event_ticket_price_to', __('Ticketpreis bis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_event_organizer_name', __('Veranstalter', 'meinweimar-core'));
self::field($post, 'mw_event_organizer_url', __('Veranstalter-Website', 'meinweimar-core'), 'url');
echo '</div>';
}
private static function render_event_occurrences(WP_Post $post): void
{
$rows = EventOccurrences::for_post($post->ID);
echo '<hr><div class="mw-event-occurrences" data-mw-event-occurrences><h4>' . esc_html__('Weitere Termine', 'meinweimar-core') . '</h4>';
echo '<p class="description">' . esc_html__('Optional: zusätzliche Termine für Veranstaltungsreihen. Der bestehende Einzeltermin bleibt als Rückwärtskompatibilität erhalten.', 'meinweimar-core') . '</p>';
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Beginn', 'meinweimar-core') . '</th><th>' . esc_html__('Ende', 'meinweimar-core') . '</th><th>' . esc_html__('Zeitzone', 'meinweimar-core') . '</th><th>' . esc_html__('Status', 'meinweimar-core') . '</th><th>' . esc_html__('Wiederholung', 'meinweimar-core') . '</th><th></th></tr></thead><tbody data-mw-occurrence-rows>';
foreach ($rows as $index => $row) {
self::render_event_occurrence_row((int) $index, $row);
}
echo '</tbody></table>';
echo '<p><button type="button" class="button" data-mw-add-occurrence>' . esc_html__('Termin hinzufügen', 'meinweimar-core') . '</button></p>';
echo '<template data-mw-occurrence-template>';
self::render_event_occurrence_row('__INDEX__', ['starts_at' => '', 'ends_at' => '', 'timezone' => wp_timezone_string() ?: 'Europe/Berlin', 'status' => 'scheduled', 'recurrence' => 'none']);
echo '</template></div>';
}
/** @param int|string $index @param array<string, string> $row */
private static function render_event_occurrence_row($index, array $row): void
{
$value = static function (string $key) use ($row): string {
$raw = (string) ($row[$key] ?? '');
if (in_array($key, ['starts_at', 'ends_at'], true) && '' !== $raw) {
try {
$date = new \DateTimeImmutable($raw);
return $date->format('Y-m-d\\TH:i');
} catch (\Exception) {
return '';
}
}
return $raw;
};
$name = static fn (string $key): string => 'mw_event_occurrences[' . $index . '][' . $key . ']';
echo '<tr data-mw-occurrence-row><td><input type="datetime-local" class="widefat" name="' . esc_attr($name('starts_at')) . '" value="' . esc_attr($value('starts_at')) . '"></td>';
echo '<td><input type="datetime-local" class="widefat" name="' . esc_attr($name('ends_at')) . '" value="' . esc_attr($value('ends_at')) . '"></td>';
echo '<td><input type="text" class="widefat" name="' . esc_attr($name('timezone')) . '" value="' . esc_attr($value('timezone')) . '" placeholder="Europe/Berlin"></td>';
echo '<td><select class="widefat" name="' . esc_attr($name('status')) . '">';
foreach (['scheduled' => __('Geplant', 'meinweimar-core'), 'cancelled' => __('Abgesagt', 'meinweimar-core'), 'postponed' => __('Verschoben', 'meinweimar-core')] as $status => $label) {
echo '<option value="' . esc_attr($status) . '"' . selected($value('status'), $status, false) . '>' . esc_html($label) . '</option>';
}
echo '</select></td><td><input type="text" class="widefat" name="' . esc_attr($name('recurrence')) . '" value="' . esc_attr($value('recurrence')) . '" placeholder="none / weekly"></td>';
echo '<td><button type="button" class="button-link-delete" data-mw-remove-occurrence>' . esc_html__('Entfernen', 'meinweimar-core') . '</button></td></tr>';
}
public static function render_geo_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
$route = ContentTypes::route_for_post_type($post->post_type);
self::render_common_fields($post, $route);
if ('services' === $route) {
self::render_place_fields($post, $route);
return;
}
self::render_listing_fields($post, $route);
}
private static function render_listing_fields(WP_Post $post, string $route): void
{
echo '<hr><div class="mw-content-stepper" data-mw-content-stepper="listing" data-mw-content-route="' . esc_attr($route) . '">';
echo '<h4>' . esc_html__('Angebots- und Fachdaten', 'meinweimar-core') . '</h4>';
echo '<p class="description">' . esc_html__('Die Felder sind nach Angebotsart gruppiert. Abhängig vom Inhaltstyp werden nur passende Fachfelder angeboten; die Werte werden gemeinsam an WordPress, REST, Website und App ausgeliefert.', 'meinweimar-core') . '</p>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="basics">';
echo '<h5>' . esc_html__('1. Angebotsgrundlagen', 'meinweimar-core') . '</h5>';
echo '<div class="mw-admin-grid">';
$offer_options = match ($route) {
'classifieds' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'offer' => __('Ich biete an', 'meinweimar-core'),
'search' => __('Ich suche', 'meinweimar-core'),
],
'jobs' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'employment' => __('Stelle ausschreiben', 'meinweimar-core'),
],
default => [],
};
if ($offer_options && FieldSchemas::has($route, 'mw_listing_offer_type')) {
self::select_field($post, 'mw_listing_offer_type', 'jobs' === $route ? __('Stellentyp', 'meinweimar-core') : __('Inseratsart', 'meinweimar-core'), $offer_options);
}
if (FieldSchemas::has($route, 'mw_listing_provider_type')) {
$provider_options = match ($route) {
'classifieds' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'private' => __('Privat', 'meinweimar-core'),
'business' => __('Unternehmen', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
],
'jobs' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'employer' => __('Arbeitgeber', 'meinweimar-core'),
'business' => __('Unternehmen', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
],
default => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'private' => __('Privat', 'meinweimar-core'),
'business' => __('Unternehmen', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
'broker' => __('Makler / Broker', 'meinweimar-core'),
],
};
self::select_field($post, 'mw_listing_provider_type', __('Anbieter', 'meinweimar-core'), $provider_options);
}
if (FieldSchemas::has($route, 'mw_listing_price_type')) {
$price_labels = [
'fixed' => __('Festpreis', 'meinweimar-core'),
'negotiable' => __('Verhandelbar', 'meinweimar-core'),
'on_request' => __('Auf Anfrage', 'meinweimar-core'),
'range' => __('Preisspanne', 'meinweimar-core'),
'free' => __('Zu verschenken', 'meinweimar-core'),
];
$price_options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach ((array) (FieldSchemas::for_route($route)['mw_listing_price_type']['constraints']['values'] ?? []) as $value) {
if (isset($price_labels[$value])) {
$price_options[$value] = $price_labels[$value];
}
}
self::select_field($post, 'mw_listing_price_type', 'jobs' === $route ? __('Vergütungsangabe', 'meinweimar-core') : __('Preisangabe', 'meinweimar-core'), $price_options);
}
if (FieldSchemas::has($route, 'mw_listing_price_amount')) {
self::field($post, 'mw_listing_price_amount', __('Betrag', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
}
if (FieldSchemas::has($route, 'mw_listing_price_currency')) {
self::select_field($post, 'mw_listing_price_currency', __('Währung', 'meinweimar-core'), ['EUR' => 'EUR', 'USD' => 'USD', 'GBP' => 'GBP']);
}
if (FieldSchemas::has($route, 'mw_listing_price_interval')) {
$interval_labels = [
'once' => __('Einmalig', 'meinweimar-core'),
'hour' => __('Pro Stunde', 'meinweimar-core'),
'month' => __('Pro Monat', 'meinweimar-core'),
'year' => __('Pro Jahr', 'meinweimar-core'),
];
$interval_options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach ((array) (FieldSchemas::for_route($route)['mw_listing_price_interval']['constraints']['values'] ?? []) as $value) {
if (isset($interval_labels[$value])) {
$interval_options[$value] = $interval_labels[$value];
}
}
self::select_field($post, 'mw_listing_price_interval', __('Preisintervall', 'meinweimar-core'), $interval_options);
}
if (FieldSchemas::has($route, 'mw_listing_available_from')) {
self::field($post, 'mw_listing_available_from', __('Verfügbar ab', 'meinweimar-core'), 'date');
}
echo '</div></div>';
if ('classifieds' === $route) {
echo '<div class="mw-content-stepper__step" data-mw-schema-step="classifieds">';
echo '<h5>' . esc_html__('2. Kleinanzeigen', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_classified_condition', __('Zustand', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'new' => __('Neu', 'meinweimar-core'),
'used' => __('Gebraucht', 'meinweimar-core'),
'defective' => __('Defekt', 'meinweimar-core'),
]);
self::select_field($post, 'mw_classified_delivery_mode', __('Übergabe / Versand', 'meinweimar-core'), [
'pickup' => __('Abholung', 'meinweimar-core'),
'shipping' => __('Versand', 'meinweimar-core'),
'both' => __('Abholung oder Versand', 'meinweimar-core'),
'digital' => __('Digital', 'meinweimar-core'),
'none' => __('Keine Angabe', 'meinweimar-core'),
]);
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="classified-attributes">';
echo '<h5>' . esc_html__('3. Kategorieattribute', 'meinweimar-core') . '</h5><p class="description">' . esc_html__('Diese Angaben werden später je Kategorie weiter verfeinert. Unpassende Attribute bleiben leer und werden nicht ausgegeben.', 'meinweimar-core') . '</p><div class="mw-admin-grid">';
self::field($post, 'mw_classified_brand', __('Marke', 'meinweimar-core'));
self::field($post, 'mw_classified_model', __('Modell / Typ', 'meinweimar-core'));
self::field($post, 'mw_classified_color', __('Farbe', 'meinweimar-core'));
self::field($post, 'mw_classified_size', __('Größe / Maße', 'meinweimar-core'));
self::field($post, 'mw_classified_material', __('Material', 'meinweimar-core'));
self::field($post, 'mw_classified_item_age', __('Alter / Kaufjahr', 'meinweimar-core'));
self::field($post, 'mw_classified_original_price', __('Ursprünglicher Preis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::select_field($post, 'mw_classified_payment_mode', __('Bevorzugte Zahlung', 'meinweimar-core'), [
'' => __('Nicht angegeben', 'meinweimar-core'),
'cash' => __('Barzahlung', 'meinweimar-core'),
'bank_transfer' => __('Überweisung', 'meinweimar-core'),
'secure_payment' => __('Sichere Online-Zahlung', 'meinweimar-core'),
'other' => __('Andere', 'meinweimar-core'),
]);
echo '</div></div>';
}
if ('jobs' === $route) {
echo '<div class="mw-content-stepper__step" data-mw-schema-step="jobs">';
echo '<h5>' . esc_html__('2. Stellenprofil', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_job_employment_type', __('Beschäftigungsart', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'full_time' => __('Vollzeit', 'meinweimar-core'),
'part_time' => __('Teilzeit', 'meinweimar-core'),
'temporary' => __('Befristet', 'meinweimar-core'),
'freelance' => __('Freiberuflich', 'meinweimar-core'),
'apprenticeship' => __('Ausbildung', 'meinweimar-core'),
'internship' => __('Praktikum', 'meinweimar-core'),
]);
self::select_field($post, 'mw_job_work_mode', __('Arbeitsmodell', 'meinweimar-core'), [
'onsite' => __('Vor Ort', 'meinweimar-core'),
'hybrid' => __('Hybrid', 'meinweimar-core'),
'remote' => __('Fernarbeit', 'meinweimar-core'),
]);
self::field($post, 'mw_job_salary_min', __('Vergütung von', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_job_salary_max', __('Vergütung bis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_job_application_url', __('Bewerbungs-URL', 'meinweimar-core'), 'url');
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="job-details">';
echo '<h5>' . esc_html__('3. Stellen- und Bewerbungsdetails', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::field($post, 'mw_job_reference_code', __('Referenznummer', 'meinweimar-core'));
self::field($post, 'mw_job_department', __('Abteilung / Team', 'meinweimar-core'));
self::field($post, 'mw_job_work_hours', __('Arbeitszeit / Schichtmodell', 'meinweimar-core'));
self::field($post, 'mw_job_start_date', __('Arbeitsbeginn', 'meinweimar-core'), 'date');
self::field($post, 'mw_job_application_deadline', __('Bewerbungsfrist', 'meinweimar-core'), 'date');
self::field($post, 'mw_job_education', __('Ausbildung / Abschluss', 'meinweimar-core'));
self::field($post, 'mw_job_experience_years', __('Erfahrung in Jahren', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.5']);
self::select_field($post, 'mw_job_application_method', __('Bewerbungsweg', 'meinweimar-core'), [
'' => __('Nicht angegeben', 'meinweimar-core'),
'url' => __('Bewerbungsseite', 'meinweimar-core'),
'email' => __('E-Mail', 'meinweimar-core'),
'form' => __('Formular', 'meinweimar-core'),
'phone' => __('Telefon', 'meinweimar-core'),
]);
self::field($post, 'mw_job_application_email', __('Bewerbungs-E-Mail', 'meinweimar-core'), 'email');
echo '</div>';
self::textarea_field($post, 'mw_job_responsibilities', __('Aufgaben / Verantwortlichkeiten', 'meinweimar-core'));
self::textarea_field($post, 'mw_job_requirements', __('Anforderungen / Qualifikationen', 'meinweimar-core'));
self::textarea_field($post, 'mw_job_benefits', __('Vorteile / Arbeitgeberleistungen', 'meinweimar-core'));
self::textarea_field($post, 'mw_job_languages', __('Sprachen', 'meinweimar-core'));
echo '</div>';
}
if ('real-estate' === $route) {
echo '<div class="mw-content-stepper__step" data-mw-schema-step="property">';
echo '<h5>' . esc_html__('2. Immobiliendaten', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_property_transaction_type', __('Vermarktungsart', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'sale' => __('Kaufen', 'meinweimar-core'),
'rent' => __('Miete', 'meinweimar-core'),
]);
$property_kind_options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (FieldSchemas::property_kind_options(self::category_slugs_for_route($post->ID, $route)) as $value => $label) {
$property_kind_options[$value] = __($label, 'meinweimar-core');
}
self::select_field($post, 'mw_property_kind', __('Immobilienart', 'meinweimar-core'), $property_kind_options);
self::field($post, 'mw_property_area', __('Wohn-/Nutzfläche in m²', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.1']);
self::select_field($post, 'mw_property_rooms', __('Zimmer', 'meinweimar-core'), self::property_room_options());
self::field($post, 'mw_property_rooms_custom', __('Eigene Zimmeranzahl', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '100', 'step' => '0.1']);
self::select_field($post, 'mw_property_bathrooms', __('Bäder', 'meinweimar-core'), self::property_count_options());
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="property-details">';
echo '<h5>' . esc_html__('3. Objekt-, Preis- und Energiedaten', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_property_bedrooms', __('Schlafzimmer', 'meinweimar-core'), self::property_count_options());
self::select_field($post, 'mw_property_floor', __('Etage', 'meinweimar-core'), self::property_floor_options());
self::field($post, 'mw_property_total_floors', __('Etagen insgesamt', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '200', 'step' => '1']);
self::field($post, 'mw_property_year_built', __('Baujahr', 'meinweimar-core'), 'number', ['min' => '1000', 'max' => '2200', 'step' => '1']);
self::select_field($post, 'mw_property_condition', __('Zustand', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'new' => __('Neubau / neu', 'meinweimar-core'),
'first_occupancy' => __('Erstbezug', 'meinweimar-core'),
'renovated' => __('Renoviert / saniert', 'meinweimar-core'),
'well_kept' => __('Gepflegt', 'meinweimar-core'),
'needs_renovation' => __('Renovierungsbedürftig', 'meinweimar-core'),
]);
self::field($post, 'mw_property_last_renovated', __('Letzte Modernisierung', 'meinweimar-core'), 'number', ['min' => '1000', 'max' => '2200', 'step' => '1']);
self::field($post, 'mw_property_cold_rent', __('Kaltmiete', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_additional_costs', __('Nebenkosten', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::checkbox_field($post, 'mw_property_heating_costs_included', __('Heizkosten in Nebenkosten enthalten', 'meinweimar-core'));
self::field($post, 'mw_property_warm_rent', __('Warmmiete', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_deposit', __('Kaution', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_house_fee', __('Hausgeld', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
echo '<div class="mw-schema-dependent-group" data-mw-commission-group><h6>' . esc_html__('Provision', 'meinweimar-core') . '</h6><p class="description">' . esc_html__('Wähle verbindlich provisionsfrei oder provisionspflichtig. Bei einer Provision werden nur die zur Vermarktungsart passenden strukturierten Angaben eingeblendet.', 'meinweimar-core') . '</p>';
self::select_field($post, 'mw_property_commission_applicable', __('Provision fällig?', 'meinweimar-core'), [
'no' => __('Nein — provisionsfrei', 'meinweimar-core'),
'yes' => __('Ja — provisionspflichtig', 'meinweimar-core'),
]);
self::select_field($post, 'mw_property_commission_calculation', __('Berechnungsart', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'percentage' => __('Prozentsatz', 'meinweimar-core'),
'fixed' => __('Fester Betrag', 'meinweimar-core'),
'monthly_rent' => __('Monatliche Kaltmieten', 'meinweimar-core'),
]);
self::field($post, 'mw_property_commission_total', __('Gesamtprovision', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_commission_months', __('Anzahl Kaltmieten', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '24', 'step' => '0.01']);
self::field($post, 'mw_property_commission_buyer_share', __('Anteil Käufer', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_commission_seller_share', __('Anteil Verkäufer', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::select_field($post, 'mw_property_commission_vat', __('Umsatzsteuer', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'included' => __('Enthalten', 'meinweimar-core'),
'excluded' => __('Nicht enthalten', 'meinweimar-core'),
]);
self::select_field($post, 'mw_property_commission_payer', __('Zahlungspflichtig', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'buyer' => __('Käufer', 'meinweimar-core'),
'seller' => __('Verkäufer', 'meinweimar-core'),
'both' => __('Beide Parteien', 'meinweimar-core'),
'tenant' => __('Mieter', 'meinweimar-core'),
'landlord' => __('Vermieter', 'meinweimar-core'),
]);
echo '</div>';
self::select_field($post, 'mw_property_energy_certificate_type', __('Energieausweis', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'demand' => __('Bedarfsausweis', 'meinweimar-core'),
'consumption' => __('Verbrauchsausweis', 'meinweimar-core'),
'none' => __('Nicht vorhanden / nicht erforderlich', 'meinweimar-core'),
]);
self::field($post, 'mw_property_energy_value', __('Endenergiekennwert', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.1']);
self::select_field($post, 'mw_property_energy_class', __('Energieeffizienzklasse', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'A_PLUS' => 'A+', 'A' => 'A', 'B' => 'B', 'C' => 'C', 'D' => 'D', 'E' => 'E', 'F' => 'F', 'G' => 'G', 'H' => 'H',
]);
self::select_field($post, 'mw_property_heating_type', __('Heizungsart', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'district' => __('Fernwärme', 'meinweimar-core'), 'gas' => __('Gas', 'meinweimar-core'), 'oil' => __('Öl', 'meinweimar-core'),
'heat_pump' => __('Wärmepumpe', 'meinweimar-core'), 'electric' => __('Elektrisch', 'meinweimar-core'), 'wood' => __('Holz / Pellets', 'meinweimar-core'),
'solar' => __('Solar', 'meinweimar-core'), 'other' => __('Andere', 'meinweimar-core'),
]);
self::select_field($post, 'mw_property_energy_source', __('Energieträger', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'), 'district' => __('Fernwärme', 'meinweimar-core'), 'gas' => __('Gas', 'meinweimar-core'),
'oil' => __('Öl', 'meinweimar-core'), 'electric' => __('Strom', 'meinweimar-core'), 'wood' => __('Holz / Pellets', 'meinweimar-core'),
'solar' => __('Solar', 'meinweimar-core'), 'other' => __('Andere', 'meinweimar-core'),
]);
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="property-amenities">';
echo '<h5>' . esc_html__('4. Ausstattung und Goodies', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
foreach ([
'mw_property_amenity_kitchen' => __('Einbauküche', 'meinweimar-core'),
'mw_property_amenity_balcony' => __('Balkon / Loggia', 'meinweimar-core'),
'mw_property_amenity_terrace' => __('Terrasse', 'meinweimar-core'),
'mw_property_amenity_garden' => __('Garten', 'meinweimar-core'),
'mw_property_amenity_basement' => __('Keller', 'meinweimar-core'),
'mw_property_amenity_attic' => __('Dachboden', 'meinweimar-core'),
'mw_property_amenity_elevator' => __('Aufzug', 'meinweimar-core'),
'mw_property_amenity_guest_toilet' => __('Gäste-WC', 'meinweimar-core'),
'mw_property_amenity_air_conditioning' => __('Klimaanlage', 'meinweimar-core'),
'mw_property_amenity_floor_heating' => __('Fußbodenheizung', 'meinweimar-core'),
'mw_property_amenity_fireplace' => __('Kamin / Ofen', 'meinweimar-core'),
'mw_property_amenity_furnished' => __('Möbliert', 'meinweimar-core'),
'mw_property_amenity_barrier_free' => __('Barrierearm / rollstuhlgerecht', 'meinweimar-core'),
'mw_property_amenity_pets_allowed' => __('Haustiere erlaubt', 'meinweimar-core'),
'mw_property_amenity_garage' => __('Garage / Tiefgarage', 'meinweimar-core'),
'mw_property_amenity_carport' => __('Carport', 'meinweimar-core'),
'mw_property_amenity_e_charging' => __('E-Ladepunkt', 'meinweimar-core'),
] as $key => $label) {
self::checkbox_field($post, $key, $label);
}
self::field($post, 'mw_property_parking_spaces', __('Stellplätze', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '1']);
self::field($post, 'mw_property_parking_price', __('Stellplatzpreis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
echo '</div></div>';
}
echo '<div class="mw-content-stepper__step" data-mw-schema-step="legacy">';
echo '<h5>' . esc_html__('Kompatibilität und Veröffentlichung', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
if (FieldSchemas::has($route, 'mw_listing_status')) {
self::field($post, 'mw_listing_status', __('Angebotsstatus', 'meinweimar-core'), 'text', ['placeholder' => 'active']);
}
if (FieldSchemas::has($route, 'mw_contact_preference')) {
self::field($post, 'mw_contact_preference', __('Kontaktpräferenz', 'meinweimar-core'), 'text', ['placeholder' => 'messaging']);
}
if (FieldSchemas::has($route, 'mw_price_label')) {
self::field($post, 'mw_price_label', __('Preis-/Vergütungstext', 'meinweimar-core'));
}
if (FieldSchemas::has($route, 'mw_company_name')) {
self::field($post, 'mw_company_name', __('Organisation oder Unternehmen', 'meinweimar-core'));
}
if (FieldSchemas::has($route, 'mw_job_featured')) {
self::select_field($post, 'mw_job_featured', __('Top-Job', 'meinweimar-core'), [
'0' => __('Nein', 'meinweimar-core'),
'1' => __('Ja', 'meinweimar-core'),
]);
}
if (FieldSchemas::has($route, 'mw_expire_date')) {
self::field($post, 'mw_expire_date', __('Ablaufdatum', 'meinweimar-core'), 'date');
}
echo '</div></div></div>';
}
public static function render_editorial_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
echo '<p class="description">' . esc_html__('Locale und redaktionelle Inhalte werden von Website und App gemeinsam verwendet.', 'meinweimar-core') . '</p>';
self::locale_field($post);
self::render_gallery_field($post);
}
private static function render_common_fields(WP_Post $post, string $route): void
{
echo '<p class="description">' . esc_html__('Diese Felder sind Teil des gemeinsamen WordPress-/REST-Datenvertrags. Sichtbare Oberflächen werden später über Templates, Elementor oder Flutter umgesetzt.', 'meinweimar-core') . '</p>';
echo '<div class="mw-admin-grid">';
self::field($post, 'mw_address_line1', __('Adresse', 'meinweimar-core'));
self::field($post, 'mw_address_line2', __('Adresszusatz', 'meinweimar-core'));
self::field($post, 'mw_postal_code', __('PLZ', 'meinweimar-core'));
self::field($post, 'mw_locality', __('Ort', 'meinweimar-core'));
self::field($post, 'mw_region', __('Region', 'meinweimar-core'));
self::field($post, 'mw_country', __('Land', 'meinweimar-core'), 'text', ['placeholder' => 'DE']);
self::field($post, 'mw_phone', __('Telefon', 'meinweimar-core'), 'tel');
self::field($post, 'mw_email', __('E-Mail', 'meinweimar-core'), 'email');
self::field($post, 'mw_website_url', __('Website', 'meinweimar-core'), 'url');
self::field($post, 'mw_facebook_url', __('Facebook-URL', 'meinweimar-core'), 'url');
self::field($post, 'mw_twitter_url', __('Twitter/X-URL', 'meinweimar-core'), 'url');
self::field($post, 'mw_video_url', __('Video-URL', 'meinweimar-core'), 'url');
self::field($post, 'mw_geo_latitude', __('Breitengrad', 'meinweimar-core'), 'number', ['step' => 'any', 'min' => '-90', 'max' => '90']);
self::field($post, 'mw_geo_longitude', __('Längengrad', 'meinweimar-core'), 'number', ['step' => 'any', 'min' => '-180', 'max' => '180']);
self::field($post, 'mw_geo_precision', __('Geo-Genauigkeit', 'meinweimar-core'), 'text', ['placeholder' => 'exact']);
self::select_field(
$post,
'mw_geo_source',
__('Geo-Quelle', 'meinweimar-core'),
[
'unknown' => __('Unbekannt', 'meinweimar-core'),
'manual' => __('Manuell geprüft', 'meinweimar-core'),
'geocoder' => __('Geocoding-Dienst', 'meinweimar-core'),
'import' => __('Kontrollierter Import', 'meinweimar-core'),
'legacy' => __('Legacy-Referenz', 'meinweimar-core'),
]
);
self::locale_field($post);
echo '</div>';
self::select_field(
$post,
'mw_geo_privacy',
__('Geo-Privacy', 'meinweimar-core'),
[
'public' => __('Öffentlich — genaue Position', 'meinweimar-core'),
'approximate' => __('Ungefähr — Position wird gerundet', 'meinweimar-core'),
'private' => __('Privat — Position nicht öffentlich ausgeben', 'meinweimar-core'),
]
);
if (FieldSchemas::has($route, 'mw_opening_hours')) {
self::textarea_field($post, 'mw_opening_hours', __('Öffnungszeiten', 'meinweimar-core'));
}
self::render_gallery_field($post);
if (ImageSuggestions::supports_target_type($route)) {
self::media_placeholder_field($post);
}
}
public static function enqueue_assets(): void
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || !in_array((string) $screen->post_type, array_values(ContentTypes::public_post_types()), true)) {
return;
}
wp_enqueue_media();
wp_enqueue_script('meinweimar-admin-editor', URL . 'assets/js/admin-editor.js', ['jquery'], VERSION, true);
$term_slugs = [];
foreach (FieldSchemas::category_admin_contract() as $rule) {
$taxonomy = (string) ($rule['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
continue;
}
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false]);
if (is_wp_error($terms)) {
continue;
}
$term_slugs[$taxonomy] = [];
foreach ($terms as $term) {
$term_slugs[$taxonomy][(string) $term->term_id] = (string) $term->slug;
}
}
wp_localize_script('meinweimar-admin-editor', 'MeinWeimarAdminEditor', [
'categoryRules' => FieldSchemas::category_admin_contract(),
'termSlugs' => $term_slugs,
]);
}
public static function admin_columns(array $columns): array
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
$post_type = $screen ? (string) $screen->post_type : '';
$is_geo = in_array($post_type, ContentTypes::geo_post_types(), true);
$is_event = 'mw_event' === $post_type;
$result = [];
foreach ($columns as $key => $label) {
$result[$key] = $label;
if ('title' === $key) {
$result['mw_content_locale'] = __('Locale', 'meinweimar-core');
if ($is_geo) {
$result['mw_geo_status'] = __('Geo', 'meinweimar-core');
$result['mw_listing_status'] = __('Status', 'meinweimar-core');
}
if ($is_event) {
$result['mw_event_date'] = __('Eventdatum', 'meinweimar-core');
}
}
}
return $result;
}
public static function render_admin_column(string $column, int $post_id): void
{
switch ($column) {
case 'mw_content_locale':
echo esc_html((string) (get_post_meta($post_id, 'mw_content_locale', true) ?: '—'));
break;
case 'mw_geo_status':
$post_type = (string) get_post_type($post_id);
if (!in_array($post_type, ContentTypes::geo_post_types(), true)) {
echo esc_html__('—', 'meinweimar-core');
break;
}
$privacy = (string) (get_post_meta($post_id, 'mw_geo_privacy', true) ?: 'public');
$latitude = get_post_meta($post_id, 'mw_geo_latitude', true);
$longitude = get_post_meta($post_id, 'mw_geo_longitude', true);
$available = '' !== (string) $latitude && '' !== (string) $longitude;
printf(
'%s · %s',
esc_html($available ? __('vorhanden', 'meinweimar-core') : __('fehlt', 'meinweimar-core')),
esc_html($privacy)
);
break;
case 'mw_listing_status':
$post_type = (string) get_post_type($post_id);
if (!in_array($post_type, ContentTypes::geo_post_types(), true)) {
echo esc_html__('—', 'meinweimar-core');
break;
}
echo esc_html((string) (get_post_meta($post_id, 'mw_listing_status', true) ?: '—'));
break;
case 'mw_event_date':
if ('mw_event' !== get_post_type($post_id)) {
echo esc_html__('—', 'meinweimar-core');
break;
}
echo esc_html((string) (get_post_meta($post_id, 'mw_event_start', true) ?: '—'));
break;
}
}
private static function field(WP_Post $post, string $key, string $label, string $type = 'text', array $attributes = []): void
{
$value = get_post_meta($post->ID, $key, true);
[$visible, $required] = self::field_render_state($post, $key);
if ($required && !array_key_exists('required', $attributes)) {
$attributes['required'] = 'required';
}
$attribute_string = '';
foreach ($attributes as $attribute => $attribute_value) {
$attribute_string .= ' ' . esc_attr($attribute) . '="' . esc_attr((string) $attribute_value) . '"';
}
echo '<p' . ($visible ? '' : ' hidden') . '><label for="' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br>';
echo '<input class="widefat" data-mw-field-key="' . esc_attr($key) . '" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '" type="' . esc_attr($type) . '" value="' . esc_attr((string) $value) . '"' . $attribute_string . '></p>';
}
private static function textarea_field(WP_Post $post, string $key, string $label): void
{
$value = get_post_meta($post->ID, $key, true);
[$visible, $required] = self::field_render_state($post, $key);
echo '<p' . ($visible ? '' : ' hidden') . '><label for="' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br>';
echo '<textarea class="widefat" data-mw-field-key="' . esc_attr($key) . '" rows="3" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '"' . ($required ? ' required="required"' : '') . '>' . esc_textarea((string) $value) . '</textarea></p>';
}
private static function select_field(WP_Post $post, string $key, string $label, array $options): void
{
$current = (string) get_post_meta($post->ID, $key, true);
if ('mw_property_transaction_type' === $key) {
// Render legacy lease/temporary storage with the current public
// rent choice without rewriting metadata during a read.
$current = (string) FieldSchemas::sanitize_value($key, $current, 'real-estate');
}
[$visible, $required] = self::field_render_state($post, $key);
echo '<p' . ($visible ? '' : ' hidden') . '><label for="' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br><select class="widefat" data-mw-field-key="' . esc_attr($key) . '" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '"' . ($required ? ' required="required"' : '') . '>';
foreach ($options as $value => $text) {
echo '<option value="' . esc_attr($value) . '"' . selected($current, $value, false) . '>' . esc_html($text) . '</option>';
}
echo '</select></p>';
}
/** @return array<string,string> */
private static function property_room_options(): array
{
$options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (['1', '1.5', '2', '2.5', '3', '3.5', '4', '4.5', '5', '5.5', '6'] as $value) {
$options[$value] = $value;
}
$options['custom'] = __('Eigene Anzahl', 'meinweimar-core');
return $options;
}
/** @return array<string,string> */
private static function property_count_options(): array
{
$options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (['0', '1', '2', '3', '4', '5', '6'] as $value) {
$options[$value] = $value;
}
$options['more_than_6'] = __('Mehr als 6', 'meinweimar-core');
return $options;
}
/** @return array<string,string> */
private static function property_floor_options(): array
{
$options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (range(1, 15) as $value) {
$options[(string) $value] = (string) $value;
}
$options['other'] = __('Andere', 'meinweimar-core');
return $options;
}
private static function checkbox_field(WP_Post $post, string $key, string $label): void
{
[$visible, $required] = self::field_render_state($post, $key);
$value = FieldSchemas::sanitize_value($key, get_post_meta($post->ID, $key, true), 'real-estate');
echo '<p' . ($visible ? '' : ' hidden') . '><label><input type="hidden" name="' . esc_attr($key) . '" value="0">';
echo '<input type="checkbox" data-mw-field-key="' . esc_attr($key) . '" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '" value="1"' . checked('1', $value, false) . ($required ? ' required="required"' : '') . '> ' . esc_html($label) . '</label></p>';
}
/**
* Resolve the initial editor state from the canonical schema. JavaScript
* keeps this state interactive, but the first render must already hide
* category-inapplicable fields and mark category-required fields. This
* also keeps the editor usable when admin JavaScript is unavailable.
*
* @return array{0: bool, 1: bool}
*/
private static function field_render_state(WP_Post $post, string $key): array
{
$route = ContentTypes::route_for_post_type($post->post_type);
if (!FieldSchemas::has($route, $key)) {
return [true, false];
}
$category_slugs = self::category_slugs_for_route($post->ID, $route);
$values = FieldSchemas::values_for_post($post, $route);
$visible = FieldSchemas::field_applies($route, $key, $category_slugs, $values);
$required = $visible && in_array(
$key,
FieldSchemas::required_fields_for_categories($route, $category_slugs, $values),
true
);
return [$visible, $required];
}
private static function tri_state_options(): array
{
return [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'yes' => __('Ja', 'meinweimar-core'),
'no' => __('Nein', 'meinweimar-core'),
];
}
private static function locale_field(WP_Post $post): void
{
$current = Settings::sanitize_locale(get_post_meta($post->ID, 'mw_content_locale', true));
$locales = (array) (Settings::public_config()['localization']['available_locales'] ?? []);
echo '<p><label for="mw_content_locale"><strong>' . esc_html__('Inhalts-Locale', 'meinweimar-core') . '</strong></label><br>';
echo '<select class="widefat" id="mw_content_locale" name="mw_content_locale">';
echo '<option value="">' . esc_html__('Unmarkiert — zentrale Fallback-Auswahl', 'meinweimar-core') . '</option>';
foreach ($locales as $locale) {
echo '<option value="' . esc_attr((string) $locale) . '"' . selected($current, $locale, false) . '>' . esc_html((string) $locale) . '</option>';
}
echo '</select></p>';
}
private static function render_gallery_field(WP_Post $post): void
{
$ids = ContentTypes::sanitize_gallery_ids(get_post_meta($post->ID, 'mw_gallery_ids', true));
echo '<p><strong>' . esc_html__('Galerie / weitere Bilder', 'meinweimar-core') . '</strong><br>';
echo '<input type="hidden" id="mw-gallery-ids" name="mw_gallery_ids" value="' . esc_attr(implode(',', $ids)) . '">';
echo '<button type="button" class="button" id="mw-select-gallery">' . esc_html__('Bilder aus Mediathek wählen', 'meinweimar-core') . '</button> ';
echo '<span id="mw-gallery-preview">' . esc_html($ids ? sprintf(__('%d Bilder ausgewählt', 'meinweimar-core'), count($ids)) : __('Noch keine Bilder ausgewählt', 'meinweimar-core')) . '</span></p>';
echo '<p class="description">' . esc_html__('Die Reihenfolge wird an Website, Elementor und App gemeinsam ausgeliefert. Alternativ bleibt das Beitragsbild die Preview.', 'meinweimar-core') . '</p>';
}
private static function media_placeholder_field(WP_Post $post): void
{
self::select_field(
$post,
'mw_media_placeholder',
__('Primärbild ist Platzhalter', 'meinweimar-core'),
[
'0' => __('Nein — Bild ist redaktionell verwendbar', 'meinweimar-core'),
'1' => __('Ja — Bild bleibt sichtbar, Vorschlag erlauben', 'meinweimar-core'),
]
);
}
public static function save_place(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
self::save_common_fields($post_id, 'places');
self::save_place_fields($post_id, 'places');
Geocoding::sync_post($post_id);
}
public static function save_event(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
self::save_common_fields($post_id, 'events');
foreach (FieldSchemas::editor_for_route('events', 'event') as $key => $field) {
if (!array_key_exists($key, $_POST)) {
continue;
}
self::save_field($post_id, $key, FieldSchemas::sanitize_value($key, wp_unslash($_POST[$key] ?? ''), 'events'));
}
$occurrences = EventOccurrences::sanitize_rows(wp_unslash($_POST[EventOccurrences::META_KEY] ?? []));
if ($occurrences) {
update_post_meta($post_id, EventOccurrences::META_KEY, $occurrences);
} else {
delete_post_meta($post_id, EventOccurrences::META_KEY);
}
Geocoding::sync_post($post_id);
}
public static function save_geo(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
$route = ContentTypes::route_for_post_type($post->post_type);
self::save_common_fields($post_id, $route);
if ('services' === $route) {
self::save_place_fields($post_id, $route);
} else {
self::save_listing_fields($post_id, $route);
}
Geocoding::sync_post($post_id);
}
public static function save_editorial(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
self::save_field($post_id, 'mw_content_locale', Settings::sanitize_locale(wp_unslash($_POST['mw_content_locale'] ?? '')));
self::save_gallery($post_id);
}
private static function save_common_fields(int $post_id, string $route): void
{
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['content', 'address', 'contact', 'geo', 'hours'], true)) {
continue;
}
if (in_array($key, ['mw_gallery_ids', 'mw_media_placeholder'], true)) {
continue;
}
if (!array_key_exists($key, $_POST)) {
continue;
}
$raw = wp_unslash($_POST[$key] ?? '');
self::save_field($post_id, $key, FieldSchemas::sanitize_value($key, $raw, $route));
}
if (ImageSuggestions::supports_target_type($route)) {
self::save_field($post_id, 'mw_media_placeholder', self::allowed_value($_POST['mw_media_placeholder'] ?? '0', ['0', '1']));
}
self::save_gallery($post_id);
}
private static function save_place_fields(int $post_id, string $route): void
{
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['place_details', 'lifecycle'], true)) {
continue;
}
if (!array_key_exists($key, $_POST)) {
continue;
}
self::save_field($post_id, $key, FieldSchemas::sanitize_value($key, wp_unslash($_POST[$key] ?? ''), $route));
}
}
/**
* Resolve the assigned category slugs for a route before persisting
* category-dependent fields. Empty assignments remain permissive so that
* legacy/imported posts are not silently stripped before categorisation.
*
* @return list<string>
*/
private static function category_slugs_for_route(int $post_id, string $route): array
{
$contract = FieldSchemas::category_admin_contract()[$route] ?? [];
$taxonomy = (string) ($contract['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
return [];
}
$terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'slugs']);
if (is_wp_error($terms)) {
return [];
}
return array_values(array_filter(array_map('sanitize_title', (array) $terms)));
}
/**
* Resolve category slugs from the pending classic-editor request. The
* taxonomy metabox is saved after wp_insert_post_data runs, so validation
* must inspect the submitted term IDs/slugs first. An explicitly empty
* taxonomy submission means that categories are being cleared and must not
* silently fall back to the post's previous terms.
*
* @return list<string>
*/
private static function category_slugs_for_insert(int $post_id, string $route): array
{
$contract = FieldSchemas::category_admin_contract()[$route] ?? [];
$taxonomy = (string) ($contract['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
return [];
}
$tax_input = isset($_POST['tax_input']) && is_array($_POST['tax_input'])
? $_POST['tax_input']
: [];
if (!array_key_exists($taxonomy, $tax_input)) {
return $post_id > 0 ? self::category_slugs_for_route($post_id, $route) : [];
}
return self::normalize_category_slugs($taxonomy, $tax_input[$taxonomy]);
}
/**
* Resolve submitted term IDs or slugs through the same taxonomy contract
* for classic-editor and REST saves.
*
* @return list<string>
*/
private static function normalize_category_slugs(string $taxonomy, $submitted): array
{
$submitted = is_array($submitted) ? $submitted : [$submitted];
$slugs = [];
foreach ($submitted as $value) {
$value = is_scalar($value) ? (string) wp_unslash($value) : '';
if ('' === trim($value)) {
continue;
}
$term = is_numeric($value)
? get_term(absint($value), $taxonomy)
: get_term_by('slug', sanitize_title($value), $taxonomy);
if ($term && !is_wp_error($term)) {
$slugs[] = sanitize_title((string) $term->slug);
}
}
return array_values(array_unique(array_filter($slugs)));
}
/**
* Resolve category terms from a Core REST request before WordPress writes
* the taxonomy. An omitted taxonomy falls back to existing terms on an
* update; an explicit empty list means categories are being cleared.
*
* @return list<string>
*/
private static function category_slugs_for_rest_insert(int $post_id, string $route, WP_REST_Request $request): array
{
$contract = FieldSchemas::category_admin_contract()[$route] ?? [];
$taxonomy = (string) ($contract['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
return [];
}
if (!$request->has_param($taxonomy)) {
return $post_id > 0 ? self::category_slugs_for_route($post_id, $route) : [];
}
return self::normalize_category_slugs($taxonomy, $request->get_param($taxonomy));
}
/**
* Build the same sanitized listing values used by save_listing_fields()
* before WordPress decides whether the post may become public.
*
* @return array<string, mixed>
*/
private static function listing_values_for_insert(int $post_id, string $route): array
{
$values = [];
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
$raw = array_key_exists($key, $_POST)
? wp_unslash($_POST[$key] ?? '')
: ($post_id > 0 ? get_post_meta($post_id, $key, true) : '');
$values[$key] = FieldSchemas::sanitize_value($key, $raw, $route);
}
return $values;
}
/**
* Build sanitized listing values from Core REST meta parameters before
* the REST controller persists them. Existing metadata remains valid for
* partial updates, matching the classic-editor save contract.
*
* @return array<string, mixed>
*/
private static function listing_values_for_rest_insert(int $post_id, string $route, WP_REST_Request $request): array
{
$meta = $request->get_param('meta');
$meta = is_array($meta) ? $meta : [];
$values = [];
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
if (array_key_exists($key, $meta)) {
$raw = $meta[$key];
} elseif ($request->has_param($key)) {
$raw = $request->get_param($key);
} else {
$raw = $post_id > 0 ? get_post_meta($post_id, $key, true) : '';
}
$values[$key] = FieldSchemas::sanitize_value($key, $raw, $route);
}
return $values;
}
/**
* Prevent an authenticated classic-editor publish from bypassing
* category-dependent required fields. Draft saves remain possible so the
* editor can complete the object; the existing redirect notice explains
* which fields are missing. Core REST publish transitions use the
* adjacent REST pre-insert gate below.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $postarr
* @param array<string, mixed> $unsanitized_postarr
* @return array<string, mixed>
*/
public static function validate_required_fields_before_insert(array $data, array $postarr, array $unsanitized_postarr, bool $update): array
{
unset($unsanitized_postarr, $update);
if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || 'publish' !== (string) ($data['post_status'] ?? '')) {
return $data;
}
$nonce = isset($_POST[self::NONCE]) ? sanitize_text_field(wp_unslash($_POST[self::NONCE])) : '';
if ('' === $nonce || !wp_verify_nonce($nonce, self::NONCE)) {
return $data;
}
$post_type = (string) ($data['post_type'] ?? $postarr['post_type'] ?? '');
$route = ContentTypes::route_for_post_type($post_type);
if (!in_array($route, ['classifieds', 'jobs', 'real-estate'], true)) {
return $data;
}
$post_id = absint($postarr['ID'] ?? 0);
if ($post_id > 0) {
if (!current_user_can('edit_post', $post_id)) {
return $data;
}
} elseif (!current_user_can('edit_posts')) {
return $data;
}
$category_slugs = self::category_slugs_for_insert($post_id, $route);
if (!$category_slugs) {
return $data;
}
$missing = FieldSchemas::required_fields_for_categories(
$route,
$category_slugs,
self::listing_values_for_insert($post_id, $route)
);
if (!$missing) {
return $data;
}
self::$pending_required_fields = array_values(array_unique(array_merge(self::$pending_required_fields, $missing)));
$data['post_status'] = 'draft';
return $data;
}
/**
* Keep Core REST publishing aligned with the classic editor. The REST
* controller handles authentication and persistence; this filter only
* downgrades an otherwise-public listing to draft when its selected
* category requires missing schema fields.
*
* @param object|WP_Error $prepared_post
* @return object|WP_Error
*/
public static function validate_required_fields_before_rest_insert($prepared_post, WP_REST_Request $request)
{
if (is_wp_error($prepared_post) || !is_object($prepared_post) || 'publish' !== (string) ($prepared_post->post_status ?? '')) {
return $prepared_post;
}
$post_type = (string) ($prepared_post->post_type ?? '');
$route = ContentTypes::route_for_post_type($post_type);
if (!in_array($route, ['classifieds', 'jobs', 'real-estate'], true)) {
return $prepared_post;
}
$post_id = absint($prepared_post->ID ?? 0);
if ($post_id > 0 ? !current_user_can('edit_post', $post_id) : !current_user_can('edit_posts')) {
return $prepared_post;
}
$category_slugs = self::category_slugs_for_rest_insert($post_id, $route, $request);
if (!$category_slugs) {
return $prepared_post;
}
$missing = FieldSchemas::required_fields_for_categories(
$route,
$category_slugs,
self::listing_values_for_rest_insert($post_id, $route, $request)
);
if ($missing) {
$prepared_post->post_status = 'draft';
}
return $prepared_post;
}
private static function save_listing_fields(int $post_id, string $route): void
{
$category_slugs = self::category_slugs_for_route($post_id, $route);
$values = [];
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
$raw = array_key_exists($key, $_POST)
? wp_unslash($_POST[$key] ?? '')
: ('boolean01' === ($field['input'] ?? '') ? '0' : get_post_meta($post_id, $key, true));
$values[$key] = FieldSchemas::sanitize_value($key, $raw, $route);
}
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
if (($category_slugs || $values) && !FieldSchemas::field_applies($route, $key, $category_slugs, $values)) {
delete_post_meta($post_id, $key);
// Keep subsequent dependent fields from seeing a value whose
// parent field was just removed as invalid or inapplicable.
$values[$key] = '';
continue;
}
if (!array_key_exists($key, $_POST)) {
// A partial editor save must validate the persisted value as
// well. Older editor surfaces and imported posts may not
// submit every schema field, but an existing value remains a
// valid answer to a category-dependent requirement.
continue;
}
self::save_field($post_id, $key, $values[$key] ?? '');
}
$missing = FieldSchemas::required_fields_for_categories($route, $category_slugs, $values);
if ($missing) {
self::$pending_required_fields = array_values(array_unique(array_merge(self::$pending_required_fields, $missing)));
}
}
public static function redirect_validation_notice(string $location): string
{
if (!self::$pending_required_fields) {
return $location;
}
return add_query_arg(
'mw_required_listing_fields',
implode(',', array_map('sanitize_key', self::$pending_required_fields)),
$location
);
}
public static function render_validation_notice(): void
{
$raw = isset($_GET['mw_required_listing_fields']) ? sanitize_text_field(wp_unslash($_GET['mw_required_listing_fields'])) : '';
$keys = array_values(array_filter(array_map('sanitize_key', explode(',', $raw))));
if (!$keys) {
return;
}
$labels = array_map(static fn (string $key): string => ucwords(str_replace('_', ' ', preg_replace('/^mw_/', '', $key))), $keys);
echo '<div class="notice notice-error is-dismissible"><p>' . esc_html__('Bitte ergänze die kategorieabhängigen Pflichtfelder:', 'meinweimar-core') . ' ' . esc_html(implode(', ', $labels)) . '</p></div>';
}
private static function save_gallery(int $post_id): void
{
$ids = ContentTypes::sanitize_gallery_ids(wp_unslash($_POST['mw_gallery_ids'] ?? ''));
if (!$ids) {
delete_post_meta($post_id, 'mw_gallery_ids');
return;
}
update_post_meta($post_id, 'mw_gallery_ids', $ids);
}
private static function can_save(int $post_id, WP_Post $post): bool
{
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return false;
}
if (wp_is_post_revision($post_id) || !in_array($post->post_type, array_values(ContentTypes::public_post_types()), true)) {
return false;
}
if (!isset($_POST[self::NONCE])) {
if (!defined('REST_REQUEST') || !REST_REQUEST) {
return false;
}
} elseif (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST[self::NONCE])), self::NONCE)) {
return false;
}
return current_user_can('edit_post', $post_id);
}
private static function coordinate($value, float $minimum, float $maximum): string
{
if ('' === (string) $value || !is_numeric($value)) {
return '';
}
$value = (float) $value;
return ($value >= $minimum && $value <= $maximum) ? (string) $value : '';
}
private static function allowed_value($value, array $allowed): string
{
$value = sanitize_text_field(wp_unslash((string) $value));
return in_array($value, $allowed, true) ? $value : $allowed[0];
}
private static function save_field(int $post_id, string $key, $value): void
{
if ('' === $value || 0 === $value) {
delete_post_meta($post_id, $key);
return;
}
update_post_meta($post_id, $key, $value);
}
}
