diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts index 7ca94eda..6145aa1f 100644 --- a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts +++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts @@ -56,8 +56,9 @@ import { MIME, validateOPDSURL, getOPDSNavLink, + getUnaddedPopularCatalogs, } from '@/app/opds/utils/opdsUtils'; -import type { OPDSBaseLink } from '@/types/opds'; +import type { OPDSBaseLink, OPDSCatalog } from '@/types/opds'; import { fetchWithAuth } from '@/app/opds/utils/opdsReq'; const mockFetchWithAuth = vi.mocked(fetchWithAuth); @@ -781,4 +782,44 @@ describe('opdsUtils', () => { expect(getOPDSNavLink([{ type: 'application/opds+json' }])).toBeUndefined(); }); }); + + describe('getUnaddedPopularCatalogs', () => { + const popular: OPDSCatalog[] = [ + { id: 'gutenberg', name: 'Project Gutenberg', url: 'https://m.gutenberg.org/ebooks.opds/' }, + { + id: 'standardebooks', + name: 'Standard Ebooks', + url: 'https://standardebooks.org/feeds/opds', + }, + ]; + + it('returns all popular catalogs when none are added', () => { + expect(getUnaddedPopularCatalogs(popular, [])).toEqual(popular); + }); + + it('drops a popular catalog already present in the added list', () => { + const added: OPDSCatalog[] = [ + { id: '1', name: 'Project Gutenberg', url: 'https://m.gutenberg.org/ebooks.opds/' }, + ]; + const result = getUnaddedPopularCatalogs(popular, added); + expect(result.map((c) => c.id)).toEqual(['standardebooks']); + }); + + it('matches by normalized URL (case and surrounding whitespace)', () => { + const added: OPDSCatalog[] = [ + { id: '1', name: 'Gutenberg', url: ' HTTPS://M.GUTENBERG.ORG/EBOOKS.OPDS/ ' }, + ]; + const result = getUnaddedPopularCatalogs(popular, added); + expect(result.map((c) => c.id)).toEqual(['standardebooks']); + }); + + it('excludes disabled popular catalogs', () => { + const withDisabled: OPDSCatalog[] = [ + ...popular, + { id: 'manybooks', name: 'ManyBooks', url: 'https://manybooks.net/opds/', disabled: true }, + ]; + const result = getUnaddedPopularCatalogs(withDisabled, []); + expect(result.map((c) => c.id)).toEqual(['gutenberg', 'standardebooks']); + }); + }); }); diff --git a/apps/readest-app/src/app/opds/components/CatalogManager.tsx b/apps/readest-app/src/app/opds/components/CatalogManager.tsx index 956aaf04..5a5944e0 100644 --- a/apps/readest-app/src/app/opds/components/CatalogManager.tsx +++ b/apps/readest-app/src/app/opds/components/CatalogManager.tsx @@ -29,7 +29,7 @@ import { eventDispatcher } from '@/utils/event'; import { SectionTitle } from '@/components/settings/primitives'; import { deleteSubscriptionState, loadSubscriptionState } from '@/services/opds'; import type { OPDSSubscriptionState } from '@/services/opds/types'; -import { validateOPDSURL } from '../utils/opdsUtils'; +import { getUnaddedPopularCatalogs, validateOPDSURL } from '../utils/opdsUtils'; import { FailedDownloadsDialog } from './FailedDownloadsDialog'; import { formatOPDSCustomHeadersInput, @@ -129,7 +129,11 @@ export function CatalogManager({ inSubPage = false }: CatalogManagerProps = {}) const [headerError, setHeaderError] = useState(''); const [proxyConsentError, setProxyConsentError] = useState(''); const [isValidating, setIsValidating] = useState(false); - const popularCatalogs = appService?.isOnlineCatalogsAccessible ? POPULAR_CATALOGS : []; + // Only surface popular catalogs the user hasn't already added; otherwise an + // added entry would render in both sections and read as a duplicate (#4782). + const popularCatalogs = appService?.isOnlineCatalogsAccessible + ? getUnaddedPopularCatalogs(POPULAR_CATALOGS, catalogs) + : []; const [subscriptionStates, setSubscriptionStates] = useState< Record >({}); @@ -593,53 +597,46 @@ export function CatalogManager({ inSubPage = false }: CatalogManagerProps = {})
{_('Popular Catalogs')}
- {popularCatalogs - .filter((catalog) => !catalog.disabled) - .map((catalog) => { - const isAdded = catalogs.some((c) => c.url === catalog.url); - return ( -
-
-

- -

- {catalog.description && ( -

- {catalog.description} -

- )} -
- {!isAdded && ( - - )} - -
-
+ {popularCatalogs.map((catalog) => ( +
+
+

+ +

+ {catalog.description && ( +

+ {catalog.description} +

+ )} +
+ +
- ); - })} +
+
+ ))}
diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts index 4a3e0ae1..41041626 100644 --- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts +++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts @@ -1,6 +1,6 @@ import { isOPDSCatalog } from 'foliate-js/opds.js'; import { replace as expandURITemplate, getVariables } from 'foliate-js/uri-template.js'; -import { OPDSBaseLink } from '@/types/opds'; +import { OPDSBaseLink, OPDSCatalog } from '@/types/opds'; import { EXTS } from '@/libs/document'; import { fetchWithAuth } from './opdsReq'; @@ -319,6 +319,25 @@ export const validateOPDSURL = async ( } }; +/** + * Filter the built-in "popular" OPDS catalogs down to those the user hasn't + * already added to their personal list. Matching is by normalized URL (trim + + * lowercase), mirroring the store's `findByUrl` dedup so case/whitespace + * differences still hide a popular entry once it's been added. Disabled + * popular entries are always excluded. Without this an added popular catalog + * would keep rendering in the Popular section and look like a duplicate + * (issue #4782). + */ +export const getUnaddedPopularCatalogs = ( + popularCatalogs: OPDSCatalog[], + addedCatalogs: OPDSCatalog[], +): OPDSCatalog[] => { + const addedUrls = new Set(addedCatalogs.map((c) => c.url.trim().toLowerCase())); + return popularCatalogs.filter( + (catalog) => !catalog.disabled && !addedUrls.has(catalog.url.trim().toLowerCase()), + ); +}; + export const getFileExtFromPath = (pathname: string, delimiter = '/'): string => { const parts = pathname.split(delimiter); for (const ext of Object.values(EXTS)) {