diff --git a/includes/class-importer.php b/includes/class-importer.php index d1d149f..9034276 100644 --- a/includes/class-importer.php +++ b/includes/class-importer.php @@ -9,38 +9,42 @@ if (!defined('ABSPATH')) { class Thalim_HAL_Importer_Logic { - // HAL doc type -> WordPress category ID - private const DOC_TYPE_MAP = [ - 'ART' => 16, // Article - 'COUV' => 16, // Chapitre -> Articles - 'OUV' => 15, // Ouvrage -> Ouvrages - 'COMM' => 13, // Communication -> Communications - 'ISSUE' => 16, // Direction de numéro -> Articles - 'PROCEEDINGS' => 15, // Direction d'ouvrage/Proceedings -> Ouvrages - 'THESE' => 14, // Thèse -> Soutenances - 'HDR' => 14, // HDR -> Soutenances - 'SON' => 19, // Son -> Captations audio/vidéo - 'VIDEO' => 19, // Vidéo -> Captations audio/vidéo - 'NOTICE' => 16, // Notice/recension -> Articles - 'BLOG' => 19, // Blog/tribune -> Médias - 'TRAD' => 15, // Traduction -> Ouvrages (fonction auteur "Traduction") - 'REPORT' => 4, // Rapport -> Publications et productions - 'UNDEFINED' => 4, // Non défini -> Publications et productions - 'POSTER' => 4, // Poster -> Publications et productions - 'OTHER' => 4, // Autre -> Publications et productions + // HAL doc type -> slug de catégorie WP (résolu en term_id au runtime — + // les IDs auto-incrémentés ne survivent pas à une réimportation de base) + private const DOC_TYPE_SLUGS = [ + 'ART' => 'articles', // Article + 'COUV' => 'articles', // Chapitre -> Articles + 'OUV' => 'ouvrages', // Ouvrage -> Ouvrages + 'COMM' => 'communications', // Communication -> Communications + 'ISSUE' => 'articles', // Direction de numéro -> Articles + 'PROCEEDINGS' => 'ouvrages', // Direction d'ouvrage/Proceedings -> Ouvrages + 'THESE' => 'soutenances', // Thèse -> Soutenances + 'HDR' => 'soutenances', // HDR -> Soutenances + 'SON' => 'medias', // Son -> Médias + 'VIDEO' => 'medias', // Vidéo -> Médias + 'NOTICE' => 'articles', // Notice/recension -> Articles + 'BLOG' => 'medias', // Blog/tribune -> Médias + 'TRAD' => 'ouvrages', // Traduction -> Ouvrages (fonction auteur "Traduction") + 'REPORT' => 'publications-et-productions', // Rapport -> Publications et productions + 'UNDEFINED' => 'publications-et-productions', // Non défini -> Publications et productions + 'POSTER' => 'publications-et-productions', // Poster -> Publications et productions + 'OTHER' => 'publications-et-productions', // Autre -> Publications et productions ]; // Doc types that use date_de_debut instead of datetime private const EVENT_DOC_TYPES = ['COMM', 'THESE', 'HDR', 'SON', 'VIDEO']; - // Pods IDs — queried from the DB, stable per installation - private const POD_ID_POST = 8; - private const FIELD_ID_CATEGORIE = 16; // "Type d'annonce" (picks from WP category) - private const FIELD_ID_MEMBRES = 178; - private const FIELD_ID_AUTRE_MBRES = 195; // autre_membres (unused in import, for reference) - private const FIELD_ID_AXES = 270; // axes_thematiques (picks from axe_thematique) - private const FIELD_ID_PROGRAMMES = 271; // programmes_de_recherche (picks from programme_de_recherche) - private const FIELD_ID_ETIQUETTES = 652; // étiquettes (picks from post_tag) + /** + * Résout un slug de catégorie en term_id (cache statique par requête). + */ + private function cat_id_by_slug(string $slug): ?int { + static $cache = []; + if (!array_key_exists($slug, $cache)) { + $term = get_term_by('slug', $slug, 'category'); + $cache[$slug] = $term ? (int) $term->term_id : null; + } + return $cache[$slug]; + } /** Source of the axes applied on the last import(): 'spip' | 'coauthors' | 'owner' | 'none'. */ public $last_axes_source = 'none'; @@ -77,14 +81,19 @@ class Thalim_HAL_Importer_Logic { * Get category ID for HAL doc type */ public function get_category_id($doc_type) { - return self::DOC_TYPE_MAP[$doc_type] ?? null; + $slug = self::DOC_TYPE_SLUGS[$doc_type] ?? null; + return $slug ? $this->cat_id_by_slug($slug) : null; } /** - * Get doc type mappings + * Get doc type mappings (doc type => category term_id) */ public function get_doc_type_map() { - return self::DOC_TYPE_MAP; + $map = []; + foreach (self::DOC_TYPE_SLUGS as $type => $slug) { + $map[$type] = $this->cat_id_by_slug($slug); + } + return $map; } /** @@ -150,34 +159,10 @@ class Thalim_HAL_Importer_Logic { $post_id = wp_insert_post($post_args, true); if (is_wp_error($post_id)) return $post_id; - // --- Category — Pods triple-storage pattern --- - $cat_id = self::DOC_TYPE_MAP[$doc_type] ?? null; + // --- Category — stockage Pods centralisé (cf. class-pods-storage.php) --- + $cat_id = $this->get_category_id($doc_type); if ($cat_id) { - global $wpdb; - - // 1. Native WP category assignment - wp_set_post_categories($post_id, [$cat_id]); - - // 2. Pods postmeta: single integer value - update_post_meta($post_id, 'categorie', $cat_id); - - // 3. Pods _pods_ meta: serialized array of one integer - update_post_meta($post_id, '_pods_categorie', [$cat_id]); - - // 4. wp_podsrel row - $wpdb->insert( - $wpdb->prefix . 'podsrel', - [ - 'pod_id' => self::POD_ID_POST, - 'field_id' => self::FIELD_ID_CATEGORIE, - 'item_id' => $post_id, - 'related_pod_id' => 0, - 'related_field_id'=> 0, - 'related_item_id' => $cat_id, - 'weight' => 0, - ], - ['%d', '%d', '%d', '%d', '%d', '%d', '%d'] - ); + Thalim_HAL_Pods_Storage::set_categorie($post_id, $cat_id); } // --- Core meta --- @@ -220,41 +205,13 @@ class Thalim_HAL_Importer_Logic { update_post_meta($post_id, 'fonction_auteur', 'Traduction // Translation'); } - // --- Keywords -> étiquettes (Pods triple-storage, picks from post_tag) --- - $hal_keywords = $hal_doc['keyword_s'] ?? []; - if (!empty($hal_keywords)) { - $matched_term_ids = $this->match_keywords_to_tags($hal_keywords); - if (!empty($matched_term_ids)) { - global $wpdb; - - // 1. Native WP term relationship - wp_set_object_terms($post_id, $matched_term_ids, 'post_tag', true); - - // 2. Individual postmeta rows (one per term ID) - foreach ($matched_term_ids as $tid) { - add_post_meta($post_id, 'etiquettes', (string) $tid); - } - - // 3. _pods_etiquettes: serialized array of term IDs as integers - update_post_meta($post_id, '_pods_etiquettes', array_map('intval', $matched_term_ids)); - - // 4. wp_podsrel rows - foreach ($matched_term_ids as $weight => $tid) { - $wpdb->insert( - $wpdb->prefix . 'podsrel', - [ - 'pod_id' => self::POD_ID_POST, - 'field_id' => self::FIELD_ID_ETIQUETTES, - 'item_id' => $post_id, - 'related_pod_id' => 0, - 'related_field_id' => 0, - 'related_item_id' => (int) $tid, - 'weight' => $weight, - ], - ['%d', '%d', '%d', '%d', '%d', '%d', '%d'] - ); - } - } + // --- Keywords HAL + tags SPIP -> étiquettes (une seule écriture Pods) --- + $etiquette_ids = $this->match_keywords_to_tags($hal_doc['keyword_s'] ?? []); + if (!empty($spip_context['tags'])) { + $etiquette_ids = array_merge($etiquette_ids, array_map('intval', $spip_context['tags'])); + } + if (!empty($etiquette_ids)) { + Thalim_HAL_Pods_Storage::set_relation($post_id, 'etiquettes', $etiquette_ids, 'post_tag'); } // --- Date meta --- @@ -303,50 +260,27 @@ class Thalim_HAL_Importer_Logic { } } - // --- Reference bibliographique from citationFull_s (cats 4, 15, 16) --- + // --- Reference bibliographique from citationFull_s (publications/ouvrages/articles) --- + $citation_cats = array_filter([ + $this->cat_id_by_slug('publications-et-productions'), + $this->cat_id_by_slug('ouvrages'), + $this->cat_id_by_slug('articles'), + ]); $citation = $hal_doc['citationFull_s'] ?? ''; - if ($citation && in_array($cat_id, [4, 15, 16])) { + if ($citation && in_array($cat_id, $citation_cats, true)) { update_post_meta($post_id, 'reference_bibliographique', wp_kses_post($citation)); } - // --- Store matched THALIM members — Pods triple-storage pattern + // --- Store matched THALIM members --- if (!empty($matched_user_ids)) { - global $wpdb; - - // 1. Individual postmeta rows (one per user ID, as string) - foreach ($matched_user_ids as $uid) { - add_post_meta($post_id, 'membres', (string) $uid); - } - - // 2. _pods_ meta: serialized PHP array of user IDs as integers - update_post_meta($post_id, '_pods_membres', array_map('intval', $matched_user_ids)); - - // 3. wp_podsrel rows (one per user, weight = position) - foreach ($matched_user_ids as $weight => $uid) { - $wpdb->insert( - $wpdb->prefix . 'podsrel', - [ - 'pod_id' => self::POD_ID_POST, - 'field_id' => self::FIELD_ID_MEMBRES, - 'item_id' => $post_id, - 'related_pod_id' => 0, - 'related_field_id'=> 0, - 'related_item_id' => (int) $uid, - 'weight' => $weight, - ], - ['%d', '%d', '%d', '%d', '%d', '%d', '%d'] - ); - } + Thalim_HAL_Pods_Storage::set_relation($post_id, 'membres', $matched_user_ids, null); } // --- Axes thématiques : cascade (SPIP direct > co-auteurs > owner) --- $axes_resolution = $this->resolve_axes_cascade($matched_user_ids, $spip_context); $this->last_axes_source = $axes_resolution['source']; if (!empty($axes_resolution['term_ids'])) { - $this->set_pods_taxonomy_multi( - $post_id, 'axes_thematiques', self::FIELD_ID_AXES, - $axes_resolution['term_ids'], 'axe_thematique' - ); + Thalim_HAL_Pods_Storage::set_relation($post_id, 'axes_thematiques', $axes_resolution['term_ids'], 'axe_thematique'); } // --- Programmes de recherche : SPIP direct OR keyword matching --- @@ -354,25 +288,7 @@ class Thalim_HAL_Importer_Logic { ? array_map('intval', $spip_context['programmes']) : $this->match_terms_by_keywords($hal_doc['keyword_s'] ?? [], 'programme_de_recherche'); if (!empty($prog_ids)) { - $this->set_pods_taxonomy_multi( - $post_id, 'programmes_de_recherche', self::FIELD_ID_PROGRAMMES, - $prog_ids, 'programme_de_recherche' - ); - } - - // --- Étiquettes SPIP directes (en plus du matching HAL déjà fait plus haut) --- - if (!empty($spip_context['tags'])) { - // Merge avec les tags déjà posés par le bloc étiquettes plus haut - $existing = wp_get_object_terms($post_id, 'post_tag', ['fields' => 'ids']); - $merged = array_values(array_unique(array_merge( - is_array($existing) ? array_map('intval', $existing) : [], - array_map('intval', $spip_context['tags']) - ))); - $this->set_pods_taxonomy_multi( - $post_id, 'etiquettes', self::FIELD_ID_ETIQUETTES, - array_diff($merged, is_array($existing) ? $existing : []), - 'post_tag' - ); + Thalim_HAL_Pods_Storage::set_relation($post_id, 'programmes_de_recherche', $prog_ids, 'programme_de_recherche'); } // Unmatched authors as free text — remove matched names from the full list @@ -389,11 +305,6 @@ class Thalim_HAL_Importer_Logic { update_post_meta($post_id, 'autrepersonnes', implode(', ', array_values($unmatched))); } - // --- Polylang: assign French language --- - if (function_exists('pll_set_post_language')) { - pll_set_post_language($post_id, 'fr'); - } - return $post_id; } @@ -510,41 +421,4 @@ class Thalim_HAL_Importer_Logic { return $ts ? date('Y-m-d', $ts) : ''; } - /** - * Generic Pods triple-storage writer for multi-value taxonomy fields. - * Writes to: wp_term_relationships, postmeta rows, _pods_ meta, wp_podsrel. - */ - private function set_pods_taxonomy_multi(int $post_id, string $field_name, int $field_id, array $term_ids, string $taxonomy): void { - if (empty($term_ids)) return; - global $wpdb; - $term_ids = array_values(array_unique(array_map('intval', $term_ids))); - - // 1. wp_term_relationships - wp_set_object_terms($post_id, $term_ids, $taxonomy, true); - - // 2. postmeta (one row per term ID, as string) - foreach ($term_ids as $tid) { - add_post_meta($post_id, $field_name, (string) $tid); - } - - // 3. _pods_ meta: serialized array of ints - update_post_meta($post_id, '_pods_' . $field_name, $term_ids); - - // 4. wp_podsrel rows (weight = position) - foreach ($term_ids as $weight => $tid) { - $wpdb->insert( - $wpdb->prefix . 'podsrel', - [ - 'pod_id' => self::POD_ID_POST, - 'field_id' => $field_id, - 'item_id' => $post_id, - 'related_pod_id' => 0, - 'related_field_id' => 0, - 'related_item_id' => (int) $tid, - 'weight' => $weight, - ], - ['%d', '%d', '%d', '%d', '%d', '%d', '%d'] - ); - } - } } diff --git a/includes/class-pods-storage.php b/includes/class-pods-storage.php new file mode 100644 index 0000000..f479d72 --- /dev/null +++ b/includes/class-pods-storage.php @@ -0,0 +1,165 @@ + cache par requête */ + private static $ids = []; + + /** + * ID du pod `post` (post_type _pods_pod), 0 si introuvable. + */ + public static function pod_id(): int { + if (!isset(self::$ids['__pod'])) { + global $wpdb; + self::$ids['__pod'] = (int) $wpdb->get_var( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type = '_pods_pod' AND post_name = 'post' LIMIT 1" + ); + } + return self::$ids['__pod']; + } + + /** + * ID d'un champ du pod `post` résolu par nom, 0 si introuvable. + */ + public static function field_id(string $field_name): int { + if (!isset(self::$ids[$field_name])) { + global $wpdb; + self::$ids[$field_name] = (int) $wpdb->get_var($wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type = '_pods_field' AND post_name = %s AND post_parent = %d + LIMIT 1", + $field_name, + self::pod_id() + )); + } + return self::$ids[$field_name]; + } + + /** + * Écrit une relation multi-valeurs (terms ou users) façon Pods : + * 1. wp_term_relationships (si $taxonomy fournie) + * 2. lignes postmeta individuelles + * 3. meta `_pods_{field}` sérialisée + * 4. lignes wp_podsrel (weight = position), précédées d'un delete + * pour ne jamais créer de doublons. + * + * @param int[] $related_ids IDs liés (term_ids ou user_ids) + * @param string|null $taxonomy Taxonomy WP si le champ pointe des termes + */ + public static function set_relation(int $post_id, string $field_name, array $related_ids, ?string $taxonomy = null): void { + $related_ids = array_values(array_unique(array_map('intval', $related_ids))); + if (empty($related_ids)) { + return; + } + global $wpdb; + + // 1. Relation native WP pour les taxonomies (append) + if ($taxonomy !== null) { + wp_set_object_terms($post_id, $related_ids, $taxonomy, true); + } + + // 2. postmeta : une ligne par valeur (string, comme Pods) + // Re-écrit l'ensemble pour rester cohérent avec _pods_* et podsrel. + delete_post_meta($post_id, $field_name); + foreach ($related_ids as $rid) { + add_post_meta($post_id, $field_name, (string) $rid); + } + + // 3. _pods_{field} : tableau d'entiers sérialisé + update_post_meta($post_id, '_pods_' . $field_name, $related_ids); + + // 4. wp_podsrel + $pod_id = self::pod_id(); + $field_id = self::field_id($field_name); + if (!$pod_id || !$field_id) { + // Configuration Pods absente/inattendue : on n'écrit pas podsrel + // (les meta posées ci-dessus suffisent au front), mais on trace. + error_log(sprintf( + '[thalim-hal-importer] Pods field "%s" introuvable (pod_id=%d) — wp_podsrel non écrit pour le post %d', + $field_name, $pod_id, $post_id + )); + return; + } + $wpdb->delete( + $wpdb->prefix . 'podsrel', + ['pod_id' => $pod_id, 'field_id' => $field_id, 'item_id' => $post_id], + ['%d', '%d', '%d'] + ); + foreach ($related_ids as $weight => $rid) { + $wpdb->insert( + $wpdb->prefix . 'podsrel', + [ + 'pod_id' => $pod_id, + 'field_id' => $field_id, + 'item_id' => $post_id, + 'related_pod_id' => 0, + 'related_field_id' => 0, + 'related_item_id' => $rid, + 'weight' => $weight, + ], + ['%d', '%d', '%d', '%d', '%d', '%d', '%d'] + ); + } + } + + /** + * Assigne LA catégorie d'un post (champ pick `categorie`) : catégorie WP + * native (remplace) + stockage Pods. + */ + public static function set_categorie(int $post_id, int $cat_id): void { + if (!$cat_id) { + return; + } + wp_set_post_categories($post_id, [$cat_id]); + global $wpdb; + + update_post_meta($post_id, 'categorie', $cat_id); + update_post_meta($post_id, '_pods_categorie', [$cat_id]); + + $pod_id = self::pod_id(); + $field_id = self::field_id('categorie'); + if (!$pod_id || !$field_id) { + error_log(sprintf( + '[thalim-hal-importer] Pods field "categorie" introuvable — wp_podsrel non écrit pour le post %d', + $post_id + )); + return; + } + $wpdb->delete( + $wpdb->prefix . 'podsrel', + ['pod_id' => $pod_id, 'field_id' => $field_id, 'item_id' => $post_id], + ['%d', '%d', '%d'] + ); + $wpdb->insert( + $wpdb->prefix . 'podsrel', + [ + 'pod_id' => $pod_id, + 'field_id' => $field_id, + 'item_id' => $post_id, + 'related_pod_id' => 0, + 'related_field_id' => 0, + 'related_item_id' => $cat_id, + 'weight' => 0, + ], + ['%d', '%d', '%d', '%d', '%d', '%d', '%d'] + ); + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..d9da41b --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,17 @@ + + + WordPress Coding Standards pour thalim-hal-importer. + + . + tests/* + + + + + + + + + + diff --git a/tests/run-tests.php b/tests/run-tests.php new file mode 100644 index 0000000..4e7b9b7 --- /dev/null +++ b/tests/run-tests.php @@ -0,0 +1,65 @@ +setAccessible(true); +$d = fn(string $raw): string => $parse->invoke($importer, $raw); + +check('date complète', $d('2022-06-15'), '2022-06-15'); +check('datetime ISO', $d('2022-06-15T10:30:00Z'), '2022-06-15'); +check('année-mois', $d('2022-06'), '2022-06-01'); +check('année seule', $d('2022'), '2022-01-01'); // strtotime("2022") = heure, pas année +check('chaîne vide', $d(''), ''); +check('espaces', $d(' 2021 '), '2021-01-01'); +check('invalide', $d('not-a-date'), ''); + +echo "== get_category_id (résolution par slug) ==\n"; +check('ART → articles (16)', $importer->get_category_id('ART'), 16); +check('OUV → ouvrages (15)', $importer->get_category_id('OUV'), 15); +check('THESE → soutenances (14)', $importer->get_category_id('THESE'), 14); +check('SON → medias (19)', $importer->get_category_id('SON'), 19); +check('REPORT → publications (4)',$importer->get_category_id('REPORT'), 4); +check('type inconnu → null', $importer->get_category_id('XYZ'), null); + +echo "== Thalim_HAL_Pods_Storage (résolution par nom) ==\n"; +check('pod post = 8', Thalim_HAL_Pods_Storage::pod_id(), 8); +check('champ categorie = 16', Thalim_HAL_Pods_Storage::field_id('categorie'), 16); +check('champ membres = 178', Thalim_HAL_Pods_Storage::field_id('membres'), 178); +check('champ etiquettes = 652',Thalim_HAL_Pods_Storage::field_id('etiquettes'), 652); +check('champ axes = 270', Thalim_HAL_Pods_Storage::field_id('axes_thematiques'), 270); +check('champ inconnu = 0', Thalim_HAL_Pods_Storage::field_id('champ_bidon'), 0); + +echo "\n$count tests, $failures échec(s)\n"; +exit($failures ? 1 : 0); diff --git a/thalim-hal-importer.php b/thalim-hal-importer.php index 3e4be49..107b316 100644 --- a/thalim-hal-importer.php +++ b/thalim-hal-importer.php @@ -50,6 +50,7 @@ class Thalim_HAL_Importer { require_once THALIM_HAL_PLUGIN_DIR . 'includes/trait-admin-page-config.php'; require_once THALIM_HAL_PLUGIN_DIR . 'includes/trait-admin-page-csv-legacy.php'; require_once THALIM_HAL_PLUGIN_DIR . 'includes/class-admin-page.php'; + require_once THALIM_HAL_PLUGIN_DIR . 'includes/class-pods-storage.php'; require_once THALIM_HAL_PLUGIN_DIR . 'includes/class-importer.php'; }