glanzlicht / OnOffice-Hardimporter-Addon

OnOffice Hardimporter Addon

Verified secure showcase
30 Files 289 KB Public size 0 Redacted PHP · CSS · JavaScript · Markdown

Project overview

OnOffice-Hardimporter-Addon is a verified code showcase with 30 visible project entries across 3 top-level modules. The primary detected language is PHP; protected files remain visible in the structure while their contents stay private.

README

onOffice Elementor Bridge

Version 0.9.8 – Entwicklungsfassung von FRESH.

Zweck

Das Plugin ergänzt onOffice for WP-Websites. Es übernimmt Immobilien über dessen vorhandene API-Anbindung und speichert sie als echte lokale WordPress-Datensätze. Damit können Elementor Pro, SEO-Plugins und eigene Templates mit den Daten arbeiten, ohne an die Standardausgabe des onOffice-Plugins gebunden zu sein.

Voraussetzungen

  • WordPress 6.2+
  • PHP 8.0+
  • offizielles Plugin onOffice for WP-Websites (gegen 6.14 entwickelt)
  • darin eingerichtete/funktionierende onOffice-API-Zugangsdaten
  • Elementor Pro optional, aber für Dynamic-Template-Nutzung empfohlen

Neu in 0.2.0

  • dynamische Feldschema-Erkennung mit deutschen/überlieferten Feldbezeichnungen
  • vollständiger Feldimport in API-Blöcken statt 250-Feld-Grenze
  • Mapping für WordPress-Titel, Inhalt und Auszug
  • Taxonomien für Ort, Objektart, Vermarktungsart und Sync-Status
  • Sync-Status und onOffice-ID direkt in der Immobilienliste
  • Sync-Infobox im Immobilien-Editor
  • lokaler Galerie-Dynamic-Tag für Elementor
  • Galerie-Shortcode [oo_gallery]
  • reaktiviert archivierte Immobilien automatisch, wenn sie wieder in onOffice auftauchen

Custom Fields

Jedes geladene onOffice-Feld wird als WordPress-Metafeld gespeichert:

oo_<onoffice-feldname>

Beispiele:

  • oo_kaufpreis
  • oo_wohnflaeche
  • oo_anzahl_zimmer
  • oo_ort
  • oo_objektbeschreibung

Zusätzlich:

  • _fresh_oo_id – onOffice-ID
  • oo_gallery_ids – Array lokaler Attachment-IDs
  • oo_gallery_csv – dieselben IDs kommasepariert
  • oo_gallery_remote – Rohinformationen/Remote-URLs der onOffice-Bilder

Elementor

Normale Werte: Dynamic Tags → Custom Field und den gewünschten oo_*-Key verwenden.

Galerie: In einem Elementor-Widget, das den Dynamic-Tag-Typ „Gallery“ unterstützt, onOffice Galerie (lokal) auswählen.

Alternativ kann ein Shortcode-Widget verwendet werden:

[oo_gallery]

Verhalten bei entfernten Immobilien

Standardmäßig werden nicht mehr gelistete Immobilien nicht gelöscht, sondern auf Entwurf gesetzt und mit Status „Archiviert“ markiert. Dadurch bleiben Datensatz, Medien und Historie lokal erhalten.

Wichtiger Hinweis

Dies ist noch eine Entwicklungsfassung. Vor Produktion auf einer Staging-Seite mit dem echten Kundenaccount testen. Insbesondere kundenspezifische onOffice-Felder, Feldtypen, Bildkategorien, Mehrsprachigkeit und große Datenbestände müssen gegen die reale Installation geprüft werden.

Immobilien-Suche (0.3.0-Paket)

Zusätzlich enthalten ist onoffice-property-search.php mit dem Shortcode:

[oo_property_search]

Die Maske filtert den lokalen Immobilienbestand nach Kaufen/Mieten, Objektart, Ort, Preis, Wohnfläche, Zimmer und Suchbegriff.

Demo-Modus / Seeder (v0.4)

Unter Immobilien → Demo-Immobilien können 10 Berliner Beispielobjekte angelegt werden. Sie verwenden dieselben oo_*-Metafelder und Taxonomien wie der spätere Live-Import. Dadurch können Elementor-Templates, Loop-Grids und die Suchmaske vor Freischaltung des Kundenaccounts komplett gebaut und getestet werden. Der Löschbutton entfernt ausschließlich vom Seeder erzeugte Demo-Objekte.

0.5.2

  • Elementor Dynamic Tag fresh-oo-field als robuste benannte Data_Tag-Klasse umgesetzt.
  • Klasse wird erst im Elementor-Dynamic-Tag-Registrierungs-Hook geladen.
  • Bestehende Loop-Templates bleiben kompatibel, da der Tag-Name unverändert bleibt.

0.7.0

  • Neues Admin-Dashboard mit Statuskarten, Sync-Status, Mapping-Bereichen und einklappbaren Diagnoseansichten.
  • Elementor Dynamic Tag listet jetzt auch dynamisch erkannte onOffice-Felder.
  • Zahlen werden im Smart-Modus ohne unnötige .00 ausgegeben.
  • Meta-Box für Immobilien übersichtlicher und auf tatsächlich vorhandene Werte reduziert.

v0.7.0 — onOffice Immobiliengalerie

Neu ist ein eigenes Elementor-Widget „onOffice Immobiliengalerie“ im WooCommerce-Stil:

  • großes Hauptbild mit Thumbnail-Slider
  • Klick auf Thumbnail tauscht das Hauptbild
  • Swipe- und Tastatur-Navigation
  • optionale Fullscreen-Lightbox
  • responsive Seitenverhältnisse, Thumbnail-Größen und Abstände
  • umfangreiche Elementor-Styling-Optionen für Radien, Rahmen, Schatten, Farben und Navigation
  • nutzt automatisch die lokal importierten oo_gallery_ids der aktuellen Immobilie
  • responsive Bilder mit srcset/sizes, Lazy Loading für Thumbnails und priorisiertem Hauptbild
  • SEO/GEO-freundliches semantisches ImageGallery/ImageObject-Markup, sinnvolle Alt-Texte und optionale Captions
  • barrierearme Buttons, ARIA-Labels, Tastaturbedienung und prefers-reduced-motion

0.8.0 – Elementor Toolkit

Version 0.8.0 turns the bridge into a modular Elementor toolkit. The widgets read the locally synchronized oo_* fields of the current fresh_immobilie post, hide missing values automatically, and expose responsive Elementor style controls instead of imposing a fixed onOffice layout.

New widgets

  • onOffice Feld – any imported onOffice field with type-aware formatting, optional icon, label, prefix/suffix and empty-value handling.
  • onOffice Feldgruppe – freely configurable repeater of fields with individual icons, labels and formatting; responsive grid columns.
  • onOffice Eckdaten – a ready-to-use facts variant for living area, rooms, bedrooms, bathrooms etc., fully editable like the field group.
  • onOffice Preis – automatically chooses purchase/rental price, supports secondary price, monthly period, commission and Offer semantics.
  • onOffice Ausstattung – boolean/value-aware icon grid; entries only appear when the configured onOffice field is actually present/true.
  • onOffice Detailtabelle – freely sortable fields grouped into sections such as category, prices, areas and key facts; uses semantic dl/dt/dd markup.
  • onOffice Energieausweis – visual A+–H efficiency scale plus available energy certificate data; derives a display class from consumption when no class is supplied.
  • onOffice Immobiliengalerie – WooCommerce-style main image + thumbnail slider from v0.7 remains included.

All toolkit widgets live in their own Elementor category onOffice · FRESH and support Elementor typography, colors, borders, radii, shadows and responsive controls where appropriate. Empty data is suppressed by default to keep single-property templates reusable across heterogeneous onOffice records.

0.9.2 — Numeric formatter hotfix

Behebt einen Fehler der Smart-Formatierung, bei dem signifikante Nullen am Ende ganzer Zahlen entfernt wurden. Werte wie 160.00, 680.00, 200.00 und 299000.00 werden nun korrekt als 160, 680, 200 und 299.000 dargestellt. Dezimalwerte wie 2.50 bleiben kompakt als 2,5 erhalten.

0.9.0 — Smart Rendering

Version 0.9.0 führt eine zentrale semantische Feldlogik ein. Das Toolkit unterscheidet nun unter anderem Preise, Flächen, Energiekennwerte, Jahreszahlen, Datumswerte, Zähler, Booleans und Arrays. Dadurch werden Felder kontextgerecht formatiert und sinnlose Nullwerte intelligent ausgeblendet.

Neu bzw. erweitert:

  • Baujahr wird als 2002 statt 2.002 ausgegeben.
  • Datumswerte werden deutsch formatiert (05.04.2022).
  • Kauf-/Mietfelder werden anhand der Vermarktungsart sinnvoll gefiltert.
  • onOffice Eckdaten besitzt intelligente Presets und drei Darstellungsmodi.
  • onOffice Detailtabelle kann automatisch passende Sektionen und Felder erzeugen.
  • onOffice Energieausweis besitzt Kennwert, Klasse, Skala und Marker sowie Full/Compact/Scale-Layouts.
  • Neues Widget onOffice Objekt-Meta für Objektart, Objekttyp, Vermarktungsart und Objektnummer.
  • Neues Widget onOffice Adresse mit Datenschutzstufen und optionalem PostalAddress-Markup.

Neu in 0.9.2

  • Elementor Widget onOffice Objektkopf: Titel, Ort, Objekt-Meta und Preis in einem intelligenten Hero-Baustein.
  • Objekt-Meta mit optionalen Icons, vertikalem Modus und frei benennbarer Objektnummer.
  • Adress-Widget mit Inline/Mehrzeilen-Layout, privacy-aware Kartenlink und sauberem PostalAddress-Markup.
  • Erweiterte Styling-Optionen für Meta und Adresse.

0.9.3

  • Neues Elementor Widget onOffice Status-Badge auf Basis von oo_status2/oo_status, inklusive eigener Farben für aktive Vermarktung, Akquise, reserviert, verkauft/vermietet und sonstige Stati.
  • Optionaler globaler Farb-Override für das offizielle onOffice-Frontend (Links, Formulare, Buttons, Fokus/Hover) direkt in den Bridge-Einstellungen.
  • Immobilien-Suchfeld auf 6-Spalten-Grid angepasst; Suchbutton spannt 4 Spalten, äußerer Hintergrund transparent, weißer Hintergrund liegt nur auf dem Grid.

0.9.4 — Responsive Elementor controls

  • Erweitert die Toolkit-Widgets um responsive Desktop/Tablet/Mobile-Steuerung für sinnvolle Layoutwerte wie Ausrichtung, Spalten, Abstände, Radien, Padding und Icon-Größen.
  • Typografie bleibt über Elementors responsive Typography Group Controls steuerbar.
  • Detailtabelle und Energie-Details erhalten responsive Spaltensteuerung.
  • Galerie-Radien und aktive Rahmenbreite sind responsive.
  • Entfernt den festen Galerie-Hintergrund #F3F1ED; die Galerie ist standardmäßig transparent. Eine Hintergrundfarbe kann weiterhin bewusst im Widget gesetzt werden.

0.9.6

  • Ausstattungswerte semantisch bereinigt: verschachtelte Stellplatzdaten werden als echte Stellplatztypen/-anzahlen ausgegeben, boolesche Werte wie Haustiere nicht mehr als rohe 1.
  • Galerie gegen globale Button-Hover-Styles abgeschirmt: kein pinker Hintergrund am Hauptbild und kein pinker Rahmen an Thumbnails.

0.9.5 — Ausstattungs-Automatik & Admin-Notice-Fix

  • Admin-Hinweise anderer Plugins werden nicht mehr in den dunklen Sync-Hero hineingeschoben; der WordPress-Notice-Anker liegt außerhalb des Hero-Bereichs.
  • Das Widget onOffice Ausstattung rendert bei komplett leeren Treffern gar nichts mehr — auch keinen Hinweistext.
  • Deutlich umfangreicherer Standardkatalog an Ausstattungsmerkmalen mit Icons (u. a. Balkon, Terrasse, Fahrstuhl, Keller, Garten, Gäste-WC, Barrierefreiheit, TV, Kamin, Klima, Sauna, Wintergarten, Whirlpool, Einliegerwohnung, Denkmalschutz, Heizung, Befeuerung, Stellplatz, Boden, Internet, Haustiere, Erschließung).
  • Nicht gesetzte/inhaltlich leere Merkmale werden automatisch ausgeblendet; bei befüllten Text-/Auswahlfeldern kann der konkrete Wert mit ausgegeben werden.

0.9.7

  • Neues Elementor-Widget onOffice Immobilienbild als schlanke Alternative zur Galerie.
  • Nutzt automatisch Titelbild oder erstes Galeriebild der Immobilie.
  • Optionales Objektstatus-Badge direkt als Overlay auf dem Bild.
  • Badge-Position, Abstände, Typografie, Radius, Border, Shadow und Farben je Status in Elementor stylebar steuerbar.
  • Bildbreite, Höhe, Fit, Radius, Border und Shadow responsiv stylebar.

Neu in 0.9.8

  • Wird in onOffice „Auf Website veröffentlichen“ deaktiviert, setzt der nächste Sync eine bereits lokale Immobilie automatisch auf Entwurf.
  • Die lokale Kopie bleibt erhalten und wird bei erneuter Freigabe automatisch wieder veröffentlicht.
  • Nicht freigegebene Objekte gelten weiterhin als gesehen und werden nicht zusätzlich als „fehlend“ archiviert.

0.9.9

  • Objektkopf: Preisbereich um Provision, Zusatzpreis und Mietzeitraum ergänzt; Zusatztexte separat stylebar.

0.10.1

  • Neues Elementor-Widget „onOffice Objektanfrage“: rendert ein offizielles onOffice-Kontaktformular direkt im Immobilien-Template und setzt automatisch die onOffice Objekt-ID als Estate-Kontext.
  • Verwendet bewusst Kontaktformulare (nicht das Interessenten-/Suchprofil-Formular), damit objektbezogene Anfragen über die offizielle onOffice contactaddress-Logik laufen.

0.10.1

  • Neues Elementor-Widget „onOffice Objektkarte“ für die aktuelle Immobilie.
  • Neues Elementor-Widget „onOffice Immobilienkarte“ für mehrere veröffentlichte Immobilien.
  • Nutzt die Kartenanbieter-Einstellung des offiziellen onOffice-Plugins (OpenStreetMap oder Google Maps) oder einen Widget-Override.
  • Importiert die onOffice-Kartenfelder breitengrad, laengengrad, showGoogleMap und virtualAddress, sofern im Account lesbar.
  • Karten werden nur ausgegeben, wenn gültige Koordinaten vorhanden sind; die Mehrfachkarte berücksichtigt nur veröffentlichte WordPress-Objekte.

0.10.3

  • Stabilisiert Elementor-Editor: den in 0.10.2 hinzugefügten großen generischen Feld-Repeater im Objekt-Meta-Widget wieder entfernt.
  • Baujahr, Etage, Zustand und Verfügbar-ab bleiben als leichte Schalter im Objekt-Meta erhalten.
  • Baujahr, Etage und Zustand bleiben im Objektkopf zuschaltbar.
  • Semantische onOffice-Wertübersetzungen (z. B. luftwp → Luft-Wasser-Wärmepumpe) bleiben erhalten.

0.10.7

  • Energieausweis rendert keine leeren Zeilen mehr (z. B. 0000-00-00 bei „Gültig bis“).
  • Kein Fallback mehr vom Energieausweis-Baujahr auf das allgemeine Objekt-Baujahr.
  • Farbskala wird nur noch angezeigt, wenn eine Energieklasse vorhanden oder aus einem echten Kennwert ableitbar ist.
  • Platzhalter-/Nullwerte werden nach der Formatierung vollständig ausgeblendet.

0.10.6

  • Ausstattungs-Widget: Leere onOffice-Multiparkinglot-Strukturen werden nicht mehr als „Stellplätze“ ausgegeben. Ein Stellplatz-Eintrag erscheint nur, wenn mindestens ein Stellplatz mit Count > 0 vorhanden ist.
onoffice-elementor-bridge.phpPHP
<?php
/**
* Plugin Name: onOffice Elementor Bridge
* Description: Synchronisiert onOffice-Immobilien als lokale WordPress-Datensätze für Elementor Pro, SEO/GEO, Archivierung und freie Gestaltung.
* Version: 0.10.7
* Author: FRESH
* Requires at least: 6.2
* Requires PHP: 8.0
*/
defined('ABSPATH') || exit;
// Optional companion modules bundled with this plugin package.
if (file_exists(__DIR__ . '/includes/property-search.php')) require_once __DIR__ . '/includes/property-search.php';
if (file_exists(__DIR__ . '/includes/demo-seeder.php')) require_once __DIR__ . '/includes/demo-seeder.php';
if (file_exists(__DIR__ . '/includes/field-registry.php')) require_once __DIR__ . '/includes/field-registry.php';
final class FRESH_OnOffice_Elementor_Bridge {
const VERSION = '0.10.7';
const CPT = 'fresh_immobilie';
const CRON_HOOK = 'fresh_oo_bridge_sync';
const OPTION_LAST_SYNC = 'fresh_oo_bridge_last_sync';
const OPTION_LOG = 'fresh_oo_bridge_log';
const OPTION_SETTINGS = 'fresh_oo_bridge_settings';
const OPTION_SCHEMA = 'fresh_oo_bridge_field_schema';
const META_OO_ID = '_fresh_oo_id';
const META_PREFIX = 'oo_';
private static $instance = null;
public static function instance() { return self::$instance ?: (self::$instance = new self()); }
private function __construct() {
add_action('init', [$this, 'register_content_types']);
add_action('admin_menu', [$this, 'admin_menu']);
add_action('admin_post_fresh_oo_sync', [$this, 'handle_manual_sync']);
add_action('admin_post_fresh_oo_save_settings', [$this, 'handle_save_settings']);
add_action(self::CRON_HOOK, [$this, 'sync']);
add_filter('cron_schedules', [$this, 'cron_schedules']);
add_action('plugins_loaded', [$this, 'maybe_reschedule']);
add_filter('manage_' . self::CPT . '_posts_columns', [$this, 'admin_columns']);
add_action('manage_' . self::CPT . '_posts_custom_column', [$this, 'admin_column_content'], 10, 2);
add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
add_shortcode('oo_gallery', [$this, 'gallery_shortcode']);
add_action('elementor/dynamic_tags/register', [$this, 'register_elementor_dynamic_tags']);
add_action('elementor/widgets/register', [$this, 'register_elementor_widgets']);
add_action('elementor/elements/categories_registered', [$this, 'register_elementor_category']);
add_action('elementor/frontend/after_register_scripts', [$this, 'register_gallery_assets']);
add_action('elementor/frontend/after_register_styles', [$this, 'register_gallery_assets']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_onoffice_color_overrides'], 99);
register_activation_hook(__FILE__, [__CLASS__, 'activate']);
register_deactivation_hook(__FILE__, [__CLASS__, 'deactivate']);
}
public static function activate() {
self::instance()->register_content_types();
flush_rewrite_rules();
$settings = self::instance()->settings();
if (!empty($settings['auto_sync']) && !wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time() + 120, $settings['interval'], self::CRON_HOOK);
}
}
public static function deactivate() {
wp_clear_scheduled_hook(self::CRON_HOOK);
flush_rewrite_rules();
}
public function cron_schedules($schedules) {
$schedules['fresh_oo_15min'] = ['interval' => 900, 'display' => 'Alle 15 Minuten'];
$schedules['fresh_oo_30min'] = ['interval' => 1800, 'display' => 'Alle 30 Minuten'];
$schedules['fresh_oo_hourly'] = ['interval' => 3600, 'display' => 'Stündlich'];
return $schedules;
}
public function maybe_reschedule() {
$settings = $this->settings();
$wanted = $settings['interval'];
$event = wp_get_scheduled_event(self::CRON_HOOK);
if (empty($settings['auto_sync'])) {
if ($event) wp_clear_scheduled_hook(self::CRON_HOOK);
return;
}
if (!$event || $event->schedule !== $wanted) {
wp_clear_scheduled_hook(self::CRON_HOOK);
wp_schedule_event(time() + 120, $wanted, self::CRON_HOOK);
}
}
public function register_content_types() {
register_post_type(self::CPT, [
'labels' => [
'name' => 'Immobilien', 'singular_name' => 'Immobilie',
'add_new_item' => 'Immobilie hinzufügen', 'edit_item' => 'Immobilie bearbeiten',
'view_item' => 'Immobilie ansehen', 'search_items' => 'Immobilien durchsuchen',
],
'public' => true, 'show_ui' => true, 'show_in_rest' => true,
'menu_icon' => 'dashicons-building',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'has_archive' => true, 'rewrite' => ['slug' => 'immobilien'],
'publicly_queryable' => true, 'exclude_from_search' => false,
]);
$taxes = [
'immo_ort' => ['Ort', 'Orte', 'ort'],
'immo_objektart' => ['Objektart', 'Objektarten', 'objektart'],
'immo_vermarktungsart' => ['Vermarktungsart', 'Vermarktungsarten', 'vermarktungsart'],
'immo_status' => ['Status', 'Status', 'status'],
];
foreach ($taxes as $tax => $cfg) {
register_taxonomy($tax, self::CPT, [
'labels' => ['name' => $cfg[1], 'singular_name' => $cfg[0]],
'public' => true, 'show_ui' => true, 'show_in_rest' => true,
'hierarchical' => false, 'rewrite' => ['slug' => 'immobilien-' . $cfg[2]],
]);
}
}
private function settings() {
return wp_parse_args(get_option(self::OPTION_SETTINGS, []), [
'auto_sync' => 1,
'interval' => 'fresh_oo_30min',
'archive_missing' => 1,
'import_images' => 1,
'published_only' => 1,
'title_field' => 'objekttitel',
'content_field' => 'objektbeschreibung',
'excerpt_field' => 'lage',
'location_field' => 'ort',
'object_type_field' => 'objektart',
'marketing_type_field' => 'vermarktungsart',
'override_onoffice_colors' => 0,
'onoffice_primary_color' => '#b8945d',
'onoffice_hover_color' => '#171717',
'onoffice_text_color' => '#ffffff',
]);
}
private function official_plugin_ready() {
return class_exists('onOffice\\WPlugin\\SDKWrapper')
&& class_exists('onOffice\\WPlugin\\API\\APIClientActionGeneric')
&& class_exists('onOffice\\SDK\\onOfficeSDK');
}
public function admin_menu() {
add_submenu_page('edit.php?post_type=' . self::CPT, 'onOffice Sync', 'onOffice Sync', 'manage_options', 'fresh-oo-bridge', [$this, 'render_admin']);
}
private function schema_label($key, $props) {
foreach (['label', 'name', 'content'] as $candidate) {
if (!empty($props[$candidate]) && is_scalar($props[$candidate])) return wp_strip_all_tags((string)$props[$candidate]);
}
return $key;
}
private function field_select($name, $selected, array $schema, $fallbacks = []) {
$keys = array_values(array_unique(array_merge($fallbacks, array_keys($schema))));
echo '<select class="fresh-oo-select" name="' . esc_attr($name) . '">';
foreach ($keys as $key) {
$props = $schema[$key] ?? [];
$label = $this->schema_label($key, is_array($props) ? $props : []);
printf('<option value="%s" %s>%s — %s</option>', esc_attr($key), selected($selected, $key, false), esc_html($label), esc_html($key));
}
echo '</select>';
}
private function api_credentials_present(): bool {
return (string)get_option('onoffice-settings-apikey', '') !== ''
&& (string)get_option('onoffice-settings-apisecret', '') !== '';
}
private function last_log_state(array $log): array {
$last = $log ? (string)end($log) : '';
if ($last === '') return ['neutral', 'Noch kein Sync', 'Noch kein Protokoll vorhanden.'];
if (stripos($last, 'FEHLER') !== false || stripos($last, 'Abbruch') !== false) {
return ['error', 'Sync mit Fehler', $last];
}
if (preg_match('/Fertig:\s*(\d+) neu,\s*(\d+) aktualisiert,\s*(\d+) übersprungen/i', $last, $m)) {
return ['success', 'Sync erfolgreich', sprintf('%d neu · %d aktualisiert · %d übersprungen', $m[1], $m[2], $m[3])];
}
return ['neutral', 'Sync abgeschlossen', $last];
}
public function render_admin() {
if (!current_user_can('manage_options')) return;
$settings = $this->settings();
$last = get_option(self::OPTION_LAST_SYNC);
$log = (array)get_option(self::OPTION_LOG, []);
$schema = (array)get_option(self::OPTION_SCHEMA, []);
$ready = $this->official_plugin_ready();
$credentials = $this->api_credentials_present();
$counts = wp_count_posts(self::CPT);
[$log_state, $log_title, $log_summary] = $this->last_log_state($log);
$published = (int)($counts->publish ?? 0);
$archived = (int)($counts->draft ?? 0);
$next_sync = wp_next_scheduled(self::CRON_HOOK);
?>
<div class="wrap fresh-oo-admin">
<style>
.fresh-oo-admin{--oo-ink:#17211d;--oo-muted:#66736d;--oo-line:#dce4df;--oo-soft:#f3f7f4;--oo-card:#fff;--oo-accent:#1f6b4f;--oo-accent-2:#184f3d;--oo-good:#18794e;--oo-bad:#b42318;max-width:1180px;margin-top:24px;color:var(--oo-ink)}
.fresh-oo-admin *{box-sizing:border-box}.fresh-oo-admin h1,.fresh-oo-admin h2,.fresh-oo-admin h3{color:var(--oo-ink)}.fresh-oo-admin-notice-anchor{margin:0!important}.fresh-oo-hero-title{color:#fff;margin:0;font-size:30px;line-height:1.15;font-weight:600}
.fresh-oo-hero{position:relative;overflow:hidden;padding:30px 32px;border-radius:18px;background:linear-gradient(135deg,#17211d 0%,#254a3b 100%);color:#fff;box-shadow:0 12px 36px rgba(23,33,29,.14);margin-bottom:18px}
.fresh-oo-hero:after{content:"";position:absolute;right:-80px;top:-120px;width:320px;height:320px;border-radius:50%;background:rgba(255,255,255,.06)}
.fresh-oo-kicker{font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;opacity:.72;margin-bottom:8px}.fresh-oo-hero p{max-width:720px;font-size:14px;line-height:1.6;margin:12px 0 0;color:rgba(255,255,255,.78)}
.fresh-oo-version{display:inline-flex;align-items:center;margin-left:8px;padding:4px 8px;border:1px solid rgba(255,255,255,.18);border-radius:99px;font-size:11px;vertical-align:middle;color:#fff;font-weight:600}
.fresh-oo-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:20px}.fresh-oo-actions .button{min-height:38px;border-radius:9px;padding:4px 14px}.fresh-oo-actions .button-primary{background:#fff;border-color:#fff;color:#183d30;font-weight:700}.fresh-oo-actions .button-primary:hover{background:#f3f7f4;border-color:#f3f7f4;color:#183d30}
.fresh-oo-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:16px 0}.fresh-oo-stat{background:var(--oo-card);border:1px solid var(--oo-line);border-radius:14px;padding:16px 18px;min-height:104px;box-shadow:0 3px 12px rgba(23,33,29,.035)}
.fresh-oo-stat-label{font-size:12px;color:var(--oo-muted);font-weight:600;margin-bottom:8px}.fresh-oo-stat-value{font-size:21px;font-weight:750;line-height:1.2}.fresh-oo-stat-sub{font-size:12px;color:var(--oo-muted);margin-top:6px;line-height:1.45}
.fresh-oo-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:#98a29d}.fresh-oo-dot.good{background:var(--oo-good)}.fresh-oo-dot.bad{background:var(--oo-bad)}
.fresh-oo-layout{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(300px,.8fr);gap:16px;align-items:start}.fresh-oo-panel{background:#fff;border:1px solid var(--oo-line);border-radius:16px;box-shadow:0 3px 14px rgba(23,33,29,.035);margin-bottom:16px;overflow:hidden}.fresh-oo-panel-head{padding:18px 20px;border-bottom:1px solid var(--oo-line);background:#fbfcfb}.fresh-oo-panel-head h2{font-size:17px;margin:0}.fresh-oo-panel-head p{margin:5px 0 0;color:var(--oo-muted);font-size:13px}.fresh-oo-panel-body{padding:20px}
.fresh-oo-setting{display:grid;grid-template-columns:230px minmax(0,1fr);gap:20px;padding:14px 0;border-bottom:1px solid #edf1ee}.fresh-oo-setting:last-child{border-bottom:0}.fresh-oo-setting-title{font-weight:650}.fresh-oo-setting-help{font-size:12px;color:var(--oo-muted);margin-top:4px;line-height:1.45}.fresh-oo-setting-control label{display:inline-flex;gap:8px;align-items:center}.fresh-oo-setting-control input[type=checkbox]{width:18px;height:18px}
.fresh-oo-select{width:100%;max-width:520px;min-height:38px;border-color:#cfd8d3!important;border-radius:8px!important}.fresh-oo-admin select{min-height:38px;border-radius:8px;border-color:#cfd8d3}
.fresh-oo-save{display:flex;justify-content:flex-end;padding-top:18px}.fresh-oo-save .button{border-radius:8px;min-height:38px;padding:3px 16px}
.fresh-oo-statusbox{padding:14px 15px;border-radius:12px;background:var(--oo-soft);border:1px solid var(--oo-line);margin-bottom:12px}.fresh-oo-statusbox.success{background:#eef8f3;border-color:#c7e6d5}.fresh-oo-statusbox.error{background:#fff2f0;border-color:#f4c7c2}.fresh-oo-status-title{font-weight:700;margin-bottom:4px}.fresh-oo-status-text{font-size:12px;color:var(--oo-muted);line-height:1.5}
.fresh-oo-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:#f1f4f2;padding:2px 6px;border-radius:5px}.fresh-oo-mini{font-size:12px;color:var(--oo-muted);line-height:1.55}.fresh-oo-mini strong{color:var(--oo-ink)}
.fresh-oo-details{border-top:1px solid var(--oo-line)}.fresh-oo-details summary{cursor:pointer;list-style:none;padding:16px 20px;font-weight:700;background:#fff}.fresh-oo-details summary::-webkit-details-marker{display:none}.fresh-oo-details summary:after{content:"+";float:right;color:var(--oo-muted);font-size:18px;line-height:1}.fresh-oo-details[open] summary:after{content:"−"}.fresh-oo-details-content{padding:0 20px 20px}
.fresh-oo-table-wrap{max-height:460px;overflow:auto;border:1px solid var(--oo-line);border-radius:10px}.fresh-oo-admin .widefat{border:0}.fresh-oo-admin .widefat thead th{position:sticky;top:0;background:#f7f9f7;z-index:1}.fresh-oo-admin .widefat td,.fresh-oo-admin .widefat th{padding:10px 12px}
.fresh-oo-log{margin:0;background:#111916;color:#dbe8e1;border-radius:10px;padding:16px;max-height:320px;overflow:auto;white-space:pre-wrap;font-size:12px;line-height:1.6}
.fresh-oo-pill{display:inline-flex;align-items:center;border-radius:99px;padding:4px 8px;font-size:11px;font-weight:700;background:#edf3ef;color:#315745}.fresh-oo-link{color:var(--oo-accent);font-weight:600;text-decoration:none}.fresh-oo-link:hover{color:var(--oo-accent-2)}
@media(max-width:1000px){.fresh-oo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.fresh-oo-layout{grid-template-columns:1fr}}
@media(max-width:650px){.fresh-oo-hero{padding:24px 20px}.fresh-oo-grid{grid-template-columns:1fr}.fresh-oo-setting{grid-template-columns:1fr;gap:8px}.fresh-oo-panel-body{padding:16px}.fresh-oo-panel-head{padding:16px}}
</style>
<h1 class="fresh-oo-admin-notice-anchor screen-reader-text">Immobilien-Sync</h1>
<section class="fresh-oo-hero">
<div class="fresh-oo-kicker">FRESH · onOffice Connector</div>
<div class="fresh-oo-hero-title" role="heading" aria-level="1">Immobilien-Sync <span class="fresh-oo-version">v<?php echo esc_html(self::VERSION); ?></span></div>
<p>Importiert freigegebene onOffice-Immobilien dauerhaft nach WordPress – inklusive Feldern, Taxonomien und Bildern. Elementor arbeitet anschließend mit echten lokalen Datensätzen.</p>
<div class="fresh-oo-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="fresh_oo_sync"><?php wp_nonce_field('fresh_oo_sync'); ?>
<button class="button button-primary" type="submit" <?php disabled(!$ready || !$credentials); ?>>↻ Jetzt synchronisieren</button>
</form>
<a class="button" href="<?php echo esc_url(admin_url('edit.php?post_type='.self::CPT)); ?>">Immobilien öffnen</a>
</div>
</section>
<div class="fresh-oo-grid">
<div class="fresh-oo-stat"><div class="fresh-oo-stat-label">onOffice Verbindung</div><div class="fresh-oo-stat-value"><span class="fresh-oo-dot <?php echo ($ready && $credentials) ? 'good' : 'bad'; ?>"></span><?php echo ($ready && $credentials) ? 'Verbunden' : 'Nicht bereit'; ?></div><div class="fresh-oo-stat-sub"><?php echo $credentials ? 'API-Zugangsdaten im offiziellen Plugin erkannt.' : 'API-Key oder Secret fehlen.'; ?></div></div>
<div class="fresh-oo-stat"><div class="fresh-oo-stat-label">Lokale Immobilien</div><div class="fresh-oo-stat-value"><?php echo esc_html($published); ?></div><div class="fresh-oo-stat-sub"><?php echo esc_html($archived); ?> archiviert · Daten bleiben lokal erhalten.</div></div>
<div class="fresh-oo-stat"><div class="fresh-oo-stat-label">Erkannte onOffice-Felder</div><div class="fresh-oo-stat-value"><?php echo esc_html(count($schema)); ?></div><div class="fresh-oo-stat-sub">Werden automatisch als <span class="fresh-oo-code">oo_*</span> gespeichert.</div></div>
<div class="fresh-oo-stat"><div class="fresh-oo-stat-label">Nächster Auto-Sync</div><div class="fresh-oo-stat-value" style="font-size:17px"><?php echo ($settings['auto_sync'] && $next_sync) ? esc_html(wp_date('d.m. · H:i', (int)$next_sync)) : 'Deaktiviert'; ?></div><div class="fresh-oo-stat-sub"><?php echo $last ? 'Letzter Sync: '.esc_html(wp_date('d.m.Y H:i:s', (int)$last)) : 'Noch kein erfolgreicher Sync.'; ?></div></div>
</div>
<div class="fresh-oo-layout">
<div>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="fresh_oo_save_settings"><?php wp_nonce_field('fresh_oo_save_settings'); ?>
<section class="fresh-oo-panel">
<div class="fresh-oo-panel-head"><h2>Synchronisation</h2><p>Steuert, was aus onOffice übernommen wird und wie oft der Abgleich läuft.</p></div>
<div class="fresh-oo-panel-body">
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Automatisch synchronisieren</div><div class="fresh-oo-setting-help">Führt den Import regelmäßig per WordPress-Cron aus.</div></div><div class="fresh-oo-setting-control"><label><input type="checkbox" name="auto_sync" value="1" <?php checked($settings['auto_sync']); ?>> Automatik aktiv</label></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Intervall</div><div class="fresh-oo-setting-help">Empfohlen: alle 30 Minuten.</div></div><div class="fresh-oo-setting-control"><select name="interval"><option value="fresh_oo_15min" <?php selected($settings['interval'],'fresh_oo_15min'); ?>>Alle 15 Minuten</option><option value="fresh_oo_30min" <?php selected($settings['interval'],'fresh_oo_30min'); ?>>Alle 30 Minuten</option><option value="fresh_oo_hourly" <?php selected($settings['interval'],'fresh_oo_hourly'); ?>>Stündlich</option></select></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Nur freigegebene Objekte</div><div class="fresh-oo-setting-help">Importiert nur <span class="fresh-oo-code">veroeffentlichen = 1</span> und lässt Referenzobjekte aus. Wird die Website-Freigabe später deaktiviert, setzt der nächste Sync die lokale Immobilie automatisch auf Entwurf.</div></div><div class="fresh-oo-setting-control"><label><input type="checkbox" name="published_only" value="1" <?php checked($settings['published_only']); ?>> Filter aktiv</label></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Verschwundene Objekte archivieren</div><div class="fresh-oo-setting-help">Entfernte Objekte werden auf Entwurf gesetzt – niemals automatisch gelöscht.</div></div><div class="fresh-oo-setting-control"><label><input type="checkbox" name="archive_missing" value="1" <?php checked($settings['archive_missing']); ?>> Archivieren</label></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Bilder lokal übernehmen</div><div class="fresh-oo-setting-help">Titelbild, Fotos und Grundrisse werden in die WordPress-Mediathek importiert.</div></div><div class="fresh-oo-setting-control"><label><input type="checkbox" name="import_images" value="1" <?php checked($settings['import_images']); ?>> Bilder importieren</label></div></div>
</div>
</section>
<section class="fresh-oo-panel">
<div class="fresh-oo-panel-head"><h2>Feld-Mapping</h2><p>Alle Felder werden ohnehin gespeichert. Hier definierst Du nur die WordPress-Hauptfelder und Taxonomien.</p></div>
<div class="fresh-oo-panel-body">
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Seitentitel</div><div class="fresh-oo-setting-help">Wird zum WordPress-Beitragstitel und damit zur Basis der URL.</div></div><div><?php $this->field_select('title_field',$settings['title_field'],$schema,['objekttitel','objektbezeichnung']); ?></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Hauptbeschreibung</div><div class="fresh-oo-setting-help">Wird als normaler WordPress-Inhalt gespeichert.</div></div><div><?php $this->field_select('content_field',$settings['content_field'],$schema,['objektbeschreibung']); ?></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Auszug</div><div class="fresh-oo-setting-help">Kurzer Teaser, z. B. die Lagebeschreibung.</div></div><div><?php $this->field_select('excerpt_field',$settings['excerpt_field'],$schema,['lage']); ?></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Ort</div><div class="fresh-oo-setting-help">Speist die Taxonomie „Ort“.</div></div><div><?php $this->field_select('location_field',$settings['location_field'],$schema,['ort']); ?></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Objektart</div><div class="fresh-oo-setting-help">Speist die Taxonomie „Objektart“.</div></div><div><?php $this->field_select('object_type_field',$settings['object_type_field'],$schema,['objektart']); ?></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Vermarktungsart</div><div class="fresh-oo-setting-help">Kauf, Miete, Pacht usw.</div></div><div><?php $this->field_select('marketing_type_field',$settings['marketing_type_field'],$schema,['vermarktungsart']); ?></div></div>
</div>
</section>
<section class="fresh-oo-panel">
<div class="fresh-oo-panel-head"><h2>onOffice Farben überschreiben</h2><p>Ersetzt die blauen Standardfarben des offiziellen onOffice-Plugins im Frontend, ohne dessen Dateien zu verändern.</p></div>
<div class="fresh-oo-panel-body">
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Farb-Override aktivieren</div><div class="fresh-oo-setting-help">Wirkt auf Links, Buttons, Formular-Submit, Fokusrahmen und weitere onOffice-Akzente.</div></div><div class="fresh-oo-setting-control"><label><input type="checkbox" name="override_onoffice_colors" value="1" <?php checked($settings['override_onoffice_colors']); ?>> Farben überschreiben</label></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Primärfarbe</div><div class="fresh-oo-setting-help">Buttons, Links, Fokus und aktive Akzente.</div></div><div class="fresh-oo-setting-control"><input type="color" name="onoffice_primary_color" value="<?php echo esc_attr($settings['onoffice_primary_color']); ?>"></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Hover-/Fokusfarbe</div><div class="fresh-oo-setting-help">Dunklere Farbe für Hover und aktive Zustände.</div></div><div class="fresh-oo-setting-control"><input type="color" name="onoffice_hover_color" value="<?php echo esc_attr($settings['onoffice_hover_color']); ?>"></div></div>
<div class="fresh-oo-setting"><div><div class="fresh-oo-setting-title">Text auf Buttons</div><div class="fresh-oo-setting-help">Textfarbe auf farbigen onOffice-Schaltflächen.</div></div><div class="fresh-oo-setting-control"><input type="color" name="onoffice_text_color" value="<?php echo esc_attr($settings['onoffice_text_color']); ?>"></div></div>
<div class="fresh-oo-save"><button class="button button-primary" type="submit">Einstellungen speichern</button></div>
</div>
</section>
</form>
<?php if ($schema): ?>
<section class="fresh-oo-panel">
<details class="fresh-oo-details">
<summary>Erkannte onOffice-Felder <span class="fresh-oo-pill"><?php echo esc_html(count($schema)); ?> Felder</span></summary>
<div class="fresh-oo-details-content"><p class="fresh-oo-mini">Jedes Feld steht als persistentes WordPress-Custom-Field zur Verfügung. In Elementor kannst Du den Dynamic Tag <strong>onOffice Feld</strong> oder ein normales Custom Field verwenden.</p><div class="fresh-oo-table-wrap"><table class="widefat striped"><thead><tr><th>Bezeichnung</th><th>onOffice</th><th>WordPress</th><th>Typ</th></tr></thead><tbody><?php foreach ($schema as $key => $props): $props = is_array($props) ? $props : []; ?><tr><td><?php echo esc_html($this->schema_label($key,$props)); ?></td><td><code><?php echo esc_html($key); ?></code></td><td><code><?php echo esc_html(self::META_PREFIX . sanitize_key($key)); ?></code></td><td><?php echo esc_html((string)($props['type'] ?? $props['realDataType'] ?? '')); ?></td></tr><?php endforeach; ?></tbody></table></div></div>
</details>
</section>
<?php endif; ?>
</div>
<aside>
<section class="fresh-oo-panel"><div class="fresh-oo-panel-head"><h2>Status</h2><p>Letzter Importlauf und Verbindung.</p></div><div class="fresh-oo-panel-body"><div class="fresh-oo-statusbox <?php echo esc_attr($log_state); ?>"><div class="fresh-oo-status-title"><?php echo esc_html($log_title); ?></div><div class="fresh-oo-status-text"><?php echo esc_html($log_summary); ?></div></div><div class="fresh-oo-mini"><strong>API:</strong> <?php echo ($ready && $credentials) ? 'bereit' : 'nicht bereit'; ?><br><strong>Auto-Sync:</strong> <?php echo $settings['auto_sync'] ? 'aktiv' : 'aus'; ?><br><strong>Bilder:</strong> <?php echo $settings['import_images'] ? 'lokaler Import' : 'deaktiviert'; ?><br><strong>Archivierung:</strong> <?php echo $settings['archive_missing'] ? 'aktiv' : 'deaktiviert'; ?></div></div></section>
<section class="fresh-oo-panel"><div class="fresh-oo-panel-head"><h2>Elementor</h2><p>Direkt nutzbare Dynamic Tags.</p></div><div class="fresh-oo-panel-body"><div class="fresh-oo-mini"><strong>onOffice Feld</strong><br>Text, Preise, Flächen, Zahlen und Ja/Nein-Ausgabe.<br><br><strong>onOffice Galerie</strong><br>Greift auf die lokal importierten Medien zu.<br><br><span class="fresh-oo-code">[oo_gallery]</span> steht ebenfalls zur Verfügung.</div></div></section>
<section class="fresh-oo-panel"><details class="fresh-oo-details" <?php echo $log_state === 'error' ? 'open' : ''; ?>><summary>Sync-Protokoll</summary><div class="fresh-oo-details-content"><pre class="fresh-oo-log"><?php echo esc_html($log ? implode("\n", $log) : 'Noch kein Sync-Protokoll vorhanden.'); ?></pre></div></details></section>
<section class="fresh-oo-panel"><div class="fresh-oo-panel-body"><div class="fresh-oo-mini"><strong>Datenschutz & Kontrolle</strong><br>Der Bridge-Importer speichert keine zusätzlichen API-Zugangsdaten. Er verwendet die Zugangsdaten des offiziellen onOffice-Plugins. Lokale Immobilien werden bei einem späteren API-Ausfall nicht automatisch gelöscht.</div></div></section>
</aside>
</div>
</div>
<?php
}
public function handle_save_settings() {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('fresh_oo_save_settings');
$allowed_intervals = ['fresh_oo_15min','fresh_oo_30min','fresh_oo_hourly'];
$old = $this->settings();
$settings = [
'auto_sync' => !empty($_POST['auto_sync']) ? 1 : 0,
'interval' => in_array($_POST['interval'] ?? '', $allowed_intervals, true) ? sanitize_text_field($_POST['interval']) : 'fresh_oo_30min',
'archive_missing' => !empty($_POST['archive_missing']) ? 1 : 0,
'import_images' => !empty($_POST['import_images']) ? 1 : 0,
'published_only' => !empty($_POST['published_only']) ? 1 : 0,
'title_field' => sanitize_key($_POST['title_field'] ?? $old['title_field']),
'content_field' => sanitize_key($_POST['content_field'] ?? $old['content_field']),
'excerpt_field' => sanitize_key($_POST['excerpt_field'] ?? $old['excerpt_field']),
'location_field' => sanitize_key($_POST['location_field'] ?? $old['location_field']),
'object_type_field' => sanitize_key($_POST['object_type_field'] ?? $old['object_type_field']),
'marketing_type_field' => sanitize_key($_POST['marketing_type_field'] ?? $old['marketing_type_field']),
'override_onoffice_colors' => !empty($_POST['override_onoffice_colors']) ? 1 : 0,
'onoffice_primary_color' => sanitize_hex_color($_POST['onoffice_primary_color'] ?? '') ?: $old['onoffice_primary_color'],
'onoffice_hover_color' => sanitize_hex_color($_POST['onoffice_hover_color'] ?? '') ?: $old['onoffice_hover_color'],
'onoffice_text_color' => sanitize_hex_color($_POST['onoffice_text_color'] ?? '') ?: $old['onoffice_text_color'],
];
update_option(self::OPTION_SETTINGS, $settings, false);
wp_clear_scheduled_hook(self::CRON_HOOK);
if ($settings['auto_sync']) wp_schedule_event(time()+120, $settings['interval'], self::CRON_HOOK);
wp_safe_redirect(admin_url('edit.php?post_type='.self::CPT.'&page=fresh-oo-bridge&saved=1'));
exit;
}
public function handle_manual_sync() {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('fresh_oo_sync');
$this->sync();
wp_safe_redirect(admin_url('edit.php?post_type='.self::CPT.'&page=fresh-oo-bridge&synced=1'));
exit;
}
public function sync() {
$log = ['Sync gestartet: ' . wp_date('d.m.Y H:i:s')];
if (!$this->official_plugin_ready()) {
update_option(self::OPTION_LOG, array_merge($log, ['Abbruch: offizielles onOffice-Plugin nicht aktiv oder inkompatibel.']), false);
return;
}
try {
$fields = $this->fetch_estate_fields();
if (!$fields) throw new Exception('Keine Estate-Felder von onOffice erhalten.');
ksort($fields);
update_option(self::OPTION_SCHEMA, $fields, false);
$log[] = count($fields) . ' onOffice-Felder erkannt.';
$records = $this->fetch_estates(array_keys($fields), $log);
$settings = $this->settings();
$seen = [];
$created = $updated = $skipped = 0;
foreach ($records as $record) {
$oo_id = (int)($record['id'] ?? 0);
$elements = is_array($record['elements'] ?? null) ? $record['elements'] : [];
if (!$oo_id) continue;
$existing = $this->find_post_by_oo_id($oo_id);
if ($settings['published_only']) {
$published = (string)($elements['veroeffentlichen'] ?? '0') === '1';
$reference = (string)($elements['referenz'] ?? '0') === '1';
if (!$published || $reference) {
// An object that is no longer released for the website must never
// remain publicly visible in WordPress. Keep the local copy, but
// immediately switch it to draft so it can be re-published on the
// next sync when onOffice releases it again.
if ($existing && get_post_status($existing) === 'publish') {
wp_update_post(['ID' => $existing, 'post_status' => 'draft']);
update_post_meta($existing, '_fresh_oo_unpublished_at', time());
update_post_meta($existing, '_fresh_oo_last_seen', time());
wp_set_object_terms($existing, $reference ? 'Referenz' : 'Nicht veröffentlicht', 'immo_status', false);
$log[] = sprintf('Objekt %d auf Entwurf gesetzt (%s).', $oo_id, $reference ? 'Referenzobjekt' : 'Website-Freigabe deaktiviert');
}
// Treat it as seen so archive_missing does not classify the same
// record as "missing" as well.
$seen[] = $oo_id;
$skipped++;
continue;
}
}
$seen[] = $oo_id;
$title = $this->value_or_fallback($elements, $settings['title_field'], ['objekttitel','objektbezeichnung','strasse'], 'Immobilie #' . $oo_id);
$content = $this->value_or_fallback($elements, $settings['content_field'], ['objektbeschreibung','freitext'], '');
$excerpt = $this->value_or_fallback($elements, $settings['excerpt_field'], ['lage','ausstatt_beschr'], '');
$postarr = [
'post_type' => self::CPT, 'post_status' => 'publish',
'post_title' => wp_strip_all_tags($this->stringify($title)),
'post_content' => wp_kses_post($this->stringify($content)),
'post_excerpt' => wp_strip_all_tags($this->stringify($excerpt)),
];
if ($existing) $postarr['ID'] = $existing;
$post_id = wp_insert_post($postarr, true);
if (is_wp_error($post_id)) { $log[] = 'Fehler bei Objekt '.$oo_id.': '.$post_id->get_error_message(); continue; }
update_post_meta($post_id, self::META_OO_ID, $oo_id);
update_post_meta($post_id, '_fresh_oo_last_seen', time());
delete_post_meta($post_id, '_fresh_oo_archived_at');
delete_post_meta($post_id, '_fresh_oo_unpublished_at');
update_post_meta($post_id, '_fresh_oo_raw', wp_json_encode($elements, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
foreach ($elements as $key => $value) update_post_meta($post_id, self::META_PREFIX . sanitize_key($key), $value);
$this->sync_taxonomy($post_id, 'immo_ort', $elements[$settings['location_field']] ?? null);
$this->sync_taxonomy($post_id, 'immo_objektart', $elements[$settings['object_type_field']] ?? null);
$this->sync_taxonomy($post_id, 'immo_vermarktungsart', $elements[$settings['marketing_type_field']] ?? null);
wp_set_object_terms($post_id, 'Aktiv', 'immo_status', false);
if ($settings['import_images']) $this->sync_images($post_id, $oo_id, $log);
$existing ? $updated++ : $created++;
}
if ($settings['archive_missing']) $this->archive_missing($seen, $log);
$log[] = sprintf('Fertig: %d neu, %d aktualisiert, %d übersprungen.', $created, $updated, $skipped);
update_option(self::OPTION_LAST_SYNC, time(), false);
} catch (Throwable $e) {
$message = trim((string)$e->getMessage());
$log[] = 'FEHLER: ' . ($message !== '' ? $message : get_class($e));
}
update_option(self::OPTION_LOG, $log, false);
}
private function sdk_wrapper() { $class = 'onOffice\\WPlugin\\SDKWrapper'; return new $class(); }
private function api_action($wrapper, $action_id, $resource) { $class = 'onOffice\\WPlugin\\API\\APIClientActionGeneric'; return new $class($wrapper, $action_id, $resource); }
private function fetch_estate_fields() {
$wrapper = $this->sdk_wrapper();
$sdk = 'onOffice\\SDK\\onOfficeSDK';
$action = $this->api_action($wrapper, constant($sdk.'::ACTION_ID_GET'), 'fields');
$action->setParameters([
'labels' => true, 'showContent' => true, 'showTable' => true,
'language' => substr(determine_locale(), 0, 2),
'modules' => [constant($sdk.'::MODULE_ESTATE')], 'realDataTypes' => true,
]);
$action->addRequestToQueue()->sendRequests(false);
$records = $action->getResultRecords();
$fields = [];
foreach ($records as $module) {
if (($module['id'] ?? '') !== constant($sdk.'::MODULE_ESTATE')) continue;
foreach ((array)($module['elements'] ?? []) as $name => $props) {
if ($name === 'label') continue;
$fields[$name] = is_array($props) ? $props : [];
}
}
foreach (['objekttitel','objektbeschreibung','veroeffentlichen','referenz','lage','ausstatt_beschr','ort','objektart','vermarktungsart'] as $f) $fields[$f] = $fields[$f] ?? [];
return $fields;
}
private function fetch_estates(array $field_names, array &$log) {
// Start with a deliberately small, known-good core set. Some onOffice accounts
// expose schema entries that cannot actually be read for estates. Those fields
// must not be allowed to abort the complete import.
$core = ['objekttitel','objektbeschreibung','lage','ausstatt_beschr','veroeffentlichen','referenz','kaufpreis','kaltmiete','warmmiete','wohnflaeche','grundstuecksflaeche','anzahl_zimmer','anzahl_badezimmer','plz','ort','strasse','hausnummer','objektart','nutzungsart','vermarktungsart','energieausweistyp','energyClass','energieverbrauchskennwert','endenergiebedarf','baujahr','waehrung','aussen_courtage','provisionsfrei','breitengrad','laengengrad','showGoogleMap','virtualAddress'];
$core = array_values(array_unique(array_intersect($core, array_values(array_unique(array_merge($core, $field_names))))));
$extra = array_values(array_diff(array_values(array_unique($field_names)), $core));
$merged = [];
$log[] = sprintf('Kernfelder (%d) werden geladen …', count($core));
$core_records = $this->fetch_estate_chunk_resilient($core, $log, true);
foreach ($core_records as $record) {
$id = (int)($record['id'] ?? 0); if (!$id) continue;
$merged[$id] = ['id' => $id, 'elements' => (array)($record['elements'] ?? [])];
}
$log[] = sprintf('%d Immobilien über Kernfelder gefunden.', count($merged));
if (!$merged || !$extra) return array_values($merged);
$chunks = array_chunk($extra, 30);
foreach ($chunks as $i => $chunk) {
$log[] = sprintf('Zusatzfelder %d/%d (%d Felder) …', $i+1, count($chunks), count($chunk));
foreach ($this->fetch_estate_chunk_resilient($chunk, $log, false) as $record) {
$id = (int)($record['id'] ?? 0); if (!$id || !isset($merged[$id])) continue;
$merged[$id]['elements'] = array_replace($merged[$id]['elements'], (array)($record['elements'] ?? []));
}
}
return array_values($merged);
}
private function fetch_estate_chunk_resilient(array $fields, array &$log, bool $required = false) {
if (!$fields) return [];
try {
return $this->fetch_estate_chunk($fields);
} catch (Throwable $e) {
$message = trim((string)$e->getMessage());
if (count($fields) === 1) {
$log[] = sprintf('onOffice-Feld übersprungen: %s%s', $fields[0], $message !== '' ? ' — ' . $message : '');
if ($required) {
$log[] = 'Hinweis: Ein Kernfeld war nicht lesbar; Import wird mit den übrigen Kernfeldern fortgesetzt.';
}
return [];
}
// Binary split isolates incompatible fields without throwing away the
// readable part of the schema.
$mid = max(1, intdiv(count($fields), 2));
$left = array_slice($fields, 0, $mid);
$right = array_slice($fields, $mid);
$left_records = $this->fetch_estate_chunk_resilient($left, $log, $required);
$right_records = $this->fetch_estate_chunk_resilient($right, $log, $required);
$merged = [];
foreach (array_merge($left_records, $right_records) as $record) {
$id = (int)($record['id'] ?? 0); if (!$id) continue;
if (!isset($merged[$id])) $merged[$id] = ['id' => $id, 'elements' => []];
$merged[$id]['elements'] = array_replace($merged[$id]['elements'], (array)($record['elements'] ?? []));
}
return array_values($merged);
}
}
private function fetch_estate_chunk(array $data) {
$all = []; $offset = 0; $limit = 500;
do {
$wrapper = $this->sdk_wrapper();
$sdk = 'onOffice\\SDK\\onOfficeSDK';
$action = $this->api_action($wrapper, constant($sdk.'::ACTION_ID_READ'), 'estate');
$action->setParameters([
'data' => $data, 'listlimit' => $limit, 'listoffset' => $offset,
'formatoutput' => false, 'addMainLangId' => true,
'estatelanguage' => substr(determine_locale(), 0, 2),
'outputlanguage' => substr(determine_locale(), 0, 2),
]);
$action->addRequestToQueue()->sendRequests(false);
$records = $action->getResultRecords();
$meta = $action->getResultMeta();
$all = array_merge($all, $records);
$absolute = (int)($meta['cntabsolute'] ?? count($all));
$offset += $limit;
} while ($offset < $absolute && !empty($records));
return $all;
}
private function sync_taxonomy($post_id, $taxonomy, $value) {
$terms = [];
if (is_array($value)) {
foreach ($value as $v) if (is_scalar($v) && trim((string)$v) !== '') $terms[] = trim((string)$v);
} elseif (is_scalar($value) && trim((string)$value) !== '') $terms[] = trim((string)$value);
wp_set_object_terms($post_id, array_values(array_unique($terms)), $taxonomy, false);
}
private function sync_images($post_id, $oo_id, array &$log) {
$wrapper = $this->sdk_wrapper();
$sdk = 'onOffice\\SDK\\onOfficeSDK';
$action = $this->api_action($wrapper, constant($sdk.'::ACTION_ID_GET'), 'estatepictures');
$action->setParameters([
'estateids' => [$oo_id],
'categories' => ['Titelbild','Foto','Foto_gross','Grundriss','Panorama','Lageplan','Epass_Skala'],
'language' => substr(determine_locale(), 0, 2),
]);
$action->addRequestToQueue()->sendRequests(false);
if (!$action->getResultStatus()) return;
$records = $action->getResultRecords();
$images = [];
foreach ($records as $file) {
$file_id = (string)($file['id'] ?? '');
foreach ((array)($file['elements'] ?? []) as $properties) {
if ((int)($properties['estateid'] ?? 0) !== $oo_id) continue;
$url = (string)($properties['url'] ?? ''); if (!$url) continue;
if (!preg_match('~^https?://~i', $url)) $url = 'https://' . ltrim($url, '/');
$images[] = [
'oo_file_id' => $file_id, 'url' => esc_url_raw($url),
'title' => sanitize_text_field($properties['title'] ?? ''),
'text' => sanitize_textarea_field($properties['text'] ?? ''),
'type' => sanitize_text_field($properties['type'] ?? ''),
];
}
}
update_post_meta($post_id, 'oo_gallery_remote', $images);
if (!$images) { delete_post_meta($post_id, 'oo_gallery_ids'); return; }
$attachment_ids = []; $featured = 0;
foreach ($images as $img) {
$attachment_id = $this->find_attachment_by_source($img['oo_file_id']);
if (!$attachment_id) $attachment_id = $this->sideload_image($img, $post_id);
if ($attachment_id) {
$attachment_ids[] = (int)$attachment_id;
if ($img['type'] === 'Titelbild') $featured = (int)$attachment_id;
}
}
$attachment_ids = array_values(array_unique($attachment_ids));
update_post_meta($post_id, 'oo_gallery_ids', $attachment_ids);
update_post_meta($post_id, 'oo_gallery_csv', implode(',', $attachment_ids));
if ($featured) set_post_thumbnail($post_id, $featured);
elseif ($attachment_ids && !has_post_thumbnail($post_id)) set_post_thumbnail($post_id, $attachment_ids[0]);
}
private function find_attachment_by_source($file_id) {
if (!$file_id) return 0;
$q = new WP_Query(['post_type'=>'attachment','post_status'=>'inherit','posts_per_page'=>1,'fields'=>'ids','meta_query'=>[['key'=>'_fresh_oo_file_id','value'=>$file_id]]]);
return $q->posts ? (int)$q->posts[0] : 0;
}
private function sideload_image(array $img, $post_id) {
require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php';
$tmp = download_url($img['url'], 30); if (is_wp_error($tmp)) return 0;
$path = wp_parse_url($img['url'], PHP_URL_PATH);
$name = sanitize_file_name(basename($path ?: 'onoffice-image.jpg')) ?: 'onoffice-image.jpg';
$id = media_handle_sideload(['name'=>$name,'tmp_name'=>$tmp], $post_id, $img['title'], ['post_excerpt'=>$img['text']]);
if (is_wp_error($id)) { @unlink($tmp); return 0; }
update_post_meta($id, '_fresh_oo_file_id', $img['oo_file_id']); update_post_meta($id, '_fresh_oo_source_url', $img['url']);
if ($img['text']) update_post_meta($id, '_wp_attachment_image_alt', wp_strip_all_tags($img['text']));
return (int)$id;
}
private function find_post_by_oo_id($oo_id) {
$q = new WP_Query(['post_type'=>self::CPT,'post_status'=>['publish','draft','private','pending'],'posts_per_page'=>1,'fields'=>'ids','meta_key'=>self::META_OO_ID,'meta_value'=>(string)$oo_id]);
return $q->posts ? (int)$q->posts[0] : 0;
}
private function archive_missing(array $seen, array &$log) {
$ids = get_posts(['post_type'=>self::CPT,'post_status'=>['publish','draft'],'numberposts'=>-1,'fields'=>'ids']); $archived = 0;
foreach ($ids as $post_id) {
$oo_id = (int)get_post_meta($post_id, self::META_OO_ID, true);
if ($oo_id && !in_array($oo_id, $seen, true) && get_post_status($post_id) === 'publish') {
wp_update_post(['ID'=>$post_id,'post_status'=>'draft']);
update_post_meta($post_id, '_fresh_oo_archived_at', time());
wp_set_object_terms($post_id, 'Archiviert', 'immo_status', false); $archived++;
}
}
if ($archived) $log[] = $archived . ' nicht mehr gelistete Objekte archiviert.';
}
private function stringify($value) {
if (is_scalar($value) || $value === null) return (string)$value;
if (is_array($value)) return implode(', ', array_map([$this, 'stringify'], $value));
return '';
}
private function value_or_fallback(array $elements, $preferred, array $fallbacks, $fallback = '') {
$keys = array_values(array_unique(array_merge([$preferred], $fallbacks)));
foreach ($keys as $key) if ($key && isset($elements[$key]) && $elements[$key] !== '' && $elements[$key] !== null) return $elements[$key];
return $fallback;
}
public function admin_columns($columns) {
$out = [];
foreach ($columns as $k => $v) {
$out[$k] = $v;
if ($k === 'title') { $out['oo_id'] = 'onOffice-ID'; $out['oo_sync'] = 'Sync-Status'; }
}
return $out;
}
public function admin_column_content($column, $post_id) {
if ($column === 'oo_id') echo esc_html((string)get_post_meta($post_id, self::META_OO_ID, true));
if ($column === 'oo_sync') {
$last = (int)get_post_meta($post_id, '_fresh_oo_last_seen', true);
$archived = (int)get_post_meta($post_id, '_fresh_oo_archived_at', true);
if ($archived) echo '<span style="color:#996800">● Archiviert</span><br><small>' . esc_html(wp_date('d.m.Y H:i', $archived)) . '</small>';
elseif ($last) echo '<span style="color:#008a20">● Synchron</span><br><small>' . esc_html(wp_date('d.m.Y H:i', $last)) . '</small>';
else echo '—';
}
}
public function add_meta_boxes() { add_meta_box('fresh_oo_sync_info','onOffice Sync',[$this,'render_sync_metabox'],self::CPT,'side','high'); }
public function render_sync_metabox($post) {
$id = get_post_meta($post->ID,self::META_OO_ID,true); $last=(int)get_post_meta($post->ID,'_fresh_oo_last_seen',true); $arch=(int)get_post_meta($post->ID,'_fresh_oo_archived_at',true);
echo '<p><strong>onOffice-ID:</strong><br><code>'.esc_html((string)$id).'</code></p>';
echo '<p><strong>Letzter Abgleich:</strong><br>'.($last?esc_html(wp_date('d.m.Y H:i:s',$last)):'—').'</p>';
echo '<p><strong>Status:</strong><br>'.($arch?'Archiviert seit '.esc_html(wp_date('d.m.Y H:i:s',$arch)):'Aktiv').'</p>';
echo '<p style="color:#646970">Die synchronisierten <code>oo_*</code>-Felder werden beim nächsten Abgleich aus onOffice aktualisiert.</p>';
}
public function gallery_shortcode($atts = []) {
$atts = shortcode_atts(['id'=>get_the_ID(),'size'=>'large','class'=>'oo-gallery'], $atts, 'oo_gallery');
$ids = get_post_meta((int)$atts['id'], 'oo_gallery_ids', true); if (!is_array($ids) || !$ids) return '';
$html = '<div class="'.esc_attr($atts['class']).'">';
foreach ($ids as $id) { $img = wp_get_attachment_image((int)$id, sanitize_key($atts['size'])); if ($img) $html .= $img; }
return $html . '</div>';
}
public function register_gallery_assets() {
wp_register_style('fresh-oo-property-gallery', plugins_url('assets/css/property-gallery.css', __FILE__), [], self::VERSION);
wp_register_script('fresh-oo-property-gallery', plugins_url('assets/js/property-gallery.js', __FILE__), [], self::VERSION, true);
wp_register_style('fresh-oo-toolkit', plugins_url('assets/css/toolkit.css', __FILE__), [], self::VERSION);
wp_register_style('fresh-oo-maps', plugins_url('assets/css/maps.css', __FILE__), [], self::VERSION);
wp_register_script('fresh-oo-maps', plugins_url('assets/js/maps.js', __FILE__), [], self::VERSION, true);
}
public function enqueue_onoffice_color_overrides(): void {
$settings = $this->settings();
if (empty($settings['override_onoffice_colors'])) return;
$primary = sanitize_hex_color($settings['onoffice_primary_color'] ?? '') ?: '#b8945d';
$hover = sanitize_hex_color($settings['onoffice_hover_color'] ?? '') ?: '#171717';
$text = sanitize_hex_color($settings['onoffice_text_color'] ?? '') ?: '#ffffff';
wp_register_style('fresh-oo-official-overrides', false, [], self::VERSION);
wp_enqueue_style('fresh-oo-official-overrides');
$css = sprintf(
'.oo-detailview a{color:%1$s!important}.oo-listtd.--highlight,.oo-detailsgallery .slick-next:focus .slick-next-icon,.oo-detailsgallery .slick-next:focus .slick-prev-icon,.oo-detailsgallery .slick-prev:focus .slick-next-icon,.oo-detailsgallery .slick-prev:focus .slick-prev-icon,.slick-dots li button:focus .slick-dot-icon::before{color:%1$s!important}.oo-form input:focus,.oo-form textarea:focus,.oo-form select:focus,.oo-detailview a:focus,.oo-searchformfield input:focus,.ts-wrapper.focus,.ts-wrapper:focus-visible,.ts-wrapper.single.focus,.ts-wrapper.single:focus-visible,.ts-wrapper.multi.focus,.ts-wrapper.multi:focus-visible,.oo-post-nav-links a:focus,.leadform-back:focus,.leadform-forward:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline-color:%1$s!important;border-color:%1$s!important}.oo-form input[type=submit],.oo-searchformfield input[type=submit],.leadform-back,.leadform-forward,input[type=submit].match,.oo-detailslink a,.favorize,.oo-unitslink a,.oo-leadformlink{background-color:%1$s!important;color:%3$s!important}.oo-detailslink a:hover,button.favorize:hover,.oo-unitslink a:hover,.oo-form input[type=submit]:hover,.oo-searchformfield input[type=submit]:hover,.oo-leadformlink:hover,.oo-post-nav-links a:hover,button.favorize:focus,.oo-details-btn:focus,.oo-leadformlink:focus,.oo-form input[type=submit]:focus,.oo-searchformfield input[type=submit]:focus{background:%2$s!important;color:%3$s!important}.leaflet-container a.leaflet-popup-close-button:focus,.leaflet-bar a:hover,.leaflet-bar a:focus,.gm-style div:focus,.gm-style button:focus{outline-color:%2$s!important}',
$primary, $hover, $text
);
wp_add_inline_style('fresh-oo-official-overrides', $css);
}
public function register_elementor_category($elements_manager) {
$elements_manager->add_category('fresh-onoffice', [
'title' => 'onOffice · FRESH',
'icon' => 'fa fa-building',
]);
}
public function register_elementor_widgets($widgets_manager) {
if (!class_exists('\\Elementor\\Widget_Base')) return;
require_once __DIR__ . '/includes/elementor-widget-base.php';
$files = [
'elementor-property-gallery-widget.php' => 'FRESH_OO_Property_Gallery_Widget',
'elementor-property-image-widget.php' => 'FRESH_OO_Property_Image_Widget',
'elementor-field-widget.php' => 'FRESH_OO_Field_Widget',
'elementor-field-group-widget.php' => 'FRESH_OO_Field_Group_Widget',
'elementor-property-facts-widget.php' => 'FRESH_OO_Property_Facts_Widget',
'elementor-price-widget.php' => 'FRESH_OO_Price_Widget',
'elementor-features-widget.php' => 'FRESH_OO_Features_Widget',
'elementor-detail-table-widget.php' => 'FRESH_OO_Detail_Table_Widget',
'elementor-energy-widget.php' => 'FRESH_OO_Energy_Widget',
'elementor-property-meta-widget.php' => 'FRESH_OO_Property_Meta_Widget',
'elementor-address-widget.php' => 'FRESH_OO_Address_Widget',
'elementor-property-header-widget.php' => 'FRESH_OO_Property_Header_Widget',
'elementor-status-badge-widget.php' => 'FRESH_OO_Status_Badge_Widget',
'elementor-inquiry-form-widget.php' => 'FRESH_OO_Inquiry_Form_Widget',
'elementor-property-map-widget.php' => 'FRESH_OO_Property_Map_Widget',
'elementor-estates-map-widget.php' => 'FRESH_OO_Estates_Map_Widget',
];
foreach ($files as $file => $class) {
if (!class_exists($class, false)) require_once __DIR__ . '/includes/' . $file;
if (class_exists($class, false)) $widgets_manager->register(new $class());
}
}
public function register_elementor_dynamic_tags($dynamic_tags) {
if (!class_exists('Elementor\\Core\\DynamicTags\\Data_Tag') ||
!class_exists('Elementor\\Modules\\DynamicTags\\Module')) {
return;
}
if (!class_exists('FRESH_OO_Elementor_Gallery_Tag', false)) {
require_once __DIR__ . '/includes/elementor-gallery-tag.php';
}
if (class_exists('FRESH_OO_Elementor_Gallery_Tag', false)) {
$dynamic_tags->register(new FRESH_OO_Elementor_Gallery_Tag());
}
}
}
FRESH_OnOffice_Elementor_Bridge::instance();

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

includesmodule
assetsmodule
snapshotsmodule

Technology stack

PHPCSSMarkdownJavaScript
PHP86.6%
CSS5.9%
Markdown4.7%
JavaScript2.7%