diff --git a/src/components/OcBreadcrumb/OcBreadcrumb.vue b/src/components/OcBreadcrumb/OcBreadcrumb.vue index 121a7ca6121786d1b50e4711ec62f655259b400c..f2a35146d536b4486e6f4381ea1ec286a6ac64e6 100644 --- a/src/components/OcBreadcrumb/OcBreadcrumb.vue +++ b/src/components/OcBreadcrumb/OcBreadcrumb.vue @@ -5,11 +5,11 @@ <h1>{{ index }}</h1> <OcLink v-if="!!item.to" :to="item.to"> <span v-if="item.icon" :class="item.icon" class="text-gray-400 text-xs mr-1" /> - <span class="text-gray-400 text-xs" :title="item.label">{{ truncateLabel(item.label) }}</span> + <span class="text-gray-400 text-xs" :title="item.label">{{ truncateText(item.label) }}</span> </OcLink> <span v-else> <span v-if="item.icon" :class="item.icon" class="text-gray-400 text-xs mr-1" /> - <span class="text-gray-400 text-xs" :title="item.label">{{ truncateLabel(item.label) }}</span> + <span class="text-gray-400 text-xs" :title="item.label">{{ truncateText(item.label) }}</span> </span> </template> </Breadcrumb> @@ -21,7 +21,8 @@ import type { MenuItem } from 'primevue/menuitem' import type { OcBreadcrumbItem } from '@/declarations' import { iconsDict } from '@/helpers/icons' import { computed } from 'vue' -import OcLink from '../OcLink.vue' +import OcLink from '@/components/OcLink.vue' +import { truncateText } from '@/helpers/text' const props = defineProps<{ items: OcBreadcrumbItem[] @@ -43,14 +44,6 @@ function ocBreadcrumbItem2menuItem(item: OcBreadcrumbItem): MenuItem { } } -function truncateLabel(label:string, maxSize: number=40){ - if (label.length > maxSize){ - return label.substring(0,maxSize) + '...' - } else { - return label - } -} - const formattedItems = computed(() => { if (props.items.length > 4){ return [ diff --git a/src/components/OcTopMenu.vue b/src/components/OcTopMenu.vue index ffc954455a853dbd6cfff16be3e445b09e273ea9..ac4e5c4d1540989aa04f08235ad8910e6b0e5a0d 100644 --- a/src/components/OcTopMenu.vue +++ b/src/components/OcTopMenu.vue @@ -49,7 +49,8 @@ const menuItems = computed(() => { params: { ...route.params, lang: key - } + }, + query: route.query }) } }) diff --git a/src/components/Search/OcResourceSearchFacetList/OcResourceSearchFacetList.stories.ts b/src/components/Search/OcResourceSearchFacetList/OcResourceSearchFacetList.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..717aa4a90f20ecd0f785f7d7ed5600471c673041 --- /dev/null +++ b/src/components/Search/OcResourceSearchFacetList/OcResourceSearchFacetList.stories.ts @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import OcResourceSearchFacetList from './OcResourceSearchFacetList.vue' + +const meta: Meta<typeof OcResourceSearchFacetList> = { + component: OcResourceSearchFacetList +} + +export default meta +type Story = StoryObj<typeof OcResourceSearchFacetList> + +export const Default: Story = { + render: (args) => ({ + components: { OcResourceSearchFacetList }, + setup() { + return { args } + }, + template: '<OcResourceSearchFacetList v-bind="args" />' + }), + args: { + modelValue: {} + } +} diff --git a/src/components/Search/OcResourceSearchFacetList/OcResourceSearchFacetList.vue b/src/components/Search/OcResourceSearchFacetList/OcResourceSearchFacetList.vue new file mode 100644 index 0000000000000000000000000000000000000000..0e40f9527aae421ce7ef6a8f71db0f411c3662a3 --- /dev/null +++ b/src/components/Search/OcResourceSearchFacetList/OcResourceSearchFacetList.vue @@ -0,0 +1,48 @@ +<template> + WIP + <Panel :header="t('search.resourceType')" toggleable> + <template #toggleicon="toggleIconProps"> + <i v-if="toggleIconProps.collapsed" class="fa-solid fa-chevron-down text-gray-700" /> + <i v-else class="fa-solid fa-chevron-up text-gray-700" /> + </template> + <div class="flex items-center gap-2" v-for="(dcatType, uri) in type2ResourceType" v-bind:key="uri"> + <Checkbox + v-model="model.resourceType" + :value="uri" + :disabled="props.loading" + /> + <label for="resourceType" class="capitalize"> + {{ t('resourceType.' + dcatType) }} + </label> + </div> + </Panel> +</template> + +<script setup lang="ts"> +import type { OcSearchQuery } from '@/declarations'; +import { useI18n } from 'vue-i18n'; +import Panel from 'primevue/panel' +import Checkbox from 'primevue/checkbox' +import { type2ResourceType } from '@/helpers/resourceType' + +const { t } = useI18n() + +const model = defineModel<Partial<OcSearchQuery>>({ required: true }) + +const props = defineProps({ + loading: { + type: Boolean, + default: false + } +}) + +</script> +<style scoped> +:deep(.p-panel-header) { + @apply bg-primary rounded-t text-white mb-2; +} + +:deep(.p-panel) { + @apply bg-gray-100; +} +</style> \ No newline at end of file diff --git a/src/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.stories.ts b/src/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..837a07d94016f68196668726059d061a806bedf3 --- /dev/null +++ b/src/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/vue3' +import { fn } from '@storybook/test' + +import OcResourceSearchSimple from './OcResourceSearchSimple.vue' + +const meta: Meta<typeof OcResourceSearchSimple> = { + component: OcResourceSearchSimple +} + +export default meta +type Story = StoryObj<typeof OcResourceSearchSimple> + +export const Default: Story = { + render: (args) => ({ + components: { OcResourceSearchSimple }, + setup() { + return { args } + }, + template: '<OcResourceSearchSimple v-bind="args" />' + }), + args: { + modelValue: { + params: {} + }, + onSubmit: fn() + } +} + +export const InitialValues: Story = { + render: (args) => ({ + components: { OcResourceSearchSimple }, + setup() { + return { args } + }, + template: '<OcResourceSearchSimple v-bind="args" />' + }), + args: { + modelValue: { + q: "DRIIHM", + params: { + title: ["mon titre"], + creator: ["jean", "michel"] + } + }, + onSubmit: fn() + } +} \ No newline at end of file diff --git a/src/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.vue b/src/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.vue new file mode 100644 index 0000000000000000000000000000000000000000..5a91e1924aab838158b588387e95da6f6cf20c1d --- /dev/null +++ b/src/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.vue @@ -0,0 +1,107 @@ +<template> + <InputGroup class="w-full mt-8 mb-4"> + <InputGroupAddon + class="px-4 cursor-pointer hover:bg-gray-100 hover:text-primary" + @click="emit('submit')" + > + <i class="fa-solid fa-magnifying-glass"/> + </InputGroupAddon> + <InputText + id="search" + v-model="model.q" + fluid + size="large" + :placeholder="t('community.homepage.searchBarPlaceholder')" + v-on:keyup.enter="emit('submit')" + :disabled="props.loading" + /> + <InputGroupAddon + class="px-4 cursor-pointer hover:text-black hover:bg-gray-100" + @click="model = {params:{}}" + > + <i class="fa-solid fa-xmark"/> + </InputGroupAddon> + <InputGroupAddon + v-if="searchPop" + class="inline-flex gap-2 text-gray-500 cursor-pointer hover:bg-gray-100 hover:text-black px-4" + @click="searchPop.toggle" + > + <h3>{{ t('search.searchBarParamButtonLabel') }}</h3> + <i :class="{ + 'fa-chevron-down': !searchPop.visible, + 'fa-chevron-up': searchPop.visible + }" + class="fa-solid" + /> + </InputGroupAddon> + </InputGroup> + <div id="popoverPlace" class="h-0 m-0 p-0" /> + <Popover id="popover" class="h-fit w-full" ref="searchPop" appendTo="#popoverPlace"> + <div v-for="key in Object.keys(SearchQueryParams)" v-bind:key="key"> + <label :for="key" class="font-medium">{{ translateValue(searchMetadata[key as keyof typeof SearchQueryParams].label) }}</label> + <OcMultipleField + class="mt-1 mb-2" + :name="key" + :id="key" + :inputComponent="InputText" + :inputProps="{ fluid: true }" + :initialValue="model.params[key as keyof typeof SearchQueryParams]" + v-on:updateValue="handleChange($event, key)" + /> + </div> + <div class="text-right"> + <Button class="mt-6" @click="searchPop.toggle($event), emit('submit')"> + {{ t('search.launchSearch') }} + </Button> + </div> + </Popover> +</template> + +<script setup lang="ts"> +import InputText from 'primevue/inputtext' +import InputGroup from 'primevue/inputgroup'; +import InputGroupAddon from 'primevue/inputgroupaddon'; +import Button from 'primevue/button' +import Popover from 'primevue/popover' +import { SearchQueryParams, type OcSearchQuery } from '@/declarations' +import { useI18n } from 'vue-i18n' +import { ref } from 'vue' +import OcMultipleField from '@/components/FormInputs/OcMultipleField/OcMultipleField.vue' +import { searchMetadata } from '@/modelMetadata/search' +import { useTranslateValue } from '@/composables/useTranslateValue' + +const { t } = useI18n() +const { translateValue } = useTranslateValue() + +const model = defineModel<OcSearchQuery>({ + required: true, + default: { + params: {} + } +}) + +const props = defineProps({ + loading: { + type: Boolean, + default: false + } +}) + +const emit = defineEmits<{ + submit: [] +}>() + +const searchPop = ref() + +function handleChange(newValue: any, key: string) { + model.value.params[key as keyof typeof SearchQueryParams] = newValue +} +</script> +<style scoped> +:deep(#popover) { + position: relative !important; + left: 0 !important; + top: 0 !important; + z-index: 1 !important; +} +</style> diff --git a/src/components/Search/OcSearchResultCard/OcSearchResultCard.stories.ts b/src/components/Search/OcSearchResultCard/OcSearchResultCard.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..29705f5087d812f2fe3a2d896cc02d1371f526ff --- /dev/null +++ b/src/components/Search/OcSearchResultCard/OcSearchResultCard.stories.ts @@ -0,0 +1,168 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import OcSearchResultCard from './OcSearchResultCard.vue' + +const meta: Meta<typeof OcSearchResultCard> = { + component: OcSearchResultCard +} + +export default meta +type Story = StoryObj<typeof OcSearchResultCard> + +export const Default: Story = { + render: (args) => ({ + components: { OcSearchResultCard }, + setup() { + return { args } + }, + template: '<OcSearchResultCard v-bind="args" />' + }), + args: { + searchResult: { + '@id': 'urn:testdataset:nakala:6d79f9ed-e10e-4eea-8bfe-93c0bdf370dc', + '@type': ['http://www.w3.org/ns/dcat#Dataset'], + identifier: 'test-6d79f9ed-e10e-4eea-8bfe-93c0bdf370dc', + creator: [ + { + '@id': 'urn:testdataset:nakala:benedictemadon', + familyName: 'Madon', + givenName: 'Bénédicte', + mbox: 'mailto:benedicte.madon@gmail.com', + name: 'Madon, Bénédicte' + }, + { + '@id': 'urn:testdataset:nakala:eliearnaud', + familyName: 'Arnaud', + givenName: 'Elie', + mbox: 'mailto:elie.arnaud@mnhn.fr', + name: 'Arnaud, Elie' + }, + { + '@id': 'urn:testdataset:nakala:emilielerigoleur', + familyName: 'Lerigoleur', + givenName: 'Emilie', + mbox: 'mailto:emilie.lerigoleur@univ-tlse2.fr', + name: 'Lerigoleur, Emilie' + }, + { + '@id': 'urn:testdataset:nakala:ericfoulquier', + familyName: 'Foulquier', + givenName: 'Eric', + mbox: 'mailto:eric.foulquier@univ-brest.fr', + name: 'Foulquier, Eric' + }, + { + '@id': 'urn:testdataset:nakala:etiennejeannesson', + familyName: 'Jeannesson', + givenName: 'Etienne', + mbox: 'mailto:etienne.jeannesson@ofb.gouv.fr', + name: 'Jeannesson, Etienne' + }, + { + '@id': 'urn:testdataset:nakala:iwanleberre', + familyName: 'Le Berre', + givenName: 'Iwan', + mbox: 'mailto:iwan.leberre@univ-brest.fr', + name: 'Le Berre, Iwan' + }, + { + '@id': 'urn:testdataset:nakala:jeanlucjung', + familyName: 'Jung', + givenName: 'Jean-Luc', + mbox: 'mailto:jean-luc.jung@mnhn.fr', + name: 'Jung, Jean-Luc' + }, + { + '@id': 'urn:testdataset:nakala:juliensananikone', + familyName: 'Sananikone', + givenName: 'Julien', + mbox: 'mailto:julien.sananikone@mnhn.fr', + name: 'Sananikone, Julien' + }, + { + '@id': 'urn:testdataset:nakala:laurentbouveret', + familyName: 'Bouveret', + givenName: 'Laurent', + mbox: 'mailto:laurent.bouveret@gmail.com', + name: 'Bouveret, Laurent' + }, + { + '@id': 'urn:testdataset:nakala:lorrainecoche', + familyName: 'Coché', + givenName: 'Lorraine', + mbox: 'mailto:lorraine.coche@gmail.com', + name: 'Coché, Lorraine' + }, + { + '@id': 'urn:testdataset:nakala:maximesebe', + familyName: 'Sèbe', + givenName: 'Maxime', + mbox: 'mailto:maxime.sebe@gmail.com', + name: 'Sèbe, Maxime' + }, + { + '@id': 'urn:testdataset:nakala:nadegegandilhon', + familyName: 'Gandilhon', + givenName: 'Nadège', + mbox: 'mailto:ngandilhon75@gmail.com', + name: 'Gandilhon, Nadège' + }, + { + '@id': 'urn:testdataset:nakala:pascaljeanlopez', + familyName: 'Lopez', + givenName: 'Pascal Jean', + mbox: 'mailto:pjlopez@mnhn.fr', + name: 'Lopez, Pascal Jean' + }, + { + '@id': 'urn:testdataset:nakala:romaindavid', + familyName: 'David', + givenName: 'Romain', + mbox: 'mailto:david.romain@gmail.com', + name: 'David, Romain' + }, + { + '@id': 'urn:testdataset:nakala:yvanlebras', + familyName: 'Le Bras', + givenName: 'Yvan', + mbox: 'mailto:yvan.le-bras@mnhn.fr', + name: 'Le Bras, Yvan' + } + ], + description: { + fr: "Base de données collectée dans le cadre du stage de master 2 Lorraine Coché \"Inventaire et structuration des données d'observation des mammifères marins autour de la Guadeloupe\" en 2020 (Master Écosystèmes marins tropicaux de l'Université des Antilles). Cette base de données a été constituée dans un esprit de science participative. Elle centralise et harmonise les données d'observation collectées par l'équipe du Sanctuaire Agoa (Aire Marine Protégée), l'OMMAG (Observatoire des Mammifères Marins de l'Archipel Guadeloupéen), BREACH Antilles, et les sociétés de whale-watching Cétacés Caraïbes, Guadeloupe Evasion Découverte et Aventures Marines.", + en: "Database collected as part of Lorraine Coché's Master 2 course entitled \"Inventory and structuring of marine mammal observation data around Guadeloupe\" in 2020 (Master's degree in Tropical Marine Ecosystems at the University of the West Indies). This database has been set up in the spirit of participatory science. It centralises and harmonises the observation data collected by the Agoa Sanctuary team (Marine Protected Area), OMMAG (Observatoire des Mammifères Marins de l'Archipel Guadeloupéen), BREACH Antilles, and the whale-watching companies Cétacés Caraïbes, Guadeloupe Evasion Découverte and Aventures Marines." + }, + title: { + fr: "KAKILA, Base de données d'observation des mammifères marins autour de l'archipel de la Guadeloupe dans le sanctuaire AGOA - Antilles françaises", + en: 'KAKILA, Marine mammal observation database around the Guadeloupe archipelago in the AGOA sanctuary - French West Indies' + }, + distribution: [ + 'urn:testdistribution:nakala:kakila-database', + 'urn:testdistribution:dataindores:kakila-database' + ], + parentCatalog:{ + '@id': "https://data.driihm.fr/catalogs/driihm2", + '@type': ['http://www.w3.org/ns/dcat#Catalog'], + 'identifier': "2af4cfa0-9f3b-11ee-a39a-d6e96336453a", + 'title': {"fr":"LabEx DRIIHM 2","en":"DRIIHM LabEx 2"}, + 'graph': ["ex:sandbox2"] + } + }, + community: { + description: { + fr: "Lauréat de la deuxième vague de l'appel à projet Laboratoire d'Excellence (LabEx) dans le cadre du programme « Investissements d'avenir », le LabEx DRIIHM, Dispositif de Recherche Interdisciplinaire sur les Interactions Hommes-Milieux, regroupe à ce jour 13 Observatoires Hommes-Milieux, outils d'observation de socio-écosystèmes impactés par un événement d'origine anthropique. Créés par le CNRS-INEE en 2007, ils sont répartis en France métropolitaine, en outre-mer et à l’étranger.", + en: "Laureate of the Laboratory for Excellence project (LabEx) in the program « Investment in the future », the DRIIHM LabEx, Device for Interdisciplinary Research on human-environments Interactions, aggregate 13 human-environments observatories (OHM in french), tools for observing socio-ecosystems impacted by anthropic events. Created by CNRS-INEE in 2007, they are located in metropolitan France, overseas France and abroad." + }, + identifier: "189088ec-baa9-4397-8c6f-eefde9a3790c", + title: { + fr: "Communauté du LabEx DRIIHM", + en: "DRIIHM Community" + }, + logo: "https://www.driihm.fr/images/images/logos_png/logo_DRIIHM_r%C3%A9duit.png", + name: "driihm", + isSpaceOf: "https://www.irit.fr/opencommon/agents/organization/9a20f121-c64e-4049-93a7-4bedbe819fd6", + color: 'linen' + } + } +} diff --git a/src/components/Search/OcSearchResultCard/OcSearchResultCard.vue b/src/components/Search/OcSearchResultCard/OcSearchResultCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..a3d97c9e8354ee7e519c688ba0686c8e826c6595 --- /dev/null +++ b/src/components/Search/OcSearchResultCard/OcSearchResultCard.vue @@ -0,0 +1,158 @@ +<template> + <div + class="rounded-md shadow-md border bg-gray-100" + > + <div class="p-4"> + <div id="title" class="font-semibold text-xl mb-3"> + <i :class="iconsDict[dcatType] ?? ''" :title="t('resourceType.'+dcatType)" class="fa-lg fa-fw"></i> + <OcVisibilityIcon + v-if="visibility !== 'public'" + :visibility="visibility" + class="text-[0.8rem] pt-2 -ml-4 mr-0.5" + :color="props.community.color" + /> + <OcLink + class="ml-2" + :to="{ + name: 'community.resource', + params: { + identifier: props.searchResult.identifier, + community: props.community.name, + resource: dcatType + } + }" + > + {{ translateValue(props.searchResult.title) }} + </OcLink> + </div> + <div id="subtitle" class="text-sm text-gray-600 mb-4"> + <span>{{ t('search.searchResult.id') + ': ' + props.searchResult.identifier }}</span> + <span v-if="props.searchResult.version"> | {{ t('search.searchResult.version') + ': ' + props.searchResult.version }}</span> + </div> + <div v-if="creators" id="creators" class="mb-4">{{ creators }}</div> + <div v-if="props.searchResult.description" id="description"> + <p class="font-medium mb-1">{{ t('search.searchResult.description') }}</p> + <p class="text-justify text-sm">{{ truncateText(translateValue(props.searchResult.description), 500) }}</p> + </div> + </div> + <div id="footer" class="pl-4 pr-4 pt-2 pb-2 bg-gray-200 rounded-b-md text-sm flex flex-row gap-2 justify-between"> + <span v-if="dcatType===ResourceType.CATALOG"> + {{ datasets + ' ' + t('search.searchResult.datasets') + ' ' + t('and') + ' ' + catalogues + ' ' + t('search.searchResult.catalogues') }} + </span> + <span v-if="dcatType===ResourceType.DATASET">{{ distributions + ' ' + t('search.searchResult.distributions')}}</span> + <span v-if="props.searchResult.parentCatalog && !Array.isArray(props.searchResult.parentCatalog)"> + {{ t('search.searchResult.parentCatalog') + ': ' }} + <OcLink + class="ml-1" + :to="{ + name: 'community.resource', + params: { + identifier: props.searchResult.parentCatalog.identifier, + community: props.community.name, + resource: 'catalog' + } + }" + > + {{ translateValue(props.searchResult.parentCatalog.title) }} + </OcLink> + </span> + <span v-if="Array.isArray(props.searchResult.parentCatalog)"> + <Popover ref="parentCatalogPop"> + <div v-for="parentCatalog in props.searchResult.parentCatalog" v-bind:key="parentCatalog.identifier"> + <OcLink + class="ml-1" + :to="{ + name: 'community.resource', + params: { + identifier: parentCatalog.identifier, + community: props.community.name, + resource: 'catalog' + } + }" + > + {{ translateValue(parentCatalog.title) }} + </OcLink> + </div> + </Popover> + <Button + v-if="parentCatalogPop" + @click="parentCatalogPop.toggle" + severity="secondary" + class="h-6" + > + {{ t('search.searchResult.parentCatalogs') }} + </Button> + </span> + </div> + </div> +</template> +<script setup lang="ts"> +import { useTranslateValue } from '@/composables/useTranslateValue' +import type { OcCommunity, OcOrganization, OcPerson, OcSearchResult } from '@/declarations'; +import { iconsDict } from '@/helpers/icons'; +import { getResourceTypeFromAtType, ResourceType } from '@/helpers/resourceType'; +import { computed, ref, type PropType } from 'vue' +import { useI18n } from 'vue-i18n'; +import OcLink from '@/components/OcLink.vue'; +import { getResourceVisibility } from '@/helpers/resourceVisibility'; +import { truncateText } from '@/helpers/text' +import OcVisibilityIcon from '@/components/OcVisibilityIcon/OcVisibilityIcon.vue'; +import Popover from 'primevue/popover'; +import Button from 'primevue/button'; + +const { t } = useI18n() +const { translateValue } = useTranslateValue() + +const props = defineProps({ + searchResult: { + type: Object as PropType<OcSearchResult>, + required: true + }, + community: { + type: Object as PropType<OcCommunity>, + required: true + }, + userPrivateGraph: { + type: String, + required: false + } +}) + +const dcatType = getResourceTypeFromAtType(props.searchResult['@type']) + +const visibility = getResourceVisibility(props.community, props.searchResult, props.userPrivateGraph) + +const creators = computed(() => { + if (Array.isArray(props.searchResult.creator)){ + return props.searchResult.creator.map((creator) => creatorName(creator)).join('; ') + } else if (props.searchResult.creator) { + return creatorName(props.searchResult.creator) + } else { + return undefined + } +}) + +const creatorName = (creator: OcPerson | OcOrganization) => { + if (typeof creator === 'string'){ return creator } + else if (Object.keys(creator).length === 1){ return creator['@id'] } + + else if (creator.name){ + if (typeof creator.name === 'string'){ return creator.name } + else { return translateValue(creator.name) } + } else { + return creator.familyName + ' ' + (creator.firstName ?? creator.givenName ?? '') + } +} + +const catalogues = props.searchResult.catalog?.length ?? 0 +const datasets = props.searchResult.dataset?.length ?? 0 +const distributions = props.searchResult.distribution?.length ?? 0 + +const parentCatalogPop = ref() +</script> + +<style scoped> +:deep(.p-scrollpanel-content-container){ + z-index: 0; +} +</style> \ No newline at end of file diff --git a/src/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.stories.ts b/src/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..52d7b9f790e912e05fee6eb033abb5bd8463c5ee --- /dev/null +++ b/src/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import OcSearchResultCardSkeleton from './OcSearchResultCardSkeleton.vue' + +const meta: Meta<typeof OcSearchResultCardSkeleton> = { + component: OcSearchResultCardSkeleton +} + +export default meta +type Story = StoryObj<typeof OcSearchResultCardSkeleton> + +export const Default: Story = { + render: (args) => ({ + components: { OcSearchResultCardSkeleton }, + setup() { + return { args } + }, + template: '<OcSearchResultCardSkeleton v-bind="args" />' + }), +} diff --git a/src/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue b/src/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue new file mode 100644 index 0000000000000000000000000000000000000000..5f6eec4024cd7b05114cffd5cb7e4d33734885b6 --- /dev/null +++ b/src/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue @@ -0,0 +1,25 @@ +<template> + <div class="bg-gray-100 w-full h-58 rounded-md "> + <div class="p-4 h-50"> + <div id="title" class="font-semibold text-2xl flex flex-row gap-2 mb-3"> + <p class="w-6 h-6 bg-slate-200 rounded animate-pulse"></p> + <p class="rounded bg-slate-200 w-4/5 h-6 text-white font-semibold text-2xl animate-pulse"></p> + </div> + <div id="subtitle" class="text-sm w-1/5 h-2 bg-slate-200 mb-4 animate-pulse"></div> + <div id="creators" class="rounded bg-slate-200 w-4/5 h-4 mb-4 text-white font-semibold text-2xl animate-pulse"></div> + <div id="description"> + <p class="font-medium text-gray-500 mb-1 animate-pulse">{{ t('search.searchResult.description') }}</p> + <p class="rounded text-sm bg-slate-200 h-12 w-full animate-pulse"></p> + </div> + </div> + <div id="footer" class="bg-gray-200 rounded-b-md h-8"> + </div> + </div> +</template> +<script setup lang="ts"> +import { useI18n } from 'vue-i18n'; + +const { t } = useI18n() +</script> +<style scoped> +</style> \ No newline at end of file diff --git a/src/composables/useTranslateValue.ts b/src/composables/useTranslateValue.ts index 200d5d9d259b57f79cc7b38cb2049edafb48e7e2..8c97acb5babc02249fab4ac8685f86599f06adf1 100644 --- a/src/composables/useTranslateValue.ts +++ b/src/composables/useTranslateValue.ts @@ -1,6 +1,8 @@ import type { LocalizedProperty } from '@/declarations' import { useI18n, type I18nScope } from 'vue-i18n' +const CACHE_TRANSLATE_KEYVALUE = [] + export function useTranslateValue(useScope: I18nScope = 'global') { const { t, mergeLocaleMessage } = useI18n({ useScope @@ -13,26 +15,40 @@ export function useTranslateValue(useScope: I18nScope = 'global') { return localizedProperty } - // For each localized property of an external resource, - // we add each localized value as a message in i18n. - // We take the first found value as the message key, - // this way, if we can't fallback to any locale, at least we - // print a real message. - const keyValue = Object.values(localizedProperty)[0] - - // when we have severeal values for one locale, we arbitrary - // take the first one. - const key: string = typeof keyValue === 'string' ? keyValue : keyValue[0] - - Object.keys(localizedProperty).forEach(function (locale) { - if (Array.isArray(localizedProperty[locale])) { - // when we have severeal values for one locale, we arbitrary - // take the first one. - mergeLocaleMessage(locale, { [key]: localizedProperty[locale][0] }) - } else { - mergeLocaleMessage(locale, { [key]: localizedProperty[locale] }) - } - }); + let key: string + + /** + * First time we find this localizedProperty + * + * We init it + */ + const lpCached = CACHE_TRANSLATE_KEYVALUE.find(e => e.lp === localizedProperty) + if (!lpCached) { + // For each localized property of an external resource, + // we add each localized value as a message in i18n. + // We take the first found value as the message key, + // this way, if we can't fallback to any locale, at least we + // print a real message. + const keyValue = Object.values(localizedProperty)[0] + + // when we have severeal values for one locale, we arbitrary + // take the first one. + key = typeof keyValue === 'string' ? keyValue : keyValue[0] + + Object.keys(localizedProperty).forEach(function (locale) { + if (Array.isArray(localizedProperty[locale])) { + // when we have severeal values for one locale, we arbitrary + // take the first one. + mergeLocaleMessage(locale, { [key]: localizedProperty[locale][0] }) + } else { + mergeLocaleMessage(locale, { [key]: localizedProperty[locale] }) + } + }); + CACHE_TRANSLATE_KEYVALUE.push({ keyValue, lp: localizedProperty }) + } else { + const keyValue = lpCached.keyValue + key = typeof keyValue === 'string' ? keyValue : keyValue[0] + } return t(key) } diff --git a/src/declarations.ts b/src/declarations.ts index cef60add14a2af7d2a10c40b67e84817bf448bb0..499be1e42201eb101dcdf5675293b9f49e97ad20 100644 --- a/src/declarations.ts +++ b/src/declarations.ts @@ -7,6 +7,7 @@ import { rolePlatformManager, roleCatalogManager } from '@/ability' +import type { type2ResourceType } from './helpers/resourceType' export type Credentials = { email: string @@ -130,6 +131,33 @@ export type OcDistributionSummary = OcResource & { license?: string } +/** + * A representation of a search result used + * for search page results cards + */ +export type OcSearchResult = OcResource & { + version?: string + creator?: Array<OcPerson | OcOrganization> + catalog?: string[] + dataset?: string[] + distribution?: string[] + parentCatalog?: OcCatalog | OcCatalog[] +} + +/** A representation of a search query */ +export type OcSearchQuery = { + q?: string + params: OcSearchParameters +} + +export type OcSearchParameters = Partial<Record<keyof typeof SearchQueryParams, string[]>> + +export enum SearchQueryParams { + title = "http://purl.org/dc/terms/title", + description = "http://purl.org/dc/terms/description", + creator = "http://purl.org/dc/terms/creator" +} + export type OcTreeNode = OcResource & { children: Array<OcTreeNode> catalog?: Array<OcTreeNode | string> diff --git a/src/helpers/icons.ts b/src/helpers/icons.ts index 24daa67ecb761ff78122678428fd7da5a4790fa3..fe44f757b5defd09a88ad2fcbb92161dd2ff4afb 100644 --- a/src/helpers/icons.ts +++ b/src/helpers/icons.ts @@ -12,5 +12,8 @@ export const iconsDict:Record<string, string> = { catalog: 'fa-solid fa-book-open', dataset: 'fa-solid fa-table', distribution: 'fa-solid fa-file', - service: 'fa-solid fa-cube' + service: 'fa-solid fa-cube', + search: 'fa-solid fa-magnifying-glass', + concept: 'fa-solid fa-quote-right', + unknown: 'fa-solid fa-question' } diff --git a/src/helpers/resourceType.ts b/src/helpers/resourceType.ts index 9605b9aa6585e5f068896049e455baee97323473..e2a7cb2c70b8c1d5c84a6ed061e837305750869f 100644 --- a/src/helpers/resourceType.ts +++ b/src/helpers/resourceType.ts @@ -3,6 +3,7 @@ export enum ResourceType { DATASET ='dataset', DISTRIBUTION ='distribution', SERVICE ='service', + CONCEPT = 'concept', UNKNOWN = 'unknown', } @@ -10,5 +11,15 @@ export const type2ResourceType: Record<string, ResourceType> = { 'http://www.w3.org/ns/dcat#Catalog': ResourceType.CATALOG, 'http://www.w3.org/ns/dcat#Dataset': ResourceType.DATASET, 'http://www.w3.org/ns/dcat#Distribution': ResourceType.DISTRIBUTION, - 'http://www.w3.org/ns/dcat#Service': ResourceType.SERVICE, + 'http://www.w3.org/ns/dcat#DataService': ResourceType.SERVICE, + 'http://www.w3.org/2004/02/skos/core#Concept': ResourceType.CONCEPT +} + +export const getResourceTypeFromAtType = (atType: string | string[]): ResourceType => { + if (Array.isArray(atType)){ + const compatibleTypes = atType.filter((type) => type in type2ResourceType) + return type2ResourceType[compatibleTypes[0]] ?? ResourceType.UNKNOWN + } else { + return type2ResourceType[atType] ?? ResourceType.UNKNOWN + } } \ No newline at end of file diff --git a/src/helpers/text.ts b/src/helpers/text.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb84731c65559d83189f825fd40c53c2f443d522 --- /dev/null +++ b/src/helpers/text.ts @@ -0,0 +1,7 @@ +export function truncateText(text: string, maxSize: number = 40) { + if (text.length > maxSize) { + return text.substring(0, maxSize) + ' ...' + } else { + return text + } +} diff --git a/src/locales/en.ts b/src/locales/en.ts index c8ab0f9fb3ea3a52051a839ef68cf73c14a9d244..f985907f7ba4e3f7df498a796f312965989dab14 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -126,7 +126,8 @@ export default { signIn: 'Sign in', signOut: 'Sign out', register: 'Request an account', - joinCommunity: 'Join a community' + joinCommunity: 'Join a community', + search: 'Search' }, back: 'Back', next: 'Next', @@ -342,5 +343,30 @@ Greetings, 'An error has occurred while updating your catalog. Please try again later or contact the site administrator.', seeCatalogPage: 'Go back to the catalog page', } - } + }, + search: { + title: "Search", + resourceType: "Resource type", + results: "Results", + launchSearch: "Launch the search", + searchBarParamButtonLabel: "Parameters", + searchResult: { + description: "Description", + datasets: "Datasets", + distributions: "Distributions", + parentCatalog: "Parent catalogue", + parentCatalogs: "Parent catalogues", + id: "Id", + version: "Version" + } + }, + resourceType: { + 'catalog': 'catalogue', + 'dataset': 'dataset', + 'distribution': 'distribution', + 'service': 'service', + 'concept': 'concept', + 'unknown': 'unknown', + }, + and: 'and' } diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 3a47ec62d43350606174d4d4cdaf5d62b29f3ddc..22e8be9e22b4f93e3a2b395567cd1a43f0139f30 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -127,7 +127,8 @@ export default { signIn: 'Se connecter', signOut: 'Déconnexion', register: "Demander la création d'un compte", - joinCommunity: 'Rejoindre une communauté' + joinCommunity: 'Rejoindre une communauté', + search: 'Rechercher' }, back: 'Précédent', next: 'Suivant', @@ -357,5 +358,31 @@ Cordialement, 'Une erreur est survenue lors de la mise à jour de votre catalogue. Merci de réessayer plus tard ou bien de contacter l\'administrateur du site.', seeCatalogPage: 'Retourner à la page du catalogue', }, - } + }, + search: { + title: "Rechercher", + resourceType: "Type de ressource", + results: "Résultats", + launchSearch: "Lancer la recherche", + searchBarParamButtonLabel: "Paramètres", + searchResult: { + description: "Description", + catalogues: "Sous-catalogues", + datasets: "Jeux de données", + distributions: "Distributions", + parentCatalog: "Catalogue parent", + parentCatalogs: "Catalogues parent", + id: "Id", + version: "Version" + } + }, + resourceType: { + 'catalog': 'catalogue', + 'dataset': 'jeu de données', + 'distribution': 'distribution', + 'service': 'service', + 'concept': 'concept', + 'unknown': 'inconnu', + }, + and: 'et' } diff --git a/src/modelMetadata/search.ts b/src/modelMetadata/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe7799a195962731d78256b2018358f13980e9ba --- /dev/null +++ b/src/modelMetadata/search.ts @@ -0,0 +1,22 @@ +import type { OcModelMetadata, OcSearchParameters } from "@/declarations"; + +export const searchMetadata: OcModelMetadata<OcSearchParameters> = { + title: { + label: { + fr: "Titre", + en: "Title" + } + }, + description: { + label: { + fr: "Description", + en: "Description" + } + }, + creator: { + label: { + fr: "Auteur", + en: "Author" + } + } +} diff --git a/src/pages/community/[community].search.vue b/src/pages/community/[community].search.vue new file mode 100644 index 0000000000000000000000000000000000000000..e076162917e0738812641c00663837eade5664d8 --- /dev/null +++ b/src/pages/community/[community].search.vue @@ -0,0 +1,187 @@ +<template> + <OcLayoutSimple :breadcrumb-items="breadcrumbItems" :is-authenticated="accountStore.isAuthenticated"> + <div class="flex flex-row"> + <div class="p-4 pl-8 w-9/12 ml-auto mr-auto"> + <h1 class="font-title text-4xl uppercase font-bold mb-4 text-primary"> + {{ t('search.title') }} + </h1> + <OcResourceSearchSimple + v-model="searchQuery" + :loading="searching" + v-on:submit="submit" + /> + <div v-if="!searching"> + <h4 v-if="searched" class="font-bold text-xl mb-4"> + {{ searchResultList.length }} {{ t('search.results') }} + </h4> + <h4 v-if="errorMessage!!">{{ errorMessage }}</h4> + <template v-for="result in searchResultObjects" v-bind:key="result"> + <OcSearchResultCard + :search-result="result" + :community="community" + class="mb-4" + /> + </template> + </div> + <div v-else> + <h4 v-if="searching" class="font-bold text-xl mb-4"> + <i class="animate-spin fas fa-spinner" /> + {{ t('loading') }} + </h4> + <OcSearchResultCardSkeleton v-for="n in itemsPerPage" v-bind:key="n" class="mb-4"/> + </div> + <Paginator + class="mt-8" + :always-show="false" + :rows="itemsPerPage" + v-model:first="start" + :total-records="searchResultList.length" + v-on:page="submit" + /> + </div> + </div> + </OcLayoutSimple> +</template> + +<script setup lang="ts"> +import { useTranslateValue } from '@/composables/useTranslateValue' +import { useAccountData } from '@/dataLoaders/account' +import { useCommunityData } from '@/dataLoaders/community' +import { SearchQueryParams, type OcBreadcrumbItem, type OcSearchQuery, type OcSearchResult } from '@/declarations' +import OcLayoutSimple from '@/layout/OcLayoutSimple/OcLayoutSimple.vue' +import { useAccountStore } from '@/stores/account' +import { computed, onBeforeMount, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import OcSearchResultCard from '@/components/Search/OcSearchResultCard/OcSearchResultCard.vue' +import OcSearchResultCardSkeleton from '@/components/Search/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue' +import { getSearchResults, searchResources } from '@/sparql/search' +import Paginator from 'primevue/paginator' +import { useRoute, useRouter } from 'vue-router' +import OcResourceSearchSimple from '@/components/Search/OcResourceSearchSimple/OcResourceSearchSimple.vue' + +definePage({ + name: 'community.search', + meta: { + needsAuth: false, + loaders: [useCommunityData, useAccountData] + } +}) + +const accountStore = useAccountStore() +const { locale, t } = useI18n() + +const { translateValue } = useTranslateValue() + +const { data: community } = useCommunityData() + +const route = useRoute() +const router = useRouter() + +const itemsPerPage = 5 +const start = ref(0) + +const searchQuery = ref<OcSearchQuery>({params: {}}) + +const searchResultList = ref<string[]>([]) +const searchResultObjects = ref<OcSearchResult[]>([]) + +const searched = ref(false) +const searching = ref(false) +const errorMessage = ref<string | null>(null) + +const submit = () => { + router.push({ + name: "community.search", + params: { + lang: locale.value, + community: community.value.name + }, + query: { + q: searchQuery.value.q, + ...searchQuery.value.params, + start: start.value + } + }) +} + +const search = async () => { + if (searching.value) { + return + } + + searching.value = true + searchResultObjects.value = [] + try { + searchResultList.value = await searchResources(searchQuery.value, accountStore.auth) + } catch (e) { + console.error(e) + errorMessage.value = (e as Error).message ?? 'Error' + } + + await getResultsForStart(start.value) + + searching.value = false + searched.value = true +} + +const getResultsForStart = async (start: number) => { + const resourceUriList = searchResultList.value.slice(start, start + itemsPerPage) + if (resourceUriList.length) { + searching.value = true + try { + searchResultObjects.value = await getSearchResults( + resourceUriList, + accountStore.auth + ) + } catch (e) { + console.error(e) + errorMessage.value = (e as Error).message ?? 'Error' + } + searching.value = false + } else { + console.log("No results") + } +} + +const breadcrumbItems = computed<OcBreadcrumbItem[]>(() => [ + { + label: translateValue(community.value.title), + key: 'community', + type: 'community', + to: { name: 'community', params: { community: community.value.name } } + }, + { + label: t('breadcrumb.search'), + key: 'search', + type: 'search' + } +]) + +const loadRouteParams = () => { + if (route.query.q){ searchQuery.value.q = route.query.q as string } + searchQuery.value.params = {} + for (const param in SearchQueryParams){ + if (Array.isArray(route.query[param])){ + searchQuery.value.params[param as keyof typeof SearchQueryParams] = route.query[param] as string[] + } else { + if (route.query[param]){ + searchQuery.value.params[param as keyof typeof SearchQueryParams] = [route.query[param] as string] + } + } + } + start.value = route.query.start ? parseInt(route.query.start as string) : 0 +} + +watch(() => route.query.start, () => { getResultsForStart(start.value) }) +watch(() => route.query, search) + +onBeforeMount(() => { + if (Object.keys(route.query).length > 0) { + loadRouteParams() + search() + } +}) +</script> + +<style scoped> +</style> diff --git a/src/pages/community/[community]/index.vue b/src/pages/community/[community]/index.vue index 7177e849b3dc9a933e75e68a301447fe70d82f54..68bbe797a1001792945734a053087bfb8e7d05ed 100644 --- a/src/pages/community/[community]/index.vue +++ b/src/pages/community/[community]/index.vue @@ -22,15 +22,18 @@ {{ t('community.homepage.searchBarLegend') }} </h3> - <IconField class="w-full mt-4 mb-4"> - <InputText - id="search" - fluid - size="large" - :placeholder="t('community.homepage.searchBarPlaceholder')" - /> - <InputIcon class="fa-solid fa-magnifying-glass" /> - </IconField> + <IconField class="w-full mt-8 mb-4"> + <InputText + id="search" + v-model="queryString" + fluid + size="large" + :placeholder="t('community.homepage.searchBarPlaceholder')" + v-on:keyup.enter="search" + /> + <InputIcon class="fa-solid fa-magnifying-glass fa-lg cursor-pointer hover:text-primary" @click="search"/> + </IconField> + </section> <section @@ -71,7 +74,7 @@ </template> <script setup lang="ts"> -import { computed } from 'vue' +import { computed, ref } from 'vue' import { useAccountStore } from '@/stores/account' import { useI18n } from 'vue-i18n' import { useTranslateValue } from '@/composables/useTranslateValue' @@ -85,6 +88,7 @@ import { getColor } from '@/helpers/communityColor' import { useCommunityData } from '@/dataLoaders/community' import { useTreeStore } from '@/stores/tree' import { useAbility } from '@casl/vue' +import { useRouter } from 'vue-router' definePage({ name: 'community', @@ -98,9 +102,10 @@ treeStore.state.selectedNodeKey = undefined const { data: community } = useCommunityData() -const { t } = useI18n() +const { locale, t } = useI18n() const { translateValue } = useTranslateValue() const { cannot } = useAbility() +const router = useRouter() const account = useAccountStore() @@ -108,4 +113,20 @@ const title = computed(() => translateValue(community.value?.title)) const abstract = computed(() => translateValue(community.value?.abstract)) const logoUrl = computed(() => community.value?.logo ?? null) const color = computed(() => getColor(community.value)) + +const queryString = ref('') + +const search = () => { + router.push({ + name: 'community.search', + params: { + lang: locale.value, + community: community.value.name, + }, + query: { + q: queryString.value, + start: 0 + } + }) +} </script> diff --git a/src/sparql/search.ts b/src/sparql/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0807945a651c0c22d7bdc034e03acf77d71f2ca --- /dev/null +++ b/src/sparql/search.ts @@ -0,0 +1,189 @@ +import type { Credentials, OcSearchQuery, OcSearchResult } from '@/declarations' +import { executeSparqlConstruct, executeSparqlSelect } from './sparql' +import type { ContextDefinition } from 'jsonld' +import { resourceContext } from './resource' + +/** + * Get results URI of a search query + */ +export const searchResources = async (query: OcSearchQuery, auth?: Credentials) => { + let queryFilter = '' + if (query.q) { + queryFilter = ` + VALUES ?p { dct:title dct:description } + ?resource ?p ?o. + FILTER regex(?o, "${query.q}", "i") + ` + } + + if (query.params?.title){ + query.params.title.forEach((title, index) => { + queryFilter += ` + ?resource dct:title ?title${index}. + FILTER regex(?title${index}, "${title}", "i") + ` + }) + } + + if (query.params?.description){ + query.params.description.forEach((description, index) => { + queryFilter += ` + ?resource dct:description ?description${index}. + FILTER regex(?description${index}, "${description}", "i") + ` + }) + } + + if (query.params?.creator){ + query.params.creator.forEach((creator, index) => { + queryFilter += ` + ?resource dct:creator ?creator${index}. + VALUES ?pCreator${index} { foaf:name foaf:givenName foaf:familyName foaf:firstName } + ?creator${index} ?pCreator${index} ?oCreator${index}. + FILTER regex(?oCreator${index}, "${creator}", "i") + ` + }) + } + + /** + * On retire les ressources ayant un espace dans leur IRI car SPARQL ne les autorise pas + * + * FILTER (!contains(str(?resource), ' ')) + */ + const res = await executeSparqlSelect( + ` + SELECT DISTINCT ?resource + WHERE { + ?resource rdf:type ?type. + ?resource dct:identifier ?identifier. + + FILTER (!contains(str(?resource), ' ')) + + ${queryFilter} + + } ORDER BY ASC(?resource) + `, + { + auth: auth + } + ) + + return res.map((result) => result.resource.value) +} + +/** Get searchResults objects from URI list */ +export const getSearchResults = async (resourceUriList: string[], auth?: Credentials) => { + const searchResultContext: ContextDefinition = { + ...resourceContext, + catalog: { + '@id': 'http://www.w3.org/ns/dcat#catalog', + '@type': '@id', + '@container': '@set' + }, + dataset: { + '@id': 'http://www.w3.org/ns/dcat#dataset', + '@type': '@id', + '@container': '@set' + }, + distribution: { + '@id': 'http://www.w3.org/ns/dcat#distribution', + '@type': '@id', + '@container': '@set' + }, + creator: { + '@id': 'http://purl.org/dc/terms/creator' + }, + parentCatalog: { + '@id': 'http://purl.org/dc/terms/isPartOf' + }, + version: { + '@id': 'http://www.w3.org/ns/dcat#version' + } + } + + const creatorContext: ContextDefinition = { + familyName: { + '@id': 'http://xmlns.com/foaf/0.1/familyName' + }, + firstName: { + '@id': 'http://xmlns.com/foaf/0.1/firstName' + }, + givenName: { + '@id': 'http://xmlns.com/foaf/0.1/givenName' + }, + name: { + '@id': 'http://xmlns.com/foaf/0.1/name', + '@container': '@language' + } + } + + const frame = { + '@context': {...searchResultContext, ...creatorContext}, + '@type': [ + 'http://www.w3.org/ns/dcat#Catalog', + 'http://www.w3.org/ns/dcat#Dataset', + 'http://www.w3.org/ns/dcat#Distribution', + 'http://www.w3.org/ns/dcat#DataService', + 'http://www.w3.org/2004/02/skos/core#Concept' + ], + contains: { + creator: { + '@id': 'http://purl.org/dc/terms/creator', + '@embed': '@always', + }, + parentCatalog: { + '@id': 'http://purl.org/dc/terms/isPartOf', + '@embed': '@always' + } + } + } + + const formattedUris = '<' + resourceUriList.join('> <') + '>' + + const res = await executeSparqlConstruct<OcSearchResult>( + ` + CONSTRUCT { + ?s ?p ?o. + ?s oct:graph ?g. + ?s dct:identifier ?identifier. + ?s dct:creator ?creator. + ?creator ?p2 ?o2. + ?s dct:isPartOf ?parentCatalog. + ?parentCatalog ?pparentCatalog ?oparentCatalog. + } + WHERE { + VALUES ?s { ${formattedUris} } + VALUES ?p { + rdf:type + dct:title + dct:description + dcat:version + dcat:catalog + dcat:dataset + dcat:distribution + } + ?s ?p ?o. + OPTIONAL { + ?s dct:creator ?creator. + VALUES ?p2 { foaf:name foaf:givenName foaf:familyName foaf:firstName } + ?creator ?p2 ?o2. + } + OPTIONAL { + ?parentCatalog dcat:dataset|dcat:catalog ?s. + VALUES ?pparentCatalog { dct:identifier dct:title } + ?parentCatalog ?pparentCatalog ?oparentCatalog. + } + GRAPH ?g { + ?s dct:identifier ?identifier. + } + } + `, + { + context: searchResultContext, + auth: auth, + frame: frame, + withoutContext: true + } + ) + return res.sort((a, b) => a['@id'] > b['@id']) +} diff --git a/src/sparql/sparql.ts b/src/sparql/sparql.ts index 9426382a9266f6f0afbc09bf07defda4ff90a0b6..87e687c7f8b9cf391090df9aeadda6bf96229ed6 100644 --- a/src/sparql/sparql.ts +++ b/src/sparql/sparql.ts @@ -17,6 +17,7 @@ export async function executeSparqlConstruct<T>( auth?: Credentials context?: ContextDefinition frame?: Frame + withoutContext?: boolean } ): Promise<T[]> { const response = await httpFetch( @@ -24,8 +25,8 @@ export async function executeSparqlConstruct<T>( { method: 'POST', headers: { - 'Content-Type': 'application/sparql-update', - Accept: 'application/ld+json' + 'Content-Type': "application/sparql-update", + "Accept": options?.withoutContext === true ? "application/x-ld+json" : "application/ld+json" }, body: constructQuery, auth: options?.auth @@ -34,11 +35,11 @@ export async function executeSparqlConstruct<T>( const data = JSON.parse(response.data) // No data, no need to continue - if (!data || !data['@context']) { + if (!data || !(data['@context'] || options?.withoutContext!!)) { return [] } - const context = options?.context ?? data['@context'] + const context = { ...data['@context'], ...options?.context } let compacted = await compact(data, context) @@ -85,9 +86,11 @@ function normalizeIdentifier(object: NodeObject) { console.warn('Object as several identifier : ', identifier) identifier = identifier[0] } - return typeof identifier === 'string' - ? identifier - : ((identifier as NodeObject)?.['@value'] as string) + if (typeof identifier === 'string') { + return identifier + } else { + return ((identifier as NodeObject)?.['@value'] as string) ?? ((identifier as NodeObject)?.['@id'] as string) + } } // Normalizing identifiers diff --git a/typed-router.d.ts b/typed-router.d.ts index 2a41d9f69496ada04128300fab81c10a1207a3ff..341f99c91b3177ba85a913ec72f0fd89449e089a 100644 --- a/typed-router.d.ts +++ b/typed-router.d.ts @@ -30,6 +30,7 @@ declare module 'vue-router/auto-routes' { 'community.dashboard.dataset': RouteRecordInfo<'community.dashboard.dataset', '/:lang/community/:community/dashboard/dataset', { community: ParamValue<true> }, { community: ParamValue<false> }>, 'community.datasets.new': RouteRecordInfo<'community.datasets.new', '/:lang/community/:community/dataset/new', { community: ParamValue<true> }, { community: ParamValue<false> }>, 'community.join': RouteRecordInfo<'community.join', '/:lang/community/:community/join', { community: ParamValue<true> }, { community: ParamValue<false> }>, + 'community.search': RouteRecordInfo<'community.search', '/:lang/community/:community/search', { community: ParamValue<true> }, { community: ParamValue<false> }>, 'connection': RouteRecordInfo<'connection', '/:lang/connection', Record<never, never>, Record<never, never>>, 'logout': RouteRecordInfo<'logout', '/:lang/logout', Record<never, never>, Record<never, never>>, 'profile': RouteRecordInfo<'profile', '/:lang/profile', Record<never, never>, Record<never, never>>,