diff --git a/src/features/quote/components/AuthorInfo.jsx b/src/features/quote/components/AuthorInfo.jsx index fa2bef0b..d2503bc1 100644 --- a/src/features/quote/components/AuthorInfo.jsx +++ b/src/features/quote/components/AuthorInfo.jsx @@ -20,17 +20,22 @@ export default function AuthorInfo({ const t = useT(); const showAuthorImg = localStorage.getItem('authorImg') !== 'false'; const hasLink = authorOccupation !== 'Unknown' && authorlink !== null; - const trimmedLicense = authorimglicense?.substring(0, 40) + - (authorimglicense?.length > 40 ? '…' : ''); return (
- {showAuthorImg && ( -
- {!authorimg && authorimg !== undefined && } -
- )} + {showAuthorImg && + (authorimglicense && authorimg ? ( + +
+ {!authorimg && authorimg !== undefined && } +
+
+ ) : ( +
+ {!authorimg && authorimg !== undefined && } +
+ ))} {author ? (
@@ -38,11 +43,6 @@ export default function AuthorInfo({ {authorOccupation && authorOccupation !== 'Unknown' && ( {authorOccupation} )} - {authorimglicense && ( - - {trimmedLicense} - - )}
) : (
diff --git a/src/features/quote/hooks/useQuoteLoader.js b/src/features/quote/hooks/useQuoteLoader.js index f3afdc61..96804e66 100644 --- a/src/features/quote/hooks/useQuoteLoader.js +++ b/src/features/quote/hooks/useQuoteLoader.js @@ -2,21 +2,13 @@ import { useCallback } from 'react'; import variables from 'config/variables'; import offline_quotes from '../offline_quotes.json'; import { shouldUpdateByFrequency, resetStartTime } from 'utils/frequencyManager'; -import { safeParseJSON } from 'utils/jsonStorage'; -import { useCachedFetch } from 'hooks/useCachedFetch'; import { QueueManager } from 'utils/backgroundQueue'; +import { fetchAuthorFromWikidata } from 'utils/wikidata/wikidataAuthorFetcher'; /** * Custom hook for loading quote data from various sources */ export function useQuoteLoader(updateQuote) { - // Initialize cache hook for author images - const { fetchWithCache: fetchAuthorImage } = useCachedFetch({ - cacheKey: 'authorImageCache', - timestampKey: 'authorImageCacheTimestamp', - expiryDays: 7, - }); - const getAuthorLink = useCallback((author) => { return localStorage.getItem('authorLink') === 'false' || author === 'Unknown' ? null @@ -25,78 +17,75 @@ export function useQuoteLoader(updateQuote) { .join('_')}`; }, []); + /** + * Fetch author data from Wikidata (image, occupation, description, etc.) + * @param {string} author - Author name + * @returns {Promise} Author data including image, occupation, and license info + */ + const getAuthorDataFromWikidata = useCallback( + async (author) => { + if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') { + return { + authorimg: null, + authorimglicense: null, + authorOccupation: null, + authorlink: null, + }; + } + + try { + const lang = variables.languagecode.split('_')[0]; + const wikidataResult = await fetchAuthorFromWikidata(author, lang); + + if (!wikidataResult) { + return { + authorimg: null, + authorimglicense: null, + authorOccupation: null, + authorlink: getAuthorLink(author), // Fallback to Wikipedia link + }; + } + + return { + authorimg: wikidataResult.imageUrl || null, + authorimglicense: wikidataResult.imageLicense || null, + authorOccupation: wikidataResult.occupation || null, + authorlink: wikidataResult.wikipediaLink || getAuthorLink(author), + }; + } catch (e) { + console.error('Error fetching author data from Wikidata:', e); + return { + authorimg: null, + authorimglicense: null, + authorOccupation: null, + authorlink: getAuthorLink(author), + }; + } + }, + [getAuthorLink], + ); + const stripHTML = useCallback((html) => { const tmpdoc = new DOMParser().parseFromString(html, 'text/html'); return tmpdoc.body.textContent || ''; }, []); - const getAuthorImg = useCallback( + // Get cached author data or fetch from Wikidata and cache it + const getCachedAuthorData = useCallback( async (author) => { if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') { - return { authorimg: null, authorimglicense: null }; + return { + authorimg: null, + authorimglicense: null, + authorOccupation: null, + authorlink: null, + }; } - try { - const lang = variables.languagecode.split('_')[0]; - const pageData = await fetch( - `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${author}&origin=*&prop=pageimages&format=json&pithumbsize=100`, - ).then((res) => res.json()); - - const authorPage = Object.values(pageData.query.pages)[0]; - const authorimg = authorPage?.thumbnail?.source; - - if (!authorimg) { - return { authorimg: null, authorimglicense: null }; - } - - const licenseData = await fetch( - `https://${lang}.wikipedia.org/w/api.php?action=query&prop=imageinfo&iiprop=extmetadata&titles=File:${authorPage.pageimage}&origin=*&format=json`, - ).then((res) => res.json()); - - const licensePage = Object.values(licenseData.query.pages)[0]; - const metadata = licensePage?.imageinfo?.[0]?.extmetadata; - const license = metadata?.LicenseShortName; - const photographer = stripHTML( - metadata?.Attribution?.value || metadata?.Artist?.value || '', - ) - .replace(/©\s/, '') - .replace(/ \(talk\)/, ''); - - if (!photographer) { - return { authorimg: null, authorimglicense: null }; - } - - if (license?.value === 'Public domain') { - return { authorimg, authorimglicense: null }; - } - - const authorimglicense = photographer - ? `© ${photographer}${license ? `. ${license.value}` : ''}`.replace(/copyright\s/i, '') - : license?.value || null; - - return { authorimg, authorimglicense }; - } catch (e) { - console.error(e); - return { authorimg: null, authorimglicense: null }; - } + // Wikidata fetcher has its own caching mechanism + return await getAuthorDataFromWikidata(author); }, - [stripHTML], - ); - - // Get cached author image or fetch and cache it - const getCachedAuthorImg = useCallback( - async (author) => { - if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') { - return { authorimg: null, authorimglicense: null }; - } - - return await fetchAuthorImage(author, async () => { - const result = await getAuthorImg(author); - // Return only if we have actual data - return result.authorimg || result.authorimglicense ? result : null; - }); - }, - [fetchAuthorImage, getAuthorImg], + [getAuthorDataFromWikidata], ); // Select raw quote data without author images (non-blocking) @@ -133,7 +122,7 @@ export function useQuoteLoader(updateQuote) { quote: `"${selected.quote}"`, author: selected.author, authorlink: getAuthorLink(selected.author), - needsAuthorImg: true, + needsAuthorData: true, noQuote: false, }; } @@ -145,7 +134,7 @@ export function useQuoteLoader(updateQuote) { quote: '"' + quote.quote + '"', author: quote.author, authorlink: getAuthorLink(quote.author), - needsAuthorImg: false, // Offline quotes don't get author images + needsAuthorData: false, // Offline quotes don't get author data }; } @@ -167,7 +156,7 @@ export function useQuoteLoader(updateQuote) { quote: '"' + quote.quote + '"', author: quote.author, authorlink: getAuthorLink(quote.author), - needsAuthorImg: false, + needsAuthorData: false, }; } @@ -178,51 +167,54 @@ export function useQuoteLoader(updateQuote) { return { quote: `"${data.quote}"`, author: displayAuthor, - realAuthor: hasAuthor ? data.author : null, // For Wikipedia lookup + realAuthor: hasAuthor ? data.author : null, // For Wikidata lookup authorlink: hasAuthor ? getAuthorLink(data.author) : null, fallbackauthorimg: data.fallbackauthorimg, - needsAuthorImg: hasAuthor && !data.noAuthorImg, + needsAuthorData: hasAuthor && !data.noAuthorImg, }; }, [getAuthorLink]); - // Fetch complete quote data including author image (for prefetching) + // Fetch complete quote data including author data (for prefetching) const fetchCompleteQuote = useCallback( async (quoteData) => { if (!quoteData || quoteData.noQuote) return quoteData; - // If author image is needed, fetch it - if (quoteData.needsAuthorImg) { + // If author data is needed, fetch it + if (quoteData.needsAuthorData) { const authorToLookup = quoteData.realAuthor || quoteData.author; try { - const authorImgData = await getCachedAuthorImg(authorToLookup); + const authorData = await getCachedAuthorData(authorToLookup); - // For quote packs, use fallback if Wikipedia fails - if (!authorImgData.authorimg && quoteData.fallbackauthorimg) { + // For quote packs, use fallback if Wikidata fails + if (!authorData.authorimg && quoteData.fallbackauthorimg) { return { ...quoteData, authorimg: quoteData.fallbackauthorimg, authorimglicense: null, + authorOccupation: authorData.authorOccupation || null, + authorlink: authorData.authorlink || quoteData.authorlink, }; } return { ...quoteData, - ...authorImgData, + ...authorData, }; } catch (e) { - console.error('Failed to fetch author image:', e); - // Return quote data with fallback or no image + console.error('Failed to fetch author data:', e); + // Return quote data with fallback or no data return { ...quoteData, authorimg: quoteData.fallbackauthorimg || null, authorimglicense: null, + authorOccupation: null, }; } } return quoteData; }, - [getCachedAuthorImg], + [getCachedAuthorData], ); const doOffline = useCallback(() => { @@ -233,6 +225,7 @@ export function useQuoteLoader(updateQuote) { author: quote.author, authorlink: getAuthorLink(quote.author), authorimg: '', + authorOccupation: null, }); }, [updateQuote, getAuthorLink]); @@ -243,10 +236,6 @@ export function useQuoteLoader(updateQuote) { if (localStorage.getItem('quoteQueue') === null) { localStorage.setItem('quoteQueue', JSON.stringify([])); } - if (localStorage.getItem('authorImageCache') === null) { - localStorage.setItem('authorImageCache', JSON.stringify({})); - localStorage.setItem('authorImageCacheTimestamp', Date.now().toString()); - } if (localStorage.getItem('quotePrefetchEnabled') === null) { localStorage.setItem('quotePrefetchEnabled', 'true'); } @@ -278,15 +267,16 @@ export function useQuoteLoader(updateQuote) { authorlink: getAuthorLink(author), authorimg: null, // Will be updated asynchronously authorimglicense: null, + authorOccupation: null, }); - // Fetch author image asynchronously (non-blocking) - getCachedAuthorImg(author).then((authorimgdata) => { + // Fetch author data asynchronously (non-blocking) + getCachedAuthorData(author).then((authorData) => { updateQuote({ quote, author, - authorlink: getAuthorLink(author), - ...authorimgdata, + authorlink: authorData.authorlink || getAuthorLink(author), + ...authorData, }); }); @@ -308,28 +298,33 @@ export function useQuoteLoader(updateQuote) { return updateQuote({ noQuote: true }); } - // For non-queue fetch, display immediately then enhance with author image - if (rawQuote.needsAuthorImg) { + // For non-queue fetch, display immediately then enhance with author data + if (rawQuote.needsAuthorData) { // Display quote text immediately updateQuote({ ...rawQuote, authorimg: rawQuote.fallbackauthorimg || null, authorimglicense: null, + authorOccupation: null, }); - // Fetch author image asynchronously - const authorToLookup = rawQuote.realAuthor || rawQuote.author; - getCachedAuthorImg(authorToLookup).then((authorImgData) => { - updateQuote({ - ...rawQuote, - ...(authorImgData.authorimg - ? authorImgData - : { - authorimg: rawQuote.fallbackauthorimg || null, - authorimglicense: null, - }), + // Fetch author data asynchronously (after a brief delay to ensure snappy initial render) + setTimeout(() => { + const authorToLookup = rawQuote.realAuthor || rawQuote.author; + getCachedAuthorData(authorToLookup).then((authorData) => { + updateQuote({ + ...rawQuote, + ...(authorData.authorimg + ? authorData + : { + authorimg: rawQuote.fallbackauthorimg || null, + authorimglicense: null, + authorOccupation: authorData.authorOccupation || null, + authorlink: authorData.authorlink || rawQuote.authorlink, + }), + }); }); - }); + }, 0); quoteData = rawQuote; // Use for prefetch reference } else { @@ -339,7 +334,7 @@ export function useQuoteLoader(updateQuote) { } // Step 3: Display current quote (if from queue, it's already complete) - if (cachedQueue.length > 0 || !quoteData.needsAuthorImg) { + if (cachedQueue.length > 0 || !quoteData.needsAuthorData) { updateQuote(quoteData); } @@ -351,28 +346,31 @@ export function useQuoteLoader(updateQuote) { console.warn('Could not save currentQuote to localStorage:', e); } - // Step 5: Prefetch next 3 quotes asynchronously (non-blocking) + // Step 5: Prefetch next 3 quotes asynchronously (non-blocking, deferred) if (queueManager.needsPrefetch() && !offline) { const spaceNeeded = queueManager.getSpaceNeeded(); - Promise.all( - Array.from({ length: spaceNeeded }, async () => { - const rawQuote = selectQuoteData(); - if (rawQuote.noQuote) return null; - return await fetchCompleteQuote(rawQuote); - }), - ) - .then((newQuotes) => { - const validQuotes = newQuotes.filter(Boolean); - if (validQuotes.length > 0) { - queueManager.push(validQuotes); - } - }) - .catch((error) => { - console.error('Failed to prefetch quotes:', error); - }); + // Defer prefetch to avoid blocking initial quote display + setTimeout(() => { + Promise.all( + Array.from({ length: spaceNeeded }, async () => { + const rawQuote = selectQuoteData(); + if (rawQuote.noQuote) return null; + return await fetchCompleteQuote(rawQuote); + }), + ) + .then((newQuotes) => { + const validQuotes = newQuotes.filter(Boolean); + if (validQuotes.length > 0) { + queueManager.push(validQuotes); + } + }) + .catch((error) => { + console.error('Failed to prefetch quotes:', error); + }); + }, 100); // Small delay to ensure current quote renders first } - }, [updateQuote, getAuthorLink, getCachedAuthorImg, selectQuoteData, fetchCompleteQuote]); + }, [updateQuote, getAuthorLink, getCachedAuthorData, selectQuoteData, fetchCompleteQuote]); return { getQuote, diff --git a/src/utils/wikidata/wikidataAuthorFetcher.js b/src/utils/wikidata/wikidataAuthorFetcher.js new file mode 100644 index 00000000..b7eedeb4 --- /dev/null +++ b/src/utils/wikidata/wikidataAuthorFetcher.js @@ -0,0 +1,286 @@ +/** + * Wikidata API integration for fetching author information + * Provides author images, occupations, and metadata with multilingual support + */ + +/* global URLSearchParams */ + +import { safeParseJSON } from '../jsonStorage'; + +const WIKIDATA_API = 'https://www.wikidata.org/w/api.php'; +const CACHE_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** + * Get cached author data from localStorage + * @param {string} authorName - Author name + * @param {string} language - Language code (e.g., 'en', 'fr') + * @returns {object|null} Cached author data or null + */ +function getCachedAuthorData(authorName, language) { + const cacheKey = `wikidataAuthor_${authorName.toLowerCase()}_${language}`; + const cached = safeParseJSON(cacheKey); + + if (!cached) { + return null; + } + + // Check if cache has expired + const now = Date.now(); + if (cached.timestamp && now - cached.timestamp > CACHE_EXPIRY) { + localStorage.removeItem(cacheKey); + return null; + } + + return cached.data; +} + +/** + * Cache author data in localStorage + * @param {string} authorName - Author name + * @param {string} language - Language code + * @param {object} data - Author data to cache + */ +function cacheAuthorData(authorName, language, data) { + const cacheKey = `wikidataAuthor_${authorName.toLowerCase()}_${language}`; + localStorage.setItem( + cacheKey, + JSON.stringify({ + timestamp: Date.now(), + data, + }), + ); +} + +/** + * Search for an author entity in Wikidata by name + * @param {string} authorName - Author name to search + * @param {string} language - Language code for search (default: 'en') + * @returns {Promise} Wikidata entity ID (e.g., 'Q937') or null + */ +async function searchAuthorEntity(authorName, language = 'en') { + try { + const params = new URLSearchParams({ + action: 'wbsearchentities', + search: authorName, + language: language, + limit: '1', + format: 'json', + origin: '*', + type: 'item', + }); + + const response = await fetch(`${WIKIDATA_API}?${params}`); + const data = await response.json(); + + if (data.search && data.search.length > 0) { + return data.search[0].id; + } + + return null; + } catch (error) { + console.error('Error searching Wikidata author:', error); + return null; + } +} + +/** + * Extract occupation labels from Wikidata claims + * @param {object} claims - Wikidata claims object + * @param {string} language - Preferred language + * @returns {Promise} Formatted occupation string or null + */ +async function extractOccupations(claims, language) { + if (!claims || !claims.P106) { + return null; + } + + try { + const occupationIds = claims.P106.filter((claim) => claim.mainsnak?.datavalue?.value?.id) + .map((claim) => claim.mainsnak.datavalue.value.id) + .slice(0, 1); // Only take the first/primary occupation + + if (occupationIds.length === 0) { + return null; + } + + // Fetch occupation label + const params = new URLSearchParams({ + action: 'wbgetentities', + ids: occupationIds[0], + props: 'labels', + languages: `${language}|en`, + format: 'json', + origin: '*', + }); + + const response = await fetch(`${WIKIDATA_API}?${params}`); + const data = await response.json(); + + const entity = data.entities?.[occupationIds[0]]; + if (!entity?.labels) return null; + + // Try preferred language first, then English + const label = entity.labels[language]?.value || entity.labels['en']?.value; + return label || null; + } catch (error) { + console.error('Error extracting occupations:', error); + return null; + } +} + +/** + * Get image URL using Wikimedia Commons API (more reliable than MD5 hashing) + * @param {string} filename - Image filename from Wikidata + * @returns {Promise} Image URL or null + */ +async function getCommonsImageUrl(filename) { + try { + const params = new URLSearchParams({ + action: 'query', + titles: `File:${filename}`, + prop: 'imageinfo', + iiprop: 'url', + iiurlwidth: '300', + format: 'json', + origin: '*', + }); + + const response = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`); + const data = await response.json(); + + const pages = data.query?.pages; + if (!pages) return null; + + const pageId = Object.keys(pages)[0]; + const imageUrl = pages[pageId]?.imageinfo?.[0]?.thumburl || pages[pageId]?.imageinfo?.[0]?.url; + + return imageUrl || null; + } catch (error) { + console.error('Error fetching Commons image URL:', error); + return null; + } +} + +/** + * Extract Wikipedia link from Wikidata sitelinks + * @param {object} sitelinks - Wikidata sitelinks object + * @param {string} language - Preferred language + * @returns {string|null} Wikipedia URL or null + */ +function extractWikipediaLink(sitelinks, language) { + if (!sitelinks) { + return null; + } + + // Try preferred language wiki first + const preferredSite = `${language}wiki`; + if (sitelinks[preferredSite]) { + return sitelinks[preferredSite].url; + } + + // Fall back to English Wikipedia + if (sitelinks.enwiki) { + return sitelinks.enwiki.url; + } + + return null; +} + +/** + * Fetch author information from Wikidata + * @param {string} authorName - Author name + * @param {string} language - Language code (default: 'en') + * @returns {Promise} Author data object or null + */ +export async function fetchAuthorFromWikidata(authorName, language = 'en') { + if (!authorName || authorName === 'Unknown') { + return null; + } + + // Check cache first + const cached = getCachedAuthorData(authorName, language); + if (cached) { + return cached; + } + + try { + // Step 1: Search for author entity + const entityId = await searchAuthorEntity(authorName, language); + if (!entityId) { + // Cache negative result to avoid repeated lookups + cacheAuthorData(authorName, language, null); + return null; + } + + // Step 2: Fetch entity data + const params = new URLSearchParams({ + action: 'wbgetentities', + ids: entityId, + props: 'claims|sitelinks|descriptions', + languages: `${language}|en`, + format: 'json', + origin: '*', + }); + + const response = await fetch(`${WIKIDATA_API}?${params}`); + const data = await response.json(); + const entity = data.entities?.[entityId]; + + if (!entity) { + cacheAuthorData(authorName, language, null); + return null; + } + + // Step 3: Extract data + const claims = entity.claims; + const sitelinks = entity.sitelinks; + + // Get occupation + const occupation = await extractOccupations(claims, language); + + // Get image + let imageUrl = null; + if (claims.P18 && claims.P18.length > 0) { + const filename = claims.P18[0].mainsnak?.datavalue?.value; + if (filename) { + imageUrl = await getCommonsImageUrl(filename); + } + } + + // Get Wikipedia link + const wikipediaLink = extractWikipediaLink(sitelinks, language); + + // Get description + const description = + entity.descriptions?.[language]?.value || entity.descriptions?.['en']?.value; + + const authorData = { + entityId, + occupation, + imageUrl, + wikipediaLink, + description, + imageLicense: imageUrl ? 'Wikimedia Commons' : null, + }; + + // Cache result + cacheAuthorData(authorName, language, authorData); + + return authorData; + } catch (error) { + console.error('Error fetching author from Wikidata:', error); + return null; + } +} + +/** + * Clear all Wikidata cache + */ +export function clearWikidataCache() { + const keys = Object.keys(localStorage); + keys.forEach((key) => { + if (key.startsWith('wikidataAuthor_')) { + localStorage.removeItem(key); + } + }); +}