1
<?php
2
/**
3
* Plugin Name: onOffice Elementor Bridge
4
* Description: Synchronisiert onOffice-Immobilien als lokale WordPress-Datensätze für Elementor Pro, SEO/GEO, Archivierung und freie Gestaltung.
5
* Version: 0.10.7
6
* Author: FRESH
7
* Requires at least: 6.2
8
* Requires PHP: 8.0
9
*/
10
11
defined('ABSPATH') || exit;
12
13
// Optional companion modules bundled with this plugin package.
14
if (file_exists(__DIR__ . '/includes/property-search.php')) require_once __DIR__ . '/includes/property-search.php';
15
if (file_exists(__DIR__ . '/includes/demo-seeder.php')) require_once __DIR__ . '/includes/demo-seeder.php';
16
if (file_exists(__DIR__ . '/includes/field-registry.php')) require_once __DIR__ . '/includes/field-registry.php';
17
18
final class FRESH_OnOffice_Elementor_Bridge {
19
const VERSION = '0.10.7';
20
const CPT = 'fresh_immobilie';
21
const CRON_HOOK = 'fresh_oo_bridge_sync';
22
const OPTION_LAST_SYNC = 'fresh_oo_bridge_last_sync';
23
const OPTION_LOG = 'fresh_oo_bridge_log';
24
const OPTION_SETTINGS = 'fresh_oo_bridge_settings';
25
const OPTION_SCHEMA = 'fresh_oo_bridge_field_schema';
26
const META_OO_ID = '_fresh_oo_id';
27
const META_PREFIX = 'oo_';
28
29
private static $instance = null;
30
31
public static function instance() { return self::$instance ?: (self::$instance = new self()); }
32
33
private function __construct() {
34
add_action('init', [$this, 'register_content_types']);
35
add_action('admin_menu', [$this, 'admin_menu']);
36
add_action('admin_post_fresh_oo_sync', [$this, 'handle_manual_sync']);
37
add_action('admin_post_fresh_oo_save_settings', [$this, 'handle_save_settings']);
38
add_action(self::CRON_HOOK, [$this, 'sync']);
39
add_filter('cron_schedules', [$this, 'cron_schedules']);
40
add_action('plugins_loaded', [$this, 'maybe_reschedule']);
41
add_filter('manage_' . self::CPT . '_posts_columns', [$this, 'admin_columns']);
42
add_action('manage_' . self::CPT . '_posts_custom_column', [$this, 'admin_column_content'], 10, 2);
43
add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
44
add_shortcode('oo_gallery', [$this, 'gallery_shortcode']);
45
add_action('elementor/dynamic_tags/register', [$this, 'register_elementor_dynamic_tags']);
46
add_action('elementor/widgets/register', [$this, 'register_elementor_widgets']);
47
add_action('elementor/elements/categories_registered', [$this, 'register_elementor_category']);
48
add_action('elementor/frontend/after_register_scripts', [$this, 'register_gallery_assets']);
49
add_action('elementor/frontend/after_register_styles', [$this, 'register_gallery_assets']);
50
add_action('wp_enqueue_scripts', [$this, 'enqueue_onoffice_color_overrides'], 99);
51
register_activation_hook(__FILE__, [__CLASS__, 'activate']);
52
register_deactivation_hook(__FILE__, [__CLASS__, 'deactivate']);
53
}
54
55
public static function activate() {
56
self::instance()->register_content_types();
57
flush_rewrite_rules();
58
$settings = self::instance()->settings();
59
if (!empty($settings['auto_sync']) && !wp_next_scheduled(self::CRON_HOOK)) {
60
wp_schedule_event(time() + 120, $settings['interval'], self::CRON_HOOK);
61
}
62
}
63
64
public static function deactivate() {
65
wp_clear_scheduled_hook(self::CRON_HOOK);
66
flush_rewrite_rules();
67
}
68
69
public function cron_schedules($schedules) {
70
$schedules['fresh_oo_15min'] = ['interval' => 900, 'display' => 'Alle 15 Minuten'];
71
$schedules['fresh_oo_30min'] = ['interval' => 1800, 'display' => 'Alle 30 Minuten'];
72
$schedules['fresh_oo_hourly'] = ['interval' => 3600, 'display' => 'Stündlich'];
73
return $schedules;
74
}
75
76
public function maybe_reschedule() {
77
$settings = $this->settings();
78
$wanted = $settings['interval'];
79
$event = wp_get_scheduled_event(self::CRON_HOOK);
80
if (empty($settings['auto_sync'])) {
81
if ($event) wp_clear_scheduled_hook(self::CRON_HOOK);
82
return;
83
}
84
if (!$event || $event->schedule !== $wanted) {
85
wp_clear_scheduled_hook(self::CRON_HOOK);
86
wp_schedule_event(time() + 120, $wanted, self::CRON_HOOK);
87
}
88
}
89
90
public function register_content_types() {
91
register_post_type(self::CPT, [
92
'labels' => [
93
'name' => 'Immobilien', 'singular_name' => 'Immobilie',
94
'add_new_item' => 'Immobilie hinzufügen', 'edit_item' => 'Immobilie bearbeiten',
95
'view_item' => 'Immobilie ansehen', 'search_items' => 'Immobilien durchsuchen',
96
],
97
'public' => true, 'show_ui' => true, 'show_in_rest' => true,
98
'menu_icon' => 'dashicons-building',
99
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
100
'has_archive' => true, 'rewrite' => ['slug' => 'immobilien'],
101
'publicly_queryable' => true, 'exclude_from_search' => false,
102
]);
103
104
$taxes = [
105
'immo_ort' => ['Ort', 'Orte', 'ort'],
106
'immo_objektart' => ['Objektart', 'Objektarten', 'objektart'],
107
'immo_vermarktungsart' => ['Vermarktungsart', 'Vermarktungsarten', 'vermarktungsart'],
108
'immo_status' => ['Status', 'Status', 'status'],
109
];
110
foreach ($taxes as $tax => $cfg) {
111
register_taxonomy($tax, self::CPT, [
112
'labels' => ['name' => $cfg[1], 'singular_name' => $cfg[0]],
113
'public' => true, 'show_ui' => true, 'show_in_rest' => true,
114
'hierarchical' => false, 'rewrite' => ['slug' => 'immobilien-' . $cfg[2]],
115
]);
116
}
117
}
118
119
private function settings() {
120
return wp_parse_args(get_option(self::OPTION_SETTINGS, []), [
121
'auto_sync' => 1,
122
'interval' => 'fresh_oo_30min',
123
'archive_missing' => 1,
124
'import_images' => 1,
125
'published_only' => 1,
126
'title_field' => 'objekttitel',
127
'content_field' => 'objektbeschreibung',
128
'excerpt_field' => 'lage',
129
'location_field' => 'ort',
130
'object_type_field' => 'objektart',
131
'marketing_type_field' => 'vermarktungsart',
132
'override_onoffice_colors' => 0,
133
'onoffice_primary_color' => '#b8945d',
134
'onoffice_hover_color' => '#171717',
135
'onoffice_text_color' => '#ffffff',
136
]);
137
}
138
139
private function official_plugin_ready() {
140
return class_exists('onOffice\\WPlugin\\SDKWrapper')
141
&& class_exists('onOffice\\WPlugin\\API\\APIClientActionGeneric')
142
&& class_exists('onOffice\\SDK\\onOfficeSDK');
143
}
144
145
public function admin_menu() {
146
add_submenu_page('edit.php?post_type=' . self::CPT, 'onOffice Sync', 'onOffice Sync', 'manage_options', 'fresh-oo-bridge', [$this, 'render_admin']);
147
}
148
149
private function schema_label($key, $props) {
150
foreach (['label', 'name', 'content'] as $candidate) {
151
if (!empty($props[$candidate]) && is_scalar($props[$candidate])) return wp_strip_all_tags((string)$props[$candidate]);
152
}
153
return $key;
154
}
155
156
private function field_select($name, $selected, array $schema, $fallbacks = []) {
157
$keys = array_values(array_unique(array_merge($fallbacks, array_keys($schema))));
158
echo '<select class="fresh-oo-select" name="' . esc_attr($name) . '">';
159
foreach ($keys as $key) {
160
$props = $schema[$key] ?? [];
161
$label = $this->schema_label($key, is_array($props) ? $props : []);
162
printf('<option value="%s" %s>%s — %s</option>', esc_attr($key), selected($selected, $key, false), esc_html($label), esc_html($key));
163
}
164
echo '</select>';
165
}
166
167
private function api_credentials_present(): bool {
168
return (string)get_option('onoffice-settings-apikey', '') !== ''
169
&& (string)get_option('onoffice-settings-apisecret', '') !== '';
170
}
171
172
private function last_log_state(array $log): array {
173
$last = $log ? (string)end($log) : '';
174
if ($last === '') return ['neutral', 'Noch kein Sync', 'Noch kein Protokoll vorhanden.'];
175
if (stripos($last, 'FEHLER') !== false || stripos($last, 'Abbruch') !== false) {
176
return ['error', 'Sync mit Fehler', $last];
177
}
178
if (preg_match('/Fertig:\s*(\d+) neu,\s*(\d+) aktualisiert,\s*(\d+) übersprungen/i', $last, $m)) {
179
return ['success', 'Sync erfolgreich', sprintf('%d neu · %d aktualisiert · %d übersprungen', $m[1], $m[2], $m[3])];
180
}
181
return ['neutral', 'Sync abgeschlossen', $last];
182
}
183
184
public function render_admin() {
185
if (!current_user_can('manage_options')) return;
186
$settings = $this->settings();
187
$last = get_option(self::OPTION_LAST_SYNC);
188
$log = (array)get_option(self::OPTION_LOG, []);
189
$schema = (array)get_option(self::OPTION_SCHEMA, []);
190
$ready = $this->official_plugin_ready();
191
$credentials = $this->api_credentials_present();
192
$counts = wp_count_posts(self::CPT);
193
[$log_state, $log_title, $log_summary] = $this->last_log_state($log);
194
$published = (int)($counts->publish ?? 0);
195
$archived = (int)($counts->draft ?? 0);
196
$next_sync = wp_next_scheduled(self::CRON_HOOK);
197
?>
198
<div class="wrap fresh-oo-admin">
199
<style>
200
.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)}
201
.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}
202
.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}
203
.fresh-oo-hero:after{content:"";position:absolute;right:-80px;top:-120px;width:320px;height:320px;border-radius:50%;background:rgba(255,255,255,.06)}
204
.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)}
205
.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}
206
.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}
207
.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)}
208
.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}
209
.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)}
210
.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}
211
.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}
212
.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}
213
.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}
214
.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}
215
.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)}
216
.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}
217
.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}
218
.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}
219
.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)}
220
@media(max-width:1000px){.fresh-oo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.fresh-oo-layout{grid-template-columns:1fr}}
221
@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}}
222
</style>
223
224
<h1 class="fresh-oo-admin-notice-anchor screen-reader-text">Immobilien-Sync</h1>
225
<section class="fresh-oo-hero">
226
<div class="fresh-oo-kicker">FRESH · onOffice Connector</div>
227
<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>
228
<p>Importiert freigegebene onOffice-Immobilien dauerhaft nach WordPress – inklusive Feldern, Taxonomien und Bildern. Elementor arbeitet anschließend mit echten lokalen Datensätzen.</p>
229
<div class="fresh-oo-actions">
230
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
231
<input type="hidden" name="action" value="fresh_oo_sync"><?php wp_nonce_field('fresh_oo_sync'); ?>
232
<button class="button button-primary" type="submit" <?php disabled(!$ready || !$credentials); ?>>↻ Jetzt synchronisieren</button>
233
</form>
234
<a class="button" href="<?php echo esc_url(admin_url('edit.php?post_type='.self::CPT)); ?>">Immobilien öffnen</a>
235
</div>
236
</section>
237
238
<div class="fresh-oo-grid">
239
<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>
240
<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>
241
<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>
242
<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>
243
</div>
244
245
<div class="fresh-oo-layout">
246
<div>
247
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
248
<input type="hidden" name="action" value="fresh_oo_save_settings"><?php wp_nonce_field('fresh_oo_save_settings'); ?>
249
250
<section class="fresh-oo-panel">
251
<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>
252
<div class="fresh-oo-panel-body">
253
<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>
254
<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>
255
<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>
256
<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>
257
<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>
258
</div>
259
</section>
260
261
<section class="fresh-oo-panel">
262
<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>
263
<div class="fresh-oo-panel-body">
264
<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>
265
<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>
266
<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>
267
<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>
268
<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>
269
<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>
270
</div>
271
</section>
272
273
<section class="fresh-oo-panel">
274
<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>
275
<div class="fresh-oo-panel-body">
276
<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>
277
<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>
278
<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>
279
<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>
280
<div class="fresh-oo-save"><button class="button button-primary" type="submit">Einstellungen speichern</button></div>
281
</div>
282
</section>
283
</form>
284
285
<?php if ($schema): ?>
286
<section class="fresh-oo-panel">
287
<details class="fresh-oo-details">
288
<summary>Erkannte onOffice-Felder <span class="fresh-oo-pill"><?php echo esc_html(count($schema)); ?> Felder</span></summary>
289
<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>
290
</details>
291
</section>
292
<?php endif; ?>
293
</div>
294
295
<aside>
296
<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>
297
<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>
298
<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>
299
<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>
300
</aside>
301
</div>
302
</div>
303
<?php
304
}
305
306
public function handle_save_settings() {
307
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
308
check_admin_referer('fresh_oo_save_settings');
309
$allowed_intervals = ['fresh_oo_15min','fresh_oo_30min','fresh_oo_hourly'];
310
$old = $this->settings();
311
$settings = [
312
'auto_sync' => !empty($_POST['auto_sync']) ? 1 : 0,
313
'interval' => in_array($_POST['interval'] ?? '', $allowed_intervals, true) ? sanitize_text_field($_POST['interval']) : 'fresh_oo_30min',
314
'archive_missing' => !empty($_POST['archive_missing']) ? 1 : 0,
315
'import_images' => !empty($_POST['import_images']) ? 1 : 0,
316
'published_only' => !empty($_POST['published_only']) ? 1 : 0,
317
'title_field' => sanitize_key($_POST['title_field'] ?? $old['title_field']),
318
'content_field' => sanitize_key($_POST['content_field'] ?? $old['content_field']),
319
'excerpt_field' => sanitize_key($_POST['excerpt_field'] ?? $old['excerpt_field']),
320
'location_field' => sanitize_key($_POST['location_field'] ?? $old['location_field']),
321
'object_type_field' => sanitize_key($_POST['object_type_field'] ?? $old['object_type_field']),
322
'marketing_type_field' => sanitize_key($_POST['marketing_type_field'] ?? $old['marketing_type_field']),
323
'override_onoffice_colors' => !empty($_POST['override_onoffice_colors']) ? 1 : 0,
324
'onoffice_primary_color' => sanitize_hex_color($_POST['onoffice_primary_color'] ?? '') ?: $old['onoffice_primary_color'],
325
'onoffice_hover_color' => sanitize_hex_color($_POST['onoffice_hover_color'] ?? '') ?: $old['onoffice_hover_color'],
326
'onoffice_text_color' => sanitize_hex_color($_POST['onoffice_text_color'] ?? '') ?: $old['onoffice_text_color'],
327
];
328
update_option(self::OPTION_SETTINGS, $settings, false);
329
wp_clear_scheduled_hook(self::CRON_HOOK);
330
if ($settings['auto_sync']) wp_schedule_event(time()+120, $settings['interval'], self::CRON_HOOK);
331
wp_safe_redirect(admin_url('edit.php?post_type='.self::CPT.'&page=fresh-oo-bridge&saved=1'));
332
exit;
333
}
334
335
public function handle_manual_sync() {
336
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
337
check_admin_referer('fresh_oo_sync');
338
$this->sync();
339
wp_safe_redirect(admin_url('edit.php?post_type='.self::CPT.'&page=fresh-oo-bridge&synced=1'));
340
exit;
341
}
342
343
public function sync() {
344
$log = ['Sync gestartet: ' . wp_date('d.m.Y H:i:s')];
345
if (!$this->official_plugin_ready()) {
346
update_option(self::OPTION_LOG, array_merge($log, ['Abbruch: offizielles onOffice-Plugin nicht aktiv oder inkompatibel.']), false);
347
return;
348
}
349
try {
350
$fields = $this->fetch_estate_fields();
351
if (!$fields) throw new Exception('Keine Estate-Felder von onOffice erhalten.');
352
ksort($fields);
353
update_option(self::OPTION_SCHEMA, $fields, false);
354
$log[] = count($fields) . ' onOffice-Felder erkannt.';
355
356
$records = $this->fetch_estates(array_keys($fields), $log);
357
$settings = $this->settings();
358
$seen = [];
359
$created = $updated = $skipped = 0;
360
361
foreach ($records as $record) {
362
$oo_id = (int)($record['id'] ?? 0);
363
$elements = is_array($record['elements'] ?? null) ? $record['elements'] : [];
364
if (!$oo_id) continue;
365
366
$existing = $this->find_post_by_oo_id($oo_id);
367
368
if ($settings['published_only']) {
369
$published = (string)($elements['veroeffentlichen'] ?? '0') === '1';
370
$reference = (string)($elements['referenz'] ?? '0') === '1';
371
if (!$published || $reference) {
372
// An object that is no longer released for the website must never
373
// remain publicly visible in WordPress. Keep the local copy, but
374
// immediately switch it to draft so it can be re-published on the
375
// next sync when onOffice releases it again.
376
if ($existing && get_post_status($existing) === 'publish') {
377
wp_update_post(['ID' => $existing, 'post_status' => 'draft']);
378
update_post_meta($existing, '_fresh_oo_unpublished_at', time());
379
update_post_meta($existing, '_fresh_oo_last_seen', time());
380
wp_set_object_terms($existing, $reference ? 'Referenz' : 'Nicht veröffentlicht', 'immo_status', false);
381
$log[] = sprintf('Objekt %d auf Entwurf gesetzt (%s).', $oo_id, $reference ? 'Referenzobjekt' : 'Website-Freigabe deaktiviert');
382
}
383
// Treat it as seen so archive_missing does not classify the same
384
// record as "missing" as well.
385
$seen[] = $oo_id;
386
$skipped++;
387
continue;
388
}
389
}
390
391
$seen[] = $oo_id;
392
$title = $this->value_or_fallback($elements, $settings['title_field'], ['objekttitel','objektbezeichnung','strasse'], 'Immobilie #' . $oo_id);
393
$content = $this->value_or_fallback($elements, $settings['content_field'], ['objektbeschreibung','freitext'], '');
394
$excerpt = $this->value_or_fallback($elements, $settings['excerpt_field'], ['lage','ausstatt_beschr'], '');
395
396
$postarr = [
397
'post_type' => self::CPT, 'post_status' => 'publish',
398
'post_title' => wp_strip_all_tags($this->stringify($title)),
399
'post_content' => wp_kses_post($this->stringify($content)),
400
'post_excerpt' => wp_strip_all_tags($this->stringify($excerpt)),
401
];
402
if ($existing) $postarr['ID'] = $existing;
403
$post_id = wp_insert_post($postarr, true);
404
if (is_wp_error($post_id)) { $log[] = 'Fehler bei Objekt '.$oo_id.': '.$post_id->get_error_message(); continue; }
405
406
update_post_meta($post_id, self::META_OO_ID, $oo_id);
407
update_post_meta($post_id, '_fresh_oo_last_seen', time());
408
delete_post_meta($post_id, '_fresh_oo_archived_at');
409
delete_post_meta($post_id, '_fresh_oo_unpublished_at');
410
update_post_meta($post_id, '_fresh_oo_raw', wp_json_encode($elements, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
411
412
foreach ($elements as $key => $value) update_post_meta($post_id, self::META_PREFIX . sanitize_key($key), $value);
413
414
$this->sync_taxonomy($post_id, 'immo_ort', $elements[$settings['location_field']] ?? null);
415
$this->sync_taxonomy($post_id, 'immo_objektart', $elements[$settings['object_type_field']] ?? null);
416
$this->sync_taxonomy($post_id, 'immo_vermarktungsart', $elements[$settings['marketing_type_field']] ?? null);
417
wp_set_object_terms($post_id, 'Aktiv', 'immo_status', false);
418
419
if ($settings['import_images']) $this->sync_images($post_id, $oo_id, $log);
420
$existing ? $updated++ : $created++;
421
}
422
423
if ($settings['archive_missing']) $this->archive_missing($seen, $log);
424
$log[] = sprintf('Fertig: %d neu, %d aktualisiert, %d übersprungen.', $created, $updated, $skipped);
425
update_option(self::OPTION_LAST_SYNC, time(), false);
426
} catch (Throwable $e) {
427
$message = trim((string)$e->getMessage());
428
$log[] = 'FEHLER: ' . ($message !== '' ? $message : get_class($e));
429
}
430
update_option(self::OPTION_LOG, $log, false);
431
}
432
433
private function sdk_wrapper() { $class = 'onOffice\\WPlugin\\SDKWrapper'; return new $class(); }
434
private function api_action($wrapper, $action_id, $resource) { $class = 'onOffice\\WPlugin\\API\\APIClientActionGeneric'; return new $class($wrapper, $action_id, $resource); }
435
436
private function fetch_estate_fields() {
437
$wrapper = $this->sdk_wrapper();
438
$sdk = 'onOffice\\SDK\\onOfficeSDK';
439
$action = $this->api_action($wrapper, constant($sdk.'::ACTION_ID_GET'), 'fields');
440
$action->setParameters([
441
'labels' => true, 'showContent' => true, 'showTable' => true,
442
'language' => substr(determine_locale(), 0, 2),
443
'modules' => [constant($sdk.'::MODULE_ESTATE')], 'realDataTypes' => true,
444
]);
445
$action->addRequestToQueue()->sendRequests(false);
446
$records = $action->getResultRecords();
447
$fields = [];
448
foreach ($records as $module) {
449
if (($module['id'] ?? '') !== constant($sdk.'::MODULE_ESTATE')) continue;
450
foreach ((array)($module['elements'] ?? []) as $name => $props) {
451
if ($name === 'label') continue;
452
$fields[$name] = is_array($props) ? $props : [];
453
}
454
}
455
foreach (['objekttitel','objektbeschreibung','veroeffentlichen','referenz','lage','ausstatt_beschr','ort','objektart','vermarktungsart'] as $f) $fields[$f] = $fields[$f] ?? [];
456
return $fields;
457
}
458
459
private function fetch_estates(array $field_names, array &$log) {
460
// Start with a deliberately small, known-good core set. Some onOffice accounts
461
// expose schema entries that cannot actually be read for estates. Those fields
462
// must not be allowed to abort the complete import.
463
$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'];
464
$core = array_values(array_unique(array_intersect($core, array_values(array_unique(array_merge($core, $field_names))))));
465
$extra = array_values(array_diff(array_values(array_unique($field_names)), $core));
466
467
$merged = [];
468
$log[] = sprintf('Kernfelder (%d) werden geladen …', count($core));
469
$core_records = $this->fetch_estate_chunk_resilient($core, $log, true);
470
foreach ($core_records as $record) {
471
$id = (int)($record['id'] ?? 0); if (!$id) continue;
472
$merged[$id] = ['id' => $id, 'elements' => (array)($record['elements'] ?? [])];
473
}
474
$log[] = sprintf('%d Immobilien über Kernfelder gefunden.', count($merged));
475
476
if (!$merged || !$extra) return array_values($merged);
477
478
$chunks = array_chunk($extra, 30);
479
foreach ($chunks as $i => $chunk) {
480
$log[] = sprintf('Zusatzfelder %d/%d (%d Felder) …', $i+1, count($chunks), count($chunk));
481
foreach ($this->fetch_estate_chunk_resilient($chunk, $log, false) as $record) {
482
$id = (int)($record['id'] ?? 0); if (!$id || !isset($merged[$id])) continue;
483
$merged[$id]['elements'] = array_replace($merged[$id]['elements'], (array)($record['elements'] ?? []));
484
}
485
}
486
return array_values($merged);
487
}
488
489
private function fetch_estate_chunk_resilient(array $fields, array &$log, bool $required = false) {
490
if (!$fields) return [];
491
try {
492
return $this->fetch_estate_chunk($fields);
493
} catch (Throwable $e) {
494
$message = trim((string)$e->getMessage());
495
if (count($fields) === 1) {
496
$log[] = sprintf('onOffice-Feld übersprungen: %s%s', $fields[0], $message !== '' ? ' — ' . $message : '');
497
if ($required) {
498
$log[] = 'Hinweis: Ein Kernfeld war nicht lesbar; Import wird mit den übrigen Kernfeldern fortgesetzt.';
499
}
500
return [];
501
}
502
503
// Binary split isolates incompatible fields without throwing away the
504
// readable part of the schema.
505
$mid = max(1, intdiv(count($fields), 2));
506
$left = array_slice($fields, 0, $mid);
507
$right = array_slice($fields, $mid);
508
$left_records = $this->fetch_estate_chunk_resilient($left, $log, $required);
509
$right_records = $this->fetch_estate_chunk_resilient($right, $log, $required);
510
511
$merged = [];
512
foreach (array_merge($left_records, $right_records) as $record) {
513
$id = (int)($record['id'] ?? 0); if (!$id) continue;
514
if (!isset($merged[$id])) $merged[$id] = ['id' => $id, 'elements' => []];
515
$merged[$id]['elements'] = array_replace($merged[$id]['elements'], (array)($record['elements'] ?? []));
516
}
517
return array_values($merged);
518
}
519
}
520
521
private function fetch_estate_chunk(array $data) {
522
$all = []; $offset = 0; $limit = 500;
523
do {
524
$wrapper = $this->sdk_wrapper();
525
$sdk = 'onOffice\\SDK\\onOfficeSDK';
526
$action = $this->api_action($wrapper, constant($sdk.'::ACTION_ID_READ'), 'estate');
527
$action->setParameters([
528
'data' => $data, 'listlimit' => $limit, 'listoffset' => $offset,
529
'formatoutput' => false, 'addMainLangId' => true,
530
'estatelanguage' => substr(determine_locale(), 0, 2),
531
'outputlanguage' => substr(determine_locale(), 0, 2),
532
]);
533
$action->addRequestToQueue()->sendRequests(false);
534
$records = $action->getResultRecords();
535
$meta = $action->getResultMeta();
536
$all = array_merge($all, $records);
537
$absolute = (int)($meta['cntabsolute'] ?? count($all));
538
$offset += $limit;
539
} while ($offset < $absolute && !empty($records));
540
return $all;
541
}
542
543
private function sync_taxonomy($post_id, $taxonomy, $value) {
544
$terms = [];
545
if (is_array($value)) {
546
foreach ($value as $v) if (is_scalar($v) && trim((string)$v) !== '') $terms[] = trim((string)$v);
547
} elseif (is_scalar($value) && trim((string)$value) !== '') $terms[] = trim((string)$value);
548
wp_set_object_terms($post_id, array_values(array_unique($terms)), $taxonomy, false);
549
}
550
551
private function sync_images($post_id, $oo_id, array &$log) {
552
$wrapper = $this->sdk_wrapper();
553
$sdk = 'onOffice\\SDK\\onOfficeSDK';
554
$action = $this->api_action($wrapper, constant($sdk.'::ACTION_ID_GET'), 'estatepictures');
555
$action->setParameters([
556
'estateids' => [$oo_id],
557
'categories' => ['Titelbild','Foto','Foto_gross','Grundriss','Panorama','Lageplan','Epass_Skala'],
558
'language' => substr(determine_locale(), 0, 2),
559
]);
560
$action->addRequestToQueue()->sendRequests(false);
561
if (!$action->getResultStatus()) return;
562
$records = $action->getResultRecords();
563
$images = [];
564
foreach ($records as $file) {
565
$file_id = (string)($file['id'] ?? '');
566
foreach ((array)($file['elements'] ?? []) as $properties) {
567
if ((int)($properties['estateid'] ?? 0) !== $oo_id) continue;
568
$url = (string)($properties['url'] ?? ''); if (!$url) continue;
569
if (!preg_match('~^https?://~i', $url)) $url = 'https://' . ltrim($url, '/');
570
$images[] = [
571
'oo_file_id' => $file_id, 'url' => esc_url_raw($url),
572
'title' => sanitize_text_field($properties['title'] ?? ''),
573
'text' => sanitize_textarea_field($properties['text'] ?? ''),
574
'type' => sanitize_text_field($properties['type'] ?? ''),
575
];
576
}
577
}
578
update_post_meta($post_id, 'oo_gallery_remote', $images);
579
if (!$images) { delete_post_meta($post_id, 'oo_gallery_ids'); return; }
580
581
$attachment_ids = []; $featured = 0;
582
foreach ($images as $img) {
583
$attachment_id = $this->find_attachment_by_source($img['oo_file_id']);
584
if (!$attachment_id) $attachment_id = $this->sideload_image($img, $post_id);
585
if ($attachment_id) {
586
$attachment_ids[] = (int)$attachment_id;
587
if ($img['type'] === 'Titelbild') $featured = (int)$attachment_id;
588
}
589
}
590
$attachment_ids = array_values(array_unique($attachment_ids));
591
update_post_meta($post_id, 'oo_gallery_ids', $attachment_ids);
592
update_post_meta($post_id, 'oo_gallery_csv', implode(',', $attachment_ids));
593
if ($featured) set_post_thumbnail($post_id, $featured);
594
elseif ($attachment_ids && !has_post_thumbnail($post_id)) set_post_thumbnail($post_id, $attachment_ids[0]);
595
}
596
597
private function find_attachment_by_source($file_id) {
598
if (!$file_id) return 0;
599
$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]]]);
600
return $q->posts ? (int)$q->posts[0] : 0;
601
}
602
603
private function sideload_image(array $img, $post_id) {
604
require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php';
605
$tmp = download_url($img['url'], 30); if (is_wp_error($tmp)) return 0;
606
$path = wp_parse_url($img['url'], PHP_URL_PATH);
607
$name = sanitize_file_name(basename($path ?: 'onoffice-image.jpg')) ?: 'onoffice-image.jpg';
608
$id = media_handle_sideload(['name'=>$name,'tmp_name'=>$tmp], $post_id, $img['title'], ['post_excerpt'=>$img['text']]);
609
if (is_wp_error($id)) { @unlink($tmp); return 0; }
610
update_post_meta($id, '_fresh_oo_file_id', $img['oo_file_id']); update_post_meta($id, '_fresh_oo_source_url', $img['url']);
611
if ($img['text']) update_post_meta($id, '_wp_attachment_image_alt', wp_strip_all_tags($img['text']));
612
return (int)$id;
613
}
614
615
private function find_post_by_oo_id($oo_id) {
616
$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]);
617
return $q->posts ? (int)$q->posts[0] : 0;
618
}
619
620
private function archive_missing(array $seen, array &$log) {
621
$ids = get_posts(['post_type'=>self::CPT,'post_status'=>['publish','draft'],'numberposts'=>-1,'fields'=>'ids']); $archived = 0;
622
foreach ($ids as $post_id) {
623
$oo_id = (int)get_post_meta($post_id, self::META_OO_ID, true);
624
if ($oo_id && !in_array($oo_id, $seen, true) && get_post_status($post_id) === 'publish') {
625
wp_update_post(['ID'=>$post_id,'post_status'=>'draft']);
626
update_post_meta($post_id, '_fresh_oo_archived_at', time());
627
wp_set_object_terms($post_id, 'Archiviert', 'immo_status', false); $archived++;
628
}
629
}
630
if ($archived) $log[] = $archived . ' nicht mehr gelistete Objekte archiviert.';
631
}
632
633
private function stringify($value) {
634
if (is_scalar($value) || $value === null) return (string)$value;
635
if (is_array($value)) return implode(', ', array_map([$this, 'stringify'], $value));
636
return '';
637
}
638
639
private function value_or_fallback(array $elements, $preferred, array $fallbacks, $fallback = '') {
640
$keys = array_values(array_unique(array_merge([$preferred], $fallbacks)));
641
foreach ($keys as $key) if ($key && isset($elements[$key]) && $elements[$key] !== '' && $elements[$key] !== null) return $elements[$key];
642
return $fallback;
643
}
644
645
public function admin_columns($columns) {
646
$out = [];
647
foreach ($columns as $k => $v) {
648
$out[$k] = $v;
649
if ($k === 'title') { $out['oo_id'] = 'onOffice-ID'; $out['oo_sync'] = 'Sync-Status'; }
650
}
651
return $out;
652
}
653
654
public function admin_column_content($column, $post_id) {
655
if ($column === 'oo_id') echo esc_html((string)get_post_meta($post_id, self::META_OO_ID, true));
656
if ($column === 'oo_sync') {
657
$last = (int)get_post_meta($post_id, '_fresh_oo_last_seen', true);
658
$archived = (int)get_post_meta($post_id, '_fresh_oo_archived_at', true);
659
if ($archived) echo '<span style="color:#996800">● Archiviert</span><br><small>' . esc_html(wp_date('d.m.Y H:i', $archived)) . '</small>';
660
elseif ($last) echo '<span style="color:#008a20">● Synchron</span><br><small>' . esc_html(wp_date('d.m.Y H:i', $last)) . '</small>';
661
else echo '—';
662
}
663
}
664
665
public function add_meta_boxes() { add_meta_box('fresh_oo_sync_info','onOffice Sync',[$this,'render_sync_metabox'],self::CPT,'side','high'); }
666
667
public function render_sync_metabox($post) {
668
$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);
669
echo '<p><strong>onOffice-ID:</strong><br><code>'.esc_html((string)$id).'</code></p>';
670
echo '<p><strong>Letzter Abgleich:</strong><br>'.($last?esc_html(wp_date('d.m.Y H:i:s',$last)):'—').'</p>';
671
echo '<p><strong>Status:</strong><br>'.($arch?'Archiviert seit '.esc_html(wp_date('d.m.Y H:i:s',$arch)):'Aktiv').'</p>';
672
echo '<p style="color:#646970">Die synchronisierten <code>oo_*</code>-Felder werden beim nächsten Abgleich aus onOffice aktualisiert.</p>';
673
}
674
675
public function gallery_shortcode($atts = []) {
676
$atts = shortcode_atts(['id'=>get_the_ID(),'size'=>'large','class'=>'oo-gallery'], $atts, 'oo_gallery');
677
$ids = get_post_meta((int)$atts['id'], 'oo_gallery_ids', true); if (!is_array($ids) || !$ids) return '';
678
$html = '<div class="'.esc_attr($atts['class']).'">';
679
foreach ($ids as $id) { $img = wp_get_attachment_image((int)$id, sanitize_key($atts['size'])); if ($img) $html .= $img; }
680
return $html . '</div>';
681
}
682
683
684
public function register_gallery_assets() {
685
wp_register_style('fresh-oo-property-gallery', plugins_url('assets/css/property-gallery.css', __FILE__), [], self::VERSION);
686
wp_register_script('fresh-oo-property-gallery', plugins_url('assets/js/property-gallery.js', __FILE__), [], self::VERSION, true);
687
wp_register_style('fresh-oo-toolkit', plugins_url('assets/css/toolkit.css', __FILE__), [], self::VERSION);
688
wp_register_style('fresh-oo-maps', plugins_url('assets/css/maps.css', __FILE__), [], self::VERSION);
689
wp_register_script('fresh-oo-maps', plugins_url('assets/js/maps.js', __FILE__), [], self::VERSION, true);
690
}
691
692
public function enqueue_onoffice_color_overrides(): void {
693
$settings = $this->settings();
694
if (empty($settings['override_onoffice_colors'])) return;
695
$primary = sanitize_hex_color($settings['onoffice_primary_color'] ?? '') ?: '#b8945d';
696
$hover = sanitize_hex_color($settings['onoffice_hover_color'] ?? '') ?: '#171717';
697
$text = sanitize_hex_color($settings['onoffice_text_color'] ?? '') ?: '#ffffff';
698
wp_register_style('fresh-oo-official-overrides', false, [], self::VERSION);
699
wp_enqueue_style('fresh-oo-official-overrides');
700
$css = sprintf(
701
'.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}',
702
$primary, $hover, $text
703
);
704
wp_add_inline_style('fresh-oo-official-overrides', $css);
705
}
706
707
public function register_elementor_category($elements_manager) {
708
$elements_manager->add_category('fresh-onoffice', [
709
'title' => 'onOffice · FRESH',
710
'icon' => 'fa fa-building',
711
]);
712
}
713
714
public function register_elementor_widgets($widgets_manager) {
715
if (!class_exists('\\Elementor\\Widget_Base')) return;
716
require_once __DIR__ . '/includes/elementor-widget-base.php';
717
$files = [
718
'elementor-property-gallery-widget.php' => 'FRESH_OO_Property_Gallery_Widget',
719
'elementor-property-image-widget.php' => 'FRESH_OO_Property_Image_Widget',
720
'elementor-field-widget.php' => 'FRESH_OO_Field_Widget',
721
'elementor-field-group-widget.php' => 'FRESH_OO_Field_Group_Widget',
722
'elementor-property-facts-widget.php' => 'FRESH_OO_Property_Facts_Widget',
723
'elementor-price-widget.php' => 'FRESH_OO_Price_Widget',
724
'elementor-features-widget.php' => 'FRESH_OO_Features_Widget',
725
'elementor-detail-table-widget.php' => 'FRESH_OO_Detail_Table_Widget',
726
'elementor-energy-widget.php' => 'FRESH_OO_Energy_Widget',
727
'elementor-property-meta-widget.php' => 'FRESH_OO_Property_Meta_Widget',
728
'elementor-address-widget.php' => 'FRESH_OO_Address_Widget',
729
'elementor-property-header-widget.php' => 'FRESH_OO_Property_Header_Widget',
730
'elementor-status-badge-widget.php' => 'FRESH_OO_Status_Badge_Widget',
731
'elementor-inquiry-form-widget.php' => 'FRESH_OO_Inquiry_Form_Widget',
732
'elementor-property-map-widget.php' => 'FRESH_OO_Property_Map_Widget',
733
'elementor-estates-map-widget.php' => 'FRESH_OO_Estates_Map_Widget',
734
];
735
foreach ($files as $file => $class) {
736
if (!class_exists($class, false)) require_once __DIR__ . '/includes/' . $file;
737
if (class_exists($class, false)) $widgets_manager->register(new $class());
738
}
739
}
740
741
public function register_elementor_dynamic_tags($dynamic_tags) {
742
if (!class_exists('Elementor\\Core\\DynamicTags\\Data_Tag') ||
743
!class_exists('Elementor\\Modules\\DynamicTags\\Module')) {
744
return;
745
}
746
747
if (!class_exists('FRESH_OO_Elementor_Gallery_Tag', false)) {
748
require_once __DIR__ . '/includes/elementor-gallery-tag.php';
749
}
750
751
if (class_exists('FRESH_OO_Elementor_Gallery_Tag', false)) {
752
$dynamic_tags->register(new FRESH_OO_Elementor_Gallery_Tag());
753
}
754
}
755
756
}
757
758
FRESH_OnOffice_Elementor_Bridge::instance();
759