Import HAL : match des auteurs sur toutes les formes d'idHAL (résolution en fond via cron)

This commit is contained in:
2026-07-07 13:44:08 +02:00
parent c38f8b4d7e
commit f083b36369
5 changed files with 262 additions and 5 deletions

View File

@@ -561,15 +561,18 @@ class Thalim_HAL_Admin_Page {
$validity = $this->get_hal_ids_validity(array_keys($this->wp_users_by_hal_id));
$invalid_count = count(array_filter($validity, fn($v) => $v === false));
// The map may hold several idHAL forms per user (alternate valid forms);
// count distinct users for the label, not raw form keys.
$distinct_users = count(array_unique(array_map(fn($u) => $u['id'], $this->wp_users_by_hal_id)));
?>
<details style="margin-bottom:15px;background:#f0f6fc;padding:10px;border-radius:4px;border-left:4px solid #0073aa">
<summary style="cursor:pointer;font-weight:bold">
Utilisateurs WordPress avec identifiant HAL (<?php echo count($this->wp_users_by_hal_id); ?> utilisateurs<?php
if ($invalid_count > 0) echo ', <span style="color:#dc3545">' . $invalid_count . ' invalide(s)</span>';
Utilisateurs WordPress avec identifiant HAL (<?php echo $distinct_users; ?> utilisateurs<?php
if ($invalid_count > 0) echo ', <span style="color:#dc3545">' . $invalid_count . ' forme(s) invalide(s)</span>';
?>) — Cliquer pour déplier
</summary>
<table class="widefat" style="margin-top:10px;font-size:12px">
<thead><tr><th>Utilisateur</th><th>Identifiant HAL</th><th>Validité HAL</th><th>Debug (brut)</th><th>Modifier</th></tr></thead>
<thead><tr><th>Utilisateur</th><th>Identifiant HAL (formes)</th><th>Validité HAL</th><th>Debug (brut)</th><th>Modifier</th></tr></thead>
<tbody>
<?php foreach ($this->wp_users_by_hal_id as $hal_id => $user):
$valid = $validity[$hal_id] ?? null;
@@ -715,12 +718,20 @@ class Thalim_HAL_Admin_Page {
foreach ($users as $user) {
$hal_id = get_user_meta($user->ID, 'identifiant_hal', true);
if (!empty($hal_id)) {
$normalized = strtolower(trim($hal_id));
$this->wp_users_by_hal_id[$normalized] = [
$entry = [
'id' => $user->ID,
'name' => $user->display_name,
'hal_id' => trim($hal_id), // original value for API filter
];
// Register the primary form AND every alternate valid idHAL form
// of the same person (resolved in the background, read from cache
// here — no network call). Lets a publication tagged under an
// alternate form still match the member. See Thalim_HAL_Forms_Cache.
foreach (Thalim_HAL_Forms_Cache::forms_for_user($user->ID, $hal_id) as $form) {
if (!isset($this->wp_users_by_hal_id[$form])) {
$this->wp_users_by_hal_id[$form] = $entry;
}
}
}
}
}

View File

@@ -120,6 +120,95 @@ class Thalim_HAL_API {
return $result;
}
/**
* Expand a set of idHAL slugs to ALL valid idHAL forms of the same people.
*
* HAL frequently registers a person under several PREFERRED idHAL forms
* (e.g. "xavier-garnier" AND "xaviergarnier"). A publication may be tagged
* with any one of them, so matching a WP profile against a single stored
* form silently misses those tagged under an alternate form.
*
* ref/author exposes every form under the same person, sharing the same
* numeric root of `docid` ("6951-1022", "6951-180497" => person 6951).
* We resolve each input slug to its person root(s), then collect every
* idHAL form attached to those roots.
*
* NOTE: this hits the network. It is meant to be called from the background
* cron resolver (see Thalim_HAL_Forms_Cache), never from the UI/import path.
*
* @param string[] $idhals Slugs to expand (a member's stored identifiant_hal).
* @return array Map [input_slug_lowercased => [form1, form2, ...]].
* On API error a slug maps to just itself.
*/
public function expand_hal_id_forms(array $idhals) {
$out = [];
$clean = [];
foreach ($idhals as $id) {
$id = strtolower(trim((string) $id));
if ($id !== '') { $clean[$id] = true; $out[$id] = [$id]; }
}
if (empty($clean)) return $out;
$endpoint = 'https://api.archives-ouvertes.fr/ref/author/';
// Step 1: for each slug, find the person root(s) of its docid.
$slug_roots = []; // slug => [root => true]
$all_roots = []; // root => true
foreach (array_chunk(array_keys($clean), 50) as $chunk) {
$quoted = array_map(fn($id) => '"' . str_replace('"', '', $id) . '"', $chunk);
$params = [
'q=' . urlencode('idHal_s:(' . implode(' OR ', $quoted) . ')'),
'fl=' . urlencode('docid,idHal_s'),
'rows=' . (count($chunk) * 5),
'wt=json',
];
$data = $this->request($endpoint . '?' . implode('&', $params));
if (is_wp_error($data)) continue;
foreach ($data['response']['docs'] ?? [] as $doc) {
$slug = strtolower($doc['idHal_s'] ?? '');
$root = isset($doc['docid']) ? explode('-', $doc['docid'])[0] : '';
if ($slug !== '' && $root !== '') {
$slug_roots[$slug][$root] = true;
$all_roots[$root] = true;
}
}
usleep(250000);
}
if (empty($all_roots)) return $out;
// Step 2: collect every idHAL form attached to those person roots.
// NB: do NOT quote the "root-*" terms — quoting disables the Solr wildcard.
// Roots are numeric, so they are safe to inline; keep only digits defensively.
$root_forms = []; // root => [form => true]
foreach (array_chunk(array_keys($all_roots), 50) as $chunk) {
$terms = array_map(fn($r) => preg_replace('/\D/', '', $r) . '-*', $chunk);
$params = [
'q=' . urlencode('docid:(' . implode(' OR ', $terms) . ')'),
'fl=' . urlencode('docid,idHal_s'),
'rows=' . (count($chunk) * 10),
'wt=json',
];
$data = $this->request($endpoint . '?' . implode('&', $params));
if (is_wp_error($data)) continue;
foreach ($data['response']['docs'] ?? [] as $doc) {
$root = isset($doc['docid']) ? explode('-', $doc['docid'])[0] : '';
$form = strtolower($doc['idHal_s'] ?? '');
if ($root !== '' && $form !== '') $root_forms[$root][$form] = true;
}
usleep(250000);
}
// Step 3: map each input slug to the union of forms across its roots.
foreach ($clean as $slug => $_) {
$forms = [$slug => true];
foreach (array_keys($slug_roots[$slug] ?? []) as $root) {
foreach (array_keys($root_forms[$root] ?? []) as $f) $forms[$f] = true;
}
$out[$slug] = array_keys($forms);
}
return $out;
}
/**
* Test API connection
*/

View File

@@ -0,0 +1,115 @@
<?php
/**
* Background resolver + cache for alternate idHAL forms.
*
* Problem: a member's WP `identifiant_hal` stores ONE idHAL form, but HAL may
* tag their publications under other valid forms of the same person. Matching
* on the single stored form silently drops those links.
*
* Fix: resolve every member's full set of valid idHAL forms (via HAL ref/author)
* and cache them in usermeta `_hal_id_forms`. Matching then uses the cached
* forms — with ZERO network calls in the UI / import path.
*
* All resolution happens in the background via WP-Cron, in small batches, so the
* admin interface and the import flow stay as fast as before.
*/
if (!defined('ABSPATH')) {
exit;
}
class Thalim_HAL_Forms_Cache {
const META_FORMS = '_hal_id_forms'; // usermeta: JSON array of forms
const META_SOURCE = '_hal_id_forms_src'; // usermeta: the identifiant_hal the cache was built from
const CRON_HOOK = 'thalim_hal_refresh_forms';
const BATCH_SIZE = 15; // members resolved per cron tick
/** Wire the background hooks. Called once at plugins_loaded. */
public static function init() {
add_action(self::CRON_HOOK, [__CLASS__, 'run_batch']);
// Ensure the recurring event exists (hourly).
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time() + 60, 'hourly', self::CRON_HOOK);
}
// When a profile's identifiant_hal changes, invalidate its cache so the
// next background tick re-resolves it (no synchronous API call here).
add_action('updated_user_meta', [__CLASS__, 'on_user_meta_change'], 10, 4);
add_action('added_user_meta', [__CLASS__, 'on_user_meta_change'], 10, 4);
}
/** Remove the scheduled event (plugin deactivation). */
public static function clear_schedule() {
$ts = wp_next_scheduled(self::CRON_HOOK);
if ($ts) wp_unschedule_event($ts, self::CRON_HOOK);
}
/** Invalidate a user's cache when their identifiant_hal is edited. */
public static function on_user_meta_change($meta_id, $user_id, $meta_key, $_meta_value) {
if ($meta_key === 'identifiant_hal') {
delete_user_meta($user_id, self::META_SOURCE); // marks the cache stale
}
}
/**
* Read cached alternate forms for a user's identifiant_hal.
* Always returns at least the stored form itself (graceful degradation:
* behaves exactly like the old code until the background job has run).
*
* @return string[] lowercased idHAL forms.
*/
public static function forms_for_user($user_id, $identifiant_hal) {
$base = strtolower(trim((string) $identifiant_hal));
if ($base === '') return [];
$src = get_user_meta($user_id, self::META_SOURCE, true);
if ($src === $base) {
$cached = json_decode((string) get_user_meta($user_id, self::META_FORMS, true), true);
if (is_array($cached) && $cached) {
$cached[] = $base;
return array_values(array_unique(array_map('strtolower', $cached)));
}
}
// Not resolved yet (or stale) -> fall back to the single stored form.
return [$base];
}
/**
* Cron worker: resolve forms for a batch of members whose cache is missing
* or stale. Runs in the background; safe to be interrupted (idempotent).
*/
public static function run_batch() {
$users = get_users([
'meta_key' => 'identifiant_hal',
'meta_compare' => 'EXISTS',
'fields' => ['ID'],
'number' => 500,
]);
// Select those needing (re)resolution: stored form != cached source.
$todo = [];
foreach ($users as $u) {
$idhal = strtolower(trim((string) get_user_meta($u->ID, 'identifiant_hal', true)));
if ($idhal === '') continue;
$src = get_user_meta($u->ID, self::META_SOURCE, true);
if ($src !== $idhal) {
$todo[$idhal][] = (int) $u->ID; // group users sharing a slug
}
if (count($todo) >= self::BATCH_SIZE) break;
}
if (empty($todo)) return;
$api = new Thalim_HAL_API();
$expanded = $api->expand_hal_id_forms(array_keys($todo));
foreach ($todo as $idhal => $user_ids) {
$forms = $expanded[$idhal] ?? [$idhal];
foreach ($user_ids as $uid) {
update_user_meta($uid, self::META_FORMS, wp_json_encode(array_values($forms)));
update_user_meta($uid, self::META_SOURCE, $idhal);
}
}
}
}

View File

@@ -61,5 +61,42 @@ check('champ etiquettes = 652',Thalim_HAL_Pods_Storage::field_id('etiquettes'),
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 "== Thalim_HAL_Forms_Cache::forms_for_user (lecture cache, sans réseau) ==\n";
// Dégradation gracieuse : cache absent -> forme unique.
check('cache absent -> forme unique',
Thalim_HAL_Forms_Cache::forms_for_user(999999999, 'xavier-garnier'),
['xavier-garnier']);
check('idHAL vide -> tableau vide',
Thalim_HAL_Forms_Cache::forms_for_user(999999999, ''),
[]);
// Cache présent et à jour, sur un vrai user (métas écrites puis restaurées).
$real = get_users(['meta_key' => 'identifiant_hal', 'meta_compare' => 'EXISTS',
'fields' => ['ID'], 'number' => 1]);
if (!empty($real)) {
$ruid = (int) $real[0]->ID;
$idhal = strtolower(trim((string) get_user_meta($ruid, 'identifiant_hal', true)));
$bak_forms = get_user_meta($ruid, Thalim_HAL_Forms_Cache::META_FORMS, true);
$bak_src = get_user_meta($ruid, Thalim_HAL_Forms_Cache::META_SOURCE, true);
update_user_meta($ruid, Thalim_HAL_Forms_Cache::META_FORMS,
wp_json_encode([$idhal, $idhal . '-alt']));
update_user_meta($ruid, Thalim_HAL_Forms_Cache::META_SOURCE, $idhal);
$got = Thalim_HAL_Forms_Cache::forms_for_user($ruid, $idhal); sort($got);
$exp = [$idhal, $idhal . '-alt']; sort($exp);
check('cache présent -> formes alternatives incluses', $got, $exp);
// Cache périmé (source != idHAL courant) -> forme unique.
update_user_meta($ruid, Thalim_HAL_Forms_Cache::META_SOURCE, 'obsolete-source');
check('cache périmé -> forme unique',
Thalim_HAL_Forms_Cache::forms_for_user($ruid, $idhal), [$idhal]);
// Restauration de l'état initial.
if ($bak_src === '') { delete_user_meta($ruid, Thalim_HAL_Forms_Cache::META_SOURCE); }
else { update_user_meta($ruid, Thalim_HAL_Forms_Cache::META_SOURCE, $bak_src); }
if ($bak_forms === '') { delete_user_meta($ruid, Thalim_HAL_Forms_Cache::META_FORMS); }
else { update_user_meta($ruid, Thalim_HAL_Forms_Cache::META_FORMS, $bak_forms); }
}
echo "\n$count tests, $failures échec(s)\n";
exit($failures ? 1 : 0);

View File

@@ -46,6 +46,7 @@ class Thalim_HAL_Importer {
private function load_dependencies() {
require_once THALIM_HAL_PLUGIN_DIR . 'includes/class-hal-api.php';
require_once THALIM_HAL_PLUGIN_DIR . 'includes/class-hal-forms-cache.php';
// Traits must be loaded before the class that `use`s them.
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';
@@ -56,6 +57,8 @@ class Thalim_HAL_Importer {
private function init_hooks() {
add_action('admin_menu', [$this, 'add_admin_menu']);
// Background resolver of alternate idHAL forms (WP-Cron; no UI cost).
Thalim_HAL_Forms_Cache::init();
}
public function add_admin_menu() {
@@ -100,6 +103,8 @@ register_activation_hook(__FILE__, function() {
// Deactivation hook
register_deactivation_hook(__FILE__, function() {
delete_transient('thalim_hal_preview_data');
require_once THALIM_HAL_PLUGIN_DIR . 'includes/class-hal-forms-cache.php';
Thalim_HAL_Forms_Cache::clear_schedule();
});
// Initialize plugin