diff --git a/src/api/resolver/types.ts b/src/api/resolver/types.ts index 7129b5323..6ab101db9 100644 --- a/src/api/resolver/types.ts +++ b/src/api/resolver/types.ts @@ -4,8 +4,10 @@ export interface IADSApiResolverParams { } export interface IADSApiResolverResponse { - action: string; - links: { + action: string; // display, redirect + link?: string; + link_type?: string; // TODO: https://ui.adsabs.harvard.edu/help/api/api-docs.html#tag--resolver + links?: { count: number; link_type: string; records: [ diff --git a/src/components/FeedbackForms/MissingRecord/RecordPanel.tsx b/src/components/FeedbackForms/MissingRecord/RecordPanel.tsx index df9658653..bbc724892 100644 --- a/src/components/FeedbackForms/MissingRecord/RecordPanel.tsx +++ b/src/components/FeedbackForms/MissingRecord/RecordPanel.tsx @@ -24,7 +24,7 @@ import { getDiffSections, getDiffString, processFormValues } from './DiffUtil'; import { KeywordsField } from './KeywordsField'; import { PubDateField } from './PubDateField'; import { ReferencesField } from './ReferencesField'; -import { DiffSection, FormValues, IAuthor, IKeyword, IReference } from './types'; +import { DiffSection, FormValues, IAuthor, IKeyword, IReference, IResourceUrl } from './types'; import { UrlsField } from './UrlsField'; import { DiffSectionPanel } from './DiffSectionPanel'; import { AxiosError } from 'axios'; @@ -32,12 +32,14 @@ import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { SimpleLink } from '@/components/SimpleLink'; import { PreviewModal } from '@/components/FeedbackForms'; -import { IResourceUrl, useGetResourceLinks } from '@/lib/useGetResourceLinks'; import { useGetUserEmail } from '@/lib/useGetUserEmail'; import { parsePublicationDate } from '@/utils/common/parsePublicationDate'; import type { Database, IDocsEntity } from '@/api/search/types'; import type { IFeedbackParams } from '@/api/feedback/types'; import { useGetSingleRecord } from '@/api/search/search'; +import { useResolverQuery } from '@/api/resolver/resolver'; +import { IADSApiResolverResponse } from '@/api/resolver/types'; +import { transformUrl } from './UrlUtil'; const collections: { value: Database; label: string }[] = [ { value: 'astronomy', label: 'Astronomy and Astrophysics' }, @@ -182,10 +184,7 @@ export const RecordPanel = ({ isSuccess: urlsIsSuccess, isFetching: urlsIsFetching, refetch: urlsRefetch, - } = useGetResourceLinks({ - identifier: getValues('bibcode'), - options: { enabled: false }, - }); + } = useResolverQuery({ bibcode: getValues('bibcode'), link_type: 'ESOURCE' }, { enabled: false }); // when this tab is focused, set focus on name field useEffect(() => { @@ -358,9 +357,18 @@ export const RecordPanel = ({ }; // when url data is fetch, set then in form values - const handleUrlsLoaded = (urlsData: IResourceUrl[]) => { + const handleUrlsLoaded = (urlsData: IADSApiResolverResponse) => { + const urls = + urlsData.action === 'display' && urlsData.links?.records + ? urlsData.links.records.map((r) => decodeURIComponent(r.url)) + : urlsData.action === 'redirect' && urlsData.link + ? [decodeURIComponent(urlsData.link)] + : []; + + // tranform urls to IResourceUrl, and remove any nulls (invalid urls) + const transformedUrls = urls.map((url) => transformUrl(url)).filter((tu) => tu !== null); if (!isNew) { - setRecordOriginalFormValues((prev) => ({ ...prev, urls: urlsData })); + setRecordOriginalFormValues((prev) => ({ ...prev, urls: transformedUrls })); } }; diff --git a/src/components/FeedbackForms/MissingRecord/UrlUtil.ts b/src/components/FeedbackForms/MissingRecord/UrlUtil.ts new file mode 100644 index 000000000..45e6cf17e --- /dev/null +++ b/src/components/FeedbackForms/MissingRecord/UrlUtil.ts @@ -0,0 +1,34 @@ +import { ResourceUrlType, IResourceUrl } from './types'; + +const URL_TYPE_MAP: Record = { + arxiv: 'arXiv', + pdf: 'PDF', + doi: 'DOI', + html: 'HTML', +}; + +const RESOURCE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|svg|css|js|ico|woff2?|ttf|otf|eot|map|mp4|webm)(\?|$)/i; + +const isValidUrl = (url: string) => { + try { + const tempUrl = new URL(url); + return ['http:', 'https:'].includes(tempUrl.protocol); + } catch { + return false; + } +}; + +/** + * Transforms a URL into a structured resource link object. + * @param url + */ +export const transformUrl = (url: string) => { + if (!url || typeof url !== 'string' || !isValidUrl(url) || RESOURCE_EXT_REGEX.test(url)) { + return null; + } + + const normalizedUrl = url.toLowerCase().replace(/\/$/, ''); + const urlType = Object.keys(URL_TYPE_MAP).find((key) => normalizedUrl.includes(key)); + const type = urlType ? URL_TYPE_MAP[urlType] : 'HTML'; + return { type, url: normalizedUrl } as IResourceUrl; +}; diff --git a/src/components/FeedbackForms/MissingRecord/UrlsField.tsx b/src/components/FeedbackForms/MissingRecord/UrlsField.tsx index 5d71b3eba..3718df63c 100644 --- a/src/components/FeedbackForms/MissingRecord/UrlsField.tsx +++ b/src/components/FeedbackForms/MissingRecord/UrlsField.tsx @@ -5,8 +5,7 @@ import { Select, SelectOption } from '@/components/Select'; import { ChangeEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'; import { useFieldArray } from 'react-hook-form'; import { SelectInstance } from 'react-select'; -import { FormValues } from './types'; -import { IResourceUrl, ResourceUrlType, resourceUrlTypes } from '@/lib/useGetResourceLinks'; +import { FormValues, IResourceUrl, ResourceUrlType, resourceUrlTypes } from './types'; import { useIsClient } from '@/lib/useIsClient'; export const UrlsField = () => { diff --git a/src/components/FeedbackForms/MissingRecord/types.ts b/src/components/FeedbackForms/MissingRecord/types.ts index 22c658588..832040831 100644 --- a/src/components/FeedbackForms/MissingRecord/types.ts +++ b/src/components/FeedbackForms/MissingRecord/types.ts @@ -11,7 +11,7 @@ export interface IAuthor { export const referenceTypes = ['Raw Text', 'DOI', 'Bibcode'] as const; -export type ReferenceType = typeof referenceTypes[number]; +export type ReferenceType = (typeof referenceTypes)[number]; export interface IReference { type: ReferenceType; @@ -46,3 +46,13 @@ export type DiffSection = { type: 'array' | 'text'; newValue: string; }; + +// URLs (ESOURCE) types and interfaces + +export const resourceUrlTypes = ['arXiv', 'PDF', 'DOI', 'HTML', 'Other'] as const; + +export type ResourceUrlType = (typeof resourceUrlTypes)[number]; +export interface IResourceUrl { + type: ResourceUrlType; + url: string; +} diff --git a/src/components/__tests__/UrlUtil.test.tsx b/src/components/__tests__/UrlUtil.test.tsx new file mode 100644 index 000000000..39b8f1dc2 --- /dev/null +++ b/src/components/__tests__/UrlUtil.test.tsx @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { transformUrl } from '@/components/FeedbackForms/MissingRecord/UrlUtil'; + +describe('resourceLinks', () => { + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + test('transformUrl filters known static/resource files', () => { + expect(transformUrl('https://example.com/image.jpg')).toBeNull(); + expect(transformUrl('https://example.com/script.js')).toBeNull(); + }); + + test('transformUrl assigns correct type', () => { + expect(transformUrl('https://arxiv.org/pdf/foo.pdf')).toEqual({ + type: 'arXiv', + url: 'https://arxiv.org/pdf/foo.pdf', + }); + + expect(transformUrl('https://doi.org/10.1234')).toEqual({ + type: 'DOI', + url: 'https://doi.org/10.1234', + }); + + expect(transformUrl('https://example.com/anything')).toEqual({ + type: 'HTML', + url: 'https://example.com/anything', + }); + }); + + test('transformUrl returns null for empty or invalid URLs', () => { + expect(transformUrl('')).toBeNull(); + expect(transformUrl('invalid-url')).toBeNull(); + expect(transformUrl('https://example.com/image.png')).toBeNull(); + }); + + test('transformUrl normalizes URLs', () => { + expect(transformUrl('https://example.com/')).toEqual({ + type: 'HTML', + url: 'https://example.com', + }); + expect(transformUrl('https://example.com/page.HTML')).toEqual({ + type: 'HTML', + url: 'https://example.com/page.html', + }); + }); +}); diff --git a/src/lib/__tests__/useGetResourceLinks.test.ts b/src/lib/__tests__/useGetResourceLinks.test.ts deleted file mode 100644 index fcb04177a..000000000 --- a/src/lib/__tests__/useGetResourceLinks.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { fetchUrl, transformUrl } from '../useGetResourceLinks'; - -const htmlWithLinks = ` - -
-
- -
-
-
-

links for 2023ApJ

- -
- - -`; - -const expectedUrls = [ - { type: 'arXiv', url: 'https://arxiv.org/abs/2310.03851' }, - { type: 'arXiv', url: 'https://arxiv.org/pdf/2310.03851' }, - { type: 'DOI', url: 'https://doi.org/10.3847/1538-4357/acffbd' }, - { type: 'PDF', url: 'https://example.com/document.pdf' }, -]; - -describe('resourceLinks', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); - - test('transformUrl filters known static/resource files', () => { - expect(transformUrl('https://example.com/image.jpg')).toBeNull(); - expect(transformUrl('https://example.com/script.js')).toBeNull(); - }); - - test('transformUrl assigns correct type', () => { - expect(transformUrl('https://arxiv.org/pdf/foo.pdf')).toEqual({ - type: 'arXiv', - url: 'https://arxiv.org/pdf/foo.pdf', - }); - - expect(transformUrl('https://doi.org/10.1234')).toEqual({ - type: 'DOI', - url: 'https://doi.org/10.1234', - }); - - expect(transformUrl('https://example.com/anything')).toEqual({ - type: 'HTML', - url: 'https://example.com/anything', - }); - }); - - test('transformUrl returns null for empty or invalid URLs', () => { - expect(transformUrl('')).toBeNull(); - expect(transformUrl('invalid-url')).toBeNull(); - expect(transformUrl('https://example.com/image.png')).toBeNull(); - }); - - test('transformUrl normalizes URLs', () => { - expect(transformUrl('https://example.com/')).toEqual({ - type: 'HTML', - url: 'https://example.com', - }); - expect(transformUrl('https://example.com/page.HTML')).toEqual({ - type: 'HTML', - url: 'https://example.com/page.html', - }); - }); - - test('fetchUrl returns deduplicated transformed links', async () => { - const mockFetch = global.fetch as unknown as ReturnType; - mockFetch.mockResolvedValueOnce({ - ok: true, - redirected: false, - text: () => Promise.resolve(htmlWithLinks), - }); - - const result = await fetchUrl('fake-id'); - expect(result).toEqual(expectedUrls); - }); - - test('fetchUrl returns empty list if input has no valid links', async () => { - const mockFetch = global.fetch as unknown as ReturnType; - mockFetch.mockResolvedValueOnce({ - ok: true, - redirected: false, - text: () => Promise.resolve('
'), - }); - - const result = await fetchUrl('fake-id'); - expect(result).toEqual([]); - }); -}); - -describe('Redirected response', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); - - test('fetchUrl detects browser-followed redirect via res.redirected', async () => { - const mockFetch = global.fetch as unknown as ReturnType; - mockFetch.mockResolvedValueOnce({ - ok: true, - redirected: true, - url: 'https://doi.org/10.1234/foo', - text: () => Promise.resolve(''), - }); - - const result = await fetchUrl('test-id'); - - expect(result).toEqual([ - { - type: 'DOI', - url: 'https://doi.org/10.1234/foo', - }, - ]); - }); - - test('fetchUrl returns empty if redirected URL is not valid', async () => { - const mockFetch = global.fetch as unknown as ReturnType; - mockFetch.mockResolvedValueOnce({ - ok: true, - redirected: true, - url: '', - text: () => Promise.resolve(''), - }); - - const result = await fetchUrl('test-id'); - expect(result).toEqual([]); - }); -}); - -describe('Error responses', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); - - test('fetchUrl returns empty list on 404', async () => { - const mockFetch = global.fetch as unknown as ReturnType; - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => - Promise.resolve( - '

The requested resource does not exist

' + - '', - ), - }); - - const result = await fetchUrl('bad-bibcode'); - expect(result).toEqual([]); - }); - - test('fetchUrl returns empty list on 500', async () => { - const mockFetch = global.fetch as unknown as ReturnType; - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve('Internal Server Error'), - }); - - const result = await fetchUrl('error-bibcode'); - expect(result).toEqual([]); - }); -}); diff --git a/src/lib/useGetResourceLinks.ts b/src/lib/useGetResourceLinks.ts deleted file mode 100644 index 6e5a3321a..000000000 --- a/src/lib/useGetResourceLinks.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query'; -import { isValidURL } from '@/utils/common/isValidURL'; - -export const resourceUrlTypes = ['arXiv', 'PDF', 'DOI', 'HTML', 'Other'] as const; - -export type ResourceUrlType = (typeof resourceUrlTypes)[number]; - -export interface IResourceUrl { - type: ResourceUrlType; - url: string; -} - -interface IUseResourceLinksProps { - identifier: string; - options?: UseQueryOptions; -} - -const URL_TYPE_MAP: Record = { - arxiv: 'arXiv', - pdf: 'PDF', - doi: 'DOI', - html: 'HTML', -}; - -const RESOURCE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|svg|css|js|ico|woff2?|ttf|otf|eot|map|mp4|webm)(\?|$)/i; - -/** - * Transforms a URL into a structured resource link object. - * @param url - */ -export const transformUrl = (url: string) => { - if (!url || typeof url !== 'string' || !isValidURL(url) || RESOURCE_EXT_REGEX.test(url)) { - return null; - } - - const normalizedUrl = url.toLowerCase().replace(/\/$/, ''); - const urlType = Object.keys(URL_TYPE_MAP).find((key) => normalizedUrl.includes(key)); - const type = urlType ? URL_TYPE_MAP[urlType] : 'HTML'; - return { type, url: normalizedUrl } as IResourceUrl; -}; - -/** - * Fetches resource links for a given identifier. - * @param identifier - */ -export const fetchUrl = async (identifier: string): Promise => { - const url = `/link_gateway/${encodeURIComponent(identifier)}/ESOURCE`; - const res = await fetch(url); - - if (!res.ok) { - return []; - } - - // single-link resources redirect directly to the target URL - if (res.redirected) { - const transformedUrl = transformUrl(res.url); - return transformedUrl ? [transformedUrl] : []; - } - - const raw = await res.text(); - if (!raw) { - return []; - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(raw, 'text/html'); - const links = doc.querySelectorAll('.list-group-item a'); - - const seen = new Set(); - const output: IResourceUrl[] = []; - - for (const link of links) { - const href = link.getAttribute('href'); - if (!href) { - continue; - } - const transformed = transformUrl(href); - if (transformed && !seen.has(transformed.url)) { - seen.add(transformed.url); - output.push(transformed); - } - } - - return output; -}; - -export const useGetResourceLinks = ({ identifier, options }: IUseResourceLinksProps) => { - return useQuery(['resourceLink', identifier], () => fetchUrl(identifier), options); -};