diff --git a/package-lock.json b/package-lock.json index 9df970b..b953096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-explore", - "version": "1.4.7", + "version": "1.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-explore", - "version": "1.4.7", + "version": "1.4.9", "hasInstallScript": true, "license": "0BSD", "dependencies": { diff --git a/package.json b/package.json index fb0e0cf..d73a665 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-explore", - "version": "1.4.8", + "version": "1.4.9", "private": true, "license": "0BSD", "description": "Browse, search, inspect, and open public Qortium QDN resources.", diff --git a/src/App.tsx b/src/App.tsx index 06f0ccc..825785a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { createTranslator } from './i18n'; import { detectGitRepositoryLayout } from './qdnGitRepository'; import { NameOwnerIdentity } from './NameOwnerIdentity'; import { hasHomeBridge, qdnRequest } from './qdnRequest'; +import { loadResourceDetails } from './resourceDetails'; import { resourceFetchRequest, resourceFiles } from './resourceFiles'; import { isBrowserArchiveService, PUBLIC_QDN_SERVICES } from './services'; import { sortRows, type Sort, updatedOf } from './sort'; @@ -50,7 +51,7 @@ export function App() { useEffect(() => { applyDisplaySettings(display); const onMessage = (event: MessageEvent) => setDisplay(current => updateFromHostMessage(event.data, current) ?? current); window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [display]); useEffect(() => { if (!hasHomeBridge()) { setSourcePreviewSupported(false); return; } let active = true; void qdnRequest({ action: 'SHOW_ACTIONS' }).then(actions => { if (active) setSourcePreviewSupported(supportsSourcePreview(actions)); }).catch(() => { if (active) setSourcePreviewSupported(false); }); return () => { active = false; }; }, []); useEffect(() => { const request = resourceQuery(route); if (!request) return; let active = true; setLoading(true); setFailure(''); void qdnRequest(request).then(value => { if (!active) return; const nextResources = asResources(value); const detail = singleResourceDetailRoute(route, nextResources); if (detail) { replace(detail); return; } setResources(nextResources); }).catch(error => { if (active) setFailure(errorText(error)); }).finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [route, refresh]); - useEffect(() => { setGitFallback(''); if (route.kind !== 'detail') { setDetails(null); return; } let active = true; const resource = { service: route.service, name: route.name, identifier: route.identifier }; void Promise.all([qdnRequest>({ action: 'GET_QDN_RESOURCE_METADATA', ...resource }), qdnRequest>({ action: 'GET_QDN_RESOURCE_STATUS', ...resource }), qdnRequest>({ action: 'GET_QDN_RESOURCE_PROPERTIES', ...resource })]).then(([metadata, status, properties]) => { if (active) setDetails({ metadata, status: status as ResourceDetails['status'], properties }); }).catch(error => { if (active) setFailure(errorText(error)); }); return () => { active = false; }; }, [route]); + useEffect(() => { setGitFallback(''); if (route.kind !== 'detail') { setDetails(null); return; } let active = true; setDetails(null); setFailure(''); const resource = { service: route.service, name: route.name, identifier: route.identifier }; void loadResourceDetails(qdnRequest, resource).then(value => { if (active) setDetails(value); }).catch(error => { if (active) setFailure(errorText(error)); }); return () => { active = false; }; }, [route]); const folders = useMemo(() => route.kind === 'services' ? groupBy(resources, item => item.service) : route.kind === 'service' ? groupBy(resources, item => item.name) : route.kind === 'name-services' ? groupBy(resources, item => item.service) : [], [resources, route]); const shown = searchResults ?? (route.kind === 'resources' ? resources : []); const sortedFolders = sortRows(folders, sort, (row, key) => key === 'count' ? row.count : key === 'updated' ? row.updated : row.name); @@ -63,12 +64,13 @@ export function App() { const file = typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined; const mime = typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined; const open = dispatchOpen(detailResource, { filename: file, mimeType: mime }); + const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER'; const files = resourceFiles(details?.metadata); const selected = files.includes(route.path ?? '') ? route.path : undefined; const viewed = { ...detailResource, path: selected }; const showFiles = files.length > 1; const showGit = !selected && !gitFallback && !!detectGitRepositoryLayout(files); - return

{t('app.title')}

{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}

{open.action === 'INTERNAL_VIEWER' ? null : }{isBrowserArchiveService(detailResource.service) ? : null}
{failure ?

{failure}

: null}

{t('label.details')}

{t('label.title')}
{String(details?.metadata?.title || '—')}
{t('label.description')}
{String(details?.metadata?.description || '—')}
{t('label.status')}
{String(details?.status?.status || '—')}
{t('column.size')}
{bytes(details?.status?.size)}
{t('column.updated')}
{date(details?.status?.updated)}
{showFiles ? <>

{t('label.files')} {files.length.toLocaleString()}

{files.map(path => )}
: null}

{t('label.properties')}

{JSON.stringify(details?.properties || {}, null, 2)}

{showGit ? t('git.title') : selected || t('viewer.source')}

{selected ? : null}{gitFallback ?

{gitFallback}

: null}{showGit ? : showFiles && !selected ?

{t('viewer.selectFile')}

: }
; + return

{t('app.title')}

{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}

{open.action === 'INTERNAL_VIEWER' ? null : }{isBrowserArchiveService(detailResource.service) ? : null}
{failure ?

{failure}

: null}

{t('label.details')}

{t('label.title')}
{String(details?.metadata?.title || '—')}
{t('label.description')}
{String(details?.metadata?.description || '—')}
{t('label.status')}
{String(details?.status?.status || '—')}
{t('column.size')}
{bytes(details?.status?.size)}
{t('column.updated')}
{date(details?.status?.updated)}
{showFiles ? <>

{t('label.files')} {files.length.toLocaleString()}

{files.map(path => )}
: null}

{t('label.properties')}

{JSON.stringify(details?.properties || {}, null, 2)}

{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}

{selected ? : null}{gitFallback ?

{gitFallback}

: null}{showGit ? : showFiles && !selected ?

{t('viewer.selectFile')}

: }
; } return

{t('app.title')}

{t('app.subtitle')} {__APP_VERSION__}

{sourcePreviewSupported ? : null}
{previewMessage ?

{previewMessage}

: null}{previewFailure ?

{previewFailure}

: null}
setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') doSearch(); }} />{searchResults ? : null}

{route.kind === 'services' ? 'QDN' : route.kind === 'service' ? route.service : route.kind === 'name-services' ? route.name : `${route.service} / ${route.name}`}

{failure ?
{t('error.coreOffline')}

{failure}

: null}{loading && !resources.length ?

{t('loading')}

: null}{!searchResults && folders.length > 0 ?
{t('label.name')} toggle('count')}>{t('column.count')} toggle('updated')}>{t('column.updated')}
{sortedFolders.map(row => )}
: null}{(searchResults || route.kind === 'resources') &&
{sortedResources.map(resource => )}
}{!loading && !failure && ((searchResults && !searchResults.length) || (!searchResults && !folders.length && route.kind !== 'resources') || (route.kind === 'resources' && !resources.length)) ?

{searchResults ? t('empty.search') : t('empty.resources')}

: null}
; } diff --git a/src/contentViewer.tsx b/src/contentViewer.tsx index bf99bd3..df23be2 100644 --- a/src/contentViewer.tsx +++ b/src/contentViewer.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { previewCache, previewCacheKey } from './previewCache'; import { qdnRequest } from './qdnRequest'; import { resourceFetchRequest } from './resourceFiles'; import type { QdnResource } from './types'; @@ -43,8 +44,8 @@ function csvRows(text: string) { return text.split(/\r?\n/).filter(Boolean).map( * text for every other kind, matching what ContentViewer fetches; the Git * viewer feeds it bytes it read out of a repository instead. */ -export function ContentPreview({ kind, data, resource, properties }: { kind: ContentKind; data: string; resource: QdnResource; properties?: Record }) { - if (kind === 'binary') return

This resource cannot be rendered safely in Explore. Use Download to save its original bytes.

; +export function ContentPreview({ kind, data, resource, properties, binaryMessage }: { kind: ContentKind; data: string; resource: QdnResource; properties?: Record; binaryMessage?: string }) { + if (kind === 'binary') return

{binaryMessage || 'This resource cannot be rendered safely in Explore. Use Download to save its original bytes.'}

; if (kind === 'image') return {filename(resource,; if (kind === 'json') { try { return
{JSON.stringify(JSON.parse(data), null, 2)}
; } catch { return
{data}
; } } if (kind === 'csv') { const rows = csvRows(data); return
{rows.map((row, i) => {row.map((cell, j) => i === 0 ? : )})}
{cell}{cell}
; } @@ -52,17 +53,24 @@ export function ContentPreview({ kind, data, resource, properties }: { kind: Con return
{data}
; } -export function ContentViewer({ resource, properties }: { resource: QdnResource; properties?: Record }) { +export function ContentViewer({ resource, properties, binaryMessage }: { resource: QdnResource; properties?: Record; binaryMessage?: string }) { const [state, setState] = useState<{ data?: string; error?: string; loading: boolean }>({ loading: true }); const kind = classifyContent(resource, properties); + const cacheKey = previewCacheKey(resource, kind); useEffect(() => { if (kind === 'binary') { setState({ loading: false }); return; } + const cached = previewCache.get(cacheKey); + if (cached !== undefined) { setState({ data: cached, loading: false }); return; } let active = true; setState({ loading: true }); - void qdnRequest(resourceFetchRequest(resource, { binary: kind === 'image', maxBytes: CONTENT_MAX_BYTES })).then(data => { if (active) setState({ data: toText(data), loading: false }); }).catch(error => { if (active) setState({ error: error instanceof Error ? error.message : 'Unable to fetch content.', loading: false }); }); + void qdnRequest(resourceFetchRequest(resource, { binary: kind === 'image', maxBytes: CONTENT_MAX_BYTES })).then(data => { + const text = toText(data); + previewCache.set(cacheKey, text); + if (active) setState({ data: text, loading: false }); + }).catch(error => { if (active) setState({ error: error instanceof Error ? error.message : 'Unable to fetch content.', loading: false }); }); return () => { active = false; }; - }, [kind, resource.identifier, resource.name, resource.path, resource.service]); - if (kind === 'binary') return ; + }, [cacheKey, kind, resource.identifier, resource.name, resource.path, resource.service]); + if (kind === 'binary') return ; if (state.loading) return

Loading preview…

; if (state.error) return

{state.error}

; return ; diff --git a/src/locales/en.ts b/src/locales/en.ts index 8ed878a..8d29e61 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -11,6 +11,7 @@ export const EN_STRINGS = { 'label.status': 'Status', 'label.title': 'Title', 'label.type': 'Type', 'label.unknown': 'Unknown', 'loading': 'Loading…', 'search.help': 'Search titles, descriptions, and resource fields across public QDN.', 'thumbnail.placeholder': 'Preview unavailable', 'viewer.download': 'Download resource', 'viewer.binary': 'This resource cannot be rendered safely in Explore.', - 'viewer.empty': 'This resource has no text content.', 'viewer.json': 'JSON', 'viewer.selectFile': 'Select a file to preview it here.', 'viewer.source': 'Source preview', + 'viewer.empty': 'This resource has no text content.', 'viewer.json': 'JSON', 'viewer.openInHome': 'Use Open to preview this resource in Home’s built-in viewer, or Download to save its original bytes.', + 'viewer.preview': 'Preview', 'viewer.selectFile': 'Select a file to preview it here.', 'viewer.source': 'Source preview', 'preview.canceled': 'File selection was canceled.', 'preview.choosing': 'Choosing file…', 'preview.opened': 'Preview opened in Home.', } as const; diff --git a/src/previewCache.test.ts b/src/previewCache.test.ts new file mode 100644 index 0000000..8831b97 --- /dev/null +++ b/src/previewCache.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { PreviewCache, previewCacheKey } from './previewCache'; + +describe('PreviewCache', () => { + it('keeps recently used previews and evicts the least recently used entry', () => { + const cache = new PreviewCache(2, 100); + cache.set('first', 'one'); + cache.set('second', 'two'); + expect(cache.get('first')).toBe('one'); + cache.set('third', 'three'); + expect(cache.get('second')).toBeUndefined(); + expect(cache.get('first')).toBe('one'); + expect(cache.get('third')).toBe('three'); + }); + + it('does not retain a preview larger than the byte budget', () => { + const cache = new PreviewCache(2, 4); + cache.set('large', 'abc'); + expect(cache.get('large')).toBeUndefined(); + }); + + it('keys previews by the complete resource and selected path', () => { + expect(previewCacheKey({ service: 'IMAGE', name: 'alice', identifier: 'photo', path: 'one.png' }, 'image')) + .not.toBe(previewCacheKey({ service: 'IMAGE', name: 'alice', identifier: 'photo', path: 'two.png' }, 'image')); + }); +}); diff --git a/src/previewCache.ts b/src/previewCache.ts new file mode 100644 index 0000000..5611c4a --- /dev/null +++ b/src/previewCache.ts @@ -0,0 +1,47 @@ +const MAX_ENTRIES = 12; +const MAX_BYTES = 24 * 1024 * 1024; + +type PreviewEntry = { data: string; size: number }; + +export class PreviewCache { + private readonly entries = new Map(); + private totalBytes = 0; + + constructor( + private readonly maxEntries = MAX_ENTRIES, + private readonly maxBytes = MAX_BYTES, + ) {} + + get(key: string) { + const entry = this.entries.get(key); + if (!entry) return undefined; + this.entries.delete(key); + this.entries.set(key, entry); + return entry.data; + } + + set(key: string, data: string) { + const size = data.length * 2; + const old = this.entries.get(key); + if (old) { + this.totalBytes -= old.size; + this.entries.delete(key); + } + if (size > this.maxBytes) return; + this.entries.set(key, { data, size }); + this.totalBytes += size; + while (this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes) { + const oldestKey = this.entries.keys().next().value; + if (typeof oldestKey !== 'string') break; + const oldest = this.entries.get(oldestKey); + this.entries.delete(oldestKey); + this.totalBytes -= oldest?.size ?? 0; + } + } +} + +export const previewCache = new PreviewCache(); + +export function previewCacheKey(resource: { service: string; name: string; identifier?: string; path?: string }, kind: string) { + return [resource.service, resource.name, resource.identifier || 'default', resource.path || '', kind].join('\u0000'); +} diff --git a/src/resourceDetails.test.ts b/src/resourceDetails.test.ts new file mode 100644 index 0000000..83912e5 --- /dev/null +++ b/src/resourceDetails.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { QdnRequest } from './qdnRequest'; +import { loadResourceDetails } from './resourceDetails'; + +const resource = { service: 'IMAGE', name: 'alice', identifier: 'photo' }; + +describe('loadResourceDetails', () => { + it('does not race the properties endpoint against a resource download', async () => { + const request = vi.fn(async (value: QdnRequest) => value.action === 'GET_QDN_RESOURCE_STATUS' + ? { status: 'PUBLISHED' } + : { title: 'Photo' }); + const details = await loadResourceDetails(request, resource); + expect(details.status?.status).toBe('PUBLISHED'); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('loads optional properties for a ready resource', async () => { + const request = vi.fn(async (value: QdnRequest) => { + if (value.action === 'GET_QDN_RESOURCE_STATUS') return { status: 'READY' }; + if (value.action === 'GET_QDN_RESOURCE_PROPERTIES') return { filename: 'photo.png' }; + return { title: 'Photo' }; + }); + const details = await loadResourceDetails(request, resource); + expect(details.properties).toEqual({ filename: 'photo.png' }); + expect(request).toHaveBeenCalledTimes(3); + }); + + it('loads optional properties for an already downloaded resource', async () => { + const request = vi.fn(async (value: QdnRequest) => value.action === 'GET_QDN_RESOURCE_STATUS' + ? { status: 'DOWNLOADED' } + : value.action === 'GET_QDN_RESOURCE_PROPERTIES' + ? { filename: 'data.json' } + : {}); + const details = await loadResourceDetails(request, resource); + expect(details.properties).toEqual({ filename: 'data.json' }); + }); + + it('keeps usable details when an optional response fails', async () => { + const request = vi.fn(async (value: QdnRequest) => { + if (value.action === 'GET_QDN_RESOURCE_METADATA') throw new Error('metadata unavailable'); + return { status: 'PUBLISHED' }; + }); + await expect(loadResourceDetails(request, resource)).resolves.toMatchObject({ status: { status: 'PUBLISHED' } }); + }); + + it('fails when neither metadata nor status is available', async () => { + const request = vi.fn(async () => { throw new Error('Core unavailable'); }); + await expect(loadResourceDetails(request, resource)).rejects.toThrow('Core unavailable'); + }); +}); diff --git a/src/resourceDetails.ts b/src/resourceDetails.ts new file mode 100644 index 0000000..e854457 --- /dev/null +++ b/src/resourceDetails.ts @@ -0,0 +1,44 @@ +import type { QdnRequest } from './qdnRequest'; +import type { ResourceDetails } from './types'; + +type Request = (request: QdnRequest) => Promise; + +function errorMessage(reason: unknown) { + return reason instanceof Error ? reason.message : 'Could not load resource details.'; +} + +export async function loadResourceDetails( + request: Request, + resource: { service: string; name: string; identifier?: string }, +): Promise { + const [metadataResult, statusResult] = await Promise.allSettled([ + request({ action: 'GET_QDN_RESOURCE_METADATA', ...resource }), + request({ action: 'GET_QDN_RESOURCE_STATUS', ...resource }), + ]); + if (metadataResult.status === 'rejected' && statusResult.status === 'rejected') { + throw new Error(errorMessage(statusResult.reason || metadataResult.reason)); + } + + const metadata = metadataResult.status === 'fulfilled' && metadataResult.value && typeof metadataResult.value === 'object' + ? metadataResult.value as Record + : undefined; + const status = statusResult.status === 'fulfilled' && statusResult.value && typeof statusResult.value === 'object' + ? statusResult.value as ResourceDetails['status'] + : undefined; + let properties: Record | undefined; + + // Core's properties endpoint can initiate a download and return a temporary + // 1401 while ContentViewer is already fetching the same resource. Only ask + // for optional presentation hints once Core says the resource is ready. + if (status?.status === 'READY' || status?.status === 'DOWNLOADED') { + try { + const value = await request({ action: 'GET_QDN_RESOURCE_PROPERTIES', ...resource }); + properties = value && typeof value === 'object' ? value as Record : undefined; + } catch { + // Filename and MIME hints are optional; a miss must not poison a usable + // status, metadata response, or preview. + } + } + + return { metadata, status, properties }; +}