diff --git a/api/src/core/adapters/GitHub/getExternalData.ts b/api/src/core/adapters/GitHub/getExternalData.ts index 6c7efb697..01ea113ce 100644 --- a/api/src/core/adapters/GitHub/getExternalData.ts +++ b/api/src/core/adapters/GitHub/getExternalData.ts @@ -28,7 +28,8 @@ export const getGitHubSoftwareExternalData: GetSoftwareExternal = memoize( const repoLanguages = await gitHubApi.repo.getLanguages({ repoUrl }); const devIds = repoDevs - ?.map(dev => dev.id) + ?.filter(dev => dev && dev.type === "User") // FILTER BOT + .map(dev => dev.id) .filter(a => { return a !== undefined; }) ?? []; @@ -62,9 +63,21 @@ export const getGitHubSoftwareExternalData: GetSoftwareExternal = memoize( "@type": "Person", name: dev.name ?? dev.login, identifiers: [ - identifersUtils.makeUserGitHubIdentifer({ username: dev.name ?? dev.login, userId: dev.id }) + identifersUtils.makeUserGitHubIdentifer({ + name: dev.name ?? dev.login, + userId: dev.id, + url: dev.html_url + }), + ...(dev.twitter_username + ? [identifersUtils.makeTwitterPersonIdentifer({ username: dev.twitter_username })] + : []), + ...(dev.gravatar_id + ? [identifersUtils.makeGravatarPersonIdentifer({ gravatarId: dev.gravatar_id })] + : []) + // TODO Orcid when available ], - url: dev.blog ?? undefined, + ...(dev.email ? { email: dev.email } : {}), + url: dev.blog ?? dev.html_url, affiliations: dev.company ? [ { diff --git a/api/src/core/adapters/RNSR/API/get.ts b/api/src/core/adapters/RNSR/API/get.ts new file mode 100644 index 000000000..5b772edd8 --- /dev/null +++ b/api/src/core/adapters/RNSR/API/get.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { identifersUtils } from "../../../../tools/identifiersTools"; +import { SchemaOrganization, SchemaPostalAddress } from "../../dbApi/kysely/kysely.database"; + +type RNSROrganisation = { + numero_national_de_structure: string; + libelle: string; + sigle: string; + annee_de_creation: string; + type_de_structure: string; + code_de_type_de_structure: string; + code_de_niveau_de_structure: string; + site_web: string; + adresse: string; + code_postal: string; + commune: string; + nom_du_responsable: string[]; + prenom_du_responsable: string[]; + titre_du_responsable: string[]; + label_numero: string[]; + tutelles: string[]; + sigles_des_tutelles: string[]; + code_de_nature_de_tutelle: string[]; + nature_de_tutelle: string[]; + uai_des_tutelles: string[]; + siret_des_tutelles: string[]; + code_de_type_de_tutelle: string[]; + type_de_tutelle: string[]; + numero_de_structure_enfant: string[] | null; + numero_de_structure_parent: string | null; + numero_de_structure_historique: string | null; + type_de_succession: string | null; + code_de_type_de_succession: string | null; + annee_d_effet_historique: string | null; + code_domaine_scientifique: string[]; + domaine_scientifique: string[]; + code_panel_erc: string | null; + panel_erc: string | null; + fiche_rnsr: string; +}; + +type RNSRResponse = { + total_count: number; + results: Array; +}; + +const convertToSchemaOrganization = (organisation: RNSROrganisation): SchemaOrganization => { + const address: SchemaPostalAddress = { + "@type": "PostalAddress", + addressCountry: "France", + addressCountryCode: "FR", + postalCode: organisation.code_postal, + addressLocality: organisation.commune, + streetAddress: organisation.adresse + }; + + const alternateName: string[] = organisation.sigle ? [organisation.sigle] : []; + + const parentOrganizations: SchemaOrganization[] = []; + if (organisation.code_de_type_de_tutelle) { + organisation.code_de_type_de_tutelle.forEach((code, index) => { + if (code === "TUTE") { + parentOrganizations.push({ + "@type": "Organization", + name: organisation.tutelles[index], + alternateName: [organisation.sigles_des_tutelles[index]], + identifiers: [ + identifersUtils.makeSIRENIdentifier({ SIREN: organisation.siret_des_tutelles[index] }) + ] + }); + } + }); + } + + const schemaOrganization: SchemaOrganization = { + "@type": "Organization", + name: organisation.libelle, + url: organisation.site_web, + identifiers: [identifersUtils.makeRNSROrgaIdentifer({ rnrsId: organisation.numero_national_de_structure })], + parentOrganizations: parentOrganizations.length > 0 ? parentOrganizations : undefined, + foundingDate: organisation.annee_de_creation, + alternateName: alternateName.length > 0 ? alternateName : undefined, + address: address, + additionalType: [organisation.type_de_structure] + }; + + return schemaOrganization; +}; + +const getOrganizationByRNSR = async (rnsrId: string): Promise => { + const url = `https://data.enseignementsup-recherche.gouv.fr/api/explore/v2.1/catalog/datasets/fr-esr-structures-recherche-publiques-actives/records/?limit=10&offset=0&where=numero_national_de_structure%3A%22${rnsrId}%22`; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`getOrganizationByRNSR : Erreur HTTP: ${response.status}`); + } + + const data = await response.json(); + + return data; + } catch (error) { + console.error("getOrganizationByRNSR : Erreur lors de la récupération des données:", error); + throw error; + } +}; + +export const getOrganisationFromRNSRApi = async (params: { + rnsrId: string; +}): Promise => { + const { rnsrId } = params; + const result = await getOrganizationByRNSR(rnsrId); + if (result.total_count === 0) return undefined; + if (result.total_count > 2) throw Error("Too much results"); + + return convertToSchemaOrganization(result.results[0]); +}; diff --git a/api/src/core/adapters/RNSR/API/getOrganisation.ts b/api/src/core/adapters/RNSR/API/getOrganisation.ts new file mode 100644 index 000000000..2d542d854 --- /dev/null +++ b/api/src/core/adapters/RNSR/API/getOrganisation.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +interface ApiResponse { + total_count: number; + results: Array>; + // Ajoute d'autres champs si nécessaire selon la structure de la réponse +} + +interface ApiError { + message: string; + status: number; +} + +/** + * Récupère les données d'une structure depuis l'API du MESRI. + * @param numeroNationalDeStructure Le numéro national de la structure (ex: "201221027H") + * @returns Une promesse avec les données de la structure ou une erreur + */ +async function getStructureData(numeroNationalDeStructure: string): Promise { + const baseUrl = "https://data.enseignementsup-recherche.gouv.fr/api/explore/v2.1/catalog"; + const dataset = "fr-esr-repertoire-national-structures-recherche"; + const url = new URL(`${baseUrl}/datasets/${dataset}/records`); + + // Construction des paramètres de la requête + url.searchParams.append("where", `numero_national_de_structure="${numeroNationalDeStructure}"`); + url.searchParams.append("limit", "1"); + + try { + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Erreur HTTP: ${response.status}`); + } + const data: ApiResponse = await response.json(); + return data; + } catch (error) { + return { + message: error instanceof Error ? error.message : "Erreur inconnue", + status: error instanceof Error && "status" in error ? (error.status as number) : 500 + }; + } +} diff --git a/api/src/core/adapters/RNSR/index.ts b/api/src/core/adapters/RNSR/index.ts new file mode 100644 index 000000000..f0f2593bb --- /dev/null +++ b/api/src/core/adapters/RNSR/index.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { SourceGateway } from "../../ports/SourceGateway"; +import { Source } from "../../usecases/readWriteSillData"; +import { getOrganisationFromRNSRApi } from "./API/get"; + +export type RNSRSourceGateway = SourceGateway & { + organization: NonNullable; +}; + +export const rnsrSourceGateway: RNSRSourceGateway = { + sourceType: "RNSR", + organization: { + getOrganization: (params: { organizationId: string; source?: Source }) => { + const org = getOrganisationFromRNSRApi({ rnsrId: params.organizationId }); + return org; + } + } +}; diff --git a/api/src/core/adapters/dbApi/kysely/createAuthorOrganizationsRepository.ts b/api/src/core/adapters/dbApi/kysely/createAuthorOrganizationsRepository.ts new file mode 100644 index 000000000..9d542f5d4 --- /dev/null +++ b/api/src/core/adapters/dbApi/kysely/createAuthorOrganizationsRepository.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { Kysely } from "kysely"; +import { AuthorOrganizationsRepository } from "../../../ports/DbApiV2"; +import { Database } from "./kysely.database"; + +export const createPgAuthorOrganisationsRepository = (db: Kysely): AuthorOrganizationsRepository => ({ + getAll: async (params?: { ids?: Array }) => { + let query = db.selectFrom("author_organizations").select("organization"); + + if (params?.ids && params.ids.length > 0) { + query = query.where("id", "in", params.ids); + } + + const result = await query.execute(); + return result.map(row => row.organization); + }, + get: async ({ id }) => { + const result = await db + .selectFrom("author_organizations") + .select("organization") + .where("id", "==", id) + .executeTakeFirstOrThrow(); + return result.organization; + }, + save: async ({ organization }) => { + await db + .insertInto("author_organizations") + .values({ id: organization.name, organization: organization }) + .onConflict(oc => + oc.columns(["id"]).doUpdateSet({ + organization: organization + }) + ) + .execute(); + + return; + }, + checkIfSaved: async ({ ids }) => { + const result = await db.selectFrom("author_organizations").select("id").execute(); + + const flatResult = result.map(org => org.id); + const output: Record = {}; + ids.forEach(id => { + output[id] = flatResult.includes(id); + }); + return output; + }, + flush: async () => { + await db.deleteFrom("author_organizations").execute(); + } +}); diff --git a/api/src/core/adapters/dbApi/kysely/createPgDbApi.ts b/api/src/core/adapters/dbApi/kysely/createPgDbApi.ts index d71ecab34..ef15e67e8 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgDbApi.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgDbApi.ts @@ -16,6 +16,7 @@ import { createPgSoftwareReferentRepository, createPgSoftwareUserRepository } from "./createPgUserAndReferentRepository"; +import { createPgAuthorOrganisationsRepository } from "./createAuthorOrganizationsRepository"; import { Database } from "./kysely.database"; export const createKyselyPgDbApi = (db: Kysely): DbApiV2 => { @@ -28,6 +29,7 @@ export const createKyselyPgDbApi = (db: Kysely): DbApiV2 => { softwareReferent: createPgSoftwareReferentRepository(db), softwareUser: createPgSoftwareUserRepository(db), session: createPgSessionRepository(db), + authorOrganization: createPgAuthorOrganisationsRepository(db), attributeDefinition: createPgAttributeDefinitionRepository(db), getCompiledDataPrivate: createGetCompiledData(db) }; diff --git a/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts b/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts index 7dee2cf03..fd371addc 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts @@ -9,9 +9,15 @@ import { DatabaseDataType, PopulatedExternalData, SoftwareRepository } from "../ import type { LocalizedString } from "../../../ports/GetSoftwareExternalData"; import { SoftwareInList, Software } from "../../../usecases/readWriteSillData"; import type { Os, RuntimePlatform, SimilarSoftware } from "../../../types"; -import { Database } from "./kysely.database"; +import { Database, SchemaOrganization, SchemaPerson } from "./kysely.database"; import { stripNullOrUndefinedValues, transformNullToUndefined } from "./kysely.utils"; -import { mergeExternalData } from "./mergeExternalData"; +import { + isSameOrganization, + isSamePerson, + mergeExternalData, + mergeOrganizations, + mergePersons +} from "./mergeExternalData"; type CountRow = { softwareId: number; organization: string | null; countType: string; count: string }; const aggregateCounts = ( @@ -686,6 +692,192 @@ export const createPgSoftwareRepository = (db: Kysely): SoftwareReposi sourceSlug, softwareId: softwareId ?? undefined })); + }, + // Alternative index + getSoftwareIdsByAuthors: async ({ search }) => { + type ResultQ = { + softwareId: number | null; + authors: SchemaOrganization | SchemaPerson; + }[]; + type ResultFunction = { + authors: SchemaOrganization | SchemaPerson; + softwareIds: number[]; + }; + const resultQuery = await db + .selectFrom("software_external_datas") + .select([ + "software_external_datas.softwareId as softwareId", + sql< + SchemaOrganization | SchemaPerson + >`jsonb_array_elements("software_external_datas"."authors")`.as("authors") + ]) + .execute(); + + const result: ResultFunction[] = []; + + while (resultQuery.length !== 0) { + let personA: ResultFunction = { + authors: resultQuery[0].authors, + softwareIds: [Number(resultQuery[0].softwareId)] + }; + resultQuery.splice(0, 1); + + if (personA.authors["@type"] === "Person") { + resultQuery.reduce( + ( + acc: ResultQ, + val: { softwareId: number | null; authors: SchemaOrganization | SchemaPerson }, + index: number + ) => { + if (val.authors["@type"] === "Person" && personA.authors["@type"] === "Person") { + if (isSamePerson(val.authors, personA.authors)) { + personA.authors = mergePersons(personA.authors, val.authors); + personA.softwareIds.push(Number(val.softwareId)); + resultQuery.splice(index, 1); + } + return acc; + } + // If Orga or not the same + acc.push(val); + return acc; + }, + [] + ); + } + + result.push(personA); + } + + if (search) { + if (search.name) { + const searchCrit = search.name; + result.filter(row => row.authors.name.includes(searchCrit)); + } + if (search.identifier) { + const searchCritValue = search.identifier.value; + if (search.identifier.key) { + const searchCritKey = search.identifier.key; + result.filter(row => + row.authors.identifiers?.some( + id => + id.subjectOf?.additionalType?.includes(searchCritKey) && + id.value.includes(searchCritValue) + ) + ); + } else { + result.filter(row => row.authors.identifiers?.some(id => id.value.includes(searchCritValue))); + } + } + } + + return result.map(row => { + return { + ...row.authors, + producer: row.softwareIds.map(a => a.toString()) + }; + }); + }, + getSoftwareIdsByOrganisation: async ({ search }) => { + type OrganizationRow = { + organization: SchemaOrganization; + softwareId: number; + }; + + const test = sql`WITH RECURSIVE FlattenedOrganizations AS ( + -- Base case: Select the root affiliations + SELECT + jsonb_array_elements(author->'affiliations') AS orga, + software_external_datas."softwareId" AS "softwareId" + FROM + software_external_datas, + jsonb_array_elements(software_external_datas.authors) AS author + + UNION ALL + + -- Recursive case: Select parent organizations + SELECT + jsonb_array_elements(fo.orga->'parentOrganizations') AS orga, + fo."softwareId" + FROM + FlattenedOrganizations AS fo + WHERE + fo.orga->'parentOrganizations' IS NOT NULL +) + +SELECT DISTINCT + orga AS organization, + "softwareId" +FROM + FlattenedOrganizations;`; + + const resultQuery = await test.execute(db); + const resultArray = resultQuery.rows; + + // First innocent iteration + const resultMap: Map = resultArray.reduce((map, org) => { + const actual = { ...org.organization, producer: [org.softwareId.toString()] }; + const saved = map.get(org.organization.name); + if (!saved) { + map.set(org.organization.name, actual); + } else { + const toSet = mergeOrganizations(saved, actual); + + map.set(org.organization.name, toSet); + } + return map; + }, new Map()); + + // Second + const keys = Array.from(resultMap.keys()); + const deduplicatedResultMap = new Map(); + + for (let i = 0; i < keys.length; i++) { + const currentKey = keys[i]; + const currentOrg = resultMap.get(currentKey)!; + + let isDuplicate = false; + + for (const [existingKey, existingOrg] of deduplicatedResultMap) { + if (isSameOrganization(currentOrg, existingOrg)) { + const mergedOrg = mergeOrganizations(existingOrg, currentOrg); + deduplicatedResultMap.set(existingKey, mergedOrg); + isDuplicate = true; + break; + } + } + + if (!isDuplicate) { + deduplicatedResultMap.set(currentKey, currentOrg); + } + } + + let sortedArray = Array.from(deduplicatedResultMap.values()); + + if (search) { + if (search.name) { + const searchCrit = search.name; + sortedArray = sortedArray.filter(row => row.name.toLowerCase().includes(searchCrit.toLowerCase())); + } + if (search.identifier) { + const searchCritValue = search.identifier.value; + if (search.identifier.key) { + const searchCritKey = search.identifier.key; + sortedArray = sortedArray.filter(row => + row.identifiers?.some( + id => + id.subjectOf?.additionalType?.includes(searchCritKey) && + id.value.includes(searchCritValue) + ) + ); + } else { + sortedArray = sortedArray.filter(row => + row.identifiers?.some(id => id.value.includes(searchCritValue)) + ); + } + } + } + + return sortedArray; } }; }; diff --git a/api/src/core/adapters/dbApi/kysely/createPgSourceRepository.ts b/api/src/core/adapters/dbApi/kysely/createPgSourceRepository.ts index fde2abb17..01d3fa986 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgSourceRepository.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgSourceRepository.ts @@ -4,7 +4,7 @@ import { Kysely } from "kysely"; import { SourceRepository } from "../../../ports/DbApiV2"; -import { Database } from "./kysely.database"; +import { Database, ExternalDataOriginKind } from "./kysely.database"; import { stripNullOrUndefinedValues } from "./kysely.utils"; export const createPgSourceRepository = (db: Kysely): SourceRepository => ({ @@ -36,5 +36,13 @@ export const createPgSourceRepository = (db: Kysely): SourceRepository .where("kind", "=", "wikidata") .orderBy("priority", "asc") .executeTakeFirstOrThrow() - .then(row => stripNullOrUndefinedValues(row)) + .then(row => stripNullOrUndefinedValues(row)), + getByType: async (params: { type: ExternalDataOriginKind }) => + db + .selectFrom("sources") + .selectAll() + .where("kind", "=", params.type) + .orderBy("priority", "asc") + .execute() + .then(arr => arr.map(stripNullOrUndefinedValues)) }); diff --git a/api/src/core/adapters/dbApi/kysely/kysely.database.ts b/api/src/core/adapters/dbApi/kysely/kysely.database.ts index ddc532f72..7d39d9e78 100644 --- a/api/src/core/adapters/dbApi/kysely/kysely.database.ts +++ b/api/src/core/adapters/dbApi/kysely/kysely.database.ts @@ -34,6 +34,36 @@ export type SchemaOrganization = { url?: string; identifiers?: SchemaIdentifier[]; parentOrganizations?: SchemaOrganization[]; + // + foundingDate?: string; + alternateName?: string[]; // Accronym + description?: string; + sameAs?: string[]; + address?: SchemaPostalAddress; + memberOf?: SchemaOrganization[]; + additionalType?: string[]; // education, government, facility, funder + image?: URL | string; // logo + // + producer?: string[]; // software Ids +}; + +export type SchemaPostalAddress = { + "@type": "PostalAddress"; + addressCountry?: string; + addressCountryCode?: string; + addressRegion?: string; + addressLocality?: string; + postalCode?: string; + streetAddress?: string; + postOfficeBoxNumber?: string; + geo?: SchemaGeoCoordinates; +}; + +type SchemaGeoCoordinates = { + "@type": "GeoCoordinates"; + latitude: number; + longitude: number; + elevation?: number; }; // from https://schema.org/Person @@ -43,6 +73,7 @@ export type SchemaPerson = { identifiers?: SchemaIdentifier[]; url?: string; affiliations?: SchemaOrganization[]; + producer?: Array; }; // from https://schema.org/WebSite @@ -85,6 +116,7 @@ export type Database = { sources: SourcesTable; user_sessions: SessionsTable; software_attribute_definitions: SoftwareAttributeDefinitionsTable; + author_organizations: AuthorOrganizationsTable; }; type UsersTable = { @@ -130,7 +162,16 @@ type InstancesTable = { }; type ExternalId = string; -export type ExternalDataOriginKind = "wikidata" | "HAL" | "ComptoirDuLibre" | "CNLL" | "Zenodo" | "GitHub" | "GitLab"; +export type ExternalDataOriginKind = + | "wikidata" + | "HAL" + | "ComptoirDuLibre" + | "CNLL" + | "Zenodo" + | "GitHub" + | "GitLab" + | "RNSR" + | "ROR"; type LocalizedString = Partial>; export type AttributeKind = "boolean" | "string" | "number" | "date" | "url"; @@ -254,6 +295,11 @@ type SessionsTable = { loggedOutAt: Date | null; }; +type AuthorOrganizationsTable = { + id: string; + organization: SchemaOrganization; +}; + // ---------- compiled data ---------- // TODO DELETE ? export namespace PgComptoirDuLibre { diff --git a/api/src/core/adapters/dbApi/kysely/mergeExternalData.ts b/api/src/core/adapters/dbApi/kysely/mergeExternalData.ts index 1247ea655..4dd0a6b6a 100644 --- a/api/src/core/adapters/dbApi/kysely/mergeExternalData.ts +++ b/api/src/core/adapters/dbApi/kysely/mergeExternalData.ts @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: 2021-2025 DINUM // SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes // SPDX-License-Identifier: MIT - import merge from "deepmerge"; import { DatabaseDataType, PopulatedExternalData } from "../../../ports/DbApiV2"; +import { SchemaIdentifier, SchemaOrganization, SchemaPerson } from "./kysely.database"; import { mergeArrays } from "../../../utils"; +import { mergeDepuplicateIdentifierArray } from "../../../../tools/identifiersTools"; export const mergeExternalData = ( externalData: PopulatedExternalData[] @@ -15,7 +16,159 @@ export const mergeExternalData = ( return rest; } externalData.sort((a, b) => b.priority - a.priority); - const merged = merge.all(externalData, { arrayMerge: mergeArrays }); + const merged = merge.all(externalData, appDeepMergeOptions); const { slug: _slug, priority: _priority, kind: _kind, sourceUrl: _sourceUrl, ...rest } = merged; return rest; }; + +// Helper function to check if an identifier is present in an array of identifiers +const isIdentifierInArray = (identifier: SchemaIdentifier, identifiersArray: SchemaIdentifier[]): boolean => { + return identifiersArray.some( + item => + item.subjectOf?.url === identifier.subjectOf?.url && + item.value === identifier.value && + identifier.additionalType === item.additionalType + ); +}; + +export const isSamePerson = (personA: SchemaPerson, personB: SchemaPerson): boolean => { + if (personA.name.toLowerCase() === personB.name.toLowerCase()) return true; + + // Is email an id? + if (!personA.identifiers || !personB.identifiers) { + return false; + } + + // Check if at least one identifier of personA is present in personB's identifiers + return personA.identifiers.some(identifierA => isIdentifierInArray(identifierA, personB.identifiers!)); +}; + +// Function to merge two Person objects +export const mergePersons = (personA: SchemaPerson, personB: SchemaPerson): SchemaPerson => { + // Merge identifiers without duplicates + const mergedIdentifiers = [...(personA.identifiers || [])]; + for (const identifierB of personB.identifiers || []) { + if (!isIdentifierInArray(identifierB, mergedIdentifiers)) { + mergedIdentifiers.push(identifierB); + } + } + + // Merge affiliations + const mergedAffiliations = mergeOrganizationArrays(personA.affiliations, personB.affiliations); + + // Return a new merged object + return { + "@type": "Person", + name: personA.name, // Keep the name of personA (or personB, they are the same) + identifiers: mergedIdentifiers, + url: personA.url || personB.url, // Take the URL of whoever has one + affiliations: mergedAffiliations + }; +}; + +export const mergePersonArrays = ( + personListA: SchemaPerson[] | undefined, + personListB: SchemaPerson[] | undefined +): SchemaPerson[] => { + // If both arrays are empty or undefined, return an empty array + if (!personListA?.length && !personListB?.length) { + return []; + } + // If array1 is empty or undefined, return a copy of array2 + if (!personListA?.length) { + return personListB ? personListB : []; + } + // If array2 is empty or undefined, return a copy of array1 + if (!personListB?.length) { + return personListA; + } + + // Create a copy of the first array to avoid modifying it directly + const mergedArray = [...personListA]; + + personListB.forEach(personB => { + // Check if the person already exists in the merged array + const existingIndex = mergedArray.findIndex(personA => isSamePerson(personA, personB)); + + if (existingIndex !== -1) { + // If the person exists, merge the two objects + mergedArray[existingIndex] = mergePersons(mergedArray[existingIndex], personB); + } else { + // Otherwise, add the person to the array + mergedArray.push(personB); + } + }); + + return mergedArray; +}; + +/* Organization */ +export const isSameOrganization = (orgA: SchemaOrganization, orgB: SchemaOrganization): boolean => { + if (orgA.name === orgB.name) { + return true; + } + if (!orgA.identifiers || !orgB.identifiers) { + return false; + } + return orgA.identifiers.some(identifierA => isIdentifierInArray(identifierA, orgB.identifiers!)); +}; + +export const mergeOrganizations = (orgA: SchemaOrganization, orgB: SchemaOrganization): SchemaOrganization => { + // Merge identifiers without duplicates + const mergedIdentifiers = mergeDepuplicateIdentifierArray(orgA.identifiers, orgB.identifiers); + + // Merge parent organizations without duplicates (optional, depending on your needs) + const mergedParentOrganizations = orgA.parentOrganizations ?? []; + orgB.parentOrganizations?.forEach(orgParentB => { + if (!mergedParentOrganizations.some(item => isSameOrganization(item, orgParentB))) { + mergedParentOrganizations.push(orgParentB); + } + }); + + return { + ...merge.all([orgA, orgB]), + identifiers: mergedIdentifiers, + parentOrganizations: mergedParentOrganizations + }; +}; + +export const mergeOrganizationArrays = ( + array1: SchemaOrganization[] | undefined, + array2: SchemaOrganization[] | undefined +): SchemaOrganization[] => { + // If both arrays are empty or undefined, return an empty array + if (!array1?.length && !array2?.length) { + return []; + } + // If array1 is empty or undefined, return a copy of array2 + if (!array1?.length) { + return array2 ? array2 : []; + } + // If array2 is empty or undefined, return a copy of array1 + if (!array2?.length) { + return array1; + } + + const mergedArray = [...array1]; + for (const orgB of array2) { + const existingIndex = mergedArray.findIndex(orgA => isSameOrganization(orgA, orgB)); + if (existingIndex !== -1) { + mergedArray[existingIndex] = mergeOrganizations(mergedArray[existingIndex], orgB); + } else { + mergedArray.push(orgB); + } + } + return mergedArray; +}; + +/* App deep merge options */ +const appCustomeMerge = (key: string) => { + if (key === "developers") { + return (personListA: SchemaPerson[] | undefined, _: SchemaPerson[] | undefined) => personListA; + } +}; + +export const appDeepMergeOptions = { + arrayMerge: mergeArrays, + customMerge: appCustomeMerge +}; diff --git a/api/src/core/adapters/dbApi/kysely/migrations/1773831755120_add-new-type.ts b/api/src/core/adapters/dbApi/kysely/migrations/1773831755120_add-new-type.ts new file mode 100644 index 000000000..8790b9f71 --- /dev/null +++ b/api/src/core/adapters/dbApi/kysely/migrations/1773831755120_add-new-type.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("sources") + .alterColumn("kind", col => col.setDataType("text")) + .execute(); + + await db.schema.dropType("external_data_origin_type").execute(); + await db.schema + .createType("external_data_origin_type") + .asEnum(["wikidata", "HAL", "ComptoirDuLibre", "CNLL", "Zenodo", "GitLab", "GitHub", "RNSR", "ROR"]) + .execute(); + + await db.schema + .alterTable("sources") + .alterColumn("kind", col => + col.setDataType(sql`external_data_origin_type USING kind::external_data_origin_type`) + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("software_external_datas").dropColumn("repoMetadata").execute(); + + await db.schema + .alterTable("sources") + .alterColumn("kind", col => col.setDataType("text")) + .execute(); + + await db.schema.dropType("external_data_origin_type").execute(); + await db.schema + .createType("external_data_origin_type") + .asEnum(["wikidata", "HAL", "ComptoirDuLibre", "CNLL", "Zenodo", "GitLab", "GitHub"]) + .execute(); + + await db.schema + .alterTable("sources") + .alterColumn("kind", col => + col.setDataType(sql`external_data_origin_type USING kind::external_data_origin_type`) + ) + .execute(); +} diff --git a/api/src/core/adapters/dbApi/kysely/migrations/1774541162869_create-author-org.ts b/api/src/core/adapters/dbApi/kysely/migrations/1774541162869_create-author-org.ts new file mode 100644 index 000000000..45284e1b1 --- /dev/null +++ b/api/src/core/adapters/dbApi/kysely/migrations/1774541162869_create-author-org.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("author_organizations") + .addColumn("id", "text", col => col.primaryKey()) + .addColumn("organization", "jsonb", col => col.notNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("authors_organizations").execute(); +} diff --git a/api/src/core/adapters/hal/HalAPI/index.ts b/api/src/core/adapters/hal/HalAPI/index.ts index 12ee993f0..6d5ff2f55 100644 --- a/api/src/core/adapters/hal/HalAPI/index.ts +++ b/api/src/core/adapters/hal/HalAPI/index.ts @@ -79,7 +79,8 @@ export const makeHalAPIGateway = (source?: Source): HALAPIGateway => { } if (res.status === 404) { - throw new HAL.API.FetchError(res.status); + console.error(`Got 404 on ${url}`); + return undefined; } const json = await res.json(); diff --git a/api/src/core/adapters/hal/HalAPI/types/HAL.ts b/api/src/core/adapters/hal/HalAPI/types/HAL.ts index 4ac515adf..2f10a1459 100644 --- a/api/src/core/adapters/hal/HalAPI/types/HAL.ts +++ b/api/src/core/adapters/hal/HalAPI/types/HAL.ts @@ -286,6 +286,7 @@ export namespace HAL { parentValid_s: string; ror_s: string | string[]; rorUrl_s: string; + rnsr_s: string | string[]; text: string; exte_autocomplete: string; type_s: string; diff --git a/api/src/core/adapters/hal/getHalSoftwareExternalData.ts b/api/src/core/adapters/hal/getHalSoftwareExternalData.ts index 7cc20d020..51f329451 100644 --- a/api/src/core/adapters/hal/getHalSoftwareExternalData.ts +++ b/api/src/core/adapters/hal/getHalSoftwareExternalData.ts @@ -30,11 +30,19 @@ const buildParentOrganizationTree = async ( if (!structure) throw new Error(`Couldn't get data for structure docid : ${structureId}`); + const rorstring = Array.isArray(structure.ror_s) ? structure.ror_s?.[0] : structure.ror_s; + return { "@type": "Organization", "name": structure.name_s, "url": structure.ror_s?.[0] ?? structure.ror_s ?? structure?.url_s, - "parentOrganizations": await buildParentOrganizationTree(structure?.parentDocid_i, halAPIGateway) + "parentOrganizations": await buildParentOrganizationTree(structure?.parentDocid_i, halAPIGateway), + identifiers: [ + ...(rorstring ? [identifersUtils.makeRorOrgaIdentifer({ rorId: rorstring })] : []), + ...(structure.rnsr_s?.[0] || structure.rnsr_s + ? [identifersUtils.makeRNSROrgaIdentifer({ rnrsId: structure.rnsr_s?.[0] ?? structure.rnsr_s })] + : []) + ] }; }) ); @@ -148,11 +156,27 @@ export const getHalSoftwareExternal: GetSoftwareExternal = memoize( return { "@type": "Organization" as const, "name": structure.name_s, - "url": structure.ror_s?.[0] ?? structure.ror_s ?? structure?.url_s, + "url": structure?.url_s ?? structure.ror_s?.[0] ?? structure.ror_s, "parentOrganizations": await buildParentOrganizationTree( structure.parentDocid_i, halAPIGateway - ) + ), + identifiers: [ + ...(structure.ror_s?.[0] || structure.ror_s + ? [ + identifersUtils.makeRorOrgaIdentifer({ + rorId: structure.ror_s?.[0] ?? structure.ror_s + }) + ] + : []), + ...(structure.rnsr_s?.[0] || structure.rnsr_s + ? [ + identifersUtils.makeRNSROrgaIdentifer({ + rnrsId: structure.rnsr_s?.[0] ?? structure.rnsr_s + }) + ] + : []) + ] }; }) ); diff --git a/api/src/core/adapters/resolveAdapter.ts b/api/src/core/adapters/resolveAdapter.ts index 05ed4805a..3297205e5 100644 --- a/api/src/core/adapters/resolveAdapter.ts +++ b/api/src/core/adapters/resolveAdapter.ts @@ -10,10 +10,24 @@ import { comptoirDuLibreSourceGateway } from "./comptoirDuLibre"; import { zenodoSourceGateway } from "./zenodo"; import { cnllSourceGateway } from "./CNLL"; import { gitHubSourceGateway } from "./GitHub"; +import { rorSourceGateway } from "./ror.org"; +import { rnsrSourceGateway } from "./RNSR"; import { gitLabSourceGateway } from "./GitLab"; +import { ExternalDataOriginKind } from "./dbApi/kysely/kysely.database"; export const resolveAdapterFromSource = (source: DatabaseDataType.SourceRow, feature?: Feature): SourceGateway => { - switch (source.kind) { + return resolveAdapterFromSourceType(source.kind, feature); +}; + +export const filterSourceByFeature = (sources: DatabaseDataType.SourceRow[], feature: Feature) => { + return sources.filter(source => { + const gateway = resolveAdapterFromSourceType(source.kind); + return Object.hasOwn(gateway, feature); + }); +}; + +export const resolveAdapterFromSourceType = (sourceType: ExternalDataOriginKind, feature?: Feature): SourceGateway => { + switch (sourceType) { case "HAL": if (feature && !Object.hasOwn(halSourceGateway, feature)) throw new Error(`halSourceGateway doesn't implemend ${feature}`); @@ -42,8 +56,16 @@ export const resolveAdapterFromSource = (source: DatabaseDataType.SourceRow, fea if (feature && !Object.hasOwn(gitLabSourceGateway, feature)) throw new Error(`gitLabSourceGateway doesn't implemend ${feature}`); return gitLabSourceGateway; + case "ROR": + if (feature && !Object.hasOwn(rorSourceGateway, feature)) + throw new Error(`rorSourceGateway doesn't implemend ${feature}`); + return rorSourceGateway; + case "RNSR": + if (feature && !Object.hasOwn(rnsrSourceGateway, feature)) + throw new Error(`rnsrSourceGateway doesn't implemend ${feature}`); + return rnsrSourceGateway; default: - const unreachableCase: never = source.kind; + const unreachableCase: never = sourceType; throw new Error(`Unreachable case: ${unreachableCase}`); } }; diff --git a/api/src/core/adapters/ror.org/API/getOrganization.test.ts b/api/src/core/adapters/ror.org/API/getOrganization.test.ts new file mode 100644 index 000000000..7730e3970 --- /dev/null +++ b/api/src/core/adapters/ror.org/API/getOrganization.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { makeRorOrgApi, RorSource } from "./index"; +import { describe, expect, it, beforeAll } from "vitest"; + +describe("fetchRorOrganizationById - Integration Tests", () => { + let rorApiAgent: RorSource; + + beforeAll(async () => { + rorApiAgent = makeRorOrgApi(); + }); + + it("should return a SchemaOrganization for a valid ROR ID", async () => { + const result = await rorApiAgent.organization.get("02feahw73"); + + expect(result).not.toBeNull(); + + expect(result).toHaveProperty("name", "Centre National de la Recherche Scientifique"); + expect(result).toHaveProperty("foundingDate", "1939"); + + expect(result?.sameAs).toBeUndefined(); + + expect(result?.identifiers).toBeDefined(); + expect(result?.identifiers).toEqual([ + { + "@type": "PropertyValue", + "additionalType": "Organization", + "subjectOf": { + "@type": "Website", + "additionalType": "ROR", + "name": "Research Organization Registry", + "url": new URL("https://ror.org/") + }, + "url": "https://ror.org/02feahw73", + "value": "02feahw73" + }, + { + "@type": "PropertyValue", + "additionalType": "fundref", + "subjectOf": { + "@type": "Website", + "additionalType": "CROSSREF", + "name": "One of the official Identifier Registration Agencies", + "url": new URL("https://www.crossref.org/") + }, + "url": "https://api.crossref.org/funders/501100004794", + "value": "501100004794" + }, + { + "@type": "PropertyValue", + "subjectOf": { + "@type": "Website", + "additionalType": "GRID", + "name": "Global Research Identifier Database", + "url": new URL("https://www.grid.ac/") + }, + "value": "grid.4444.0" + }, + { + "@type": "PropertyValue", + "subjectOf": { + "@type": "Website", + "additionalType": "INSI", + "name": "International Standard Name Identifier", + "url": new URL("https://insi.org/") + }, + "url": "http://isni.org/isni/0000 0001 2259 7504", + "value": "0000 0001 2259 7504" + }, + { + "@type": "PropertyValue", + "name": "ID on Wikidata", + "subjectOf": { + "@type": "Website", + "additionalType": "wikidata", + "name": "Wikidata", + "url": new URL("https://www.wikidata.org/") + }, + "url": "https://www.wikidata.org/wiki/Q280413", + "value": "Q280413" + } + ]); + }); + + it("should return a SchemaOrganization for a valid ROR ID", async () => { + const result = await rorApiAgent.organization.get("03cwzta72"); + + expect(result).not.toBeNull(); + + expect(result).toHaveProperty("name", "Direction des Energies"); + expect(result).toHaveProperty("foundingDate", "2020"); + + expect(result?.sameAs).toBeUndefined(); + + expect(result?.identifiers).toBeDefined(); + expect(result?.identifiers).toEqual([ + { + "@type": "PropertyValue", + "additionalType": "Organization", + "subjectOf": { + "@type": "Website", + "additionalType": "ROR", + "name": "Research Organization Registry", + "url": new URL("https://ror.org/") + }, + "url": "https://ror.org/03cwzta72", + "value": "03cwzta72" + }, + { + "@type": "PropertyValue", + "subjectOf": { + "@type": "Website", + "additionalType": "GRID", + "name": "Global Research Identifier Database", + "url": new URL("https://www.grid.ac/") + }, + "value": "grid.457258.9" + }, + { + "@type": "PropertyValue", + "subjectOf": { + "@type": "Website", + "additionalType": "INSI", + "name": "International Standard Name Identifier", + "url": new URL("https://insi.org/") + }, + "url": "http://isni.org/isni/0000 0001 2180 4137", + "value": "0000 0001 2180 4137" + }, + { + "@type": "PropertyValue", + "name": "ID on Wikidata", + "subjectOf": { + "@type": "Website", + "additionalType": "wikidata", + "name": "Wikidata", + "url": new URL("https://www.wikidata.org/") + }, + "url": "https://www.wikidata.org/wiki/Q30299415", + "value": "Q30299415" + } + ]); + }); + + it("should return null for an invalid ROR ID", async () => { + const result = await rorApiAgent.organization.get("invalidRorId"); + + expect(result).toBeUndefined(); + }); + + it("should handle API errors gracefully", async () => { + const result = await rorApiAgent.organization.get("malformed_id"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/api/src/core/adapters/ror.org/API/getOrganization.ts b/api/src/core/adapters/ror.org/API/getOrganization.ts new file mode 100644 index 000000000..9cf99baa8 --- /dev/null +++ b/api/src/core/adapters/ror.org/API/getOrganization.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { identifersUtils } from "../../../../tools/identifiersTools"; +import { SchemaOrganization } from "../../dbApi/kysely/kysely.database"; + +type RORAdmin = { + created: { + date: string; + schema_version: string; + }; + last_modified: { + date: string; + schema_version: string; + }; +}; + +type RORExternalId = { + all: string[]; + preferred: string | null; + type: string; +}; + +type RORLink = { + type: string; + value: string; +}; + +type RORGeonamesDetails = { + continent_code: string; + continent_name: string; + country_code: string; + country_name: string; + country_subdivision_code: string; + country_subdivision_name: string; + lat: number; + lng: number; + name: string; +}; + +type RORLocation = { + geonames_details: RORGeonamesDetails; + geonames_id: number; +}; + +type RORName = { + lang: string | null; + types: string[]; + value: string; +}; + +type RORRelationship = { + label: string; + type: string; + id: string; +}; + +interface RorOrganization { + admin: RORAdmin; + domains: string[]; + established: number; + external_ids: RORExternalId[]; + id: string; + links: RORLink[]; + locations: RORLocation[]; + names: RORName[]; + relationships: RORRelationship[]; + status: string; + types: string[]; +} + +const ROR_TIMEOUT_RESET = 1000; + +export const fetchRorOrganizationById = async (params: { + rorId: string; + requestInit?: RequestInit; + rateLimitRetryDuration?: number; +}): Promise => { + const { rorId, requestInit = {}, rateLimitRetryDuration = ROR_TIMEOUT_RESET } = params; + const url = `https://api.ror.org/v2/organizations/${rorId}`; + + try { + const response = await fetch(url, requestInit); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (response.status === 429) { + await new Promise(resolve => setTimeout(resolve, rateLimitRetryDuration)); + return fetchRorOrganizationById(params); + } + + const data: RorOrganization | undefined = await response.json(); + if (data) { + return rorToSchemaOrganization(data); + } + return undefined; + } catch (error) { + console.error(`Erreur lors de la récupération de l'organisation ROR (${rorId}):`, error); + return undefined; + } +}; + +const rorToSchemaOrganization = (rorOrganization: RorOrganization): SchemaOrganization => { + const schemaOrg: SchemaOrganization = { + "@type": "Organization", + name: rorOrganization.names.filter(org => org.types.includes("ror_display"))[0].value, + foundingDate: rorOrganization.established ? rorOrganization.established.toString() : undefined, + additionalType: rorOrganization.types, + identifiers: [identifersUtils.makeRorOrgaIdentifer({ rorId: rorOrganization.id })] + }; + + // Ajout de l'accronyname (si disponible) + const accronyname = rorOrganization.names.filter(org => org.types.includes("acronym"))[0]; + if (accronyname?.value) { + schemaOrg.alternateName = [accronyname.value]; + } + + // Ajouter l'URL officielle (si disponible) + const websiteUrl = rorOrganization?.links?.filter(link => link.type === "website"); + if (websiteUrl && websiteUrl.length > 0) { + schemaOrg.url = websiteUrl[0].value; + } + + // Ajouter les identifiants externes (ROR, ISNI, Wikidata, etc.) + rorOrganization.external_ids.forEach(id => { + if (!id.type) return; // Ignorer si le type n'est pas défini + schemaOrg.identifiers = schemaOrg.identifiers ?? []; + const value = id.preferred ?? id.all?.[0]; + switch (id.type) { + case "wikidata": + schemaOrg.identifiers.push(identifersUtils.makeWikidataIdentifier({ wikidataId: value })); + break; + + case "fundref": + schemaOrg.identifiers.push( + identifersUtils.makeCrossRefIdentifier({ crossRefId: value, type: id.type }) + ); + break; + + case "grid": + schemaOrg.identifiers.push(identifersUtils.makeGridIdentifier({ gridId: value })); + + break; + + case "isni": + schemaOrg.identifiers.push(identifersUtils.makeINSIIdentifier({ insiId: value })); + break; + + default: + // Ignorer les types inconnus + break; + } + }); + + // Ajouter l'adresse + if (rorOrganization?.locations?.[0]?.geonames_details) { + schemaOrg.address = { + "@type": "PostalAddress" as const, + addressLocality: rorOrganization.locations[0].geonames_details.name, + addressCountry: rorOrganization.locations[0].geonames_details.country_name + }; + } + + // Ajouter les organization parentes + const parentsOrgs = rorOrganization.relationships.filter(org => org.type === "parent"); + schemaOrg.parentOrganizations = parentsOrgs.map(parOrg => { + return { + "@type": "Organization", + name: parOrg.label, + identifers: [identifersUtils.makeRorOrgaIdentifer({ rorId: parOrg.id })] + }; + }); + + return schemaOrg; +}; diff --git a/api/src/core/adapters/ror.org/API/index.ts b/api/src/core/adapters/ror.org/API/index.ts new file mode 100644 index 000000000..d34f8dfbe --- /dev/null +++ b/api/src/core/adapters/ror.org/API/index.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { Source } from "../../../usecases/readWriteSillData"; +import { SchemaOrganization } from "../../dbApi/kysely/kysely.database"; +import { fetchRorOrganizationById } from "./getOrganization"; + +export type RorSource = { + organization: { + get: (rorId: string) => Promise; + }; +}; + +export const makeRorOrgApi = (source?: Source): RorSource => { + const headers = { + Accept: "application/json", + ...(source?.configuration?.auth ? { "Client-Id": source.configuration.auth } : {}) + }; + + return { + organization: { + get: (rorId: string) => + fetchRorOrganizationById({ + rorId, + ...{ headers }, + rateLimitRetryDuration: source?.configuration?.rateLimitRetryDuration + }) + } + }; +}; diff --git a/api/src/core/adapters/ror.org/index.ts b/api/src/core/adapters/ror.org/index.ts new file mode 100644 index 000000000..4dbf34076 --- /dev/null +++ b/api/src/core/adapters/ror.org/index.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { SourceGateway } from "../../ports/SourceGateway"; +import { Source } from "../../usecases/readWriteSillData"; +import { makeRorOrgApi } from "./API"; + +export type RORSourceGateway = SourceGateway & { + organization: NonNullable; +}; + +export const rorSourceGateway: RORSourceGateway = { + sourceType: "ROR", + organization: { + getOrganization: (params: { organizationId: string; source?: Source }) => { + const rorApiAgent = makeRorOrgApi(params.source); + return rorApiAgent.organization.get(params.organizationId); + } + } +}; diff --git a/api/src/core/adapters/wikidata/ApiAgent/entity.ts b/api/src/core/adapters/wikidata/ApiAgent/entity.ts index 2325e2727..1ad2efb0e 100644 --- a/api/src/core/adapters/wikidata/ApiAgent/entity.ts +++ b/api/src/core/adapters/wikidata/ApiAgent/entity.ts @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2021-2025 DINUM -// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes // SPDX-License-Identifier: MIT import { type WikidataEntity } from "../../../../tools/WikidataEntity"; @@ -14,8 +14,9 @@ export class WikidataFetchError extends Error { export async function fetchEntity(params: { wikidataId: string; requestInit?: RequestInit; + rateLimitRetryDuration?: number; }): Promise<{ entity: WikidataEntity }> { - const { wikidataId, requestInit = {} } = params; + const { wikidataId, requestInit = {}, rateLimitRetryDuration = 5000 } = params; const res = await fetch(`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`, requestInit).catch( () => undefined @@ -26,7 +27,7 @@ export async function fetchEntity(params: { } if (res.status === 429) { - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise(resolve => setTimeout(resolve, rateLimitRetryDuration)); return fetchEntity(params); } diff --git a/api/src/core/adapters/wikidata/ApiAgent/getItem.test.ts b/api/src/core/adapters/wikidata/ApiAgent/getItem.test.ts new file mode 100644 index 000000000..d10795bb7 --- /dev/null +++ b/api/src/core/adapters/wikidata/ApiAgent/getItem.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { describe, it, expect } from "vitest"; +import { getOrganisationFromApi } from "./getOrganisation"; + +describe("getOrganizationFromApi (live test)", () => { + it("should fetch and convert a real Wikidata entity to a SchemaOrganization", async () => { + // Utilise un ID d'entité Wikidata valide + const entityId = "Q280413"; + + // Appelle la fonction qui fait l'appel API réel + const result = await getOrganisationFromApi({ entityId }); + + // Vérifie que le résultat est bien un objet SchemaOrganization + expect(result).toBeDefined(); + if (result) { + expect(result["@type"]).toBe("Organization"); + expect(result.name).toEqual("Centre national de la recherche scientifique"); + expect(result.description).toEqual("organisme public français de recherche scientifique"); + expect(result.url).toEqual("https://www.cnrs.fr/"); + expect(result.foundingDate).toEqual("1939"); + expect(result.address).toEqual({ + "@type": "PostalAddress", + "addressCountry": "France", + "postalCode": "75794 cedex 16", + "streetAddress": "3 rue Michel-Ange" + }); + } + }, 10000); // Augmente le timeout si nécessaire (en ms) +}); + +describe("getOrganizationFromApi (live test)", () => { + it("should fetch and convert a real Wikidata entity to a SchemaOrganization", async () => { + // Utilise un ID d'entité Wikidata valide + const entityId = "Q70571774"; + + // Appelle la fonction qui fait l'appel API réel + const result = await getOrganisationFromApi({ entityId }); + + // Vérifie que le résultat est bien un objet SchemaOrganization + expect(result).toBeDefined(); + if (result) { + expect(result["@type"]).toBe("Organization"); + expect(result.name).toEqual( + "Institut national de recherche pour l'agriculture, l'alimentation et l'environnement" + ); + expect(result.description).toEqual("institut français de recherche public"); + expect(result.url).toEqual("https://www.inrae.fr/"); + expect(result.foundingDate).toEqual("2020"); + expect(result.address).toEqual({ + "@type": "PostalAddress", + "addressCountry": "France", + "postalCode": "75007", + "streetAddress": "147, rue de l'Université" + }); + } + }, 10000); // Augmente le timeout si nécessaire (en ms) +}); diff --git a/api/src/core/adapters/wikidata/ApiAgent/getLicenses.ts b/api/src/core/adapters/wikidata/ApiAgent/getLicenses.ts index e34290d98..3ca8e070b 100644 --- a/api/src/core/adapters/wikidata/ApiAgent/getLicenses.ts +++ b/api/src/core/adapters/wikidata/ApiAgent/getLicenses.ts @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2021-2025 DINUM -// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes // SPDX-License-Identifier: MIT export const getLicenses = async (params: { wikidataIds: string[]; requestInit?: RequestInit }) => { diff --git a/api/src/core/adapters/wikidata/ApiAgent/getOrganisation.ts b/api/src/core/adapters/wikidata/ApiAgent/getOrganisation.ts new file mode 100644 index 000000000..a58c0f747 --- /dev/null +++ b/api/src/core/adapters/wikidata/ApiAgent/getOrganisation.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { convertSourceConfigToRequestInit } from "../../../../tools/sourceConfig"; +import { GetAuthorOrganization } from "../../../ports/GetAuthorOrganization"; +import { SchemaOrganization, SchemaPostalAddress } from "../../dbApi/kysely/kysely.database"; +import { fetchRestWikidataEntity, RestWikidataEntity } from "./restApi"; +import { getWikimediaFileUrl } from "./wikimedia"; + +export const convertWikidataToSchemaOrganization = (params: { + organisationEntity: RestWikidataEntity; + streetEntity?: RestWikidataEntity; + countryEntity?: RestWikidataEntity; + logoUrl?: string; +}): SchemaOrganization => { + const { organisationEntity, streetEntity, countryEntity, logoUrl } = params; + + // Récupérer le nom principal + const name = organisationEntity.labels?.fr || organisationEntity.labels?.en; + + // Récupérer les noms alternatifs (acronymes) + const alternateName = [...new Set(organisationEntity.aliases?.fr || [])]; + + // Récupérer la description + const description = organisationEntity.descriptions?.fr || ""; + + // Récupérer l'URL principale + const url = organisationEntity.statements?.P856?.[0]?.value?.content as string | undefined; + + // Récupérer la date de fondation + let foundingDate: string | undefined; + const foundingDateStatement = organisationEntity.statements?.P571?.[0]; + const content = foundingDateStatement?.value?.content; + if (foundingDateStatement && typeof content === "object" && content !== null && "time" in content) { + if (content.time[0] === "+") { + foundingDate = new Date(content.time.slice(1, content.time.length - 1)).getFullYear().toString(); + } else { + foundingDate = new Date(content.time).getFullYear().toString(); + } + } + + // Récupérer l'adresse + let address: SchemaPostalAddress | undefined; + const addressStatement = organisationEntity.statements?.P159?.[0]; + if (addressStatement) { + const qualifiers = addressStatement.qualifiers || []; + let postalCode: string | undefined; + let streetAddress: string | undefined; + + for (const qualifier of qualifiers) { + if (qualifier.property.id === "P670" && typeof qualifier.value.content === "string") { + streetAddress = qualifier.value.content; + } else if (qualifier.property.id === "P281" && typeof qualifier.value.content === "string") { + postalCode = qualifier.value.content; + } else if ( + qualifier.property.id === "P6375" && + typeof qualifier.value.content === "object" && + "text" in qualifier.value.content + ) { + streetAddress = qualifier.value.content.text; + } + } + + if (streetEntity?.labels?.["fr"]) { + streetAddress += ", " + streetEntity?.labels?.["fr"]; + } + + address = { + "@type": "PostalAddress", + streetAddress, + postalCode, + addressCountry: countryEntity?.labels?.["fr"] + }; + } + + // Créer l'objet SchemaOrganization + const organization: SchemaOrganization = { + "@type": "Organization", + name, + url, + // identifiers, + foundingDate, + alternateName, + description, + address, + ...(logoUrl ? { image: logoUrl } : {}) + }; + + return organization; +}; + +export const getOrganisation: GetAuthorOrganization = params => { + const { organizationId, source } = params; + const apiRequestInit = convertSourceConfigToRequestInit(source?.configuration); + return getOrganisationFromApi({ + entityId: organizationId, + requestInit: apiRequestInit, + rateLimitRetryDuration: source?.configuration?.rateLimitRetryDuration + }); +}; + +export const getOrganisationFromApi = async (params: { + entityId: string; + requestInit?: RequestInit; + rateLimitRetryDuration?: number; +}): Promise => { + const org = await fetchRestWikidataEntity(params); + if (!org) return undefined; + + const addressEntityId = org?.statements?.P159?.[0].qualifiers?.find(statement => statement.property.id === "P669") + ?.value.content as string | undefined; + const addressEntity = addressEntityId + ? await fetchRestWikidataEntity({ ...params, entityId: addressEntityId }) + : undefined; + + const countryWikidataId = + (addressEntity?.statements?.P17?.[0].value.content as string | undefined) ?? + (org?.statements?.P17?.[0].value.content as string | undefined) ?? + undefined; + const countryEntity = countryWikidataId + ? await fetchRestWikidataEntity({ ...params, entityId: countryWikidataId }) + : undefined; + + // Récupération du logo + const logoFileName = org.statements?.P154?.[0]; + let logoUrl: string | undefined; + if ( + logoFileName && + logoFileName.property.data_type === "commonsMedia" && + logoFileName.value.type === "value" && + logoFileName.value.content && + typeof logoFileName.value.content === "string" + ) { + logoUrl = await getWikimediaFileUrl({ ...params, fileName: logoFileName.value.content }); + } + + return convertWikidataToSchemaOrganization({ + organisationEntity: org, + streetEntity: addressEntity, + countryEntity, + logoUrl + }); +}; diff --git a/api/src/core/adapters/wikidata/ApiAgent/index.ts b/api/src/core/adapters/wikidata/ApiAgent/index.ts index de228daa2..d6d7bb3df 100644 --- a/api/src/core/adapters/wikidata/ApiAgent/index.ts +++ b/api/src/core/adapters/wikidata/ApiAgent/index.ts @@ -1,16 +1,37 @@ -// SPDX-FileCopyrightText: 2021-2025 DINUM -// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes // SPDX-License-Identifier: MIT import { convertSourceConfigToRequestInit } from "../../../../tools/sourceConfig"; import { Source } from "../../../usecases/readWriteSillData"; import { fetchEntity } from "./entity"; +import { getOrganisationFromApi } from "./getOrganisation"; import { getLicenses } from "./getLicenses"; -export const makeWikidataAPIAgent = (source: Source) => { - const requestInit = convertSourceConfigToRequestInit(source.configuration); +export const makeWikidataAPIAgent = (source?: Source) => { + const requestInit = source?.configuration ? convertSourceConfigToRequestInit(source.configuration) : {}; + + const wikidataRequestInit = { + ...requestInit, + headers: { + ...(requestInit?.headers ?? {}), + "Accept": "application/json" + } + }; + return { - fetchEntity: (entityId: string) => fetchEntity({ wikidataId: entityId, requestInit }), - getLicenses: (wikidataIds: string[]) => getLicenses({ wikidataIds, requestInit }) + fetchEntity: (entityId: string) => + fetchEntity({ + wikidataId: entityId, + requestInit: wikidataRequestInit, + rateLimitRetryDuration: source?.configuration?.rateLimitRetryDuration + }), + getLicenses: (wikidataIds: string[]) => getLicenses({ wikidataIds, requestInit: wikidataRequestInit }), + getOrganization: (entityId: string) => + getOrganisationFromApi({ + entityId, + requestInit: wikidataRequestInit, + rateLimitRetryDuration: source?.configuration?.rateLimitRetryDuration + }) }; }; diff --git a/api/src/core/adapters/wikidata/ApiAgent/restApi.ts b/api/src/core/adapters/wikidata/ApiAgent/restApi.ts new file mode 100644 index 000000000..95a02d31f --- /dev/null +++ b/api/src/core/adapters/wikidata/ApiAgent/restApi.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +interface WikidataValue { + type: string; + content?: string | number | { [key: string]: any } | Array; +} + +interface WikidataProperty { + id: string; + data_type: string; +} + +interface WikidataStatement { + id: string; + rank: string; + qualifiers?: Array<{ + property: WikidataProperty; + value: WikidataValue; + }>; + references?: Array<{ + hash: string; + parts: Array<{ + property: WikidataProperty; + value: WikidataValue; + }>; + }>; + property: WikidataProperty; + value: WikidataValue; +} + +interface WikidataStatements { + [propertyId: string]: WikidataStatement[]; +} + +interface WikidataLabels { + [lang: string]: string; +} + +interface WikidataDescriptions { + [lang: string]: string; +} + +interface WikidataAliases { + [lang: string]: string[]; +} + +interface WikidataSitelink { + title: string; + badges: string[]; + url: string; +} + +interface WikidataSitelinks { + [site: string]: WikidataSitelink; +} + +export interface RestWikidataEntity { + type: string; + id: string; + labels: WikidataLabels; + descriptions: WikidataDescriptions; + aliases: WikidataAliases; + statements: WikidataStatements; + sitelinks: WikidataSitelinks; +} + +export const fetchRestWikidataEntity = async (params: { + entityId: string; + requestInit?: RequestInit; + rateLimitRetryDuration?: number; +}): Promise => { + const { entityId, requestInit = {}, rateLimitRetryDuration = 5000 } = params; + const url = `https://www.wikidata.org/w/rest.php/wikibase/v1/entities/items/${entityId}`; + + try { + const response = await fetch(url, requestInit); + + if (response.status === 429) { + console.debug("Wikidata Busy, retrying in ", rateLimitRetryDuration); + await new Promise(resolve => setTimeout(resolve, rateLimitRetryDuration)); + return fetchRestWikidataEntity(params); + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: RestWikidataEntity = await response.json(); + return data; + } catch (error) { + console.error("Erreur lors de la récupération de l'entité Wikidata :", error); + return undefined; + } +}; diff --git a/api/src/core/adapters/wikidata/ApiAgent/wikimedia.ts b/api/src/core/adapters/wikidata/ApiAgent/wikimedia.ts new file mode 100644 index 000000000..1b2ee15dc --- /dev/null +++ b/api/src/core/adapters/wikidata/ApiAgent/wikimedia.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +export type WikimediaImageInfo = { + query: { + pages: { + [key: string]: { + imageinfo?: Array<{ url: string }>; + }; + }; + }; +}; + +export const getWikimediaFileUrl = async (params: { + fileName: string; + requestInit?: RequestInit; + rateLimitRetryDuration?: number; +}): Promise => { + const { fileName, requestInit = {}, rateLimitRetryDuration = 5000 } = params; + const apiUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${encodeURIComponent(fileName)}&prop=imageinfo&iiprop=url&format=json&origin=*`; + + try { + const response = await fetch(apiUrl, requestInit); + + if (response.status === 429) { + console.debug("Wikidata Busy, retrying in ", rateLimitRetryDuration); + await new Promise(resolve => setTimeout(resolve, rateLimitRetryDuration)); + return getWikimediaFileUrl(params); + } + + if (!response.ok) { + throw new Error(`Erreur HTTP: ${response.status}`); + } + + const data: WikimediaImageInfo = await response.json(); + const pages = data.query.pages; + const pageId = Object.keys(pages)[0]; + const imageInfo = pages[pageId].imageinfo; + + if (!imageInfo || imageInfo.length === 0) { + throw new Error("Aucune URL trouvée pour ce fichier."); + } + + return imageInfo[0].url; + } catch (error) { + console.error("Erreur lors de la récupération de l'URL du fichier:", error); + throw error; + } +}; diff --git a/api/src/core/adapters/wikidata/getOrganization.ts b/api/src/core/adapters/wikidata/getOrganization.ts new file mode 100644 index 000000000..99497793f --- /dev/null +++ b/api/src/core/adapters/wikidata/getOrganization.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { SearchOrganizationCriteria } from "../../ports/SourceGateway"; + +type SparqlResult = { + head: { + vars: string[]; + }; + results: { + bindings: Array<{ + item: { + type: string; + value: string; // Exemple : "http://www.wikidata.org/entity/Q217271" + }; + }>; + }; +}; + +export const searchOrganizationOnWikidata = async ( + search: SearchOrganizationCriteria +): Promise => { + if (search.identifer?.base === "ROR") { + const res = await searchOrganizationhWikidataItemId(search.identifer.value); + return res ? [res] : undefined; + } +}; + +export const searchOrganizationhWikidataItemId = async (rorId: string): Promise => { + const query = ` + SELECT DISTINCT ?item + WHERE { + ?item wdt:P6782 "${rorId}" . + } + `; + + const encodedQuery = encodeURIComponent(query); + const url = `https://query.wikidata.org/sparql?query=${encodedQuery}&format=json`; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: SparqlResult = await response.json(); + + if (data.results.bindings.length > 0) { + const itemUri = data.results.bindings[0].item.value; + const itemId = itemUri.split("/")[4]; + return itemId; + } else { + console.log("Aucun résultat trouvé."); + return undefined; + } + } catch (error) { + console.error("Erreur lors de la récupération des données :", error); + return undefined; + } +}; diff --git a/api/src/core/adapters/wikidata/index.ts b/api/src/core/adapters/wikidata/index.ts index 2bb6ecb8e..f8fabebcc 100644 --- a/api/src/core/adapters/wikidata/index.ts +++ b/api/src/core/adapters/wikidata/index.ts @@ -3,6 +3,8 @@ // SPDX-License-Identifier: MIT import { SourceGateway } from "../../ports/SourceGateway"; +import { getOrganisation } from "./ApiAgent/getOrganisation"; +import { searchOrganizationOnWikidata } from "./getOrganization"; import { getWikidataForm } from "./getSoftwareForm"; import { getWikidataSoftware } from "./getWikidataSoftware"; import { getWikidataSoftwareOptions } from "./getWikidataSoftwareOptions"; @@ -10,6 +12,7 @@ import { getWikidataSoftwareOptions } from "./getWikidataSoftwareOptions"; export type WikidataGateway = SourceGateway & { softwareExtra: NonNullable; software: NonNullable; + organization: NonNullable; }; export const wikidataSourceGateway: WikidataGateway = { @@ -20,5 +23,9 @@ export const wikidataSourceGateway: WikidataGateway = { }, softwareExtra: { getSoftwareExternal: getWikidataSoftware + }, + organization: { + getOrganization: getOrganisation, + searchOrganization: searchOrganizationOnWikidata } }; diff --git a/api/src/core/bootstrap.ts b/api/src/core/bootstrap.ts index 96dfe7820..ec6875a6b 100644 --- a/api/src/core/bootstrap.ts +++ b/api/src/core/bootstrap.ts @@ -21,6 +21,10 @@ import rawUiConfig from "../customization/ui-config.json"; import { makeCreateSofware } from "./usecases/createSoftware"; import { makeUpdateSoftware } from "./usecases/updateSoftware"; import { makeRefreshExternalDataForSoftware } from "./usecases/refreshExternalData"; +import { + makeGetAndFetchSoftwareIdsByAuthorOrganization, + saveAndgetSoftwareIdsByOrganisation +} from "./usecases/getAuthorOrganization"; type PgDbConfig = { dbKind: "kysely"; kyselyDb: Kysely }; @@ -76,6 +80,7 @@ export async function bootstrapCore( fetchAndSaveExternalDataForOneSoftwarePackage: makeRefreshExternalDataForSoftware({ dbApi }), createSoftware: makeCreateSofware(dbApi), updateSoftware: makeUpdateSoftware(dbApi), + getAndFetchSoftwareIdsByAuthorOrganization: makeGetAndFetchSoftwareIdsByAuthorOrganization({ dbApi }), auth: { initiateAuth: makeInitiateAuth({ sessionRepository: dbApi.session, oidcClient }), handleAuthCallback: makeHandleAuthCallback({ @@ -88,5 +93,9 @@ export async function bootstrapCore( } }; + if (uiConfig.header.menu.devOrganizations.enabled) { + saveAndgetSoftwareIdsByOrganisation({ dbApi }); + } + return { dbApi, context, useCases, uiConfig }; } diff --git a/api/src/core/ports/DbApiV2.ts b/api/src/core/ports/DbApiV2.ts index 9034f1c4f..caeeda53c 100644 --- a/api/src/core/ports/DbApiV2.ts +++ b/api/src/core/ports/DbApiV2.ts @@ -2,7 +2,13 @@ // SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes // SPDX-License-Identifier: MIT -import type { Database, DatabaseRowOutput } from "../adapters/dbApi/kysely/kysely.database"; +import type { + Database, + DatabaseRowOutput, + ExternalDataOriginKind, + SchemaOrganization, + SchemaPerson +} from "../adapters/dbApi/kysely/kysely.database"; import { TransformRepoToCleanedRow } from "../adapters/dbApi/kysely/kysely.utils"; import type { CreateUserParams, @@ -10,6 +16,7 @@ import type { InstanceFormData, Software, SoftwareInList, + UIOrganization, UserWithId } from "../usecases/readWriteSillData"; import type { OmitFromExisting } from "../utils"; @@ -58,6 +65,14 @@ export namespace DatabaseDataType { export type SoftwareExtrinsicCreation = SoftwareExtrinsicRow & Pick; +export type SearchOptions = { + name?: string; + identifier?: { + key?: string; + value: string; + }; +}; + export interface SoftwareRepository { getFullList: () => Promise; getPublicList: () => Promise; @@ -83,6 +98,9 @@ export interface SoftwareRepository { countAddedByUser: (params: { userId: number }) => Promise; getAllSillSoftwareExternalIds: (sourceSlug: string) => Promise; unreference: (params: { softwareId: number; reason: string; time: number }) => Promise; + // Alternative index + getSoftwareIdsByAuthors: (params: { search?: SearchOptions }) => Promise>; + getSoftwareIdsByOrganisation: (params: { search?: SearchOptions }) => Promise>; } export type PopulatedExternalData = DatabaseDataType.SoftwareExternalDataRow & { @@ -179,6 +197,15 @@ export interface SourceRepository { getByName: (params: { name: string }) => Promise; getMainSource: () => Promise; getWikidataSource: () => Promise; + getByType: (params: { type: ExternalDataOriginKind }) => Promise; +} + +export interface AuthorOrganizationsRepository { + getAll: (params?: { ids?: Array }) => Promise; + get: (params: { id: string }) => Promise; + save: (params: { organization: SchemaOrganization }) => Promise; + checkIfSaved: (params: { ids: Array }) => Promise>; + flush: () => Promise; } export type Session = { @@ -219,5 +246,6 @@ export type DbApiV2 = { softwareUser: SoftwareUserRepository; session: SessionRepository; attributeDefinition: AttributeDefinitionRepository; + authorOrganization: AuthorOrganizationsRepository; getCompiledDataPrivate: () => Promise>; }; diff --git a/api/src/core/ports/GetAuthorOrganization.ts b/api/src/core/ports/GetAuthorOrganization.ts new file mode 100644 index 000000000..9c28a51a7 --- /dev/null +++ b/api/src/core/ports/GetAuthorOrganization.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { SchemaOrganization } from "../adapters/dbApi/kysely/kysely.database"; +import { Source } from "../usecases/readWriteSillData"; + +export type GetAuthorOrganization = (params: { + organizationId: string; + source: Source; +}) => Promise; diff --git a/api/src/core/ports/SourceGateway.ts b/api/src/core/ports/SourceGateway.ts index e4181520a..159d5f360 100644 --- a/api/src/core/ports/SourceGateway.ts +++ b/api/src/core/ports/SourceGateway.ts @@ -1,8 +1,9 @@ -// SPDX-FileCopyrightText: 2021-2025 DINUM -// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-FileCopyrightText: 2021-2026 DINUM +// SPDX-FileCopyrightText: 2024-2026 Université Grenoble Alpes // SPDX-License-Identifier: MIT import { ExternalDataOriginKind } from "../adapters/dbApi/kysely/kysely.database"; +import { GetAuthorOrganization } from "./GetAuthorOrganization"; import { GetSoftwareExternal } from "./GetSoftwareExternal"; import { GetSoftwareExternalDataOptions } from "./GetSoftwareExternalDataOptions"; import { GetSoftwareFormData } from "./GetSoftwareFormData"; @@ -12,6 +13,14 @@ export type Features = Feature[]; export type SoftwareLink = { externalId: string; softwareId: number; softwareName?: string }; +export type SearchOrganizationCriteria = { + name?: string; + identifer?: { + base: string; + value: string; + }; +}; + export interface SourceGateway { sourceType: ExternalDataOriginKind; software?: { @@ -22,4 +31,8 @@ export interface SourceGateway { getSoftwareExternal: GetSoftwareExternal; getDiscoverSoftwareLinks?: () => Promise; }; + organization?: { + getOrganization: GetAuthorOrganization; + searchOrganization?: (search: SearchOrganizationCriteria) => Promise; + }; } diff --git a/api/src/core/uiConfigSchema.ts b/api/src/core/uiConfigSchema.ts index 63ec0e10f..fc03fa5d9 100644 --- a/api/src/core/uiConfigSchema.ts +++ b/api/src/core/uiConfigSchema.ts @@ -19,6 +19,9 @@ const headerSchema = z.object({ catalog: z.object({ enabled: z.boolean() }), + devOrganizations: z.object({ + enabled: z.boolean() + }), addSoftware: z.object({ enabled: z.boolean() }), diff --git a/api/src/core/usecases/buildIndexes.ts b/api/src/core/usecases/buildIndexes.ts new file mode 100644 index 000000000..3d25fa400 --- /dev/null +++ b/api/src/core/usecases/buildIndexes.ts @@ -0,0 +1,27 @@ +import { SchemaOrganization, SchemaPerson } from "../adapters/dbApi/kysely/kysely.database"; +import { DbApiV2 } from "../ports/DbApiV2"; + +export type SoftwareAuthorsIndex = Record>; + +export type MakeBuildSoftwareDevelopersIndex = (dbApi: DbApiV2) => BuildSoftwareDevelopersIndex; +export type BuildSoftwareDevelopersIndex = () => Promise; + +export const refreshExternalDataByExternalIdAndSlug: MakeBuildSoftwareDevelopersIndex = (dbApi: DbApiV2) => { + return async () => { + const index: SoftwareAuthorsIndex = {}; + + // 1. Récupérer la liste complète des logiciels + const softwareList = await dbApi.software.getFullList(); + + // 2. Pour chaque logiciel, récupérer les détails + for (const software of softwareList) { + const softwareId = software.id; + const populatedSoftware = await dbApi.software.getDetails(softwareId); + + // 3. Ajouter à l'index : clé = id, valeur = developers + index[softwareId] = populatedSoftware?.authors ?? []; + } + + return index; + }; +}; diff --git a/api/src/core/usecases/getAuthorOrganization.ts b/api/src/core/usecases/getAuthorOrganization.ts new file mode 100644 index 000000000..62da72238 --- /dev/null +++ b/api/src/core/usecases/getAuthorOrganization.ts @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { compareIdentifier, deduplicateIdentifierArray } from "../../tools/identifiersTools"; +import { SchemaIdentifier, SchemaOrganization } from "../adapters/dbApi/kysely/kysely.database"; +import { mergeOrganizations } from "../adapters/dbApi/kysely/mergeExternalData"; +import { rnsrSourceGateway } from "../adapters/RNSR"; +import { rorSourceGateway } from "../adapters/ror.org"; +import { wikidataSourceGateway } from "../adapters/wikidata"; +import type { DbApiV2, SearchOptions } from "../ports/DbApiV2"; +import { UIOrganization } from "./readWriteSillData"; + +export type GetAndFetchSoftwareIdsByAuthorOrganization = (params: { + search?: SearchOptions | undefined; +}) => Promise>; + +export const makeGetAndFetchSoftwareIdsByAuthorOrganization = (deps: { dbApi: DbApiV2 }) => { + const { dbApi } = deps; + return (params: { search?: SearchOptions }) => getSoftwareIdsByOrganisation({ search: params?.search, dbApi }); +}; + +const logUsecase = "[UC:GetAuthorOrganization]"; + +const fetchAndSaveOrganization = async (dbApi: DbApiV2, organization: SchemaOrganization): Promise => { + const sources = await dbApi.source.getAll(); + const allowedSourceType = ["ROR", "wikidata", "RNSR"]; + const sourcesIndex = sources.filter(source => allowedSourceType.includes(source.kind)); + + const fetchWithIdentifer = async (identifier: SchemaIdentifier): Promise => { + if (!identifier?.subjectOf?.additionalType) return; + + const type = identifier.subjectOf.additionalType; + const source = sourcesIndex.find(source => source.kind === type); + + if (!source) { + console.debug(`${logUsecase} You don't have a source set for ${type} type`); + return; + } + + switch (identifier.subjectOf.additionalType) { + case "ROR": + return rorSourceGateway.organization.getOrganization({ + organizationId: identifier.value, + source + }); + case "wikidata": + return wikidataSourceGateway.organization.getOrganization({ + organizationId: identifier.value, + source + }); + case "RNSR": + return rnsrSourceGateway.organization.getOrganization({ + organizationId: identifier.value, + source + }); + default: + return; + } + }; + + const batchCatcher = async (identifiers: SchemaIdentifier[]): Promise => { + const deduplicatedIdentifiers = deduplicateIdentifierArray(identifiers); + const organizationFetched = await Promise.all(deduplicatedIdentifiers.map(fetchWithIdentifer)); + + if (organizationFetched.length > 0) { + const childrenIdentifiers = organizationFetched + .map(org => org?.identifiers) + .filter(id => id !== undefined) + .flat(); + + // remove actual ids from children ids + const filteredChildenIds = childrenIdentifiers.filter( + identier => + !deduplicatedIdentifiers.some(identierToRemove => compareIdentifier(identierToRemove, identier)) + ); + const moreInfo = filteredChildenIds.length > 0 ? await batchCatcher(filteredChildenIds) : undefined; + organizationFetched.push(moreInfo); + + return organizationFetched.reduce((acc, org) => { + if (!org) return acc; + if (!acc) return org; + return mergeOrganizations(org, acc); + }, undefined); + } + + return undefined; + }; + + const fetchRecursivelyOrganisation = async (organisation: SchemaOrganization): Promise => { + if (organisation.identifiers && organisation.identifiers.length > 0) { + const mergedFromSources = await batchCatcher(organisation.identifiers); + if (mergedFromSources) { + return mergeOrganizations(mergedFromSources, organisation); + } + } + + return { + ...organisation, + identifiers: organisation.identifiers ? deduplicateIdentifierArray(organisation.identifiers) : [] + }; + }; + + return dbApi.authorOrganization.save({ + organization: await fetchRecursivelyOrganisation(organization) + }); +}; + +// Option 1 : SaveThenGet +export const saveAndgetSoftwareIdsByOrganisation = async (params: { dbApi: DbApiV2; search?: SearchOptions }) => { + const { dbApi, search = {} } = params; + const logIdentifer = `${logUsecase} Save&Get -`; + + // 1. Request to make link between software and organization + const softwareIdsByOrg = await dbApi.software.getSoftwareIdsByOrganisation({ search }); + console.debug(`${logIdentifer} found ${softwareIdsByOrg.length} organisations`); + + // 2. Complementary request on organization sources to get more info about the sources + const resultIds = softwareIdsByOrg.map(org => org.name); + const idsVerified = await dbApi.authorOrganization.checkIfSaved({ ids: resultIds }); + const idsToFetch = Object.entries(idsVerified) + .filter(([_, value]) => value === false) + .map(([key]) => key); + console.debug(`${logIdentifer} Need to fetch ${idsToFetch.length} organisations`); + + // TODO : paralelle instead of series -> Timeout issues + // orgsToFetch.filter(org => idsToFetch.includes(org.name)).forEach(org => fetchAndSaveOrganization(dbApi, org)); + const orgsToFetch = softwareIdsByOrg.filter(org => idsToFetch.includes(org.name)); + let index = 0; + console.time(`${logIdentifer} 💾 Saved ${orgsToFetch.length} organisations 🏛️`); + for (const org of orgsToFetch) { + console.log(`${logIdentifer} 💾 Saving ${index}/${orgsToFetch.length} 🏛️ : ${org.name}`); + await fetchAndSaveOrganization(dbApi, org); + index++; + } + + console.timeEnd(`${logIdentifer} 💾 Saved ${orgsToFetch.length} organisations 🏛️`); + + // 3. Return saved data + const allOrgs = await dbApi.authorOrganization.getAll({ ids: resultIds }); + return allOrgs.filter(org => org.identifiers && org.identifiers.length > 0); +}; + +// Option 2 : Get = // getSoftwareIdsByOrganisation +export const getSoftwareIdsByOrganisation = async (params: { dbApi: DbApiV2; search?: SearchOptions }) => { + const { dbApi, search = {} } = params; + + const softwareIdsByOrg = await dbApi.software.getSoftwareIdsByOrganisation({ search }); + const resultIds = softwareIdsByOrg.map(org => org.name); + + return (await dbApi.authorOrganization.getAll({ ids: resultIds })).filter( + org => org.identifiers && org.identifiers.length > 0 + ); +}; + +// Option 3 : Update = Delete -> Save +export const updateSoftwareIdsByOrganisation = async (params: { dbApi: DbApiV2 }) => { + const { dbApi } = params; + const logIdentifer = `${logUsecase} Update -`; + + await dbApi.authorOrganization.flush(); + console.debug(`${logIdentifer} Flush table - Done`); + + const softwareIdsByOrg = await dbApi.software.getSoftwareIdsByOrganisation({}); + console.debug(`${logIdentifer} Regenerate the org tree with last updated data - Done`); + + // 3. Improve organization getting on API Endpoints + let index = 0; + console.time(`${logIdentifer} 💾 Saved organisations 🏛️`); + for (const org of softwareIdsByOrg) { + console.log(`${logIdentifer} 💾 Saving ${index}/${softwareIdsByOrg.length} 🏛️ : ${org.name}`); + await fetchAndSaveOrganization(dbApi, org); + index++; + } + + console.timeEnd(`${logIdentifer} 💾 Saved organisations 🏛️`); +}; diff --git a/api/src/core/usecases/getSoftwareFormAutoFillDataFromExternalAndOtherSources.ts b/api/src/core/usecases/getSoftwareFormAutoFillDataFromExternalAndOtherSources.ts index 00a625a1c..4f88cb061 100644 --- a/api/src/core/usecases/getSoftwareFormAutoFillDataFromExternalAndOtherSources.ts +++ b/api/src/core/usecases/getSoftwareFormAutoFillDataFromExternalAndOtherSources.ts @@ -30,12 +30,12 @@ export const makeGetSoftwareFormAutoFillDataFromExternalAndOtherSources = const { comptoirDuLibreApi } = context; const mainSource = await context.dbApi.source.getMainSource(); - const gateway = resolveAdapterFromSource(mainSource); - if (!gateway.softwareExtra?.getSoftwareExternal) - throw new Error(`getSoftwareExternal not implemented on ${mainSource.kind}`); + const mainSourceGateway = resolveAdapterFromSource(mainSource); + if (!mainSourceGateway.softwareExtra?.getSoftwareExternal) + throw new Error(`[UC.refreshExternalData] getSoftwareExternal not implemented on ${mainSource.kind}`); const [softwareExternal, comptoirDuLibre] = await Promise.all([ - gateway.softwareExtra.getSoftwareExternal({ externalId, source: mainSource }), + mainSourceGateway.softwareExtra.getSoftwareExternal({ externalId, source: mainSource }), comptoirDuLibreApi.getComptoirDuLibre() ]); diff --git a/api/src/core/usecases/importFromSource.ts b/api/src/core/usecases/importFromSource.ts index 34f734d4f..09d9c516c 100644 --- a/api/src/core/usecases/importFromSource.ts +++ b/api/src/core/usecases/importFromSource.ts @@ -72,7 +72,9 @@ const resolveAllIdsAccordingToSource = async (source: Source): Promise throw new Error("[UC:Import] Not Implemented, but you can specify the list of ids you want to import"); // Secondary Sources case "CNLL": - throw new Error("[UC:Import] Import if not possible from a secondary source"); + case "RNSR": + case "ROR": + throw new Error("[UC:Import] Import if not possible from a secondary or non software source"); default: const shouldNotBeReached: never = source.kind; throw new Error("[UC:Import] Not Implemented", shouldNotBeReached); diff --git a/api/src/core/usecases/index.ts b/api/src/core/usecases/index.ts index 10425e041..82d76babb 100644 --- a/api/src/core/usecases/index.ts +++ b/api/src/core/usecases/index.ts @@ -15,11 +15,13 @@ import { InitiateAuth } from "./auth/initiateAuth"; import { HandleAuthCallback } from "./auth/handleAuthCallback"; import { InitiateLogout } from "./auth/logout"; import { RefreshSession } from "./auth/refreshSession"; +import { GetAndFetchSoftwareIdsByAuthorOrganization } from "./getAuthorOrganization"; export type UseCases = { getSoftwareFormAutoFillDataFromExternalAndOtherSources: GetSoftwareFormAutoFillDataFromExternalAndOtherSources; fetchAndSaveExternalDataForAllSoftware: FetchAndSaveExternalDataForAllSoftware; fetchAndSaveExternalDataForOneSoftwarePackage: FetchAndSaveExternalDataForSoftware; + getAndFetchSoftwareIdsByAuthorOrganization: GetAndFetchSoftwareIdsByAuthorOrganization; getUser: GetUser; auth: { initiateAuth: InitiateAuth; diff --git a/api/src/core/usecases/readWriteSillData/types.ts b/api/src/core/usecases/readWriteSillData/types.ts index ea5e3e46a..dff3d587a 100644 --- a/api/src/core/usecases/readWriteSillData/types.ts +++ b/api/src/core/usecases/readWriteSillData/types.ts @@ -148,3 +148,7 @@ export type InstanceFormData = { instanceUrl: string | undefined; isPublic: boolean; }; + +export interface UIOrganization extends SchemaOrganization { + producer?: Array; +} diff --git a/api/src/core/usecases/refreshExternalData.ts b/api/src/core/usecases/refreshExternalData.ts index 007fac680..40f99f602 100644 --- a/api/src/core/usecases/refreshExternalData.ts +++ b/api/src/core/usecases/refreshExternalData.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { DatabaseDataType, DbApiV2 } from "../ports/DbApiV2"; -import { resolveAdapterFromSource } from "../adapters/resolveAdapter"; +import { filterSourceByFeature, resolveAdapterFromSource } from "../adapters/resolveAdapter"; type ParamsOfrefreshExternalDataUseCase = { dbApi: DbApiV2; @@ -88,7 +88,9 @@ const discoverNewSoftwareLinks = async (dbApi: DbApiV2): Promise => { return activeSoftwareId ?? link.softwareId; }; - for (const source of sources) { + const filteredSources = filterSourceByFeature(sources, "softwareExtra"); + + for (const source of filteredSources) { const gateway = resolveAdapterFromSource(source, "softwareExtra"); if (!gateway.softwareExtra?.getDiscoverSoftwareLinks) continue; diff --git a/api/src/customization/ui-config.json b/api/src/customization/ui-config.json index f70c4c701..06534a0c5 100644 --- a/api/src/customization/ui-config.json +++ b/api/src/customization/ui-config.json @@ -14,6 +14,9 @@ "catalog": { "enabled": true }, + "devOrganizations": { + "enabled": false + }, "addSoftware": { "enabled": true }, diff --git a/api/src/lib/ApiTypes.ts b/api/src/lib/ApiTypes.ts index cb935f69f..5d9def7db 100644 --- a/api/src/lib/ApiTypes.ts +++ b/api/src/lib/ApiTypes.ts @@ -10,7 +10,6 @@ export type { ExternalDataOriginKind, SchemaIdentifier as Identifier, SchemaPerson as Person, - SchemaOrganization as Organization, ScholarlyArticle, RepoMetadata } from "../core/adapters/dbApi/kysely/kysely.database"; @@ -24,7 +23,8 @@ export type { SoftwareFormData, DeclarationFormData, InstanceFormData, - Source + Source, + UIOrganization as Organization } from "../core/usecases/readWriteSillData"; export type { diff --git a/api/src/rpc/router.ts b/api/src/rpc/router.ts index 6efba62fa..a5c57b4de 100644 --- a/api/src/rpc/router.ts +++ b/api/src/rpc/router.ts @@ -33,6 +33,7 @@ export type UseCasesUsedOnRouter = Pick< | "createSoftware" | "updateSoftware" | "fetchAndSaveExternalDataForOneSoftwarePackage" + | "getAndFetchSoftwareIdsByAuthorOrganization" | "auth" >; @@ -149,7 +150,21 @@ export function createRouter(params: { return { referentCount }; }), "getCurrentUser": loggedProcedure.query(({ ctx: { currentUser } }): UserWithId | undefined => currentUser), - + "getSoftwareIdsByOrganisation": loggedProcedure + .input( + z + .object({ + name: z.string().optional(), + identifier: z + .object({ + key: z.string().optional(), + value: z.string() + }) + .optional() + }) + .optional() + ) + .query(async ({ input }) => useCases.getAndFetchSoftwareIdsByAuthorOrganization({ search: input })), // -------------- PROTECTED PROCEDURES -------------- "getExternalSoftwareOptions": protectedProcedure .input( diff --git a/api/src/rpc/translations/en_default.json b/api/src/rpc/translations/en_default.json index f8613fd60..31529d18f 100644 --- a/api/src/rpc/translations/en_default.json +++ b/api/src/rpc/translations/en_default.json @@ -367,6 +367,7 @@ "siteTitle": "République
Française", "home title": "Home - Interministerial Free Software Catalog", "title": "Interministerial Free Software Catalog", + "devOrganizations": "Devloper Organizations", "navigation welcome": "Welcome to the $t(common.appAccronym)", "navigation catalog": "Software catalog", "navigation add software": "Add software or instance", @@ -383,6 +384,16 @@ "authorCard": { "affiliatedStructure": "Affiliated structures" }, + "organizationCard": { + "seeSoftwareDetails": "See {{count}} software items", + "organizationType": "Type", + "city": "City", + "organizationDescription": "Description", + "parentOrganizations": "Parent organizations" + }, + "organizationSearch": { + "placeholder": "Search for organization by name" + }, "footer": { "contribute": "Contribute" }, @@ -402,5 +413,9 @@ "organization is required": "You need to provide an organization to be able to use Sill. Please provide one below.", "update": "Update", "organization": "Organization" + }, + "organizationList": { + "searchResults": "{{count}} organizations found", + "softwareList": "Software list associated with this organization" } } diff --git a/api/src/rpc/translations/fr_default.json b/api/src/rpc/translations/fr_default.json index e848cc700..3b8ad107e 100644 --- a/api/src/rpc/translations/fr_default.json +++ b/api/src/rpc/translations/fr_default.json @@ -374,6 +374,7 @@ "siteTitle": "République
Française", "home title": "Accueil - Socle Interministériel des Logiciels Libres", "title": "Socle Interministériel des Logiciels Libres", + "devOrganizations": "Organizations développeuses", "navigation welcome": "Bienvenue sur le $t(common.appAccronym)", "navigation catalog": "Catalogue de logiciels", "navigation add software": "Ajouter un logiciel ou une instance ", @@ -390,6 +391,16 @@ "authorCard": { "affiliatedStructure": "Structures associées" }, + "organizationCard": { + "seeSoftwareDetails": "Voir les {{count}} logiciels", + "organizationType": "Type", + "city": "Ville", + "organizationDescription": "Description", + "parentOrganizations": "Organisations parents" + }, + "organizationSearch": { + "placeholder": "Rechercher une organisation par son nom" + }, "footer": { "contribute": "Contribuez" }, @@ -409,5 +420,9 @@ "organization is required": "Vous devez préciser l'organisation à laquelle vous appartenez", "update": "Mettre à jour", "organization": "Organisation" + }, + "organizationList": { + "searchResults": "{{count}} organisations trouvés", + "softwareList": "Liste des logiciels associés à cette organisation" } } diff --git a/api/src/rpc/translations/schema.json b/api/src/rpc/translations/schema.json index e37cb1bad..63804d65e 100644 --- a/api/src/rpc/translations/schema.json +++ b/api/src/rpc/translations/schema.json @@ -702,6 +702,51 @@ "youAreReferent" ] }, + "organizationCard": { + "type": "object", + "properties": { + "seeSoftwareDetails": { + "type": "string" + }, + "organizationType": { + "type": "string" + }, + "organizationDescription": { + "type": "string" + }, + "city": { + "type": "string" + }, + "parentOrganizations": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["organizationType", "organizationDescription", "city", "parentOrganizations"] + }, + "organizationList": { + "type": "object", + "properties": { + "searchResults": { + "type": "string" + }, + "softwareList": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["searchResults", "softwareList"] + }, + "organizationSearch": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["placeholder"] + }, "softwareCatalogControlled": { "type": "object", "properties": { @@ -1503,6 +1548,9 @@ "type": "string", "description": "Title text" }, + "devOrganizations": { + "type": "string" + }, "navigation welcome": { "type": "string" }, diff --git a/api/src/rpc/update.ts b/api/src/rpc/update.ts index 6fbb6b1c3..49cc7edab 100644 --- a/api/src/rpc/update.ts +++ b/api/src/rpc/update.ts @@ -8,8 +8,11 @@ import { assert } from "tsafe/assert"; import { Database } from "../core/adapters/dbApi/kysely/kysely.database"; import { createPgDialect } from "../core/adapters/dbApi/kysely/kysely.dialect"; import { makeRefreshExternalDataAll } from "../core/usecases/refreshExternalData"; +import { updateSoftwareIdsByOrganisation } from "../core/usecases/getAuthorOrganization"; import { createKyselyPgDbApi } from "../core/adapters/dbApi/kysely/createPgDbApi"; import { DbApiV2 } from "../core/ports/DbApiV2"; +import { uiConfigSchema } from "../core/uiConfigSchema"; +import rawUiConfig from "../customization/ui-config.json"; type PgDbConfig = { dbKind: "kysely"; kyselyDb: Kysely }; @@ -35,6 +38,7 @@ export async function startUpdateService(params: { console.log("[RPC:Update] Starting fetching of external data on remote sources"); console.time("[RPC:Update] Fetching of external data on remote sources: Done"); const { isDevEnvironnement, databaseUrl, updateSkipTimingInMinutes, updateSoftwareIds, ...rest } = params; + const uiConfig = uiConfigSchema.parse(rawUiConfig); assert>(); @@ -47,7 +51,7 @@ export async function startUpdateService(params: { "kyselyDb": kyselyDb }); - const refreshExternalData = await makeRefreshExternalDataAll({ + const refreshExternalData = makeRefreshExternalDataAll({ dbApi, minuteSkipSince: updateSkipTimingInMinutes ?? 180, softwareIdsToRefresh: updateSoftwareIds @@ -55,5 +59,9 @@ export async function startUpdateService(params: { await refreshExternalData(); + if (uiConfig.header.menu.devOrganizations.enabled) { + await updateSoftwareIdsByOrganisation({ dbApi }); + } + console.timeEnd("[RPC:Update] Fetching of external data on remote sources: Done"); } diff --git a/api/src/tools/identifiersTools.ts b/api/src/tools/identifiersTools.ts index 44130f079..6da608e76 100644 --- a/api/src/tools/identifiersTools.ts +++ b/api/src/tools/identifiersTools.ts @@ -81,6 +81,56 @@ const zenodoSource: WebSite = { additionalType: "Zenodo" }; +const twitterSource: WebSite = { + "@type": "Website" as const, + name: "Twitter", + url: new URL("https://x.com/"), + additionalType: "Twitter" +}; + +const gravatarSource: WebSite = { + "@type": "Website" as const, + name: "Gravatar", + url: new URL("https://gravatar.com/"), + additionalType: "Gravatar" +}; + +const rorSource: WebSite = { + "@type": "Website" as const, + name: "Research Organization Registry", + url: new URL("https://ror.org/"), + additionalType: "ROR" +}; + +const rnsrSource: WebSite = { + "@type": "Website" as const, + name: "Répertoire national des structures de recherche", + url: new URL("https://www.data.gouv.fr/datasets/repertoire-national-des-structures-de-recherche-rnsr"), + additionalType: "RNSR" +}; + +type CrossRefType = "fundref"; +const crossRefSource: WebSite = { + "@type": "Website" as const, + name: "One of the official Identifier Registration Agencies", + url: new URL("https://www.crossref.org"), + additionalType: "CROSSREF" +}; + +const gridSource = { + "@type": "Website" as const, + name: "Global Research Identifier Database", + url: new URL("https://www.grid.ac"), + additionalType: "GRID" +}; + +const insiSource = { + "@type": "Website" as const, + name: "International Standard Name Identifier", + url: new URL("https://insi.org"), + additionalType: "INSI" +}; + export const identifersUtils = { makeGenericIdentifier: (params: { value: string; url?: string | URL }): SchemaIdentifier => { const { value, url } = params; @@ -213,13 +263,13 @@ export const identifersUtils = { ...(additionalType ? { additionalType: additionalType } : {}) }; }, - makeUserGitHubIdentifer: (params: { username: string; userId: number }): SchemaIdentifier => { - const { username, userId } = params; + makeUserGitHubIdentifer: (params: { name: string; userId: number; url: string }): SchemaIdentifier => { + const { url, userId, name } = params; return { "@type": "PropertyValue" as const, - value: username, + value: name, valueReference: userId.toString(), - url: `https://github.com/${username}`, + url: url, subjectOf: gitHubSource, additionalType: "User" }; @@ -270,18 +320,126 @@ export const identifersUtils = { }, additionalType: "User" }; + }, + makeGravatarPersonIdentifer: (params: { gravatarId: string }): SchemaIdentifier => { + const { gravatarId } = params; + return { + "@type": "PropertyValue" as const, + value: gravatarId, + url: `https://www.gravatar.com/${gravatarId}`, // username or hash md5 of email address + subjectOf: gravatarSource, + additionalType: "Person" + }; + }, + makeTwitterPersonIdentifer: (params: { username: string }): SchemaIdentifier => { + const { username } = params; + return { + "@type": "PropertyValue" as const, + value: username, + url: `https://twitter.com/${username}`, + subjectOf: twitterSource, + additionalType: "Person" + }; + }, + makeOrcidPersonIdentifer: (params: { orcidId: string; username?: string }): SchemaIdentifier => { + const { orcidId, username } = params; + return { + "@type": "PropertyValue" as const, + value: orcidId, + url: `https://orcid.org/${orcidId}`, + subjectOf: orcidSource, + ...(username ? { name: `ID on ${username}` } : {}), + additionalType: "Person" + }; + }, + makeRorOrgaIdentifer: (params: { rorId: string }): SchemaIdentifier => { + const { rorId } = params; + const cleanRORUrl = (output: string) => (output.includes("https://ror.org") ? output.split("/")[3] : output); + const rorID = cleanRORUrl(rorId); + return { + "@type": "PropertyValue" as const, + value: rorID, + url: `https://ror.org/${rorID}`, + subjectOf: rorSource, + additionalType: "Organization" + }; + }, + makeRNSROrgaIdentifer: (params: { rnrsId: string }): SchemaIdentifier => { + const { rnrsId } = params; + return { + "@type": "PropertyValue" as const, + value: rnrsId, + url: `https://appliweb.dgri.education.fr/rnsr/PresenteStruct.jsp?PUBLIC=OK&numNatStruct=${rnrsId}`, + subjectOf: rnsrSource, + additionalType: "Organization" + }; + }, + makeCrossRefIdentifier: (params: { type: CrossRefType; crossRefId: string }): SchemaIdentifier => { + const { type, crossRefId } = params; + + const base = { + "@type": "PropertyValue" as const, + value: crossRefId, + subjectOf: crossRefSource, + additionalType: type + }; + + switch (type) { + case "fundref": + return { + ...base, + url: `https://api.crossref.org/funders/${crossRefId}` + }; + default: + const unreachableCase: never = type; + throw new Error(`Unreachable case: ${unreachableCase}`); + } + }, + makeGridIdentifier: (params: { gridId: string }): SchemaIdentifier => { + const { gridId } = params; + return { + "@type": "PropertyValue" as const, + value: gridId, + subjectOf: gridSource + }; + }, + makeINSIIdentifier: (params: { insiId: string }): SchemaIdentifier => { + const { insiId } = params; + return { + "@type": "PropertyValue" as const, + value: insiId, + url: `http://isni.org/isni/${insiId}`, + subjectOf: insiSource + }; } }; -const compareIdentifier = (id1: SchemaIdentifier, id2: SchemaIdentifier): boolean => { - if (id1.value === id2.value && id1.subjectOf?.url === id2.subjectOf?.url) return true; +export const compareIdentifier = (id1: SchemaIdentifier, id2: SchemaIdentifier): boolean => { + if (id1.value === id2.value && id1.subjectOf?.url.toString() === id2.subjectOf?.url.toString()) return true; return false; }; +export const deduplicateIdentifierArray = (arr: SchemaIdentifier[]): SchemaIdentifier[] => { + const deduplicated: SchemaIdentifier[] = []; + + for (const identier of arr) { + if (!deduplicated.some(identier1 => compareIdentifier(identier1, identier))) { + deduplicated.push(identier); + } + } + + return deduplicated; +}; + export const mergeDepuplicateIdentifierArray = ( - arr1: SchemaIdentifier[], - arr2: SchemaIdentifier[] + arr1: SchemaIdentifier[] | undefined, + arr2: SchemaIdentifier[] | undefined ): SchemaIdentifier[] => { + if (!arr1 || arr1.length === 0) { + if (!arr2 || arr2.length === 0) return []; + return arr2; + } + if (!arr2 || arr2.length === 0) return arr1; const filtered = arr2.filter(identier => !arr1.some(identier1 => compareIdentifier(identier1, identier))); return arr1.concat(filtered); diff --git a/deployment-examples/docker-compose/customization/ui-config.json b/deployment-examples/docker-compose/customization/ui-config.json index 09a3878bd..9871b4d6e 100644 --- a/deployment-examples/docker-compose/customization/ui-config.json +++ b/deployment-examples/docker-compose/customization/ui-config.json @@ -13,6 +13,9 @@ "catalog": { "enabled": true }, + "devOrganizations": { + "enabled": true + }, "addSoftware": { "enabled": true }, diff --git a/web/src/core/adapter/sillApi.ts b/web/src/core/adapter/sillApi.ts index e5ceda720..20c8b84d9 100644 --- a/web/src/core/adapter/sillApi.ts +++ b/web/src/core/adapter/sillApi.ts @@ -184,7 +184,9 @@ export function createSillApi(params: { url: string }): SillApi { await trpcClient.unreferenceSoftware.mutate(params).catch(errorHandler); sillApi.getSoftwareList.clear(); - } + }, + getSoftwareIdsByOrganisation: params => + trpcClient.getSoftwareIdsByOrganisation.query(params) }; return sillApi; diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 8d3c4d9cb..38ea3518d 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -59,9 +59,9 @@ export async function bootstrapCore( await Promise.all([ dispatch(usecases.uiConfig.protectedThunks.initialize()), dispatch(usecases.sillApiVersion.protectedThunks.initialize()), - dispatch(usecases.externalDataOrigin.protectedThunks.initialize()), dispatch(usecases.source.protectedThunks.initialize()), dispatch(usecases.softwareCatalog.protectedThunks.initialize()), + dispatch(usecases.organizationList.thunks.initialize()), // move later ? dispatch(usecases.generalStats.protectedThunks.initialize()), dispatch(usecases.redirect.protectedThunks.initialize()), dispatch(usecases.userAuthentication.thunks.getCurrentUser()) diff --git a/web/src/core/ports/SillApi.ts b/web/src/core/ports/SillApi.ts index ca249f0ba..ccc59dd8f 100644 --- a/web/src/core/ports/SillApi.ts +++ b/web/src/core/ports/SillApi.ts @@ -120,6 +120,9 @@ export type SillApi = { unreferenceSoftware: ( params: TrpcRouterInput["unreferenceSoftware"] ) => Promise; + getSoftwareIdsByOrganisation: ( + params: TrpcRouterInput["getSoftwareIdsByOrganisation"] + ) => Promise; }; //NOTE: We make sure we don't forget queries diff --git a/web/src/core/usecases/externalDataOrigin/thunks.ts b/web/src/core/usecases/externalDataOrigin/thunks.ts deleted file mode 100644 index 6ba212611..000000000 --- a/web/src/core/usecases/externalDataOrigin/thunks.ts +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: 2021-2025 DINUM -// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes -// SPDX-License-Identifier: MIT - -import { ExternalDataOriginKind } from "api"; -import type { Thunks } from "core/bootstrap"; -import { createUsecaseContextApi } from "redux-clean-architecture"; - -const { getContext, setContext } = createUsecaseContextApi<{ - externalDataOrigin: ExternalDataOriginKind; -}>(); - -export const thunks = { - getExternalDataOrigin: - () => - (...args): ExternalDataOriginKind => { - const [, , rootContext] = args; - - const { externalDataOrigin } = getContext(rootContext); - - return externalDataOrigin; - } -} satisfies Thunks; - -export const protectedThunks = { - initialize: - () => - async (...args) => { - const [, , rootContext] = args; - - const { sillApi } = rootContext; - - setContext(rootContext, { - externalDataOrigin: await sillApi.getExternalSoftwareDataOrigin() - }); - } -} satisfies Thunks; diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 4c5c5295b..c78600176 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -15,9 +15,9 @@ import * as userAuthentication from "./userAuthentication"; import * as redirect from "./redirect"; import * as declarationRemoval from "./declarationRemoval"; import * as userProfile from "./userProfile"; -import * as externalDataOrigin from "./externalDataOrigin"; import * as source from "./source.slice"; import * as uiConfig from "./uiConfig.slice"; +import * as organizationList from "./organizationList"; export const usecases = { source, @@ -28,8 +28,8 @@ export const usecases = { declarationForm, instanceForm, userAccountManagement, + organizationList, sillApiVersion, - externalDataOrigin, softwareUserAndReferent, generalStats, userAuthentication, diff --git a/web/src/core/usecases/externalDataOrigin/index.ts b/web/src/core/usecases/organizationList/index.ts similarity index 88% rename from web/src/core/usecases/externalDataOrigin/index.ts rename to web/src/core/usecases/organizationList/index.ts index 5b846c6e6..c0396c684 100644 --- a/web/src/core/usecases/externalDataOrigin/index.ts +++ b/web/src/core/usecases/organizationList/index.ts @@ -4,3 +4,4 @@ export * from "./state"; export * from "./thunks"; +export * from "./selectors"; diff --git a/web/src/core/usecases/organizationList/selectors.ts b/web/src/core/usecases/organizationList/selectors.ts new file mode 100644 index 000000000..8a6af84b5 --- /dev/null +++ b/web/src/core/usecases/organizationList/selectors.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; +import { createSelector } from "redux-clean-architecture"; +import { assert } from "tsafe/assert"; + +const readyState = (rootState: RootState) => { + const state = rootState[name]; + + if (state.stateDescription !== "ready") { + return undefined; + } + + return state; +}; + +const errorState = (rootState: RootState) => { + const state = rootState[name]; + + if (state.stateDescription !== "error") { + return undefined; + } + + return state; +}; + +const isReady = createSelector(readyState, state => state !== undefined); + +const error = createSelector(errorState, state => state?.error); + +const list = createSelector(readyState, readyState => readyState?.list); + +const filtered = createSelector(readyState, readyState => readyState?.filtered); + +const search = createSelector(readyState, readyState => readyState?.search); + +const selected = createSelector(readyState, readyState => readyState?.selected); + +const main = createSelector( + isReady, + selected, + list, + search, + filtered, + error, + (isReady, selected, list, search, filtered, error) => { + if (error) { + return { + isReady: false as const, + error + }; + } + + if (!isReady) { + return { + isReady: false as const + }; + } + + assert(list !== undefined); + + return { + isReady: true as const, + selected, + search, + filtered, + list + }; + } +); + +export const selectors = { main }; diff --git a/web/src/core/usecases/organizationList/state.ts b/web/src/core/usecases/organizationList/state.ts new file mode 100644 index 000000000..93857cd06 --- /dev/null +++ b/web/src/core/usecases/organizationList/state.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { Organization } from "api/dist/src/lib/ApiTypes"; +import { + createUsecaseActions, + createObjectThatThrowsIfAccessed +} from "redux-clean-architecture"; + +export type State = { + stateDescription: string; + error: string | undefined; + selected: string | undefined; + search: string | undefined; + filtered: Array; + list: Record; +}; + +export const name = "organizationList" as const; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + initialized: (_state, { payload }: { payload: State }) => payload, + selectOrganization: (state, { payload }: { payload: string }) => { + return { ...state, selected: payload }; + }, + setSearchQuery: (state, { payload }: { payload: string }) => { + return { ...state, search: payload }; + }, + setOrganizations: (state, { payload }: { payload: Array }) => { + return { ...state, filtered: payload }; + } + } +}); diff --git a/web/src/core/usecases/organizationList/thunks.ts b/web/src/core/usecases/organizationList/thunks.ts new file mode 100644 index 000000000..14e34a542 --- /dev/null +++ b/web/src/core/usecases/organizationList/thunks.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import type { Thunks } from "core/bootstrap"; +import { actions } from "./state"; +import { ApiTypes } from "api"; + +export const thunks = { + initialize: + () => + async (...args): Promise => { + const [dispatch, getState, { sillApi, evtAction }] = args; + + const orgs = await sillApi.getSoftwareIdsByOrganisation(); + + dispatch( + actions.initialized({ + stateDescription: "ready", + error: undefined, + list: orgs.reduce( + (acc, item) => { + acc[item.name] = item; + return acc; + }, + {} as Record + ), + selected: undefined, + search: undefined, + filtered: [] + }) + ); + }, + selectOrgnisation: + (params: { organizationKey: string }) => + async (...args): Promise => { + const [dispatch, getState, { sillApi, evtAction }] = args; + const { organizationKey } = params; + + const orgs = await sillApi.getSoftwareIdsByOrganisation(); + + dispatch(actions.selectOrganization(organizationKey)); + }, + searchOrganization: + (params: { keySearch: string }) => + async (...args): Promise => { + const [dispatch, getState, { sillApi, evtAction }] = args; + const { keySearch } = params; + + dispatch(actions.setSearchQuery(keySearch)); + + const orgs = await sillApi.getSoftwareIdsByOrganisation({ name: keySearch }); + + dispatch(actions.setOrganizations(orgs.map(org => org.name))); + } +} satisfies Thunks; diff --git a/web/src/ui/pages/index.ts b/web/src/ui/pages/index.ts index 29376cc15..ef6ecd9b6 100644 --- a/web/src/ui/pages/index.ts +++ b/web/src/ui/pages/index.ts @@ -16,6 +16,8 @@ import * as softwareUserAndReferent from "./softwareUserAndReferent"; import * as terms from "./terms"; import * as redirect from "./redirect"; import * as userProfile from "./userProfile"; +import * as organizationList from "./organizationList"; +import * as organizationDetails from "./orgnizationDetails"; import { objectKeys } from "tsafe/objectKeys"; import type { UnionToIntersection } from "tsafe"; @@ -27,6 +29,8 @@ export const pages = { home, instanceForm, readme, + organizationList, + organizationDetails, softwareCatalog, softwareDetails, softwareForm, diff --git a/web/src/ui/pages/organizationList/0rganizationList.tsx b/web/src/ui/pages/organizationList/0rganizationList.tsx new file mode 100644 index 000000000..5b3219ce9 --- /dev/null +++ b/web/src/ui/pages/organizationList/0rganizationList.tsx @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { ApiTypes } from "api"; +import { OrganizationCard } from "../../shared/OrganizationCard"; +import { OrganizationSearch } from "./OrganizationSearch"; +import type { PageRoute } from "./route"; +import { useCore, useCoreState } from "core"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createUseDebounce } from "powerhooks/useDebounce"; + +import { tss } from "tss-react"; +import { fr } from "@codegouvfr/react-dsfr"; +import { Tabs } from "@codegouvfr/react-dsfr/Tabs"; +import { useBreakpointsValues } from "@codegouvfr/react-dsfr/useBreakpointsValues"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { useWindowInnerSize } from "powerhooks/useWindowInnerSize"; +import { useTranslation } from "react-i18next"; + +type Props = { + className?: string; + route: PageRoute; +}; + +const { useDebounce } = createUseDebounce({ delay: 400 }); + +export default function OrganizationList(props: Props) { + const { className, route } = props; + + const { t } = useTranslation(); + const { cx, classes } = useStyles(); + + const { organizationList } = useCore().functions; + const state = useCoreState("organizationList", "main"); + const [localInput, setLocalInput] = useState(""); + + useDebounce(() => { + organizationList.searchOrganization({ keySearch: localInput }); + }, [localInput]); + + const list = state.list ?? {}; + const listToShow = state.filtered ?? []; + + const toShow = + listToShow.length > 0 + ? Object.fromEntries( + Object.entries(list).filter(([key]) => listToShow.includes(key)) + ) + : list; + + type TypeRow = { + count: number; + key: string; + label: string; + }; + const orgTypeMap = new Map(); + + for (const org of Object.values(toShow)) { + if (org.additionalType) { + for (const type of org.additionalType) { + const key = type + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/^(\d)/, "_$1") + .toLowerCase(); + + if (orgTypeMap.has(key)) { + const existing = orgTypeMap.get(key)!; + orgTypeMap.set(key, { + ...existing, + count: existing.count + 1 + }); + } else { + orgTypeMap.set(key, { + count: 1, + key: key, + label: type + }); + } + } + } + } + + const typeTabs = Array.from(orgTypeMap.values()).map(row => ({ + tabId: row.key, + label: row.label + })); + + const [selectedTabId, setSelectedTabId] = useState("all"); + + let orgArray = toShow + ? Object.values(toShow).sort( + (a, b) => (b.producer?.length ?? 0) - (a.producer?.length ?? 0) + ) + : []; + + if (selectedTabId && selectedTabId != "all") + orgArray = orgArray.filter(org => + org.additionalType?.includes(orgTypeMap.get(selectedTabId)?.label ?? "") + ); + + const notFound = orgArray.length === 0 && state.search && state.search.length > 0; + + return ( + <> +
+ +
+
+ {t("organizationList.searchResults", { + count: orgArray ? Object.values(orgArray).length : 0 + })} +
+
+ + {notFound &&
No result found for {state.search}
} + {!notFound && ( + + )} +
+
+ + ); +} + +function RowVirtualizerDynamicWindow(props: { + organizations: Array; +}) { + const { organizations } = props; + + const { columnCount } = (function useClosure() { + const { breakpointsValues } = useBreakpointsValues(); + + const { windowInnerWidth } = useWindowInnerSize(); + + const columnCount = (() => { + if (windowInnerWidth < breakpointsValues.md) { + return 1; + } + + if (windowInnerWidth < breakpointsValues.xl) { + return 2; + } + + return 3; + })(); + + return { columnCount }; + })(); + + const organizationsGroupedByLine = useMemo(() => { + const groupedorganizations: (ApiTypes.Organization | undefined)[][] = []; + + for (let i = 0; i < organizations.length; i += columnCount) { + const row: ApiTypes.Organization[] = []; + + for (let j = 0; j < columnCount; j++) { + row.push(organizations[i + j]); + } + + groupedorganizations.push(row); + } + + return groupedorganizations; + }, [organizations, columnCount]); + + const parentRef = useRef(null); + + const parentOffsetRef = useRef(0); + + useLayoutEffect(() => { + parentOffsetRef.current = parentRef.current?.offsetTop ?? 0; + }, []); + + const height = 332; + + const virtualizer = useWindowVirtualizer({ + count: organizationsGroupedByLine.length, + estimateSize: () => height, + scrollMargin: parentOffsetRef.current, + overscan: 5 + }); + const items = virtualizer.getVirtualItems(); + + const { css } = useStyles(); + + const gutter = fr.spacing("4v"); + + return ( +
+
+
+ {items.map(virtualRow => ( +
+
+ {organizationsGroupedByLine[virtualRow.index].map( + (organization, i) => { + if (organization === undefined) { + return
; + } + + return ( + + ); + } + )} +
+
+ ))} +
+
+
+ ); +} + +const useStyles = tss.withName({ OrganizationList }).create({ + root: { + paddingBottom: fr.spacing("30v"), + [fr.breakpoints.down("md")]: { + paddingBottom: fr.spacing("20v") + } + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + ...fr.spacing("margin", { + topBottom: "4v" + }), + [fr.breakpoints.down("md")]: { + flexWrap: "wrap" + } + }, + softwareCount: { + marginBottom: 0 + }, + sort: { + display: "flex", + alignItems: "center", + gap: fr.spacing("2v"), + + "&&>select": { + width: "auto", + marginTop: 0 + }, + [fr.breakpoints.down("md")]: { + marginTop: fr.spacing("4v") + } + } +}); diff --git a/web/src/ui/pages/organizationList/OrganizationSearch.tsx b/web/src/ui/pages/organizationList/OrganizationSearch.tsx new file mode 100644 index 000000000..a447ee9ef --- /dev/null +++ b/web/src/ui/pages/organizationList/OrganizationSearch.tsx @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { useState, useId } from "react"; +import { tss } from "tss-react"; +import { SearchBar } from "@codegouvfr/react-dsfr/SearchBar"; +import { fr } from "@codegouvfr/react-dsfr"; +import { assert } from "tsafe/assert"; +import { Equals } from "tsafe"; +import { useTranslation } from "react-i18next"; + +export type Props = { + className?: string; + + search: string; + onSearchChange: (search: string) => void; +}; + +export function OrganizationSearch(props: Props) { + const { + className, + + search, + onSearchChange, + + ...rest + } = props; + + /** Assert to make sure all props are deconstructed */ + assert>(); + + const { t } = useTranslation(); + + const [filtersWrapperDivElement, setFiltersWrapperDivElement] = + useState(null); + + const { classes, cx } = useStyles({ + filterWrapperMaxHeight: filtersWrapperDivElement?.scrollHeight ?? 0 + }); + + return ( +
+
+ { + const [inputElement, setInputElement] = + useState(null); + + return ( + + onSearchChange(event.currentTarget.value) + } + onKeyDown={event => { + if (event.key === "Escape") { + assert(inputElement !== null); + inputElement.blur(); + } + }} + /> + ); + }} + /> +
+
+ ); +} + +OrganizationSearch.displayName = "SoftwareCatalogSearch"; + +const useStyles = tss + .withName({ SoftwareCatalogSearch: OrganizationSearch }) + .withParams<{ filterWrapperMaxHeight: number }>() + .create(({ filterWrapperMaxHeight }) => ({ + root: { + "&:before": { + content: "none" + } + }, + basicSearch: { + display: "flex", + paddingTop: fr.spacing("6v") + }, + searchBar: { + flex: 1 + }, + filterButton: { + backgroundColor: fr.colors.decisions.background.actionLow.blueFrance.default, + "&&&:hover": { + backgroundColor: fr.colors.decisions.background.actionLow.blueFrance.hover + }, + color: fr.colors.decisions.text.actionHigh.blueFrance.default, + marginLeft: fr.spacing("4v") + }, + filtersWrapper: { + transition: "max-height 0.2s ease-out", + maxHeight: filterWrapperMaxHeight, + overflow: "hidden", + marginTop: fr.spacing("4v"), + display: "grid", + gridTemplateColumns: `repeat(4, minmax(20%, 1fr))`, + columnGap: fr.spacing("4v"), + [fr.breakpoints.down("md")]: { + gridTemplateColumns: `repeat(1, 1fr)` + }, + paddingLeft: fr.spacing("1v") + }, + filterSelectGroup: { + "&:not(:last-of-type)": { + borderRight: `1px ${fr.colors.decisions.border.default.grey.default} solid`, + paddingRight: fr.spacing("4v") + }, + [fr.breakpoints.down("md")]: { + "&:not(:last-of-type)": { + border: "none" + } + } + }, + multiSelect: { + marginTop: fr.spacing("2v"), + paddingRight: 0, + "& > .MuiInputBase-input": { + padding: 0 + }, + "& > .MuiSvgIcon-root": { + display: "none" + } + } + })); diff --git a/web/src/core/usecases/externalDataOrigin/state.ts b/web/src/ui/pages/organizationList/index.ts similarity index 56% rename from web/src/core/usecases/externalDataOrigin/state.ts rename to web/src/ui/pages/organizationList/index.ts index bc27c4d58..47c7ce55f 100644 --- a/web/src/core/usecases/externalDataOrigin/state.ts +++ b/web/src/ui/pages/organizationList/index.ts @@ -2,6 +2,6 @@ // SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes // SPDX-License-Identifier: MIT -export const name = "externalDataOrigin"; - -export const reducer = null; +import { lazy } from "react"; +export * from "./route"; +export const LazyComponent = lazy(() => import("./0rganizationList")); diff --git a/web/src/ui/pages/organizationList/route.ts b/web/src/ui/pages/organizationList/route.ts new file mode 100644 index 000000000..b943285fe --- /dev/null +++ b/web/src/ui/pages/organizationList/route.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { createGroup, defineRoute, createRouter, type Route, param } from "type-route"; +import { appPath } from "urls"; + +export const routeDefs = { + organizationList: defineRoute( + { + search: param.query.optional.string.default("") + }, + () => appPath + "/organization-list" + ) +}; + +export const routeGroup = createGroup(Object.values(createRouter(routeDefs).routes)); + +export type PageRoute = Route; + +export const getDoRequireUserLoggedIn: (route: PageRoute) => boolean = () => false; diff --git a/web/src/ui/pages/orgnizationDetails/OrganizationDetails.tsx b/web/src/ui/pages/orgnizationDetails/OrganizationDetails.tsx new file mode 100644 index 000000000..86ed08bcc --- /dev/null +++ b/web/src/ui/pages/orgnizationDetails/OrganizationDetails.tsx @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { OrganizationCard } from "../../shared/OrganizationCard"; +import type { PageRoute } from "./route"; +import { useCoreState } from "core"; + +import { tss } from "tss-react"; +import { fr } from "@codegouvfr/react-dsfr"; +import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; +import { routes } from "ui/routes"; +import { SoftwareRowVirtualizerDynamicWindow } from "../softwareCatalog/SoftwareCatalogControlled"; + +type Props = { + className?: string; + route: PageRoute; +}; + +export default function organizationDetails(props: Props) { + const { className, route } = props; + + const { t } = useTranslation(); + const { cx, classes } = useStyles(); + + const key = route.params.key; + + const state = useCoreState("organizationList", "main"); + const org = state?.list && key ? state.list[key] : undefined; + + const { allSoftwares } = useCoreState("softwareCatalog", "main"); + + const filteredSoftware = Array.isArray(org?.producer) + ? allSoftwares.filter(softwareItem => + org.producer?.includes(softwareItem.id.toString()) + ) + : []; + + const linksBySoftwareName = useMemo( + () => + Object.fromEntries( + filteredSoftware.map(({ name, id }) => [ + name, + /* prettier-ignore */ + { + "softwareDetails": routes.softwareDetails({ "id": id }).link, + "declareUsageForm": routes.declarationForm({ "id": id }).link, + "softwareUsersAndReferents": routes.softwareUsersAndReferents({ "id": id }).link + } + ]) + ), + [filteredSoftware] + ); + + const renderingOptions = { showSoftwareDetailsButton: false }; + + return ( + <> +
+
+
{key ?? "test"}
+
+
+ {org && ( + + )} +
+ +
+
List of software
+ +
+
+ + ); +} + +const useStyles = tss.withName({ organizationDetails }).create({ + root: { + paddingBottom: fr.spacing("30v"), + [fr.breakpoints.down("md")]: { + paddingBottom: fr.spacing("20v") + } + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + ...fr.spacing("margin", { + topBottom: "4v" + }), + [fr.breakpoints.down("md")]: { + flexWrap: "wrap" + } + }, + softwareCount: { + marginBottom: 0 + }, + sort: { + display: "flex", + alignItems: "center", + gap: fr.spacing("2v"), + + "&&>select": { + width: "auto", + marginTop: 0 + }, + [fr.breakpoints.down("md")]: { + marginTop: fr.spacing("4v") + } + } +}); diff --git a/web/src/ui/pages/orgnizationDetails/index.ts b/web/src/ui/pages/orgnizationDetails/index.ts new file mode 100644 index 000000000..1e73c417b --- /dev/null +++ b/web/src/ui/pages/orgnizationDetails/index.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { lazy } from "react"; +export * from "./route"; +export const LazyComponent = lazy(() => import("./OrganizationDetails")); diff --git a/web/src/ui/pages/orgnizationDetails/route.tsx b/web/src/ui/pages/orgnizationDetails/route.tsx new file mode 100644 index 000000000..471a7ffa1 --- /dev/null +++ b/web/src/ui/pages/orgnizationDetails/route.tsx @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2021-2025 DINUM +// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes +// SPDX-License-Identifier: MIT + +import { createGroup, defineRoute, createRouter, type Route, param } from "type-route"; +import { appPath } from "urls"; + +export const routeDefs = { + organizationDetails: defineRoute( + { + key: param.query.optional.string + }, + () => appPath + "/organization-details" + ) +}; + +export const routeGroup = createGroup(Object.values(createRouter(routeDefs).routes)); + +export type PageRoute = Route; + +export const getDoRequireUserLoggedIn: (route: PageRoute) => boolean = () => false; diff --git a/web/src/ui/pages/softwareCatalog/SoftwareCatalogControlled.tsx b/web/src/ui/pages/softwareCatalog/SoftwareCatalogControlled.tsx index e494660e7..4523747b6 100644 --- a/web/src/ui/pages/softwareCatalog/SoftwareCatalogControlled.tsx +++ b/web/src/ui/pages/softwareCatalog/SoftwareCatalogControlled.tsx @@ -178,7 +178,7 @@ export function SoftwareCatalogControlled(props: Props) { {softwares.length === 0 ? (

{t("softwareCatalogControlled.noSoftwareFound")}

) : ( - @@ -188,7 +188,7 @@ export function SoftwareCatalogControlled(props: Props) { ); } -function RowVirtualizerDynamicWindow( +export function SoftwareRowVirtualizerDynamicWindow( props: Pick ) { const { softwares, linksBySoftwareName } = props; diff --git a/web/src/ui/shared/EmojiCountryFlag.tsx b/web/src/ui/shared/EmojiCountryFlag.tsx new file mode 100644 index 000000000..d1e421155 --- /dev/null +++ b/web/src/ui/shared/EmojiCountryFlag.tsx @@ -0,0 +1,102 @@ +import React from "react"; + +const CountryFlagEmoji = (params: { country: string }) => { + const { country } = params; + + // Fonction pour convertir un code pays en emoji de drapeau + const getFlagEmoji = (countryCode: string) => { + if (!countryCode || countryCode.length !== 2) { + return null; + } + + // Convertir les lettres en codepoints Unicode régionaux + const codePoints = countryCode + .toUpperCase() + .split("") + .map(char => 127397 + char.charCodeAt(0)); + + return String.fromCodePoint(...codePoints); + }; + + // Fonction pour obtenir le code pays à partir du nom du pays + const getCountryCode = (countryName: string) => { + const countryToCode: Record = { + france: "FR", + réunion: "RE", + germany: "DE", + spain: "ES", + italy: "IT", + "united kingdom": "GB", + "united states": "US", + belgium: "BE", + austria: "AT", + netherlands: "NL", + "the netherlands": "NL", + "south africa": "ZA", + denmark: "DK", + canada: "CA", + brazil: "BR", + colombia: "CO", + japan: "JP", + china: "CN", + algeria: "DZ", + "french guiana": "GF", + ukraine: "UA", + australia: "AU", + switzerland: "CH", + russia: "RU", + india: "IN", + mexico: "MX", + norway: "NO", + sweden: "SE", + finland: "FI", + portugal: "PT", + poland: "PL", + turkey: "TR", + greece: "GR", + argentina: "AR", + chile: "CL", + "new zealand": "NZ", + ireland: "IE", + luxembourg: "LU", + "czech republic": "CZ", + hungary: "HU", + romania: "RO", + "saudi arabia": "SA", + egypt: "EG", + morocco: "MA", + tunisia: "TN", + "south korea": "KR", + thailand: "TH", + vietnam: "VN", + indonesia: "ID", + malaysia: "MY", + singapore: "SG", + philippines: "PH", + israel: "IL", + "united arab emirates": "AE", + qatar: "QA", + kuwait: "KW" + }; + + const normalizedName = countryName.toLowerCase(); + return countryToCode[normalizedName] || countryName.toUpperCase(); + }; + + // Déterminer si l'entrée est un code pays ou un nom de pays + const isCountryCode = (input: string) => { + return input && input.length === 2 && /^[A-Za-z]+$/.test(input); + }; + + // Récupérer le code pays + const countryCode = isCountryCode(country) + ? country.toUpperCase() + : getCountryCode(country); + + // Générer l'emoji + const flagEmoji = getFlagEmoji(countryCode); + + return {flagEmoji || "🌍"}; +}; + +export default CountryFlagEmoji; diff --git a/web/src/ui/shared/Header/Header.tsx b/web/src/ui/shared/Header/Header.tsx index 5cb3f53a2..ec97ba348 100644 --- a/web/src/ui/shared/Header/Header.tsx +++ b/web/src/ui/shared/Header/Header.tsx @@ -63,6 +63,13 @@ export const Header = memo( : t("header.navigation add software") }); } + if (uiConfig?.header.menu.devOrganizations.enabled) { + navigations.push({ + isActive: routeName === routes.organizationList.name, + linkProps: routes.organizationList().link, + text: t("header.devOrganizations") + }); + } if (uiConfig?.header.menu.about.enabled) { navigations.push({ isActive: routeName === routes.readme.name, diff --git a/web/src/ui/shared/LogoURLButton.tsx b/web/src/ui/shared/LogoURLButton.tsx index 4d7efe4c8..63f6094ed 100644 --- a/web/src/ui/shared/LogoURLButton.tsx +++ b/web/src/ui/shared/LogoURLButton.tsx @@ -6,19 +6,60 @@ import { tss } from "tss-react"; import Button from "@codegouvfr/react-dsfr/Button"; import { ReactNode } from "react"; import { FrIconClassName, RiIconClassName } from "@codegouvfr/react-dsfr"; -import { ApiTypes } from "api"; + +// Type guard function +export const isLogoHandle = (value: string): boolean => { + const validLogoHandles: LogoHandle[] = [ + "GitLab", + "HAL", + "wikidata", + "SWH", + "Orcid", + "doi", + "GitHub", + "ComptoirDuLibre", + "FramaLibre", + "CNLL", + "Zenodo", + "ROR", + "GRID", + "ISNI", + "CROSSREF", + "RNSR" + ]; + return validLogoHandles.includes(value as LogoHandle); +}; + +export type LogoHandle = + | "GitLab" + | "HAL" + | "wikidata" + | "SWH" + | "Orcid" + | "doi" + | "GitHub" + | "ComptoirDuLibre" + | "FramaLibre" + | "CNLL" + | "Zenodo" + | "ROR" + | "GRID" + | "ISNI" + | "CROSSREF" + | "RNSR"; export type Props = { // from Button iconId?: FrIconClassName | RiIconClassName; priority?: "primary" | "secondary" | "tertiary" | "tertiary no outline"; + size?: "small" | "large" | "medium"; children?: ReactNode; className?: string; // Specific - url: URL | string | undefined; + url?: URL | string | undefined; labelFromURL?: boolean; label?: string; - type?: ApiTypes.Catalogi.SourceKind; + type?: LogoHandle; }; const resolveLogoFromURL = ( @@ -70,6 +111,30 @@ const resolveLogoFromURL = ( return resolveLogoFromType("FramaLibre"); } + if (urlString.includes("ror.org")) { + return resolveLogoFromType("ROR"); + } + + if (urlString.includes("appliweb.dgri.education.fr/rnsr")) { + return resolveLogoFromType("RNSR"); + } + + if (urlString.includes("rnsr.adc.education.fr")) { + return resolveLogoFromType("RNSR"); + } + + if (urlString.includes("isni.org")) { + return resolveLogoFromType("ISNI"); + } + + if (urlString.includes("grid.org")) { + return resolveLogoFromType("GRID"); + } + + if (urlString.includes("api.crossref.org")) { + return resolveLogoFromType("CROSSREF"); + } + return { URLlogo: undefined, textFromURL: new URL(urlString).hostname.replace("www.", "") @@ -77,7 +142,7 @@ const resolveLogoFromURL = ( }; const resolveLogoFromType = ( - sourceType: ApiTypes.Catalogi.SourceKind + sourceType: LogoHandle ): { URLlogo: URL | undefined; textFromURL: string | undefined } => { switch (sourceType) { case "HAL": @@ -150,7 +215,37 @@ const resolveLogoFromType = ( URLlogo: new URL("https://cnll.fr/static/img/logo-cnll.svg"), textFromURL: "CNLL" }; - + case "ROR": + return { + URLlogo: new URL("https://ror.org/img/ror-logo.svg"), + textFromURL: "ROR" + }; + case "GRID": + return { + URLlogo: new URL( + "https://grid.ac/assets/big-logo-ee7b8b390ece80dc0c59f5c5a46e2fd09c58d0315ebcced04516b91611f141be.svg" + ), + textFromURL: "GRID" + }; + case "ISNI": + return { + URLlogo: new URL( + "https://upload.wikimedia.org/wikipedia/commons/4/4e/International_Standard_Name_Identifier.png" + ), + textFromURL: "ISNI" + }; + case "RNSR": + return { + URLlogo: new URL( + "https://rnsr.adc.education.fr/assets/img/logo_rnsr.png" + ), + textFromURL: "RNSR" + }; + case "CROSSREF": + return { + URLlogo: new URL("https://www.crossref.org/favicon.ico"), + textFromURL: "Crossref" + }; default: sourceType satisfies never; return { @@ -160,26 +255,72 @@ const resolveLogoFromType = ( } }; +const buildUrlFromType = (sourceType: LogoHandle, label: string): string | undefined => { + switch (sourceType) { + case "HAL": + return `https://hal.science/${label}`; + case "Orcid": + return `https://orcid.org/${label}`; + case "wikidata": + return `https://www.wikidata.org/wiki/${label}`; + case "doi": + return `https://orcid.org/${label}`; // TODO + case "SWH": + return `https://orcid.org/${label}`; // TODO + case "GitLab": + return `https://orcid.org/${label}`; // TODO + case "GitHub": + return `https://github.com/${label}`; + case "ComptoirDuLibre": + return `https://orcid.org/${label}`; // TODO + case "FramaLibre": + return `https://orcid.org/${label}`; // TODO + case "Zenodo": + return `https://orcid.org/${label}`; // TODO + case "CNLL": + return `https://orcid.org/${label}`; // TODO + case "ROR": + return `https://ror.org/${label}`; // TODO + case "ISNI": + return `http://isni.org/isni/${label}`; + case "CROSSREF": + return `https://orcid.org/${label}`; // TODO + case "RNSR": + return `https://rnsr.adc.education.fr//structure/${label}`; + case "GRID": + return undefined; + default: + sourceType satisfies never; + return undefined; + } +}; + export function LogoURLButton(props: Props) { const { url, label, labelFromURL, type, + size = "medium", priority = "primary", className, iconId } = props; - if (!url) return null; + let urlToConvert = !url && type && label ? buildUrlFromType(type, label) : url; - const urlString = typeof url === "string" ? url : url?.href; + const urlString = + typeof urlToConvert === "string" ? urlToConvert : urlToConvert?.href; const { classes } = useStyles(); const getUrlMetadata = () => { if (type) return resolveLogoFromType(type); - return resolveLogoFromURL(url); + if (urlToConvert) return resolveLogoFromURL(urlToConvert); + return { + URLlogo: undefined, + textFromURL: undefined + }; }; const { URLlogo, textFromURL } = getUrlMetadata(); @@ -194,6 +335,7 @@ export function LogoURLButton(props: Props) { return (