diff --git a/CHANGELOG.PATCHED.md b/CHANGELOG.PATCHED.md index 0b80218..227bff2 100644 --- a/CHANGELOG.PATCHED.md +++ b/CHANGELOG.PATCHED.md @@ -1,5 +1,8 @@ # CHANGELOG - PATCHED +**v0.9.9-patched.nightly.494.4** + - Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406) + **v0.9.9-patched.nightly.494.3** - Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404) diff --git a/PATCHES.md b/PATCHES.md index 7106b1d..9ec90c7 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -1,7 +1,8 @@ # CUMMULATIVE PATCHES ## Pending Patched Changes - *Changes that's already on the fork and waiting to be reviewed for merge into original Atlas* + *Changes that's already on the fork and waiting to be reviewed for merge into original Atlas* + - Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406) - Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404) - Removed the stale restart popup and hint on the Show debug console toggle — it applies immediately to all open windows.[#399](https://github.com/towerwatchman/Atlas/pull/399) - (Dev-Only) DevTools no longer auto-opens in dev mode unless explicitly enabled in config. diff --git a/electron/ipc/games.js b/electron/ipc/games.js index 643714a..4dee1c2 100644 --- a/electron/ipc/games.js +++ b/electron/ipc/games.js @@ -22,6 +22,7 @@ const { runDatabaseAudit, getInvalidMappingCount } = require('../db/audit') const { getCatalogIndexStatus, rebuildCatalogIndex } = require('../db/catalogIndex') const { runClientAudit, repairClientAuditSection } = require('../db/clientAudit') const { auditSeasonMerges, applySeasonMerge, applyAllSeasonMerges } = require('../db/seasonMerge') +const { parseCatalogRef } = require('../library/catalogRef') // Guards against two full rebuilds interleaving their chunked transactions on // the single shared sqlite connection. @@ -448,6 +449,32 @@ function registerGamesHandlers(ctx) { return { total: Number(result?.total || 0) } }) + // One Browse entry by catalog ref. Single-row fetch so a banner click + // doesn't page the catalog. + ipcMain.handle('get-catalog-entry', async (_, ref) => { + if (!BROWSE_MODE_ENABLED) return { success: false, error: 'Browse is not available' } + const raw = typeof ref === 'string' ? ref : ref?.ref + const parsed = parseCatalogRef(raw) + if (!parsed) return { success: false, error: 'Unknown catalog entry' } + try { + const result = await getCatalogGames( + getAssetBasePath(), + process.defaultApp, + { + hydrateKeys: [`${parsed.kind}:${parsed.id}`], + offset: 0, + limit: 1, + mediaStorageMode: getMediaStorageMode(), + }, + ) + const game = result?.games?.[0] || null + if (!game) return { success: false, error: 'Catalog entry not found' } + return { success: true, game: withMedia(game) } + } catch (err) { + return { success: false, error: err?.message || String(err) } + } + }) + ipcMain.handle('wishlist-add', async (_, entry = {}) => { return await addWishlistEntry(entry) }) diff --git a/electron/preload.js b/electron/preload.js index fa74087..17a3935 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld("electronAPI", { }, getCatalogGames: (args = {}) => ipcRenderer.invoke("get-catalog-games", args), getCatalogCount: (args = {}) => ipcRenderer.invoke("get-catalog-count", args), + getCatalogEntry: (ref) => ipcRenderer.invoke("get-catalog-entry", ref), addWishlistEntry: (entry) => ipcRenderer.invoke("wishlist-add", entry), removeWishlistEntry: (identity) => ipcRenderer.invoke("wishlist-remove", identity), diff --git a/src/App.jsx b/src/App.jsx index 282f5ce..771ea5a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1982,7 +1982,7 @@ const App = () => {
{logoVariant === 'colored' ? ( {
{viewTitle}
diff --git a/src/components/detail/page/ActionBar.jsx b/src/components/detail/page/ActionBar.jsx index e9cd5d5..dcd0ac4 100644 --- a/src/components/detail/page/ActionBar.jsx +++ b/src/components/detail/page/ActionBar.jsx @@ -159,7 +159,7 @@ export default function ActionBar({ {showBack && (
diff --git a/src/components/downloads/DownloadsPage.jsx b/src/components/downloads/DownloadsPage.jsx index df93da5..51c3f6f 100644 --- a/src/components/downloads/DownloadsPage.jsx +++ b/src/components/downloads/DownloadsPage.jsx @@ -5,7 +5,7 @@ import HostIcon from './HostIcon.jsx' import { toMediaSrc } from '../../utils/mediaSrc.js' import { describeBuild } from './linkSections.js' import { threadUrlForGame } from './threadUrl.js' -import { keepsBothVersions, bannerTargetFor } from './cardFacts.js' +import { keepsBothVersions, downloadBannerTarget, downloadCatalogRef } from './cardFacts.js' // ── Downloads page ─────────────────────────────────────────────────────────── // @@ -528,16 +528,13 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame, ? (item.totalBytes - item.receivedBytes) / rate : null const transferring = item.state === 'downloading' - // Where the banner goes. Installed titles open inside Atlas; everything else - // opens the thread it came from, which is the page a user wants while they - // are still deciding. A download with no library record -- Browse, wishlist -- - // has neither, and the banner stays inert rather than becoming a dead link. - const gameThreadUrl = threadUrlForGame(game) - // The download's own page on the host, behind the host name. Guarded on the - // scheme: a row can carry a non-http url and opening one externally is not - // something to do on the strength of a substring. + // Banner stays in-app. Installed opens the library entry, otherwise the Browse entry when known. + const catalogRef = downloadCatalogRef(item, game) + const bannerTarget = downloadBannerTarget({ game, catalogRef }) const hostUrl = /^https?:\/\//i.test(String(item.url || '')) ? item.url : '' - const bannerTarget = bannerTargetFor({ game, threadUrl: gameThreadUrl, hostUrl }) + const threadUrl = threadUrlForGame(game) + const buildDesc = describeBuild(item.buildLabel) + const buildChipClass = 'inline-block max-w-full truncate rounded border border-border bg-tertiary/50 px-1.5 py-0.5 text-[11px] text-text' const working = WORKING_STATES.includes(item.state) const errored = item.state === 'failed' || item.state === 'install_failed' const tone = errored ? 'danger' : item.state === 'done' ? 'success' : 'accent' @@ -549,23 +546,15 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame, > + ) : ( + {buildDesc} + )} )} diff --git a/src/components/downloads/cardFacts.js b/src/components/downloads/cardFacts.js index c7000f6..bd3ec8d 100644 --- a/src/components/downloads/cardFacts.js +++ b/src/components/downloads/cardFacts.js @@ -29,26 +29,28 @@ export function keepsBothVersions(item, game) { } /** - * Where the banner click goes: 'game' | 'thread' | 'host' | null. + * Where the download banner goes: 'game' | 'catalog' | null. * - * Installed titles open inside Atlas; anything else opens the page a user would - * want while still deciding. The order matters more than it looks: - * - * - A library game with no thread url still opens its game page. Requiring a - * thread url made Steam imports and local titles silently dead, which was a - * regression against the original behaviour of opening the game page for any - * row that had a record at all. - * - A row with no record at all falls back to the host page. That was - * originally left inert on the understanding it meant Browse and wishlist - * downloads only; it actually covers every download of a game not already in - * the library, which is the common case, and an inert banner there reads as - * broken rather than deliberate. + * Banner stays in-app; the host chip already opens the download URL. + * Installed opens the library entry. Not installed opens Browse when the + * entry is known. Otherwise falls back to the library entry when there is + * one (local titles have no Browse entry). Else nowhere. + */ +export function downloadBannerTarget({ game = null, catalogRef = null } = {}) { + if (game && isInstalledGame(game)) return 'game' + if (catalogRef) return 'catalog' + if (game) return 'game' + return null +} + +/** + * Which catalog entry this download belongs to. + * Uses the saved reference first, then the game's Atlas id if needed. + * Returns null if neither is available. */ -export function bannerTargetFor({ game = null, threadUrl = '', hostUrl = '' } = {}) { - if (game) { - if (isInstalledGame(game)) return 'game' - return threadUrl ? 'thread' : 'game' - } - if (threadUrl) return 'thread' - return hostUrl ? 'host' : null +export function downloadCatalogRef(item, game) { + if (item?.catalogRef) return item.catalogRef + const atlas = game?.atlas_id ?? game?.atlasId + if (atlas) return `catalog:${atlas}` + return null } diff --git a/tests/download-card-facts.test.js b/tests/download-card-facts.test.js index ce6ac86..9049344 100644 --- a/tests/download-card-facts.test.js +++ b/tests/download-card-facts.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { keepsBothVersions, bannerTargetFor } from '../src/components/downloads/cardFacts.js' +import { keepsBothVersions, downloadBannerTarget, downloadCatalogRef } from '../src/components/downloads/cardFacts.js' // ── Two bugs, one root cause ───────────────────────────────────────────────── // @@ -41,31 +41,42 @@ describe('keepsBothVersions', () => { }) }) -describe('bannerTargetFor', () => { +describe('downloadBannerTarget', () => { const game = { hasInstalledVersion: false } const installedGame = { hasInstalledVersion: true } - it('opens the game in Atlas once installed', () => { - expect(bannerTargetFor({ game: installedGame, threadUrl: 'https://t/', hostUrl: 'https://h/' })) - .toBe('game') + it('opens the library entry once installed', () => { + expect(downloadBannerTarget({ game: installedGame, catalogRef: 'catalog:1' })).toBe('game') }) - it('opens the thread while it is not installed', () => { - expect(bannerTargetFor({ game, threadUrl: 'https://t/', hostUrl: 'https://h/' })).toBe('thread') + it('opens Browse when the entry is known', () => { + expect(downloadBannerTarget({ game, catalogRef: 'catalog:1' })).toBe('catalog') + expect(downloadBannerTarget({ game: null, catalogRef: 'catalog:steam:480' })).toBe('catalog') }) - it('still opens the game page when a library game has no thread', () => { - // Regression guard. Before the click targets landed, ANY row with a game - // record opened the game page; requiring a thread url made Steam imports and - // local titles -- which have no forum link -- silently dead. - expect(bannerTargetFor({ game, threadUrl: '', hostUrl: '' })).toBe('game') + it('falls back to the library row when there is one', () => { + // Local titles have no Browse entry. + expect(downloadBannerTarget({ game, catalogRef: null })).toBe('game') }) - it('falls back to the host page when there is no library record', () => { - expect(bannerTargetFor({ game: null, threadUrl: '', hostUrl: 'https://h/' })).toBe('host') + it('is inert only when there is nowhere to go', () => { + expect(downloadBannerTarget({ game: null, catalogRef: null })).toBeNull() + }) +}) + +describe('downloadCatalogRef', () => { + it('passes the stored ref through', () => { + expect(downloadCatalogRef({ catalogRef: 'catalog:steam:480' }, null)) + .toBe('catalog:steam:480') + }) + + it('falls back to the atlas id', () => { + expect(downloadCatalogRef({}, { atlas_id: 30956 })).toBe('catalog:30956') + expect(downloadCatalogRef({}, { atlasId: 30956 })).toBe('catalog:30956') }) - it('is inert only when there is genuinely nowhere to go', () => { - expect(bannerTargetFor({ game: null, threadUrl: '', hostUrl: '' })).toBeNull() + it('returns null when there is nothing to open', () => { + expect(downloadCatalogRef({}, null)).toBeNull() + expect(downloadCatalogRef({}, {})).toBeNull() }) }) diff --git a/tests/download-chip-thread.test.jsx b/tests/download-chip-thread.test.jsx new file mode 100644 index 0000000..f958c59 --- /dev/null +++ b/tests/download-chip-thread.test.jsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react' + +import DownloadsPage from '../src/components/downloads/DownloadsPage.jsx' + +// The build chip ("Full Archive" / poster heading) links to the source thread. +// A game-loaded row opens directly; a catalog-only row resolves the entry +// first, the same fetch the banner uses. Rows with no thread stay plain text. + +const item = (overrides) => ({ + recordId: null, + title: 'Game', + version: 'v1', + buildLabel: '', + catalogRef: '', + url: 'https://mega.nz/f/x', + host: 'mega.nz', + source: 'f95', + state: 'done', + totalBytes: 0, + receivedBytes: 0, + onComplete: 'replace', + bannerCandidates: [], + ...overrides, +}) + +const mount = (items, gamesByRecordId, api = {}) => { + window.electronAPI = { + downloadsList: vi.fn().mockResolvedValue({ success: true, items }), + downloadsFolder: vi.fn().mockResolvedValue({ success: false }), + openExternalUrl: vi.fn(), + getCatalogEntry: vi.fn(), + ...api, + } + return render() +} + +beforeEach(() => { vi.clearAllMocks() }) +afterEach(() => { cleanup(); delete window.electronAPI }) + +describe('build chip thread link', () => { + it('opens the thread directly when the game is loaded', async () => { + mount( + [item({ id: 1, recordId: 7 })], + new Map([[7, { hasInstalledVersion: true, f95_id: '63437' }]]), + ) + const [chip] = await waitFor(() => screen.getAllByTitle('Open source thread for this download')) + expect(chip.tagName).toBe('BUTTON') + fireEvent.click(chip) + expect(window.electronAPI.openExternalUrl).toHaveBeenCalledWith('https://f95zone.to/threads/63437/') + }) + + it('resolves the catalog entry when there is no loaded game', async () => { + mount( + [item({ id: 2, buildLabel: 'Season 1', catalogRef: 'catalog:30956' })], + new Map(), + { getCatalogEntry: vi.fn().mockResolvedValue({ + success: true, game: { siteUrl: 'https://f95zone.to/threads/some-slug.30956/' }, + }) }, + ) + const [chip] = await waitFor(() => screen.getAllByTitle('Open source thread for this download')) + fireEvent.click(chip) + await waitFor(() => expect(window.electronAPI.getCatalogEntry).toHaveBeenCalledWith('catalog:30956')) + expect(window.electronAPI.openExternalUrl) + .toHaveBeenCalledWith('https://f95zone.to/threads/some-slug.30956/') + }) + + it('opens nothing when the entry has no thread', async () => { + mount( + [item({ id: 3, buildLabel: 'Season 1', catalogRef: 'catalog:30956' })], + new Map(), + { getCatalogEntry: vi.fn().mockResolvedValue({ success: true, game: {} }) }, + ) + const [chip] = await waitFor(() => screen.getAllByTitle('Open source thread for this download')) + fireEvent.click(chip) + await waitFor(() => expect(window.electronAPI.getCatalogEntry).toHaveBeenCalled()) + expect(window.electronAPI.openExternalUrl).not.toHaveBeenCalled() + }) + + it('stays plain text when no thread is known', async () => { + mount( + [item({ id: 4, recordId: 8 })], + new Map([[8, { hasInstalledVersion: false }]]), + ) + await waitFor(() => expect(screen.getByText('Full Archive')).toBeTruthy()) + expect(screen.queryByTitle('Open source thread for this download')).toBeNull() + expect(screen.getByText('Full Archive').tagName).toBe('SPAN') + }) + + it('renders no chip for legacy rows without a build label', async () => { + mount( + [item({ id: 5, recordId: 7, buildLabel: null })], + new Map([[7, { hasInstalledVersion: true, f95_id: '63437' }]]), + ) + await waitFor(() => expect(screen.getByTitle('Remove from list')).toBeTruthy()) + expect(screen.queryByTitle('Open source thread for this download')).toBeNull() + }) +}) diff --git a/tests/navigation-labels.test.jsx b/tests/navigation-labels.test.jsx new file mode 100644 index 0000000..b7daa74 --- /dev/null +++ b/tests/navigation-labels.test.jsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import fs from 'node:fs' +import path from 'node:path' + +import HeroBanner from '../src/components/detail/page/HeroBanner.jsx' +import ActionBar from '../src/components/detail/page/ActionBar.jsx' + +afterEach(cleanup) + +// Detail back closes the overlay; header Home resets to the library grid. +const heroSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'components', 'detail', 'page', 'HeroBanner.jsx'), 'utf8') +const barSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'components', 'detail', 'page', 'ActionBar.jsx'), 'utf8') +const appSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'App.jsx'), 'utf8') + +describe('navigation labels', () => { + it('HeroBanner back button says Back', () => { + expect(heroSrc).toContain('>Back') + }) + + it('ActionBar back button says Back', () => { + expect(barSrc).toContain('title="Back"') + expect(barSrc).toContain('Back') + }) + + it('header home buttons say Home', () => { + const count = (appSrc.match(/title="Home"/g) || []).length + expect(count).toBe(2) + }) +}) + +describe('HeroBanner renders Back', () => { + const game = { title: 'Test', banner_url: null, hero_url: null } + + it('shows Back and calls onBack', () => { + const onBack = vi.fn() + render( {}} />) + const btn = screen.getByRole('button', { name: 'Back' }) + btn.click() + expect(onBack).toHaveBeenCalledTimes(1) + }) +}) + +describe('ActionBar renders Back', () => { + const game = { record_id: 1, title: 'Test', isUpdateAvailable: false } + + it('shows Back with title Back', () => { + const onBack = vi.fn() + render() + const btn = screen.getByTitle('Back') + btn.click() + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('hides Back when showBack is false', () => { + render() + expect(screen.queryByTitle('Back')).toBeNull() + }) +})