feat: integrate Wikidata API for fetching author information, including images and occupations

This commit is contained in:
alexsparkes
2026-02-01 11:49:54 +00:00
parent e25c8dedb7
commit 7c2213a38f
3 changed files with 423 additions and 139 deletions

View File

@@ -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 (
<div className="author-holder">
<div className="author">
{showAuthorImg && (
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
{!authorimg && authorimg !== undefined && <MdPerson />}
</div>
)}
{showAuthorImg &&
(authorimglicense && authorimg ? (
<Tooltip title={authorimglicense}>
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
{!authorimg && authorimg !== undefined && <MdPerson />}
</div>
</Tooltip>
) : (
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
{!authorimg && authorimg !== undefined && <MdPerson />}
</div>
))}
{author ? (
<div className="author-content">
@@ -38,11 +43,6 @@ export default function AuthorInfo({
{authorOccupation && authorOccupation !== 'Unknown' && (
<span className="subtitle">{authorOccupation}</span>
)}
{authorimglicense && (
<span className="author-license" title={authorimglicense}>
{trimmedLicense}
</span>
)}
</div>
) : (
<div className="author-content whileLoading">

View File

@@ -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<object>} 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,