Files
thalim-plugin-hal-importer/includes/class-hal-api.php

286 lines
12 KiB
PHP

<?php
/**
* HAL API Class - Handles communication with HAL API
*/
if (!defined('ABSPATH')) {
exit;
}
class Thalim_HAL_API {
// Fields to retrieve from HAL (expanded for import preview)
private const FIELDS = 'halId_s,title_s,docType_s,authFullName_s,authIdHal_s,publicationDate_s,producedDate_s,submittedDate_s,journalTitle_s,bookTitle_s,uri_s,fileMain_s,abstract_s,keyword_s,publisher_s,modifiedDate_s,doiId_s,citationFull_s,conferenceTitle_s,city_s,country_s,defenseDate_s';
/**
* Fetch publications from THALIM structure
*
* @param int $rows Number of results to fetch
* @param int $start Offset
* @param string $sort Solr sort expression
* @param string $date_from Filter start date (YYYY-MM-DD), empty = no lower bound
* @param string $date_to Filter end date (YYYY-MM-DD), empty = no upper bound
*/
public function fetch_publications($rows = 100, $start = 0, $sort = 'producedDate_tdate desc', $date_from = '', $date_to = '', $author_hal_id = '') {
$url = $this->build_url($rows, $start, $sort, $date_from, $date_to, $author_hal_id);
return $this->request($url);
}
/**
* Fetch full HAL docs by a list of hal_ids (batched).
* Uses Solr fq=halId_s:(id1 OR id2 OR ...) syntax. No structId filter —
* fetch by halId exact, regardless of structure.
*
* @param string[] $hal_ids HAL IDs to fetch.
* @param int $batch Batch size (default 100).
* @return array|WP_Error Array keyed by halId_s, or WP_Error on failure.
*/
public function fetch_by_hal_ids(array $hal_ids, int $batch = 100) {
$docs = [];
// HAL Solr's halId_s is the canonical ID without a version suffix
// (e.g. "hal-03583975", not "hal-03583975v2"). Some legacy SPIP entries
// carry a version suffix, so strip it before querying and keep a map
// to re-key the result under the original caller-supplied ID.
$originals = array_values(array_unique($hal_ids));
$stripped_map = []; // stripped_id => [original_id, ...]
foreach ($originals as $orig) {
$stripped = preg_replace('/v\d+$/', '', $orig);
$stripped_map[$stripped][] = $orig;
}
$query_ids = array_keys($stripped_map);
$chunks = array_chunk($query_ids, $batch);
foreach ($chunks as $chunk) {
$filter = 'halId_s:(' . implode(' OR ', $chunk) . ')';
$params = [
'q=' . urlencode('*:*'),
'fq=' . urlencode($filter),
'rows=' . count($chunk),
'fl=' . urlencode(self::FIELDS),
'wt=json',
];
$url = THALIM_HAL_API_BASE . '?' . implode('&', $params);
$data = $this->request($url);
if (is_wp_error($data)) return $data;
foreach ($data['response']['docs'] ?? [] as $doc) {
$canonical = $doc['halId_s'] ?? '';
if ($canonical === '') continue;
// Key the doc under every original ID that stripped to this canonical form
foreach ($stripped_map[$canonical] ?? [$canonical] as $orig) {
$docs[$orig] = $doc;
}
}
// Be polite with HAL if we have multiple chunks
if (count($chunks) > 1) usleep(250000);
}
return $docs;
}
/**
* Check which HAL IDs exist in HAL's author referential (ref/author).
* Returns map [normalized_hal_id => bool|null] — null when the API errored
* (treated as "unknown", not "invalid").
*/
public function validate_hal_ids(array $hal_ids) {
$result = [];
$clean = [];
foreach ($hal_ids as $id) {
$id = trim((string) $id);
if ($id !== '') $clean[strtolower($id)] = $id;
}
if (empty($clean)) return $result;
// Default all to false; flip to true when found
foreach ($clean as $norm => $_) $result[$norm] = false;
$endpoint = 'https://api.archives-ouvertes.fr/ref/author/';
$chunks = array_chunk(array_values($clean), 100);
foreach ($chunks as $chunk) {
// Solr OR with quoted values to tolerate dashes/dots in slugs
$quoted = array_map(fn($id) => '"' . str_replace('"', '', $id) . '"', $chunk);
$params = [
'q=' . urlencode('idHal_s:(' . implode(' OR ', $quoted) . ')'),
'fl=' . urlencode('idHal_s'),
'rows=' . count($chunk),
'wt=json',
];
$data = $this->request($endpoint . '?' . implode('&', $params));
if (is_wp_error($data)) {
foreach ($chunk as $id) $result[strtolower($id)] = null;
continue;
}
foreach ($data['response']['docs'] ?? [] as $doc) {
if (!empty($doc['idHal_s'])) {
$result[strtolower($doc['idHal_s'])] = true;
}
}
if (count($chunks) > 1) usleep(250000);
}
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
*/
public function test_connection() {
$result = $this->fetch_publications(5);
if (is_wp_error($result)) return $result;
return [
'success' => true,
'total' => $result['response']['numFound'] ?? 0,
'sample' => $result['response']['docs'] ?? []
];
}
/**
* Build API URL with proper fq parameter handling
*
* @param int $rows
* @param int $start
* @param string $sort
* @param string $date_from YYYY-MM-DD or empty
* @param string $date_to YYYY-MM-DD or empty
*/
private function build_url($rows = 5, $start = 0, $sort = 'modifiedDate_tdate desc', $date_from = '', $date_to = '', $author_hal_id = '') {
$doc_types = implode(' OR ', THALIM_HAL_DOC_TYPES);
$from = $date_from ? $date_from . 'T00:00:00Z' : '*';
$to = $date_to ? $date_to . 'T23:59:59Z' : '*';
$params = [
'q=' . urlencode('*:*'),
'fq=' . urlencode('structId_i:' . THALIM_HAL_STRUCT_ID),
'fq=' . urlencode('docType_s:(' . $doc_types . ')'),
];
if ($from !== '*' || $to !== '*') {
$params[] = 'fq=' . urlencode('producedDate_tdate:[' . $from . ' TO ' . $to . ']');
}
if ($author_hal_id !== '') {
$params[] = 'fq=' . urlencode('authIdHal_s:' . $author_hal_id);
}
$params = array_merge($params, [
'rows=' . intval($rows),
'start=' . intval($start),
'sort=' . urlencode($sort),
'fl=' . urlencode(self::FIELDS),
'wt=json'
]);
return THALIM_HAL_API_BASE . '?' . implode('&', $params);
}
/**
* Get API URL for debugging display
*/
public function get_api_url($rows = 5) {
return $this->build_url($rows, 0);
}
/**
* Make HTTP request
*/
private function request($url) {
$response = wp_remote_get($url, ['timeout' => 30, 'headers' => ['Accept' => 'application/json']]);
if (is_wp_error($response)) return $response;
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) return new WP_Error('api_error', "HTTP $code");
$data = json_decode(wp_remote_retrieve_body($response), true);
return json_last_error() === JSON_ERROR_NONE ? $data : new WP_Error('json_error', 'Invalid JSON');
}
}