diff --git a/d2s-discourse-map/d2s-discourse-map.php b/d2s-discourse-map/d2s-discourse-map.php
new file mode 100644
index 0000000..6c7c289
--- /dev/null
+++ b/d2s-discourse-map/d2s-discourse-map.php
@@ -0,0 +1,275 @@
+ 'logbuch',
+ 'category_map' => ['privatlog' => 'privatlog'],
+ ], false);
+ }
+ }
+
+ public function add_menu() {
+ add_options_page(
+ 'Discourse Tag Automapper',
+ 'Discourse Tag Automapper',
+ 'manage_options',
+ 'd2s-discourse-tag-automapper',
+ [$this, 'render_page']
+ );
+ }
+
+ private function sanitize_tag($s) {
+ $s = is_string($s) ? trim($s) : '';
+ $s = strtolower($s);
+ $s = str_replace([',',';'], '', $s);
+ $s = preg_replace('/\s+/', '-', $s);
+ $s = preg_replace('/[^a-z0-9\-_]/', '', $s);
+ return $s;
+ }
+
+ private function handle_save() {
+ if (!current_user_can('manage_options')) return;
+ if (!isset($_POST['d2s_discourse_tag_nonce']) || !wp_verify_nonce($_POST['d2s_discourse_tag_nonce'], 'd2s_discourse_tag_save')) return;
+
+ $fallback = isset($_POST['fallback_tag']) ? $this->sanitize_tag(wp_unslash($_POST['fallback_tag'])) : '';
+ $raw_map = isset($_POST['category_map']) ? (array) $_POST['category_map'] : [];
+
+ $map = [];
+ foreach ($raw_map as $cat_id => $tag) {
+ $tag = $this->sanitize_tag(wp_unslash($tag));
+ $cat_id = intval($cat_id);
+ if ($cat_id > 0 && $tag !== '') {
+ $term = get_term($cat_id, 'category');
+ if ($term && !is_wp_error($term)) $map[$term->slug] = $tag;
+ }
+ }
+
+ update_option(self::OPTION, [
+ 'fallback_tag' => $fallback !== '' ? $fallback : 'logbuch',
+ 'category_map' => $map,
+ ], false);
+
+ add_settings_error('d2s_discourse_tag_automapper', 'saved', 'Einstellungen gespeichert.', 'updated');
+ }
+
+ public function render_page() {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->handle_save();
+
+ $opt = get_option(self::OPTION);
+ $fallback = $opt['fallback_tag'] ?? 'logbuch';
+ $map = (array)($opt['category_map'] ?? []);
+
+ $cats = get_categories(['hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC']);
+
+ settings_errors('d2s_discourse_tag_automapper'); ?>
+
+
Discourse Tag Automapper
+
+
+ slug;
+ }
+ return $list;
+ }
+
+ private function compute_tags_for_post($post) {
+ $opt = get_option(self::OPTION);
+ $fallback = $opt['fallback_tag'] ?? 'logbuch';
+ $map = (array)($opt['category_map'] ?? []);
+
+ $auto = $fallback;
+ $terms = get_the_terms($post, 'category');
+
+ if (!empty($terms) && !is_wp_error($terms)) {
+ usort($terms, fn($a,$b)=> strcmp($a->name, $b->name));
+ foreach ($terms as $t) {
+ if (isset($map[$t->slug]) && $map[$t->slug] !== '') { $auto = $map[$t->slug]; break; }
+ }
+ }
+
+ $tags = [$auto]; // kein Merge mit älteren Meta-Werten
+ $tags = array_map([$this, 'sanitize_tag'], $tags);
+ $tags = array_values(array_unique(array_filter($tags)));
+
+ $this->log('[compute] post_id='.$post->ID.' cats='.wp_json_encode($this->get_post_categories_debug($post->ID)).' -> tags='.wp_json_encode($tags));
+ return $tags;
+ }
+
+ /* ===== ältere Filterpfade: falls deine Version sie nutzt ===== */
+
+ public function apply_mapping_to_params($params, $post) {
+ if (!$post instanceof WP_Post) return $params;
+ $tags = $this->compute_tags_for_post($post);
+ $params['tags'] = $tags;
+ $params['tag_names'] = $tags;
+ $this->log('[params] post_id='.$post->ID.' tags='.wp_json_encode($tags));
+ return $params;
+ }
+
+ /* ===== Späte/robuste Wege ===== */
+
+ public function apply_mapping_on_save($post_id, $post, $update) {
+ if (wp_is_post_revision($post_id)) return;
+ if ($post->post_type !== 'post') return;
+
+ $tags = $this->compute_tags_for_post($post);
+
+ update_post_meta($post_id, 'wpdc_discourse_tags', implode(',', $tags));
+ update_post_meta($post_id, 'discourse_tags', implode(',', $tags));
+ $this->log('[save_post] post_id='.$post_id.' tags='.wp_json_encode($tags));
+ }
+
+ public function apply_mapping_after_insert($post_id, $post, $update, $post_before) {
+ if ($post->post_type !== 'post') return;
+ $tags = $this->compute_tags_for_post($post);
+ update_post_meta($post_id, 'wpdc_discourse_tags', implode(',', $tags));
+ update_post_meta($post_id, 'discourse_tags', implode(',', $tags));
+ $this->log('[after_insert] post_id='.$post_id.' tags='.wp_json_encode($tags));
+ }
+
+ public function remember_post_on_publish($new_status, $old_status, $post) {
+ if (!$post instanceof WP_Post || $post->post_type !== 'post') return;
+ if ($new_status === 'publish' && $old_status !== 'publish') {
+ // ID 30s merken – reicht für den direkt anschließenden Publish-Request
+ set_transient(self::LAST_PUB_TX, intval($post->ID), 30);
+ $this->log('[remember] post_id='.$post->ID.' (publish transient gesetzt)');
+ }
+ }
+
+ public function apply_mapping_on_status_change($new_status, $old_status, $post) {
+ if (!$post instanceof WP_Post || $post->post_type !== 'post') return;
+ if ($new_status === 'publish' && $old_status !== 'publish') {
+ $this->apply_mapping_on_save($post->ID, $post, true);
+ $this->log('[status_change] post_id='.$post->ID.' '.$old_status.'→'.$new_status);
+ }
+ }
+
+ /* ===== Final: Publish-Body patchen (garantiert im Request) ===== */
+
+ public function inject_tags_into_publish_body($body, $remote_post_type) {
+ // 1) Versuche $_REQUEST (falls die WP-Discourse-Version das setzt)
+ $post_id = isset($_REQUEST['post_id']) ? intval($_REQUEST['post_id']) : (isset($_REQUEST['post']) ? intval($_REQUEST['post']) : 0);
+
+ // 2) Fallback: nimm die zuletzt veröffentlichte ID aus dem Transient
+ if (!$post_id) {
+ $post_id = intval(get_transient(self::LAST_PUB_TX));
+ if ($post_id) {
+ $this->log('[publish_body] fallback via transient post_id='.$post_id);
+ }
+ }
+
+ $post = $post_id ? get_post($post_id) : null;
+
+ if ($post instanceof WP_Post && $post->post_type === 'post') {
+ $tags = $this->compute_tags_for_post($post); // frisch, aus den aktuellen Kategorien
+ if (!empty($tags)) {
+ $body['tags'] = $tags;
+ $body['tag_names'] = $tags;
+ $this->log('[publish_body] post_id='.$post->ID.' type='.$remote_post_type.' cats='.wp_json_encode($this->get_post_categories_debug($post->ID)).' tags='.wp_json_encode($tags));
+ } else {
+ $this->log('[publish_body] post_id='.$post->ID.' type='.$remote_post_type.' tags=[] (leer)');
+ }
+ } else {
+ $this->log('[publish_body] kein gültiger Post-Kontext');
+ }
+ return $body;
+ }
+
+private function log($line) {
+ $file = trailingslashit(WP_CONTENT_DIR) . self::LOGFILE;
+ if (file_exists($file) && filesize($file) > 200 * 1024) {
+ @unlink($file); // rotate
+ }
+ $date = gmdate('Y-m-d H:i:s');
+ @file_put_contents($file, '['.$date.' UTC] '.$line.PHP_EOL, FILE_APPEND);
+}
+
+ public static function on_uninstall() {
+ delete_option(self::OPTION);
+ // Log bleibt liegen
+ }
+}
+
+new D2S_Discourse_Tag_Automapper();
+register_uninstall_hook(__FILE__, ['D2S_Discourse_Tag_Automapper', 'on_uninstall']);
\ No newline at end of file
diff --git a/d2s-discourse-map/d2s-discourse-map.zip b/d2s-discourse-map/d2s-discourse-map.zip
new file mode 100644
index 0000000..964323e
Binary files /dev/null and b/d2s-discourse-map/d2s-discourse-map.zip differ