Browse Source

fix reading internation dates containing months

imwald
Silberengel 4 months ago
parent
commit
31731dc656
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 17
      src/components/RssFeedList/index.tsx
  4. 350
      src/services/rss-feed.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "jumble-imwald",
"version": "13.2",
"version": "13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-imwald",
"version": "13.2",
"version": "13.3",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "13.2",
"version": "13.3",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",

17
src/components/RssFeedList/index.tsx

@ -123,14 +123,14 @@ export default function RssFeedList() { @@ -123,14 +123,14 @@ export default function RssFeedList() {
const fetchedItems = await rssFeedService.fetchMultipleFeeds(feedUrls, abortController.signal)
// Check if aborted after fetching
if (abortController.signal.aborted || !isMounted) {
if (isMounted) {
setRefreshing(false)
}
// Always set items if we got them, even if signal was aborted (abort might happen after fetch completes)
// Only skip setting items if component unmounted
if (!isMounted) {
setRefreshing(false)
return
}
// Set items regardless of abort status (abort might have happened after fetch completed)
if (fetchedItems.length === 0) {
// No items were successfully fetched, but don't show error if we tried
// The fetchMultipleFeeds already logs warnings for failed feeds
@ -139,6 +139,13 @@ export default function RssFeedList() { @@ -139,6 +139,13 @@ export default function RssFeedList() {
setItems(fetchedItems)
// Check if aborted after setting items (for cleanup)
if (abortController.signal.aborted) {
logger.debug('[RssFeedList] Signal was aborted after fetching, but items were set', {
itemCount: fetchedItems.length
})
}
// Set up a listener for cache updates (background refresh may add new items)
// Re-check cache after a delay to see if background refresh added items
const checkForUpdates = async () => {

350
src/services/rss-feed.service.ts

@ -57,6 +57,8 @@ class RssFeedService { @@ -57,6 +57,8 @@ class RssFeedService {
private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map()
private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
private backgroundRefreshController: AbortController | null = null
private monthMapCache: Record<string, string> | null = null
private activeFetchPromises: Map<string, Promise<RssFeed>> = new Map() // Track active fetches by URL
constructor() {
if (!RssFeedService.instance) {
@ -72,44 +74,74 @@ class RssFeedService { @@ -72,44 +74,74 @@ class RssFeedService {
// Check cache first
const cached = this.feedCache.get(url)
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
logger.debug('[RssFeedService] Returning cached feed', { url })
return cached.feed
}
// Check if already aborted
if (signal?.aborted) {
logger.warn('[RssFeedService] Signal already aborted before fetchFeed', { url })
throw new DOMException('The operation was aborted.', 'AbortError')
}
// Try multiple fetch strategies in order
const strategies = this.getFetchStrategies(url)
for (const strategy of strategies) {
// Check if aborted before trying next strategy
if (signal?.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError')
}
// Check if there's already an active fetch for this URL (deduplicate simultaneous requests)
const activeFetch = this.activeFetchPromises.get(url)
if (activeFetch) {
logger.debug('[RssFeedService] Reusing active fetch for URL', { url })
return activeFetch
}
// Create fetch promise and track it
const fetchPromise = (async () => {
try {
const xmlText = await this.fetchWithStrategy(url, strategy, signal)
if (xmlText) {
const feed = this.parseFeed(xmlText, url)
// Cache the feed
this.feedCache.set(url, { feed, timestamp: Date.now() })
return feed
}
} catch (error) {
// Don't log abort errors as warnings - they're expected during cleanup
if (error instanceof DOMException && error.name === 'AbortError') {
throw error // Re-throw abort errors immediately
// Try multiple fetch strategies in order
const strategies = this.getFetchStrategies(url)
for (const strategy of strategies) {
// Check if aborted before trying next strategy
if (signal?.aborted) {
logger.warn('[RssFeedService] Signal aborted during fetch strategies', { url, strategy: strategy.name })
throw new DOMException('The operation was aborted.', 'AbortError')
}
try {
logger.debug('[RssFeedService] Trying fetch strategy', { url, strategy: strategy.name })
const xmlText = await this.fetchWithStrategy(url, strategy, signal)
if (xmlText) {
const feed = this.parseFeed(xmlText, url)
// Cache the feed
this.feedCache.set(url, { feed, timestamp: Date.now() })
logger.info('[RssFeedService] Successfully fetched and parsed feed', {
url,
itemCount: feed.items.length,
strategy: strategy.name
})
return feed
}
} catch (error) {
// Don't log abort errors as warnings - they're expected during cleanup
if (error instanceof DOMException && error.name === 'AbortError') {
logger.warn('[RssFeedService] Fetch aborted', { url, strategy: strategy.name })
throw error // Re-throw abort errors immediately
}
logger.warn('[RssFeedService] Strategy failed', { url, strategy: strategy.name, error })
// Continue to next strategy
continue
}
}
logger.warn('[RssFeedService] Strategy failed', { url, strategy: strategy.name, error })
// Continue to next strategy
continue
// All strategies failed
throw new Error(`Failed to fetch RSS feed from ${url} after trying all available methods`)
} finally {
// Remove from active fetches when done
this.activeFetchPromises.delete(url)
}
}
})()
// All strategies failed
throw new Error(`Failed to fetch RSS feed from ${url} after trying all available methods`)
// Store the promise to deduplicate simultaneous requests
this.activeFetchPromises.set(url, fetchPromise)
return fetchPromise
}
/**
@ -160,9 +192,16 @@ class RssFeedService { @@ -160,9 +192,16 @@ class RssFeedService {
}
const controller = new AbortController()
// Use a longer timeout for RSS feeds (30 seconds) since they can be slow
// Don't abort on timeout - just log a warning, let the fetch continue
const timeoutId = setTimeout(() => {
controller.abort()
}, 15000) // 15 second timeout
logger.warn('[RssFeedService] Fetch taking longer than expected', {
url: originalUrl,
strategy: strategy.name,
elapsed: '30s'
})
// Don't abort - just log. The fetch will continue or fail naturally
}, 30000) // 30 second warning (but don't abort)
// If external signal is provided, abort our controller when external signal aborts
if (externalSignal) {
@ -448,9 +487,20 @@ class RssFeedService { @@ -448,9 +487,20 @@ class RssFeedService {
.trim()
}
}
const itemPubDate = this.parseDate(this.getTextContent(item, 'pubDate'))
const pubDateText = this.getTextContent(item, 'pubDate')
const itemPubDate = this.parseDate(pubDateText)
const itemGuid = this.getTextContent(item, 'guid') || itemLink || ''
// Log item parsing for debugging
if (!itemPubDate && pubDateText) {
logger.warn('[RssFeedService] Failed to parse pubDate for item', {
url: feedUrl,
title: itemTitle.substring(0, 50),
pubDateText,
link: itemLink
})
}
// Extract enclosure element (for audio/video files)
let enclosure: RssFeedItemEnclosure | undefined
const enclosureElement = item.querySelector('enclosure')
@ -903,16 +953,137 @@ class RssFeedService { @@ -903,16 +953,137 @@ class RssFeedService {
return html
}
/**
* Build a map of foreign month names to English abbreviations using Intl.DateTimeFormat
* This handles month names in various languages automatically
*/
private buildMonthMap(): Record<string, string> {
// Common locales that might appear in RSS feeds
const locales = ['de', 'fr', 'es', 'it', 'pt', 'pt-BR', 'ru', 'pl', 'nl', 'sv', 'no', 'da', 'fi', 'cs', 'hu', 'ro', 'sk', 'sl', 'hr', 'bg', 'el', 'tr', 'ja', 'ko', 'zh', 'ar', 'he', 'th', 'vi', 'hi', 'fa']
const monthMap: Record<string, string> = {}
const year = new Date().getFullYear()
// English month abbreviations (0-11 index to English abbrev)
const englishMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
// Build map for each locale
for (const locale of locales) {
try {
const formatter = new Intl.DateTimeFormat(locale, { month: 'short' })
for (let monthIndex = 0; monthIndex < 12; monthIndex++) {
const foreignMonth = formatter.format(new Date(year, monthIndex))
const englishMonth = englishMonths[monthIndex]
// Add both the full foreign month name and its lowercase version
if (foreignMonth && englishMonth) {
monthMap[foreignMonth] = englishMonth
monthMap[foreignMonth.toLowerCase()] = englishMonth
// Also handle common variations (first 3 letters)
if (foreignMonth.length >= 3) {
const abbrev = foreignMonth.substring(0, 3)
monthMap[abbrev] = englishMonth
monthMap[abbrev.toLowerCase()] = englishMonth
}
}
}
} catch {
// Skip locales that fail to format
continue
}
}
return monthMap
}
/**
* Parse date string into Date object
* Handles non-standard formats by skipping weekday and mapping foreign month names
*/
private parseDate(dateString: string | null): Date | null {
if (!dateString) return null
// First, try standard Date parsing
try {
return new Date(dateString)
const standardDate = new Date(dateString)
if (!isNaN(standardDate.getTime())) {
return standardDate
}
} catch {
return null
// Continue to fallback parsing
}
// Handle non-standard formats (e.g., "Don, 06 Nov 2025 15:24:25")
// Skip the weekday part (everything up to and including the first comma)
let dateToParse = dateString.trim()
const commaIndex = dateToParse.indexOf(',')
if (commaIndex > 0) {
// Skip weekday and comma, keep the rest
dateToParse = dateToParse.substring(commaIndex + 1).trim()
}
// Build month map using Intl.DateTimeFormat (lazy initialization)
if (!this.monthMapCache) {
this.monthMapCache = this.buildMonthMap()
logger.debug('[RssFeedService] Built month map', {
monthCount: Object.keys(this.monthMapCache).length,
sampleMonths: Object.entries(this.monthMapCache).slice(0, 5)
})
}
// Replace foreign month names with English equivalents
let monthReplaced = false
for (const [foreign, english] of Object.entries(this.monthMapCache)) {
// Match month name (case-insensitive, word boundary)
const regex = new RegExp(`\\b${this.escapeRegex(foreign)}\\b`, 'i')
if (regex.test(dateToParse)) {
dateToParse = dateToParse.replace(regex, english)
monthReplaced = true
logger.debug('[RssFeedService] Replaced month name', { foreign, english, original: dateString, afterReplace: dateToParse })
break
}
}
// If no timezone is specified, assume UTC (common for RSS feeds)
const hasTimezone = /[+-]\d{4}|GMT|UTC|EST|PST|CET|CEST|CST|EDT|PDT$/i.test(dateToParse)
if (!hasTimezone && dateToParse.match(/\d{2}:\d{2}:\d{2}$/)) {
// Add UTC timezone if time is present but no timezone
dateToParse += ' UTC'
}
try {
const parsedDate = new Date(dateToParse)
if (!isNaN(parsedDate.getTime())) {
logger.debug('[RssFeedService] Successfully parsed date', {
original: dateString,
parsed: dateToParse,
result: parsedDate.toISOString()
})
return parsedDate
} else {
logger.warn('[RssFeedService] Date parsing resulted in invalid date', {
original: dateString,
parsed: dateToParse,
monthReplaced
})
}
} catch (error) {
logger.warn('[RssFeedService] Date parsing threw error', {
original: dateString,
parsed: dateToParse,
error: error instanceof Error ? error.message : String(error),
monthReplaced
})
}
return null
}
/**
* Escape special regex characters in a string
*/
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
@ -1005,20 +1176,67 @@ class RssFeedService { @@ -1005,20 +1176,67 @@ class RssFeedService {
}
const cacheWasEmpty = cachedItems.length === 0
// Check which feeds are missing from cache
const normalizeUrl = (url: string) => url.trim().replace(/\/$/, '')
const cachedFeedUrls = new Set(cachedItems.map(item => normalizeUrl(item.feedUrl)))
const missingFeeds = feedUrls.filter(url => !cachedFeedUrls.has(normalizeUrl(url)))
if (missingFeeds.length > 0) {
logger.info('[RssFeedService] Some feeds are missing from cache, will fetch them', {
missingFeeds,
cachedFeedUrls: Array.from(cachedFeedUrls),
requestedFeeds: feedUrls
})
}
// Step 2: Background refresh to merge new items
// If cache is empty, we'll wait a bit for the refresh to complete
const backgroundRefresh = async () => {
if (signal?.aborted) {
const backgroundRefresh = async (refreshSignal?: AbortSignal) => {
// Use the provided signal, or fall back to the original signal
const activeSignal = refreshSignal || signal
if (activeSignal?.aborted) {
return
}
logger.info('[RssFeedService] Starting background refresh', {
feedCount: feedUrls.length,
feedUrls,
cacheWasEmpty,
cachedItemCount: cachedItems.length
})
// Check if already aborted before starting
if (activeSignal?.aborted) {
logger.warn('[RssFeedService] Background refresh aborted before starting', {
feedCount: feedUrls.length
})
return
}
try {
logger.info('[RssFeedService] Starting to fetch feeds', {
feedCount: feedUrls.length,
feedUrls,
signalAborted: activeSignal?.aborted
})
const results = await Promise.allSettled(
feedUrls.map(url => this.fetchFeed(url, signal))
feedUrls.map(url => {
if (activeSignal?.aborted) {
logger.warn('[RssFeedService] Signal aborted before fetching feed', { url })
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'))
}
logger.debug('[RssFeedService] Fetching feed', { url, signalAborted: activeSignal?.aborted })
return this.fetchFeed(url, activeSignal)
})
)
if (signal?.aborted) {
if (activeSignal?.aborted) {
logger.warn('[RssFeedService] Signal aborted after fetching feeds', {
feedCount: feedUrls.length
})
return
}
@ -1031,23 +1249,41 @@ class RssFeedService { @@ -1031,23 +1249,41 @@ class RssFeedService {
if (result.status === 'fulfilled') {
newItems.push(...result.value.items)
successCount++
logger.debug('[RssFeedService] Successfully fetched feed', { url: feedUrls[index], itemCount: result.value.items.length })
logger.info('[RssFeedService] Successfully fetched feed', {
url: feedUrls[index],
itemCount: result.value.items.length,
feedTitle: result.value.title
})
} else {
failureCount++
const error = result.reason
if (error instanceof DOMException && error.name === 'AbortError') {
abortCount++
logger.warn('[RssFeedService] Feed fetch was aborted', {
url: feedUrls[index],
reason: error.message || 'AbortError'
})
return
}
const errorMessage = error instanceof Error ? error.message : String(error)
logger.warn('[RssFeedService] Failed to fetch feed after trying all strategies', {
url: feedUrls[index],
error: errorMessage
error: errorMessage,
errorStack: error instanceof Error ? error.stack : undefined,
errorType: error?.constructor?.name
})
}
})
logger.info('[RssFeedService] Background refresh completed', {
successCount,
failureCount,
abortCount,
newItemCount: newItems.length,
totalFeeds: feedUrls.length
})
if (!signal?.aborted && successCount > 0) {
if (!activeSignal?.aborted && successCount > 0) {
// Merge new items with cached items (deduplicate by feedUrl:guid)
const itemMap = new Map<string, RssFeedItem>()
@ -1095,22 +1331,41 @@ class RssFeedService { @@ -1095,22 +1331,41 @@ class RssFeedService {
}
}
// If cache is empty, wait a bit for background refresh to populate it
if (cacheWasEmpty) {
logger.info('[RssFeedService] Cache is empty, waiting for background refresh to complete', { feedCount: feedUrls.length })
// If cache is empty OR some feeds are missing, wait a bit for background refresh
const shouldWaitForRefresh = cacheWasEmpty || missingFeeds.length > 0
if (shouldWaitForRefresh) {
logger.info('[RssFeedService] Waiting for background refresh to complete', {
feedCount: feedUrls.length,
cacheWasEmpty,
missingFeedsCount: missingFeeds.length,
missingFeeds
})
try {
// Wait up to 10 seconds for background refresh to complete
// For missing feeds, create a separate abort controller that won't be aborted by component cleanup
// This allows the fetch to complete even if the component re-renders
const backgroundAbortController = new AbortController()
const backgroundSignal = signal ? (() => {
// Combine signals: abort if either the external signal OR our background controller aborts
const combined = new AbortController()
const abort = () => combined.abort()
signal.addEventListener('abort', abort, { once: true })
backgroundAbortController.signal.addEventListener('abort', abort, { once: true })
return combined.signal
})() : backgroundAbortController.signal
// Wait up to 30 seconds for background refresh to complete (longer for missing feeds)
await Promise.race([
backgroundRefresh(),
new Promise(resolve => setTimeout(resolve, 10000))
backgroundRefresh(backgroundSignal),
new Promise(resolve => setTimeout(resolve, 30000))
])
// Re-read from cache after background refresh
try {
const refreshedItems = await indexedDb.getRssFeedItems()
const feedUrlSet = new Set(feedUrls)
const feedUrlSet = new Set(feedUrls.map(normalizeUrl))
cachedItems = refreshedItems
.filter(item => feedUrlSet.has(item.feedUrl))
.filter(item => feedUrlSet.has(normalizeUrl(item.feedUrl)))
.map(item => ({
...item,
pubDate: item.pubDate ? new Date(item.pubDate) : null
@ -1118,7 +1373,7 @@ class RssFeedService { @@ -1118,7 +1373,7 @@ class RssFeedService {
logger.info('[RssFeedService] Loaded items after background refresh', {
itemCount: cachedItems.length,
feedCount: feedUrls.length
feedCount: feedUrls.length
})
} catch (error) {
logger.warn('[RssFeedService] Failed to reload cached items after background refresh', { error })
@ -1129,7 +1384,8 @@ class RssFeedService { @@ -1129,7 +1384,8 @@ class RssFeedService {
}
}
} else {
// Cache has items, start background refresh in background (don't wait)
// Cache has all requested feeds, start background refresh in background (don't wait)
logger.debug('[RssFeedService] All feeds in cache, starting background refresh without waiting')
backgroundRefresh().catch(err => {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
logger.error('[RssFeedService] Background refresh error', { error: err })

Loading…
Cancel
Save