diff --git a/src/App.vue b/src/App.vue index 918497c..fb31d2e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,8 +4,8 @@ import { RouterView, useRoute } from "vue-router"; import { DataFactory } from "n3"; import { useUiStore } from "@/stores/ui"; import { useRdfStore } from "@/composables/rdfStore"; -import { useGetRequest } from "@/composables/api"; -import { sidenavConfigKey, apiBaseUrlConfigKey, type Profile } from "@/types"; +import { useApiRequest, useConcurrentApiRequests } from "@/composables/api"; +import { sidenavConfigKey, type Profile } from "@/types"; import MainNav from "@/components/navs/MainNav.vue"; import Breadcrumbs from "@/components/Breadcrumbs.vue"; import RightSideBar from "@/components/navs/RightSideBar.vue"; @@ -18,111 +18,121 @@ const { namedNode } = DataFactory; const version = packageJson.version; const sidenav = inject(sidenavConfigKey) as boolean; -const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; const route = useRoute(); const ui = useUiStore(); - -const { data, profiles, loading, error, doRequest } = useGetRequest(); -const { data: profData, profiles: profProfiles, loading: profLoading, error: profError, doRequest: profDoRequest } = useGetRequest(); -const { store, prefixes, parseIntoStore, qnameToIri } = useRdfStore(); -const { store: profStore, prefixes: profPrefixes, parseIntoStore: profParseIntoStore, qnameToIri: profQnameToIri } = useRdfStore(); -const { store: combinedStore, prefixes: combinedPrefixes, parseIntoStore: combinedParseIntoStore, qnameToIri: combinedQnameToIri } = useRdfStore(); +const { loading: rootLoading, error: rootError, apiGetRequest: rootApiGetRequest } = useApiRequest(); // main request to API root +const { loading: profLoading, error: profError, apiGetRequest: profApiGetRequest } = useApiRequest(); // profiles request +const { loading: concurrentLoading, hasError: concurrentHasError, concurrentApiRequests } = useConcurrentApiRequests(); // concurrent profile requests +const { store: rootStore, parseIntoStore: rootParseIntoStore, qnameToIri: rootQnameToIri } = useRdfStore(); // store for API root data +const { store: profStore, parseIntoStore: profParseIntoStore, qnameToIri: profQnameToIri } = useRdfStore(); // profiles store document.title = ui.pageTitle; -onMounted(() => { +async function getRootApiMetadata() { + // get API details + const { data: rootData } = await rootApiGetRequest("/"); + if (rootData && !rootError.value) { + rootParseIntoStore(rootData); + + // get API version + const version = rootStore.value.getObjects(null, rootQnameToIri("prez:version"), null)[0]; + ui.apiVersion = version.value; + + // get search methods per flavour + let searchMethods: {[key: string]: string[]} = {}; + rootStore.value.forObjects(object => { + let flavour = ""; + let methods: string[] = []; + rootStore.value.forEach(q => { + if (q.predicate.value === rootQnameToIri("a")) { + flavour = q.object.value.split(`${rootQnameToIri("prez:")}`)[1]; + } else if (q.predicate.value === rootQnameToIri("prez:availableSearchMethod")) { + methods.push(q.object.value.split(`${rootQnameToIri("prez:")}`)[1]); + } + }, object, null, null, null); + searchMethods[flavour] = methods; + }, null, rootQnameToIri("prez:enabledPrezFlavour"), null); + ui.searchMethods = searchMethods; + } +} + +async function getProfiles() { // if profiles don't exist in pinia if (Object.keys(ui.profiles).length === 0) { - profDoRequest(`${apiBaseUrl}/profiles`, () => { - profParseIntoStore(profData.value); + const { data: profData } = await profApiGetRequest("/profiles"); + if (profData && !profError.value) { + profParseIntoStore(profData); // get list of profiles let profileUris: {[uri: string]: { token: string; link: string; }} = {}; + profStore.value.forSubjects(subject => { profStore.value.forEach(q => { profileUris[q.subject.value] = { token: q.object.value.replace("/profiles/", ""), - link: `${apiBaseUrl}${q.object.value}` + link: q.object.value } }, subject, namedNode(profQnameToIri("prez:link")), null, null); }, namedNode(profQnameToIri("a")), namedNode(profQnameToIri("prof:Profile")), null); - - // promise.all request for each profile in parallel - Promise.all(Object.values(profileUris).map(p => fetch(p.link).then(r => r.text()))).then(values => { - // parse all results into store - values.forEach(value => { - combinedParseIntoStore(value) - }); - - let profs: Profile[] = []; - - combinedStore.value.forSubjects(subject => { - let p: Profile = { - namespace: subject.id, - token: profileUris[subject.id].token, - title: "", - description: "", - mediatypes: [], - defaultMediatype: "", - labelPredicates: [], - descriptionPredicates: [], - explanationPredicates: [] - }; - combinedStore.value.forEach(q => { - if (q.predicate.value === combinedQnameToIri("dcterms:title")) { - p.title = q.object.value; - } else if (q.predicate.value === combinedQnameToIri("dcterms:description")) { - p.description = q.object.value; - // } else if (q.predicate.value === combinedQnameToIri("dcterms:identifier")) { - // p.token = q.object.value; - } else if (q.predicate.value === combinedQnameToIri("altr-ext:hasResourceFormat")) { - p.mediatypes.push(q.object.value); - } else if (q.predicate.value === combinedQnameToIri("altr-ext:hasDefaultResourceFormat")) { - p.defaultMediatype = q.object.value; - } else if (q.predicate.value === combinedQnameToIri("altr-ext:hasLabelPredicate")) { - p.labelPredicates.push(q.object.value); - } else if (q.predicate.value === combinedQnameToIri("altr-ext:hasDescriptionPredicate")) { - p.descriptionPredicates.push(q.object.value); - } else if (q.predicate.value === combinedQnameToIri("altr-ext:hasExplanationPredicate")) { - p.explanationPredicates.push(q.object.value); - } - }, subject, null, null, null); - p.mediatypes.sort((a, b) => Number(b === p.defaultMediatype) - Number(a === p.defaultMediatype)); - profs.push(p); - }, namedNode(combinedQnameToIri("a")), namedNode(combinedQnameToIri("prof:Profile")), null); - - ui.profiles = profs.reduce<{[namespace: string]: Profile}>((obj, prof) => (obj[prof.namespace] = prof, obj), {}); // {uri: {...}, ...} - }); - }); - } - // get API details - doRequest(apiBaseUrl, () => { - parseIntoStore(data.value); - // get API version - const version = store.value.getObjects(null, qnameToIri("prez:version"), null)[0]; - ui.apiVersion = version.value; + // request each profile in parallel + const profilesData = await concurrentApiRequests(Object.values(profileUris).map(p => p.link)); - // get search methods per flavour - let searchMethods: {[key: string]: string[]} = {}; - store.value.forObjects(object => { - let flavour = ""; - let methods: string[] = []; - store.value.forEach(q => { - if (q.predicate.value === qnameToIri("a")) { - flavour = q.object.value.split(`${qnameToIri('prez:')}`)[1]; - } else if (q.predicate.value === qnameToIri("prez:availableSearchMethod")) { - methods.push(q.object.value.split(`${qnameToIri('prez:')}`)[1]); + profilesData.forEach(r => { + if (r.value) { + profParseIntoStore(r.value); } - }, object, null, null, null); - searchMethods[flavour] = methods; - }, null, qnameToIri("prez:enabledPrezFlavour"), null); - ui.searchMethods = searchMethods; - }); + }); + + let profs: Profile[] = []; + + profStore.value.forSubjects(subject => { + let p: Profile = { + namespace: subject.id, + token: profileUris[subject.id].token, + title: "", + description: "", + mediatypes: [], + defaultMediatype: "", + labelPredicates: [], + descriptionPredicates: [], + explanationPredicates: [] + }; + + profStore.value.forEach(q => { + if (q.predicate.value === profQnameToIri("dcterms:title")) { // need to use label predicate from profile + p.title = q.object.value; + } else if (q.predicate.value === profQnameToIri("dcterms:description")) { // need to use description predicate from profile + p.description = q.object.value; + // } else if (q.predicate.value === profQnameToIri("dcterms:identifier")) { + // p.token = q.object.value; + } else if (q.predicate.value === profQnameToIri("altr-ext:hasResourceFormat")) { + p.mediatypes.push(q.object.value); + } else if (q.predicate.value === profQnameToIri("altr-ext:hasDefaultResourceFormat")) { + p.defaultMediatype = q.object.value; + } else if (q.predicate.value === profQnameToIri("altr-ext:hasLabelPredicate")) { + p.labelPredicates.push(q.object.value); + } else if (q.predicate.value === profQnameToIri("altr-ext:hasDescriptionPredicate")) { + p.descriptionPredicates.push(q.object.value); + } else if (q.predicate.value === profQnameToIri("altr-ext:hasExplanationPredicate")) { + p.explanationPredicates.push(q.object.value); + } + }, subject, null, null, null); + p.mediatypes.sort((a, b) => Number(b === p.defaultMediatype) - Number(a === p.defaultMediatype)); + profs.push(p); + }, namedNode(profQnameToIri("a")), namedNode(profQnameToIri("prof:Profile")), null); + + ui.profiles = profs.reduce<{[namespace: string]: Profile}>((obj, prof) => (obj[prof.namespace] = prof, obj), {}); // {uri: {...}, ...} + } + } +} + +onMounted(async () => { + await Promise.all([getRootApiMetadata(), getProfiles()]); }); diff --git a/src/components/MapClient.d.ts b/src/components/MapClient.d.ts index 45b76fc..32e90aa 100644 --- a/src/components/MapClient.d.ts +++ b/src/components/MapClient.d.ts @@ -19,3 +19,13 @@ export enum ShapeTypes { } export type DrawingModes = 'MARKER' | 'RECTANGLE' | 'POLYGON' + +/** well known text simplified result object, as returned by mapSearch */ +export type WKTResult = { + uri: string + link: string + wkt: string + fcLabel: string + label: string + id?: string +} diff --git a/src/components/MapClient.vue b/src/components/MapClient.vue index 9165f3e..53b1e40 100644 --- a/src/components/MapClient.vue +++ b/src/components/MapClient.vue @@ -6,7 +6,7 @@ import { wktToGeoJSON } from "@terraformer/wkt" import { mapConfigKey, type MapConfig } from "@/types"; import { convertConfigTypes } from '@/util/mapSearchHelper' import type { MapOptionsCenter } from '@/types' -import type { WKTResult } from '@/stores/mapSearchStore.d'; +import type { WKTResult } from "@/components/MapClient.d"; import { ShapeTypes, type DrawingModes } from "@/components/MapClient.d"; diff --git a/src/components/ProfilesTable.vue b/src/components/ProfilesTable.vue index e90d929..4d4277d 100644 --- a/src/components/ProfilesTable.vue +++ b/src/components/ProfilesTable.vue @@ -1,7 +1,6 @@ diff --git a/src/components/search/CatPrezSearchMap.vue b/src/components/search/CatPrezSearchMap.vue index f4d8225..fcf14d1 100644 --- a/src/components/search/CatPrezSearchMap.vue +++ b/src/components/search/CatPrezSearchMap.vue @@ -1,127 +1,221 @@ @@ -135,25 +229,36 @@ onMounted(async ()=>{ type="search" name="term" class="search-input" - v-model="searchTermRef" + v-model="searchTerm" placeholder="Search..." - @keyup.enter="performSearch()" + @keyup.enter="doSearch()" > Catalogues - + Select all - - - - - { await fetchThemes(); await performSearch(); }" /> - {{ catalog.t }} + + + + + + {{ catalog.title }} @@ -161,49 +266,54 @@ onMounted(async ()=>{ Themes - + Select all - - - - - + + + + performSearch()" /> - {{ theme.pl }} + {{ theme.title }} - Show Query + Show Query Result limit - + - Search + Search Results - - - + + + Title @@ -211,20 +321,20 @@ onMounted(async ()=>{ - + - {{ result.t }} + {{ result.title }} - - - {{ result.thpllist.split('\t')[tindex] }} + + + {{ theme.title }} - - {{ result.d.length > MAX_DESC_LENGTH ? result.d.slice(0, MAX_DESC_LENGTH) + '...' : result.d }} + + {{ result.description.length > MAX_DESC_LENGTH ? result.description.slice(0, MAX_DESC_LENGTH) + '...' : result.description }} @@ -234,13 +344,13 @@ onMounted(async ()=>{ - + Spatial Search SPARQL Query - {{ sparqlQueryRef.trim() }} + {{ query.trim() }} - Copy + Copy diff --git a/src/components/search/SpacePrezSearch.vue b/src/components/search/SpacePrezSearch.vue index 209b533..8965036 100644 --- a/src/components/search/SpacePrezSearch.vue +++ b/src/components/search/SpacePrezSearch.vue @@ -1,16 +1,19 @@ diff --git a/src/components/search/SpacePrezSearchMap.vue b/src/components/search/SpacePrezSearchMap.vue index ef8bfdd..cadd4ba 100644 --- a/src/components/search/SpacePrezSearchMap.vue +++ b/src/components/search/SpacePrezSearchMap.vue @@ -1,60 +1,72 @@ @@ -202,45 +301,45 @@ const toggleAllFeatures = async (datasetNode: DatasetTreeNode, checked: boolean) Map Selection - - within km + + within km Datasets & Feature Collections - + Select all - + Expand all Collapse all - - + + - - - {{ dataset.item.object }} + + + {{ dataset.title || dataset.iri }} - + - + - - {{ fc.object }} + + {{ fc.title || fc.iri }} @@ -248,30 +347,30 @@ const toggleAllFeatures = async (datasetNode: DatasetTreeNode, checked: boolean) - Show Query + Show Query Result limit - + - Search + Search Results - - - + + + Title @@ -279,9 +378,9 @@ const toggleAllFeatures = async (datasetNode: DatasetTreeNode, checked: boolean) - + - {{ result.label }} + {{ result.label || result.uri }} {{ result.fcLabel }} @@ -292,13 +391,13 @@ const toggleAllFeatures = async (datasetNode: DatasetTreeNode, checked: boolean) - + Spatial Search SPARQL Query - {{ sparqlQueryRef.trim() }} + {{ query.trim() }} - Copy + Copy diff --git a/src/components/search/VocPrezSearch.vue b/src/components/search/VocPrezSearch.vue index 07bc4b4..a206f26 100644 --- a/src/components/search/VocPrezSearch.vue +++ b/src/components/search/VocPrezSearch.vue @@ -1,14 +1,12 @@ diff --git a/src/composables/api.ts b/src/composables/api.ts index 5ca0ecc..74d6f00 100644 --- a/src/composables/api.ts +++ b/src/composables/api.ts @@ -1,96 +1,287 @@ -import { ref } from "vue"; -import type { ProfileHeader, LinkObject } from "@/types"; +import { ref, inject } from "vue"; +import { apiBaseUrlConfigKey, type ProfileHeader, type LinkObject } from "@/types"; -// const linkHeader = `; rel="profile", ; rel="type"; token="mem"; anchor=, ; rel="type"; token="dcat"; anchor=, ; rel="self"; type="text/html"; profile="https://www.w3.org/TR/vocab-dcat/", ; rel="alternate"; type="text/turtle"; profile="https://www.w3.org/TR/vocab-dcat/", ; rel="alternate"; type="application/rdf+xml"; profile="https://www.w3.org/TR/vocab-dcat/", ; rel="alternate"; type="application/ld+json"; profile="https://www.w3.org/TR/vocab-dcat/", ; rel="alternate"; type="text/html"; profile="https://w3id.org/profile/mem", ; rel="alternate"; type="application/ld+json"; profile="https://w3id.org/profile/mem", ; rel="alternate"; type="application/rdf+xml"; profile="https://w3id.org/profile/mem", ; rel="alternate"; type="text/turtle"; profile="https://w3id.org/profile/mem", ; rel="alternate"; type="application/json"; profile="https://w3id.org/profile/mem"`; +class NetworkError extends Error { + constructor(message: string) { + super(message); + this.name = "NetworkError"; + } +} + +/** + * Parses a link header component + * + * @param link + * @returns + */ +function parseLinkHeader(link: string): LinkObject { + const [, uriRef, attrs] = link.match(/<(.+)>; (.+)/)!; + let linkObj: Partial = { uriRef }; + + attrs.split("; ").forEach(l => { + const [, lhs, rhs] = l.match(/(.+)=[\"<](.+)[\">]/) as [string, keyof Omit, string]; + linkObj[lhs] = rhs; + }); + + return linkObj as LinkObject; +} + +/** + * Make a single async GET request, receiving turtle & profile link headers + * + * @param url + * @returns + */ +async function individualApiRequest(url: string) { + const r = await fetch(url, { + method: "GET", + headers: { + "Accept": "text/anot+turtle" + } + }); + + if (!r.ok) { + throw new NetworkError(`Network error - status code ${r.status}: ${r.statusText}`); + } + + // parse link headers for profiles + const profiles = r.headers.get("link") ? getProfilesFromHeaders(r.headers.get("link")!) : []; + + const data = await r.text(); // always get turtle string + return { data, profiles }; +} + +/** + * Gets a list of profiles from Link headers + * + * @param link + * @returns + */ +function getProfilesFromHeaders(link: string): ProfileHeader[] { + let profileObj: {[uri: string]: ProfileHeader} = {} ; + + const links = link.split(", ").map(l => parseLinkHeader(l)); + + links.filter(l => l.rel === "type").forEach(l => { + profileObj[l.anchor] = { + default: false, + token: l.token, + mediatypes: [], + title: l.title, + description: "", + uri: l.anchor + }; + }); + + const defaultProfile = links.find(l => l.rel === "self")!; + profileObj[defaultProfile.profile].default = true; + links.filter(l => l.rel === "alternate").forEach(l => { + if (!profileObj[l.profile].mediatypes.map(m => m.mediatype).includes(l.type)) { + profileObj[l.profile].mediatypes.push({ title: "", mediatype: l.type, default: false }); + } + }); + + // need to sort mediatypes by default first - need to use ui.profiles + + return Object.values(profileObj); +} + +/** + * Used for making async GET requests + * + * @returns + */ export function useGetRequest() { - const data = ref(""); const loading = ref(false); const error = ref(""); - const profiles = ref([]); - function parseLinkHeader(link: string): LinkObject { - const [, uriRef, attrs] = link.match(/<(.+)>; (.+)/)!; - let linkObj: Partial = { uriRef }; + /** + * Perform an async GET request + * + * @param url + * @param headers + * @returns + */ + async function getRequest(url: string, headers: {[key: string]: string}) { + loading.value = true; + let data: string = ""; - attrs.split("; ").forEach(l => { - const [, lhs, rhs] = l.match(/(.+)=[\"<](.+)[\">]/) as [string, keyof Omit, string]; - linkObj[lhs] = rhs; - }); + try { + const r = await fetch(url, { + method: "GET", + headers: headers + }); + + if (!r.ok) { + throw new NetworkError(`Network error - status code ${r.status}: ${r.statusText}`); + } + + + data = await r.text(); // always get turtle string + } catch (e) { + if (e instanceof TypeError) { // TypeError - fetch error + error.value = e.message; + } else if (e instanceof NetworkError) { // NetworkError - status code error + error.value = e.message; + } else if (e instanceof SyntaxError) { // SyntaxError - .text() parsing error + error.value = e.message; + } + } - return linkObj as LinkObject; - } + loading.value = false; + return data; + }; - function getProfilesFromHeaders(link: string): ProfileHeader[] { - let profileObj: {[uri: string]: ProfileHeader} = {} ; + return { + loading, + error, + getRequest + }; +}; - const links = link.split(", ").map(l => parseLinkHeader(l)); +/** + * Used for making async API requests to the Prez backend + * + * @returns + */ +export function useApiRequest() { + const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; + const loading = ref(false); + const error = ref(""); - links.filter(l => l.rel === "type").forEach(l => { - profileObj[l.anchor] = { - default: false, - token: l.token, - mediatypes: [], - title: l.title, - description: "", - uri: l.anchor - }; - }); + /** + * Perform an async GET request to the Prez API + * + * @param path + * @returns data, profiles + */ + async function apiGetRequest(path: string) { + loading.value = true; + let data: string = ""; + let profiles: ProfileHeader[] = []; + + try { + ({ data, profiles } = await individualApiRequest(`${apiBaseUrl}${path}`)); + } catch (e) { + if (e instanceof TypeError) { // TypeError - fetch error + error.value = e.message; + } else if (e instanceof NetworkError) { // NetworkError - status code error + error.value = e.message; + } else if (e instanceof SyntaxError) { // SyntaxError - .text() parsing error + error.value = e.message; + } + } + + loading.value = false; + return { data, profiles }; + }; + + return { + loading, + error, + apiGetRequest + }; +}; + +/** + * Used for making multiple concurrent async API requests to the Prez backend + * + * @returns + */ +export function useConcurrentApiRequests() { + const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; + const loading = ref(false); + const hasError = ref(false); - const defaultProfile = links.find(l => l.rel === "self")!; - profileObj[defaultProfile.profile].default = true; + /** + * Perform multiple concurrent async GET requests to the Prez API + * + * @param path + * @returns data + */ + async function concurrentApiRequests(paths: string[]) { + loading.value = true; + let data: { + value: string; + profiles: ProfileHeader[]; + error: any; + }[] = []; - links.filter(l => l.rel === "alternate").forEach(l => { - if (!profileObj[l.profile].mediatypes.map(m => m.mediatype).includes(l.type)) { - profileObj[l.profile].mediatypes.push({ title: "", mediatype: l.type, default: false }); + await Promise.allSettled(paths.map(path => individualApiRequest(`${apiBaseUrl}${path}`))).then(resp => { + if (!resp.every(r => r.status === "fulfilled")) { + hasError.value = true; } + + data = resp.map(r => { + return { + value: r.status === "fulfilled" ? r.value.data: "", + profiles: r.status === "fulfilled" ? r.value.profiles : [], + error: r.status === "fulfilled" ? "" : r.reason + }; + }); }); - // need to sort mediatypes by default first - need to use ui.profiles + loading.value = false; + return data; + }; - return Object.values(profileObj); - } + return { + loading, + hasError, + concurrentApiRequests + }; +}; - function doRequest(url: string, callback = () => {}, errorCallback = () => {}) { +/** + * Used for making async SPARQL requests + */ +export function useSparqlRequest() { + const loading = ref(false); + const error = ref(""); + + /** + * Perform an async SPARQL GET request + * + * @param path + * @returns data + */ + async function sparqlGetRequest(url: string, query: string) { loading.value = true; + let data: any = ""; + let isGraphQuery = ["CONSTRUCT", "DESCRIBE"].some(e => query.includes(e)); - fetch(url) - .then(r => { - // console.log("got response") - // console.log("response", r) + try { + const r = await fetch(`${url}?query=${encodeURIComponent(query)}`, { + method: "GET", + headers: { + "Accept": isGraphQuery ? "text/turtle" : "application/sparql-results+json" + } + }); + if (!r.ok) { - // console.log("not ok", r) - // console.log(r.status) - // console.log(r.statusText) - // console.log(r.text()) - // throw new Error(`Response was not OK: status ${r.status}`); - return Promise.reject(r); // returns a Response object to catch() to access status, etc. + throw new NetworkError(`Network error - status code ${r.status}: ${r.statusText}`); } - profiles.value = r.headers.get("link") ? getProfilesFromHeaders(r.headers.get("link")!) : []; - return r.text(); - }) - .then(text => { - data.value = text; - callback(); - }) - .catch(e => { - // console.log("error", e) - if (e instanceof TypeError) { // generic JS network error - // console.log("generic error") - // console.log("message", e.message) + + + data = isGraphQuery ? await r.text() : await r.json(); + } catch (e) { + if (e instanceof TypeError) { // TypeError - fetch error + error.value = e.message; + } else if (e instanceof NetworkError) { // NetworkError - status code error + error.value = e.message; + } else if (e instanceof SyntaxError) { // SyntaxError - .text() parsing error error.value = e.message; - } else if (e instanceof Response) { - // console.log("response error") - // console.log("status", e.status) - error.value = e.statusText; } - - errorCallback(); - }) - .finally(() => { - loading.value = false; - }); - } + } + + loading.value = false; + return data; + }; - return { data, profiles, loading, error, doRequest }; + return { + loading, + error, + sparqlGetRequest + }; }; \ No newline at end of file diff --git a/src/sparqlQueries/catalogSearch.ts b/src/sparqlQueries/catalogSearch.ts new file mode 100644 index 0000000..5823825 --- /dev/null +++ b/src/sparqlQueries/catalogSearch.ts @@ -0,0 +1,98 @@ +/** + * Creates a SPARQL query that selects the top 10 most common themes + * + * @param catalogs + * @returns + */ +export function getThemesQuery(catalogs: string[] = []): string { + return ` + PREFIX skos: + PREFIX dcat: + PREFIX dcterms: + + SELECT ?theme ?title (COUNT(?theme) AS ?count) + WHERE { + ?resource a dcat:Resource ; + ^dcterms:hasPart ?hasPart . + ${catalogs.length > 0 ? `FILTER (?hasPart IN (${catalogs.map(cat=>`<${cat}>`).join(', ')}))` : ``} + ?resource dcat:theme ?theme . + ?theme skos:prefLabel ?title . + } + GROUP BY ?theme ?title + ORDER BY DESC(?count) ?title + LIMIT 10`; +} + +/** + * Creates a SPARQL query that searches dcat:Resoures filtered by catalog, themes, geometry and weighted text search + * + * @param catalogs + * @param searchTerm + * @param themes + * @param shape + * @param limit + * @returns + */ +export function catalogSpatialSearch(catalogs: string[] = [], searchTerm: string = "", themes: string[] = [], shape: string = "", limit: number = 0): string { + return ` + PREFIX dcat: + PREFIX dcterms: + PREFIX geo: + PREFIX geof: + PREFIX skos: + + SELECT ?resource ?title ?desc (GROUP_CONCAT(?theme;separator="\t") AS ?themeList) (GROUP_CONCAT(?themeLabel;separator="\t") AS ?themeListLabels) ?weight + WHERE { + # Only look for Resources (not spatial Datasets) + ?resource a dcat:Resource . + + # Only look in selected cats + ?resource ^dcterms:hasPart ?hasPart . + ${catalogs.length > 0 ? `FILTER (?hasPart IN (${catalogs.map(cat=>`<${cat}>`).join(', ')}))` : ``} + + # Weighted text search + { + SELECT DISTINCT ?resource ?title ?desc (SUM(?w) AS ?weight) + WHERE { + ?resource a dcat:Resource . + { + ?resource + dcterms:title ?title ; + dcterms:description ?desc ; + . + BIND (50 AS ?w) + FILTER REGEX(?title, "^${searchTerm}$", "i") + } UNION { + ?resource + dcterms:title ?title ; + dcterms:description ?desc ; + . + BIND (10 AS ?w) + FILTER REGEX(?title, "${searchTerm}", "i") + FILTER(?desc!="") + } UNION { + ?resource + dcterms:title ?title ; + dcterms:description ?desc ; + . + BIND (5 AS ?w) + FILTER REGEX(?desc, "${searchTerm}", "i") + } + } + GROUP BY ?resource ?title ?desc ?match + ORDER BY DESC(?weight) ?title + } + + # Theme filter. each theme's IRI is a new line in the VALUES {} + ${themes.length > 0 ? `VALUES ?theme { ${themes.map(theme=>`<${theme}>`).join(' ')} }` : ``} + ?resource dcat:theme ?theme . + ?theme skos:prefLabel ?themeLabel . + + # Spatial Filter + ${shape != '' ? ` + ?resource geo:hasBoundingBox/geo:asWKT ?wkt ; + FILTER (geof:sfOverlaps("${shape})"^^geo:wktLiteral, ?wkt))` : ``} + + } GROUP BY ?resource ?title ?desc ?weight + ${limit > 0 ? `LIMIT ${limit}` : ``}`; +} \ No newline at end of file diff --git a/src/sparqlQueries/spatialSearch.ts b/src/sparqlQueries/spatialSearch.ts new file mode 100644 index 0000000..316cd3f --- /dev/null +++ b/src/sparqlQueries/spatialSearch.ts @@ -0,0 +1,65 @@ +import { AreaTypes, type Coords } from "@/components/MapClient.d"; +import type { MapSearchConfig } from "@/types"; + +export function spatialSearchQuery( + featureCollections: string[], + coords: Coords, + areaType: AreaTypes, + radius: number, + limit: number, + config: MapSearchConfig +): string { + let shape: string; + if (coords.length == 0) { + shape = ''; + } else if (coords.length == 1) { + const coord = coords[0]; + shape = `POINT (${coord[0]} ${coord[1]})`; + } else { + shape = `POLYGON ((${coords.map(coord => `${coord[0]} ${coord[1]}`).join(', ')}))`; + } + + let spatialFilter = ""; + + switch(areaType) { + case AreaTypes.Contains: + spatialFilter = `FILTER (geof:sfContains("${shape}"^^geo:wktLiteral, ?wkt))`; + break; + case AreaTypes.Within: + spatialFilter = `FILTER (geof:sfWithin("${shape}"^^geo:wktLiteral, ?wkt))`; + break; + case AreaTypes.Nearby: + spatialFilter = `FILTER (spatialF:nearby("${shape}"^^geo:wktLiteral, ?wkt, ${radius}, unit:kilometre))`; + break; + case AreaTypes.Overlaps: + spatialFilter = `FILTER (geof:sfOverlaps("${shape}"^^geo:wktLiteral, ?wkt))`; + break; + default: + spatialFilter = ''; + } + + return `PREFIX geo: + PREFIX geof: + PREFIX rdfs: + PREFIX spatialF: + PREFIX unit: + SELECT ?f_uri ?wkt ?fc ?fc_label ?f_label + WHERE { + ?f_uri geo:hasGeometry/geo:asWKT ?wkt . + + ${featureCollections.map(fc => { + return `{ + <${fc}> rdfs:member ?f_uri ; + <${config.props.fcLabel}> ?fc_label . + BIND (<${fc}> AS ?fc) + }`; + }).join(' UNION ')} + + OPTIONAL { + ?f_uri <${config.props.fLabel}> ?f_label . + } + + ${spatialFilter} + } + LIMIT ${limit}`; +}; diff --git a/src/stores/catalogQueries.d.ts b/src/stores/catalogQueries.d.ts deleted file mode 100644 index 364c43c..0000000 --- a/src/stores/catalogQueries.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type RDCatalog = { - c: string; - t: string; -} - -export type RDTheme = { - count: string; - pl: string; - th: string; -} - -export type RDSearch = { - r: string; - t: string; - d: string; - thlist: string; - thpllist: string; - weight: string; -} - diff --git a/src/stores/catalogQueries.ts b/src/stores/catalogQueries.ts deleted file mode 100644 index 34be86f..0000000 --- a/src/stores/catalogQueries.ts +++ /dev/null @@ -1,99 +0,0 @@ -export const QUERY_GET_CATALOGS = ` -PREFIX dcterms: -PREFIX dcat: - -SELECT ?c ?t -WHERE { - GRAPH ?g { - ?c a dcat:Catalog ; - dcterms:title ?t . - } -} -ORDER BY ?t` - -export function QUERY_GET_THEMES(catalogs:string[]=[]) { - return ` -PREFIX skos: -PREFIX dcterms: -PREFIX dcat: -SELECT ?th ?pl (COUNT(?th) AS ?count) -WHERE { - ?r a dcat:Resource ; - ^dcterms:hasPart ?hasPart . - ${catalogs.length > 0 ? `FILTER (?hasPart IN (${catalogs.map(cat=>`<${cat}>`).join(', ')}))` : ``} - ?r dcat:theme ?th . - ?th skos:prefLabel ?pl . -} -GROUP BY ?th ?pl -ORDER BY DESC(?count) ?pl -LIMIT 10` -} - -export function QUERY_SEARCH(catalogs:string[]=[], searchTerms:string='', themes:string[]=[], shape:string='', limit:number=0) { - return ` - PREFIX skos: - PREFIX dcat: - PREFIX dcterms: - PREFIX geo: - PREFIX geof: - - SELECT ?r ?t ?d (GROUP_CONCAT(?th;separator="\t") AS ?thlist) (GROUP_CONCAT(?thpl;separator="\t") AS ?thpllist) ?weight - WHERE { - # Only look for Resources (not spatial Datasets) - ?r a dcat:Resource . - - # Only look in selected cats - ?r ^dcterms:hasPart ?hasPart . - ${catalogs.length > 0 ? `FILTER (?hasPart IN (${catalogs.map(cat=>`<${cat}>`).join(', ')}))` : ``} - - # Weighted text search - { - SELECT DISTINCT ?r ?t ?d (SUM(?w) AS ?weight) - WHERE { - ?r a dcat:Resource . - { - ?r - dcterms:title ?t ; - dcterms:description ?d ; - . - BIND (50 AS ?w) - FILTER REGEX(?t, "^${searchTerms}$", "i") - } - UNION - { - ?r - dcterms:title ?t ; - dcterms:description ?d ; - . - BIND (10 AS ?w) - FILTER REGEX(?t, "${searchTerms}", "i") - FILTER(?d!="") - } - UNION - { - ?r - dcterms:title ?t ; - dcterms:description ?d ; - . - BIND (5 AS ?w) - FILTER REGEX(?d, "${searchTerms}", "i") - } - } - GROUP BY ?r ?t ?d ?match - ORDER BY DESC(?weight) ?t - } - - # Theme filter. each theme's IRI is a new line in the VALUES {} - ${themes.length > 0 ? `VALUES ?th { ${themes.map(theme=>`<${theme}>`).join(' ')} }` : ``} - ?r dcat:theme ?th . - ?th skos:prefLabel ?thpl . - - # Spatial Filter - ${shape != '' ? ` - ?r geo:hasBoundingBox/geo:asWKT ?wkt ; - FILTER (geof:sfOverlaps("${shape})"^^geo:wktLiteral, ?wkt))` : ``} - - } GROUP BY ?r ?t ?d ?weight - ${limit > 0 ? `LIMIT ${limit}` : ``}` - -} diff --git a/src/stores/datasetsStore.d.ts b/src/stores/datasetsStore.d.ts deleted file mode 100644 index 26642cb..0000000 --- a/src/stores/datasetsStore.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Term } from "n3" - -/** - * @typedef {Object} MatchFilter - A match object type to provide to the N3 match function to filter results - * @property {Term | null} subject - The subject term to match - * @property {Term | null} object - The object term to match - * @property {Term | null} predicate - The predicate term to match - */ -export type MatchFilter = { - subject?: Term | null, - object?: Term | null, - predicate?: Term | null -} - -/** - * @typedef {Object} SimpleQueryResult - This simple query result object is to enable a view to easily work with a result set to display, without needing to worry about getting an id/value or processing of the literal text properly - * @property {string} subject - The subject of the triple/quad - * @property {string} predicate - The predicate of the triple/quad - * @property {string} object - The object of the triple/quad, with the literal value processed if applicable - */ -export type SimpleQueryResult = { - subject: string, - predicate: string, - object: string -} - -/** - * @typedef {Object} DatasetTreeNode - The node containing the item and featureCollection list - * @property {SimpleQueryResult} item - The item being represented in the tree node - * @property {SimpleQueryResult[]} featureCollections - The child featureCollections for the given item - */ -export type DatasetTreeNode = { - item: SimpleQueryResult, - featureCollections: SimpleQueryResult[] -} - -/** - * @typedef {DatasetTreeNode[]} DatasetTree - This tree object provides a way to store the main list of datasets and child featureCollections - */ -export type DatasetTree = DatasetTreeNode[] - diff --git a/src/stores/datasetsStore.ts b/src/stores/datasetsStore.ts deleted file mode 100644 index d199fdf..0000000 --- a/src/stores/datasetsStore.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * This is a pinia store module that makes a sparql API query to get datasets and feature collection information. - * It uses the N3.js library for parsing and querying RDF data, and axios for HTTP requests. - * This store provides a method to get a full tree representation of the datasets and their child feature collections, - * and a method to get a list of datasets with their titles. - */ -import { defineStore } from 'pinia' -import axios from 'axios' -import { Util, Store, Parser, DataFactory, Quad } from "n3"; -const { namedNode } = DataFactory; -import type { DatasetTree, MatchFilter, SimpleQueryResult } from '@/stores/datasetsStore.d' -import { mapConfigKey, type MapConfig, type MapSearchConfig } from "@/types"; -import { apiBaseUrlConfigKey } from "@/types"; -import { inject } from 'vue'; -import { convertConfigTypes } from '@/util/mapSearchHelper'; - -/** - * SPARQL query to return all datasets and feature collections required - */ -const getDatasetFeatureQuery = (config: MapSearchConfig) => { - return `PREFIX geo: -PREFIX rdf: -PREFIX prez: - -CONSTRUCT {?ds a <${config.spatial.datasetClass}> ; - <${config.props.dsLabel}> ?ds_title ; - <${config.spatial.membershipRelationship}> ?fc . - ?fc a geo:FeatureCollection ; - <${config.props.fcLabel}> ?fc_title} -WHERE { ?ds a <${config.spatial.datasetClass}> ; - <${config.spatial.membershipRelationship}> ?fc ; - <${config.props.dsLabel}> ?ds_title . - ?fc <${config.props.fcLabel}> ?fc_title - }` -} - -/** - * Converts a quad/triple object from N3 into a simple result type for easier template processing - * @param {Object} quad - The quad/triple object from N3 to convert - * @returns {SimpleQueryResult} - The simple query result object - */ -export const quadToSimpleQueryResult = (quad:any):SimpleQueryResult => { - // Note: Quad type import issues, conflict with RDF library, so has an any type for now... - return { - subject: quad.subject.id, - predicate: quad.predicate.id, - object: Util.isLiteral(quad.object) ? quad.object.value : quad.object.id - } -} - -/** - * Main search store for processing the results of a search SPARQL query - */ -export const datasetsStore = defineStore({ - id: 'datasetsStore', - - /** - * The initial store state - * @returns {Object} - */ - state: () => { - // get the default map settings - const mapConfig = convertConfigTypes(inject(mapConfigKey)) as MapConfig; - const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; - //const config = inject(configKey, defaultConfig) - return { - data: [], - success: false, - store: new Store(), - loading: false, - error: null, - apiBaseUrl, - mapConfig - } - }, - - /** - * Callable actions - */ - actions: { - - /** - * Get a list of triple/quads matching the provided filter, simplified for easier template processing - * @param {MatchFilter} filter - The filter to match the triples/quads - * @returns {SimpleQueryResult[]} - The list of matching triples/quads - */ - getMatchedData(filter:MatchFilter) { - const data:SimpleQueryResult[] = [] - - const { subject=null, predicate=null, object=null } = filter - for (const item of this.store.match(subject, predicate, object)) { - data.push(quadToSimpleQueryResult(item))// {subject: item.subject.id, predicate: item.predicate.id, object: Util.isLiteral(item.object) ? item.object.value : item.object.id}) - } - return data - }, - - /** - * Get a full tree representation of the datasets and child feature collections - * @returns {DatasetTree} - The tree representation of the datasets and child feature collections - */ - getDatasetTree() { - const datasetTree:DatasetTree = [ - ] - this.getDatasets().forEach(dataset=>{ - datasetTree.push({ - item: dataset, - featureCollections: this.getFeatureCollections([dataset.subject]) - }) - }) - return datasetTree; - - }, - - - /** - * Get a list of dataset, performs a second match on the dataset subject to get the title - * @returns SimpleQueryResult array - */ - getDatasets() { - let results: SimpleQueryResult[] = [] - const matched = this.getMatchedData({object: namedNode(this.mapConfig.search.spatial.datasetClass)}) - matched.forEach(itemLink=>{ - for(const item of this.store.match(namedNode(itemLink.subject), namedNode(this.mapConfig.search.props.dsLabel))) { - results.push(quadToSimpleQueryResult(item)) - } - }) - return results - }, - - - /** - * Returns a list of SimpleQueryResult objects representing all feature collections for a given list of dataset subjects. - * If no datasetSubjects are provided, all matching feature collections will be returned. - * @param datasetSubjects - List of dataset subjects to filter feature collections by. - * @returns List of SimpleQueryResult objects representing the feature collections. - */ - getFeatureCollections(datasetSubjects:string[]):SimpleQueryResult[] { - if(datasetSubjects.length == 0) { - return [] // changed to return empty - // simply returns all matching feature collections - // return this.getMatchedData({predicate: namedNode('http://purl.org/dc/terms/title')}) - } else { - // narrow down to a specific dataset - let results:SimpleQueryResult[] = [] - datasetSubjects.forEach(subject=>{ - const matchedFCs = this.getMatchedData({predicate: namedNode(this.mapConfig.search.spatial.membershipRelationship), subject: namedNode(subject)}) - matchedFCs.forEach(itemLink=>{ - for(const item of this.store.match(namedNode(itemLink.object), namedNode(this.mapConfig.search.props.fcLabel))) { - results.push(quadToSimpleQueryResult(item)) - } - }) - }) - return results - } - }, - - /** - * Calls the spacePrez search endpoint using the query for spacePrez - */ - async fetchSpacePrezData() { - return await this.fetchData(`${this.apiBaseUrl}/sparql`, getDatasetFeatureQuery(this.mapConfig.search)); - }, - - /** - * Makes a GET request to the specified API endpoint with the provided SPARQL query. - * Parses the response with the N3 parser and creates a new N3 store from the parsed data. - * @param apiUrl - The URL of the SPARQL endpoint. - * @param query - The SPARQL query to execute. - */ - async fetchData(apiUrl:string, query:string) { - try { - // initialise the state - const parser = new Parser(); - this.loading = true - this.success = false - - // make the API call to the SPARQL endpoint - const response = await axios.get(apiUrl, { params: { query }}) - - // process the response through the N3 parser - this.data = parser.parse(response.data) - - // create a new N3 store from the parsed data - this.store = new Store(this.data) - - // successfully processed - this.success = true - this.error = null - - } catch (error:any) { - - // set the error status - this.error = error.message - this.success = false - - } finally { - // always set loading to complete - this.loading = false - } - } - } -}) diff --git a/src/stores/mapSearchStore.d.ts b/src/stores/mapSearchStore.d.ts deleted file mode 100644 index 6cc68dc..0000000 --- a/src/stores/mapSearchStore.d.ts +++ /dev/null @@ -1,11 +0,0 @@ - - -/** well known text simplified result object, as returned by mapSearch */ -export type WKTResult = { - uri: string - link: string - wkt: string - fcLabel: string - label: string - id?: string -} diff --git a/src/stores/mapSearchStore.ts b/src/stores/mapSearchStore.ts deleted file mode 100644 index a5a163c..0000000 --- a/src/stores/mapSearchStore.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * This is a pinia store module that provides search functionality for a triple store. It uses the N3.js library for parsing and querying RDF data, and axios for HTTP requests. - * The searchStore provides a method to get a full tree representation of the datasets and their child feature collections, and a method to get a list of datasets with their titles. - */ -import { defineStore } from 'pinia' -import axios from 'axios' -import { inject } from 'vue'; -import type { WKTResult } from '@/stores/mapSearchStore.d' - -import { apiBaseUrlConfigKey } from "@/types"; - -const linkPrefix = '/object?uri=' - -/** - * Main search store for processing the results of a search SPARQL query - */ -export const mapSearchStore = defineStore({ - id: 'mapSearchStore', - - /** - * The initial store state - * @returns {Object} - */ - state: () => { - const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; - - // const config = inject(configKey, defaultConfig) - return { - data: [], - success: false, - loading: false, - error: null as null | string, - apiBaseUrl: apiBaseUrl - } - }, - - /** - * Callable actions - */ - actions: { - - /** - * Calls the spacePrez search endpoint using a passed in generated query, sets the result WKT in the data object - */ - async searchMap(query:string) { - // make the API call to the SPARQL endpoint - const url = `${this.apiBaseUrl}/sparql` - this.loading = true - this.success = false - - try { - const response = await axios.get(url, { headers: {"accept": "application/sparql-results+json"}, params: { query }}) - this.data = response.data.results.bindings.filter((item:any)=>item.fc_label?.value).map((item:any)=>{ - return { - uri: item.f_uri.value, - link: `${linkPrefix}${item.f_uri.value}`, - wkt: item.wkt.value, - fcLabel: item.fc_label?.value, - label: item.f_label.value - } - }) - // successfully processed - this.success = true - this.error = null - - } catch (error:any) { - // set the error status - this.error = error.message as string - this.data = [] - this.success = false - - } finally { - // always set loading to complete - this.loading = false - } - - } - - } -}) diff --git a/src/stores/refDataStore.ts b/src/stores/refDataStore.ts deleted file mode 100644 index f37b862..0000000 --- a/src/stores/refDataStore.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * This is a pinia store module that provides search functionality for a triple store. It uses the N3.js library for parsing and querying RDF data, and axios for HTTP requests. - * The searchStore provides a method to get a full tree representation of the datasets and their child feature collections, and a method to get a list of datasets with their titles. - */ -import { defineStore } from 'pinia' -import axios from 'axios' -import { inject } from 'vue'; -import { apiBaseUrlConfigKey } from "@/types"; - - -/** - * Factory store function - */ -export function refDataStore(id:string) { - - return defineStore( - { - id, - - /** - * The initial store state - * @returns {Object} - */ - state: () => { - const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; - - // const config = inject(configKey, defaultConfig) - return { - data: [] as Array, - success: false, - loading: false, - error: null, - apiBaseUrl: apiBaseUrl - } - }, - - /** - * Callable actions - */ - actions: { - - /** - * Calls the SPARQL endpoint with the predefined query, returns a generalised object array from the json response - */ - async fetch(query:string):Promise { - // make the API call to the SPARQL endpoint - const url = `${this.apiBaseUrl}/sparql` - this.loading = true - this.success = false - - try { - const response = await axios.get(url, { headers: {"accept": "application/sparql-results+json"}, params: { query }}) - this.data = response.data.results.bindings.map((item:any)=>{ - let result:any = {} - Object.keys(item).forEach(key=>{ - result[key] = item[key].value - }) - return result - }) - // successfully processed - this.success = true - this.error = null - - } catch (error:any) { - // set the error status - this.error = error.message - this.data = [] - this.success = false - - } finally { - // always set loading to complete - this.loading = false - } - - } - - } - }) -} - diff --git a/src/util/helpers.ts b/src/util/helpers.ts index fd8a137..5b71a3e 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -32,4 +32,23 @@ export function copyToClipboard(text: string) { export function titleCase(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); -} \ No newline at end of file +} + +/** + * Sorts an array of objects alphabetically first, and then by IRI if some elements lack a title + * + * @param a + * @param b + * @returns + */ +export const sortByTitle = (a: T, b: T): number => { + if (a.title && b.title) { + return a.title.localeCompare(b.title); + } else if (a.title) { + return -1; + } else if (b.title) { + return 1; + } else { + return a.iri.localeCompare(b.iri); + } +}; \ No newline at end of file diff --git a/src/views/AltView.vue b/src/views/AltView.vue deleted file mode 100644 index 1b68cb6..0000000 --- a/src/views/AltView.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - Alternate Profiles - - - Token - Name - Formats - Description - Namespace - - - - - {{ profile.token }} - - default - - {{ profile.title }} - - - - {{ mediatypeNames[mediatype] || mediatype }} - - default - - - {{ profile.description }} - {{ profile.namespace }} - - - - - \ No newline at end of file diff --git a/src/views/ItemListView.vue b/src/views/ItemListView.vue index 6ee1a79..53147eb 100644 --- a/src/views/ItemListView.vue +++ b/src/views/ItemListView.vue @@ -4,8 +4,8 @@ import { useRoute } from "vue-router"; import { DataFactory, type Quad_Object, type Quad_Subject } from "n3"; import { useUiStore } from "@/stores/ui"; import { useRdfStore } from "@/composables/rdfStore"; -import { useGetRequest } from "@/composables/api"; -import { apiBaseUrlConfigKey, perPageConfigKey, type Breadcrumb, type ListItem, type PrezFlavour, type Profile, type ListItemExtra, type ListItemSortable } from "@/types"; +import { useApiRequest } from "@/composables/api"; +import { apiBaseUrlConfigKey, perPageConfigKey, type Breadcrumb, type PrezFlavour, type Profile, type ListItemExtra, type ListItemSortable } from "@/types"; import ItemList from "@/components/ItemList.vue"; import AdvancedSearch from "@/components/search/AdvancedSearch.vue"; import ProfilesTable from "@/components/ProfilesTable.vue"; @@ -14,7 +14,7 @@ import PaginationComponent from "@/components/PaginationComponent.vue"; import { getPrezSystemLabel } from "@/util/prezSystemLabelMapping"; import SortableTabularList from "@/components/SortableTabularList.vue"; import LoadingMessage from "@/components/LoadingMessage.vue"; -import { ensureProfiles } from "@/util/helpers"; +import { ensureProfiles, sortByTitle } from "@/util/helpers"; const { namedNode } = DataFactory; @@ -22,8 +22,8 @@ const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; const defaultPerPage = inject(perPageConfigKey) as number; const route = useRoute(); const ui = useUiStore(); +const { loading, error, apiGetRequest } = useApiRequest(); const { store, parseIntoStore, qnameToIri } = useRdfStore(); -const { data, profiles, loading, error, doRequest } = useGetRequest(); const DEFAULT_LABEL_PREDICATES = [qnameToIri("rdfs:label")]; const DEFAULT_DESC_PREDICATES = [qnameToIri("dcterms:description")]; @@ -173,7 +173,7 @@ function getBreadcrumbs(): Breadcrumb[] { function getProperties() { // find subject & handle top-level vs feature collections & features let nodeList: (Quad_Subject | Quad_Object)[] = []; - const countQuad = store.value.getQuads(null, namedNode(qnameToIri("prez:count")), null, null)[0]; + const countQuad = store.value.getQuads(null, namedNode(qnameToIri("prez:count")), null, null)[0]; // isAltView breaks here - prez:count doesn't exist count.value = parseInt(countQuad.object.value); if (TOP_LEVEL_TYPES.includes(countQuad.subject.value)) { nodeList = store.value.getSubjects(namedNode(qnameToIri("a")), countQuad.subject, null); @@ -227,18 +227,7 @@ function getProperties() { items.value.push(c); }); - // sort by title first, then by IRI if no title - items.value.sort((a, b) => { - if (a.title && b.title) { - return a.title.localeCompare(b.title); - } else if (a.title) { - return -1; - } else if (b.title) { - return 1; - } else { - return a.iri.localeCompare(b.iri); - } - }); + items.value.sort(sortByTitle); } function getIRILocalName(iri: string) { @@ -294,40 +283,41 @@ onBeforeMount(() => { } }); -onMounted(() => { +onMounted(async () => { loading.value = true; let fullPath = Object.keys(route.query).length > 0 ? (route.query.per_page ? route.fullPath : route.fullPath + `&per_page=${perPage.value}`) : route.path + `?per_page=${perPage.value}`; - ensureProfiles().then(() => { - doRequest(`${apiBaseUrl}${fullPath}`, () => { - defaultProfile.value = ui.profiles[profiles.value.find(p => p.default)!.uri]; + await ensureProfiles(); // wait for profiles to be set in Pinia + + const { data, profiles } = await apiGetRequest(fullPath); + if (data && profiles.length > 0 && !error.value) { + defaultProfile.value = ui.profiles[profiles.find(p => p.default)!.uri]; - // if specify mediatype, or profile is not default or alt, redirect to API - if ((route.query && route.query._profile) && - (route.query._mediatype || ![defaultProfile.value.token, ALT_PROFILES_TOKEN].includes(route.query._profile as string))) { - window.location.replace(`${apiBaseUrl}${route.path}?_profile=${route.query._profile}${route.query._mediatype ? `&_mediatype=${route.query._mediatype}` : ""}`); - } + // if specify mediatype, or profile is not default or alt, redirect to API + if ((route.query && route.query._profile) && + (route.query._mediatype || ![defaultProfile.value.token, ALT_PROFILES_TOKEN].includes(route.query._profile as string))) { + window.location.replace(`${apiBaseUrl}${route.path}?_profile=${route.query._profile}${route.query._mediatype ? `&_mediatype=${route.query._mediatype}` : ""}`); + } - // disable right nav if AltView - if (isAltView.value) { - ui.rightNavConfig = { enabled: false }; - } else { - ui.rightNavConfig = { enabled: true, profiles: profiles.value, currentUrl: route.path }; - } + // disable right nav if AltView + ui.rightNavConfig = { + enabled: !isAltView.value, + profiles: profiles, + currentUrl: route.path + }; - parseIntoStore(data.value); - getProperties(); + parseIntoStore(data); + getProperties(); - document.title = `${itemType.value.label} | Prez`; - ui.breadcrumbs = getBreadcrumbs(); - }); - }); + document.title = `${itemType.value.label} | Prez`; + ui.breadcrumbs = getBreadcrumbs(); + } }); - + {{ itemType.label }} A list of {{ itemType.label }}. diff --git a/src/views/PropTableView.vue b/src/views/PropTableView.vue index 33ceba5..3b1fadb 100644 --- a/src/views/PropTableView.vue +++ b/src/views/PropTableView.vue @@ -1,10 +1,11 @@ - + diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index fdf68b7..456c50d 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -3,10 +3,9 @@ import { onMounted, ref, watch, inject } from "vue"; import { useRoute } from "vue-router"; import { DataFactory } from "n3"; import { useUiStore } from "@/stores/ui"; -import { useGetRequest } from "@/composables/api"; +import { useApiRequest } from "@/composables/api"; import { useRdfStore } from "@/composables/rdfStore"; -import { apiBaseUrlConfigKey } from "@/types"; -import type { WKTResult } from "@/stores/mapSearchStore.d"; +import type { WKTResult } from "@/components/MapClient.d"; import AdvancedSearch from "@/components/search/AdvancedSearch.vue"; import LoadingMessage from "@/components/LoadingMessage.vue"; import ErrorMessage from "@/components/ErrorMessage.vue"; @@ -27,10 +26,9 @@ interface SearchResult { source: string; }; -const apiBaseUrl = inject(apiBaseUrlConfigKey) as string; const route = useRoute(); const ui = useUiStore(); -const { data, loading, error, doRequest } = useGetRequest(); +const { loading, error, apiGetRequest } = useApiRequest(); // main request const { store, parseIntoStore, qnameToIri } = useRdfStore(); const query = ref(route.query as { [key: string]: string }); @@ -38,13 +36,15 @@ const results = ref([]); const searchMapRef = ref() const geoResults = ref([]); -function getResults() { +async function getResults() { if (route.query && route.query.term) { results.value = []; geoResults.value = []; - doRequest(`${apiBaseUrl}${route.fullPath}`, () => { - parseIntoStore(data.value); + const { data } = await apiGetRequest(route.fullPath); + + if (data && !error.value) { + parseIntoStore(data); const labelPredicateIris = LABEL_PREDICATES.map(p => qnameToIri(p)); store.value.forSubjects(subject => { @@ -81,23 +81,23 @@ function getResults() { }); } }, namedNode(qnameToIri("a")), namedNode(qnameToIri("prez:SearchResult")), null); - }); + } } } -watch(() => route.query, (newValue, oldValue) => { +watch(() => route.query, async (newValue, oldValue) => { if (Object.keys(newValue).length > 0 && newValue !== oldValue) { - getResults(); + await getResults(); } }, { deep: true }); -onMounted(() => { +onMounted(async () => { ui.rightNavConfig = { enabled: false }; document.title = "Advanced Search | Prez"; ui.pageHeading = { name: "Prez", url: "/" }; ui.breadcrumbs = [{ name: "Advanced Search", url: "/search" }]; if (Object.keys(route.query).length > 0) { - getResults(); + await getResults(); } });
{{ sparqlQueryRef.trim() }}
{{ query.trim() }}
A list of {{ itemType.label }}.