diff --git a/README.md b/README.md index 50252c2..59beada 100644 --- a/README.md +++ b/README.md @@ -17,29 +17,46 @@ Puis activer depuis l'admin WordPress. Dans le cadre du projet THALIM, le clonag ## Utilisation -Une fois activé, le plugin ajoute une page d'administration : **Outils → HAL Import** (capacité requise : `edit_others_posts`). +Deux points d'entrée selon le rôle : -La page propose deux flux d'import : +- **Admin / éditeur** (cap `edit_others_posts`) : **Outils → HAL Import** — UI complète, voit toutes les publications de la structure THALIM, peut filtrer par n'importe quel auteur. +- **Contributeur** (cap `edit_posts` sans `edit_others_posts`) : menu top-level **Importer depuis HAL** — UI réduite scopée sur son propre idHAL (cf. § Mode contributeur). -### 1. Aperçu live + import incrémental +### Aperçu live + import - Filtres : plage de dates (`producedDate_s`) et auteur (idHAL d'un membre THALIM) - Liste les publications HAL correspondantes avec statut coloré : - - **vert** : déjà importée (présence du meta `hal_id` côté WP) + - **vert** : déjà importée (présence du meta `hal_id` côté WP). Le statut WP est précisé (« Publié » ou « En attente »). Pour les pending, un bouton **Modifier** ouvre la page d'édition du post. - **jaune** : prête à être importée (au moins un auteur HAL matche un user WordPress) - **rouge** : aucun auteur THALIM identifié → ignorée -- Bouton **Importer** : crée tous les posts « prêts » en statut `pending` (à publier après relecture) +- Colonne **Action** : + - Bouton **Importer** par ligne jaune : crée le post correspondant en `pending` + - Bouton global **Importer N publication(s)** au-dessus du tableau : import en masse de toutes les lignes jaunes du tableau courant +- Après chaque import, le cache du tableau est muté en place (la ligne devient verte, les stats sont ajustées) sans nouvel appel HAL - Cache des aperçus en transient (5 min, clé hashée sur les filtres), rafraîchissable manuellement -- Bouton **Test API** pour vérifier la connexion -### 2. Import en masse via CSV (legacy SPIP) +### Mode contributeur -Permet d'importer des publications anciennes par lots de 100 : +Quand un contributeur ouvre la page : -- Upload d'un CSV avec une colonne `hal_id` + d'un fichier de contexte SPIP (axes/tags/programmes/owner par publication) -- Traitement par batchs séquentiels (cliquer plusieurs fois) -- Rapport CSV téléchargeable en fin de file -- Annulation possible à tout moment +- **Précondition idHAL** : si le user n'a pas renseigné son `identifiant_hal` sur son profil, ou si son idHAL est invalide (absent du référentiel `ref/author`), la page se bloque sur une notice avec un lien vers son profil. +- **Scoping côté serveur** : le filtre auteur est forcé à l'idHAL du contributeur, indépendamment de tout `$_POST['hal_author_id']` reçu. La cache key des aperçus est préfixée par `u|` pour éviter toute collision avec le cache admin. +- **UI réduite** : dropdown auteur masqué, panneau de validation des idHAL des autres membres masqué. +- **Notice persistante** rappelant que l'axe thématique est obligatoire avant publication, qu'une image à la une est attendue, etc. +- **Sécurité import unitaire** : `handle_import_single()` vérifie que l'idHAL du contributeur figure bien dans `authIdHal_s` du doc HAL ciblé — un POST forgé sur un `hal_id` d'une autre publi est rejeté. +- **post_author forcé** : l'importer reçoit `force_post_author = get_current_user_id()` (6e param de `import()`) pour que le contributeur soit l'auteur WP du post même s'il est 2e co-auteur dans `authIdHal_s`. +- Le statut `pending` natif de l'importer aligne nativement avec la restriction WP "contributeur = pas de publication directe" — un éditeur ou admin valide ensuite le post. + +### Validation des idHAL des membres + +Le panneau dépliable « Utilisateurs WordPress avec identifiant HAL » liste tous les users dont le meta `identifiant_hal` est rempli et vérifie chaque idHAL contre le référentiel auteur HAL (`ref/author`). Les idHAL absents du référentiel sont colorés en rouge et comptés dans l'en-tête du panneau. Résultat mis en cache 24h (transient invalidé naturellement quand la liste des users change). + +### Features désactivées (code conservé, feature flag à `false`) + +- **Card Configuration + bouton Test API** : `Thalim_HAL_Admin_Page::CONFIG_PANEL_ENABLED` +- **Import en masse via CSV (legacy SPIP)** : `Thalim_HAL_Admin_Page::CSV_IMPORT_ENABLED`. Permettait l'upload d'un CSV de `hal_id` + d'un fichier de contexte SPIP (axes/tags/programmes/owner par publication), traités par batchs de 100 avec rapport CSV final. Le code vit dans `includes/trait-admin-page-csv-legacy.php`. + +Basculer le flag à `true` dans `class-admin-page.php` pour réactiver. ## Mapping des types HAL → catégories WordPress @@ -76,12 +93,12 @@ Permet d'importer des publications anciennes par lots de 100 : ## Dédoublonnage -L'import vérifie le meta `hal_id` avant chaque insertion : une publication ne peut pas être importée deux fois. Le `is_imported($hal_id)` est aussi affiché en colonne de statut dans l'aperçu. +L'import vérifie le meta `hal_id` avant chaque insertion : une publication ne peut pas être importée deux fois. `get_imported_post($hal_id)` renvoie l'id et le `post_status` du post correspondant — utilisé dans l'aperçu pour distinguer Publié / En attente. ## Prérequis - WordPress 6.0+ -- PHP 7.4+ +- PHP 8.2+ (constantes dans les traits) - Plugin **Pods** (le pod `post` et le champ user `identifiant_hal`) - IDs de catégorie WordPress conformes au mapping (8/13/14/15/16/19) — codés en dur dans `DOC_TYPE_MAP` @@ -89,15 +106,17 @@ L'import vérifie le meta `hal_id` avant chaque insertion : une publication ne p ``` . -├── thalim-hal-importer.php # point d'entrée, constantes, bootstrap +├── thalim-hal-importer.php # point d'entrée, constantes, bootstrap └── includes/ - ├── class-hal-api.php # client API HAL (fetch_publications, fetch_by_hal_ids) - ├── class-admin-page.php # UI Tools > HAL Import (aperçu + CSV) - └── class-importer.php # mapping HAL → posts WP (triple-storage, axes cascade) + ├── class-hal-api.php # client API HAL (fetch_publications, fetch_by_hal_ids, validate_hal_ids) + ├── class-admin-page.php # UI Tools > HAL Import (aperçu + import unitaire/masse) + ├── class-importer.php # mapping HAL → posts WP (triple-storage, axes cascade) + ├── trait-admin-page-config.php # card Configuration + Test API (désactivé) + └── trait-admin-page-csv-legacy.php # import bulk CSV legacy SPIP (désactivé) ``` ## API HAL -- Base : `https://api.archives-ouvertes.fr/search/` -- Structure THALIM : `254015` +- Recherche publications : `https://api.archives-ouvertes.fr/search/` (structure THALIM `254015`) +- Référentiel auteurs : `https://api.archives-ouvertes.fr/ref/author/` (validation des idHAL) - Documentation : diff --git a/includes/class-admin-page.php b/includes/class-admin-page.php index b2a3ec7..94684fd 100644 --- a/includes/class-admin-page.php +++ b/includes/class-admin-page.php @@ -20,6 +20,7 @@ class Thalim_HAL_Admin_Page { private $api; private $message = null; private $wp_users_by_hal_id = null; // Cache: normalized_hal_id => ['id' => int, 'name' => string] + private $contributor_idhal = null; // Set by check_contributor_idhal_gate() — forced filter in contributor mode // Document type labels private const DOC_TYPE_LABELS = [ @@ -40,23 +41,97 @@ class Thalim_HAL_Admin_Page { } public function render() { - if (!current_user_can('edit_others_posts')) { + if (!current_user_can('edit_posts')) { wp_die('Unauthorized'); } + + $is_contributor = $this->is_contributor_mode(); + + // Contributor precondition: must have a valid idHAL on their profile + if ($is_contributor) { + $gate = $this->check_contributor_idhal_gate(); + if ($gate !== true) { + echo '

Importer depuis HAL

'; + echo $gate; + echo '
'; + return; + } + } + $this->handle_actions(); - echo '

THALIM HAL Importer

'; + + $title = $is_contributor ? 'Importer mes publications HAL' : 'THALIM HAL Importer'; + echo '

' . esc_html($title) . '

'; $this->render_styles(); $this->render_message(); - if (self::CONFIG_PANEL_ENABLED) { + if ($is_contributor) { + $this->render_contributor_notice(); + } + if (self::CONFIG_PANEL_ENABLED && !$is_contributor) { $this->render_config(); } $this->render_preview(); - if (self::CSV_IMPORT_ENABLED) { + if (self::CSV_IMPORT_ENABLED && !$is_contributor) { $this->render_csv_import(); } echo '
'; } + private function is_contributor_mode(): bool { + return current_user_can('edit_posts') && !current_user_can('edit_others_posts'); + } + + /** + * Returns true if the current contributor can use the page, or an HTML + * notice string explaining why not. Also caches the resolved idHAL into + * $this->contributor_idhal for downstream forcing. + */ + private function check_contributor_idhal_gate() { + $user_id = get_current_user_id(); + $idhal = trim((string) get_user_meta($user_id, 'identifiant_hal', true)); + $profile_url = get_edit_user_link($user_id); + + if ($idhal === '') { + return sprintf( + '

Votre identifiant HAL n\'est pas renseigné.

' + . '

Pour utiliser cet outil, ajoutez votre identifiant_hal (idHAL) à votre profil.

' + . '

Modifier mon profil

', + esc_url($profile_url) + ); + } + + $validity = $this->get_hal_ids_validity([strtolower($idhal)]); + $is_valid = $validity[strtolower($idhal)] ?? null; + + if ($is_valid === false) { + return sprintf( + '

Votre identifiant HAL (%s) est introuvable dans le référentiel HAL.

' + . '

Vérifiez l\'orthographe sur votre profil. La validation est mise en cache 24h.

' + . '

Modifier mon profil

', + esc_html($idhal), esc_url($profile_url) + ); + } + + // null = API error → graceful degradation, on laisse passer + $this->contributor_idhal = $idhal; + return true; + } + + private function render_contributor_notice() { + ?> +
+

À compléter avant publication :

+
    +
  • Axe(s) thématique(s) — obligatoire pour la publication.
  • +
  • Autres membres THALIM co-auteurs (champ autre_membres) si applicable.
  • +
  • Image à la une (illustration).
  • +
  • Programme(s) de recherche associé(s) si pertinent.
  • +
+

Les publications sont importées en statut En attente. Un éditeur les validera après votre complément.

+
+ is_contributor_mode(); + if ($is_contributor) { + $author_hal_id = $this->contributor_idhal; // force, ignore POST + } + $force_author = $is_contributor ? get_current_user_id() : null; + // Reuse the cached preview data — raw_docs are stored alongside processed docs $preview = $this->get_preview_data($date_from, $date_to, $author_hal_id); if (is_wp_error($preview)) { @@ -132,7 +213,7 @@ class Thalim_HAL_Admin_Page { continue; } - $post_id = $importer->import($doc, $this->wp_users_by_hal_id); + $post_id = $importer->import($doc, $this->wp_users_by_hal_id, 'pending', false, [], $force_author); if (is_wp_error($post_id)) { $errors[] = $hal_id . ': ' . $post_id->get_error_message(); } else { @@ -160,6 +241,11 @@ class Thalim_HAL_Admin_Page { $date_to = sanitize_text_field($_POST['hal_date_to'] ?? ''); $author_hal_id = sanitize_text_field($_POST['hal_author_id'] ?? ''); + $is_contributor = $this->is_contributor_mode(); + if ($is_contributor) { + $author_hal_id = $this->contributor_idhal; // force, ignore POST + } + if (!$hal_id) { $this->message = ['error', 'hal_id manquant.']; return; @@ -180,6 +266,17 @@ class Thalim_HAL_Admin_Page { return; } + // Critical security check: in contributor mode, the requested hal_id MUST + // be a publication where the contributor is an author. Defends against a + // forged POST that tries to import another member's publication. + if ($is_contributor) { + $doc_authors = array_map('strtolower', array_map('trim', $doc['authIdHal_s'] ?? [])); + if (!in_array(strtolower($this->contributor_idhal), $doc_authors, true)) { + $this->message = ['error', "Vous n'êtes pas auteur de la publication $hal_id."]; + return; + } + } + $importer = new Thalim_HAL_Importer_Logic(); if ($importer->is_imported($hal_id)) { $this->message = ['warning', "Publication $hal_id déjà importée."]; @@ -192,7 +289,8 @@ class Thalim_HAL_Admin_Page { return; } - $post_id = $importer->import($doc, $this->wp_users_by_hal_id); + $force_author = $is_contributor ? get_current_user_id() : null; + $post_id = $importer->import($doc, $this->wp_users_by_hal_id, 'pending', false, [], $force_author); if (is_wp_error($post_id)) { $this->message = ['error', "Erreur import : " . $post_id->get_error_message()]; return; @@ -240,19 +338,29 @@ class Thalim_HAL_Admin_Page { } private function render_preview() { + $is_contributor = $this->is_contributor_mode(); + // Read filters from POST (after submit) or GET (page reload with state) $date_from = sanitize_text_field($_POST['hal_date_from'] ?? $_GET['hal_date_from'] ?? ''); $date_to = sanitize_text_field($_POST['hal_date_to'] ?? $_GET['hal_date_to'] ?? ''); $author_hal_id = sanitize_text_field($_POST['hal_author_id'] ?? $_GET['hal_author_id'] ?? ''); - // Users must be loaded before rendering the dropdown + // Contributor mode: ignore any POSTed author filter, force their own idHAL. + if ($is_contributor) { + $author_hal_id = $this->contributor_idhal; + } + + // Users must be loaded before rendering the dropdown (admins/editors only) $this->load_wp_users_hal_ids(); $preview = $this->get_preview_data($date_from, $date_to, $author_hal_id); $ready_count = is_wp_error($preview) ? 0 : $preview['stats']['ready']; + $import_label = $is_contributor + ? sprintf('Importer mes %d publication(s) (En attente)', $ready_count) + : sprintf('Importer %d publication(s) (En attente)', $ready_count); ?>
-

Import Preview

+

@@ -265,17 +373,21 @@ class Thalim_HAL_Admin_Page { - + + + + + @@ -285,14 +397,14 @@ class Thalim_HAL_Admin_Page {

get_error_message()); ?>

- render_wp_users_debug(); ?> + render_wp_users_debug(); ?> render_summary($preview['stats']); ?> render_preview_table($preview['docs'], [ 'date_from' => $date_from, @@ -486,7 +598,11 @@ class Thalim_HAL_Admin_Page { } private function get_preview_cache_key($date_from, $date_to, $author_hal_id) { - return 'thalim_hal_preview_' . md5($date_from . '|' . $date_to . '|' . $author_hal_id); + // Scope the cache by user ID in contributor mode so two contributors + // don't share a cache entry (and don't collide with the admin cache + // that might use the same author_hal_id filter). + $scope = $this->is_contributor_mode() ? ('u' . get_current_user_id() . '|') : ''; + return 'thalim_hal_preview_' . md5($scope . $date_from . '|' . $date_to . '|' . $author_hal_id); } /** @@ -516,6 +632,11 @@ class Thalim_HAL_Admin_Page { } private function get_preview_data($date_from = '', $date_to = '', $author_hal_id = '') { + // Server-side override: in contributor mode, force the author filter + // to the contributor's own idHAL regardless of what was POSTed. + if ($this->is_contributor_mode() && $this->contributor_idhal) { + $author_hal_id = $this->contributor_idhal; + } $cache_key = $this->get_preview_cache_key($date_from, $date_to, $author_hal_id); $cached = get_transient($cache_key); if ($cached !== false) return $cached; diff --git a/includes/class-importer.php b/includes/class-importer.php index 1b03e4e..b33c233 100644 --- a/includes/class-importer.php +++ b/includes/class-importer.php @@ -97,7 +97,8 @@ class Thalim_HAL_Importer_Logic { array $wp_users_by_hal_id = [], string $post_status = 'pending', bool $backdate_post = false, - array $spip_context = [] + array $spip_context = [], + ?int $force_post_author = null ) { $hal_id = $hal_doc['halId_s'] ?? ''; $doc_type = $hal_doc['docType_s'] ?? ''; @@ -118,7 +119,8 @@ class Thalim_HAL_Importer_Logic { $matched_user_names[] = $user['name']; } } - $post_author = !empty($matched_user_ids) ? $matched_user_ids[0] : 1; + $post_author = $force_post_author + ?? (!empty($matched_user_ids) ? $matched_user_ids[0] : 1); // --- Create the post --- $post_args = [ diff --git a/thalim-hal-importer.php b/thalim-hal-importer.php index a64c7d8..040f096 100644 --- a/thalim-hal-importer.php +++ b/thalim-hal-importer.php @@ -58,13 +58,31 @@ class Thalim_HAL_Importer { } public function add_admin_menu() { - add_management_page( - 'HAL Import', - 'HAL Import', - 'edit_others_posts', - 'thalim-hal-importer', - [$this, 'render_admin_page'] - ); + // Admins/editors : page sous Outils (UI complète : filtres tous auteurs, debug, etc.) + if (current_user_can('edit_others_posts')) { + add_management_page( + 'HAL Import', + 'HAL Import', + 'edit_others_posts', + 'thalim-hal-importer', + [$this, 'render_admin_page'] + ); + return; + } + + // Contributeurs : menu top-level dédié (Outils est masqué pour eux par le thème). + // La classe rend une UI réduite scopée sur leur idHAL en interne. + if (current_user_can('edit_posts')) { + add_menu_page( + 'Importer depuis HAL', + 'Importer depuis HAL', + 'edit_posts', + 'thalim-hal-import-mine', + [$this, 'render_admin_page'], + 'dashicons-download', + 26 + ); + } } public function render_admin_page() {