mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 07:21:14 +01:00
Patch: Certificate validation setting + Misc fixes (#642)
- Add certificate validation setting - Fix some OIDC providers not linking emails to local users - Reintroduce sort by peers option for prowlarr results - Fix "All languages" search query reverting to default language - Fix download/request dismissal with multiple admin users - Fix download / request behavior on details modal
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
ButtonStateInfo,
|
||||
RequestPolicyMode,
|
||||
CreateRequestPayload,
|
||||
isMetadataBook,
|
||||
} from './types';
|
||||
import {
|
||||
getBookInfo,
|
||||
@@ -673,12 +674,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Find Downloads" from DetailsModal
|
||||
const handleFindDownloads = (book: Book) => {
|
||||
setSelectedBook(null);
|
||||
setReleaseBook(book);
|
||||
};
|
||||
|
||||
const submitRequest = useCallback(
|
||||
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -1327,9 +1322,16 @@ function App() {
|
||||
book={selectedBook}
|
||||
onClose={() => setSelectedBook(null)}
|
||||
onDownload={handleDownload}
|
||||
onFindDownloads={handleFindDownloads}
|
||||
onFindDownloads={(book) => {
|
||||
setSelectedBook(null);
|
||||
void handleGetReleases(book);
|
||||
}}
|
||||
onSearchSeries={handleSearchSeries}
|
||||
buttonState={getDirectActionButtonState(selectedBook.id)}
|
||||
buttonState={
|
||||
isMetadataBook(selectedBook)
|
||||
? getUniversalActionButtonState(selectedBook.id)
|
||||
: getDirectActionButtonState(selectedBook.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@ export const BookDownloadButton = ({
|
||||
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
|
||||
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
|
||||
const isRequestAction = buttonState.state === 'download' && buttonState.text === 'Request';
|
||||
const iconVariantActionIconPath = isRequestAction
|
||||
? 'M12 4.5v15m7.5-7.5h-15'
|
||||
: 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3';
|
||||
const primaryActionIconPath = isRequestAction ? 'M12 4.5v15m7.5-7.5h-15' : 'M12 4v12m0 0l-4-4m4 4 4-4M6 20h12';
|
||||
|
||||
const primaryStateClasses =
|
||||
isCompleted
|
||||
@@ -198,10 +203,10 @@ export const BookDownloadButton = ({
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconVariantActionIconPath} />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
@@ -221,7 +226,7 @@ export const BookDownloadButton = ({
|
||||
>
|
||||
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={primaryActionIconPath} />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
|
||||
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
|
||||
const isMetadata = isMetadataBook(book);
|
||||
|
||||
const metadataActionText =
|
||||
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
|
||||
? 'Find Downloads'
|
||||
: buttonState.text;
|
||||
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
|
||||
|
||||
// Build metadata grid based on mode
|
||||
@@ -336,19 +339,21 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Action button - Find Downloads (Universal) or Download (Direct) */}
|
||||
{/* Action button - mirrors search result action state/flow */}
|
||||
<button
|
||||
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
|
||||
disabled={!isMetadata && buttonState.state !== 'download'}
|
||||
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
|
||||
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
? buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
|
||||
}`}
|
||||
>
|
||||
{isMetadata ? 'Find Downloads' : buttonState.text}
|
||||
{isMetadata ? metadataActionText : buttonState.text}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -24,7 +24,13 @@ import { ReleaseCell } from './ReleaseCell';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { getNestedValue } from '../utils/objectHelpers';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues, releaseLanguageMatchesFilter, buildLanguageNormalizer } from '../utils/languageFilters';
|
||||
import {
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
getLanguageFilterValues,
|
||||
getReleaseSearchLanguageParams,
|
||||
releaseLanguageMatchesFilter,
|
||||
buildLanguageNormalizer,
|
||||
} from '../utils/languageFilters';
|
||||
|
||||
// Module-level cache for release search results
|
||||
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
|
||||
@@ -1125,10 +1131,7 @@ export const ReleaseModal = ({
|
||||
|
||||
try {
|
||||
// Resolve language codes for the API call (same logic as Apply button)
|
||||
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
|
||||
? undefined
|
||||
: langCodes;
|
||||
const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
|
||||
|
||||
// Pass indexer filter only if the source supports it (empty array = search all)
|
||||
const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer');
|
||||
@@ -1289,6 +1292,21 @@ export const ReleaseModal = ({
|
||||
return columnConfig.columns.filter(col => col.sortable) || [];
|
||||
}, [columnConfig]);
|
||||
|
||||
// Build unified list of all sort options (from sortable columns + extra_sort_options)
|
||||
const allSortOptions = useMemo(() => {
|
||||
const fromColumns = sortableColumns.map(col => ({
|
||||
label: col.label,
|
||||
sortKey: col.sort_key || col.key,
|
||||
defaultDirection: inferDefaultDirection(col.render_type) as 'asc' | 'desc',
|
||||
}));
|
||||
const fromExtra = (columnConfig.extra_sort_options || []).map(opt => ({
|
||||
label: opt.label,
|
||||
sortKey: opt.sort_key,
|
||||
defaultDirection: 'desc' as const, // Extra sort options are typically numeric (e.g., peers)
|
||||
}));
|
||||
return [...fromColumns, ...fromExtra];
|
||||
}, [sortableColumns, columnConfig.extra_sort_options]);
|
||||
|
||||
// Get current sort state for active tab (from state, localStorage, or default to null = best match)
|
||||
const currentSort = useMemo((): SortState | null => {
|
||||
// Check state first - explicit null means "Default" was selected
|
||||
@@ -1299,17 +1317,17 @@ export const ReleaseModal = ({
|
||||
const saved = getSavedSort(activeTab);
|
||||
if (saved) {
|
||||
// Verify the saved sort is still valid for this source
|
||||
const isValid = sortableColumns.some(col => (col.sort_key || col.key) === saved.key);
|
||||
const isValid = allSortOptions.some(opt => opt.sortKey === saved.key);
|
||||
if (isValid) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
// Default to null (best-match sorting)
|
||||
return null;
|
||||
}, [activeTab, sortBySource, sortableColumns]);
|
||||
}, [activeTab, sortBySource, allSortOptions]);
|
||||
|
||||
// Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column
|
||||
const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
|
||||
const handleSortChange = useCallback((sortKey: string | null, defaultDirection: 'asc' | 'desc') => {
|
||||
if (sortKey === null) {
|
||||
// "Default" selected - use best-match sorting
|
||||
setSortBySource(prev => {
|
||||
@@ -1330,16 +1348,16 @@ export const ReleaseModal = ({
|
||||
let newState: SortState;
|
||||
|
||||
if (currentState && currentState.key === sortKey) {
|
||||
// Same column - toggle direction
|
||||
// Same key - toggle direction
|
||||
newState = {
|
||||
key: sortKey,
|
||||
direction: currentState.direction === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
} else {
|
||||
// New column - use default direction for this column type
|
||||
// New key - use provided default direction
|
||||
newState = {
|
||||
key: sortKey,
|
||||
direction: inferDefaultDirection(column!.render_type),
|
||||
direction: defaultDirection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1385,7 +1403,7 @@ export const ReleaseModal = ({
|
||||
});
|
||||
|
||||
// Then, sort by explicit column, or default to book-title relevance with exact author boost
|
||||
if (currentSort && sortableColumns.length > 0) {
|
||||
if (currentSort && allSortOptions.length > 0) {
|
||||
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
|
||||
} else {
|
||||
const responseBook = releasesBySource[activeTab]?.book;
|
||||
@@ -1395,7 +1413,7 @@ export const ReleaseModal = ({
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig, book]);
|
||||
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, allSortOptions, columnConfig, book]);
|
||||
|
||||
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
|
||||
const displayFields = useMemo(() => {
|
||||
@@ -1771,8 +1789,8 @@ export const ReleaseModal = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Sort dropdown - only show if source has sortable columns */}
|
||||
{sortableColumns.length > 0 && (
|
||||
{/* Sort dropdown - only show if source has sort options */}
|
||||
{allSortOptions.length > 0 && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
@@ -1800,7 +1818,7 @@ export const ReleaseModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSortChange(null, null);
|
||||
handleSortChange(null, 'asc');
|
||||
close();
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
|
||||
@@ -1815,16 +1833,15 @@ export const ReleaseModal = ({
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{sortableColumns.map((col) => {
|
||||
const sortKey = col.sort_key || col.key;
|
||||
const isSelected = currentSort?.key === sortKey;
|
||||
{allSortOptions.map((opt) => {
|
||||
const isSelected = currentSort?.key === opt.sortKey;
|
||||
const direction = isSelected ? currentSort?.direction : null;
|
||||
return (
|
||||
<button
|
||||
key={sortKey}
|
||||
key={opt.sortKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSortChange(sortKey, col);
|
||||
handleSortChange(opt.sortKey, opt.defaultDirection);
|
||||
// Don't close - allow toggling direction
|
||||
if (!isSelected) close();
|
||||
}}
|
||||
@@ -1833,7 +1850,7 @@ export const ReleaseModal = ({
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>{col.label}</span>
|
||||
<span>{opt.label}</span>
|
||||
{isSelected && direction && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
{direction === 'asc' ? (
|
||||
@@ -1955,11 +1972,7 @@ export const ReleaseModal = ({
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
|
||||
try {
|
||||
// Resolve language codes for the API call
|
||||
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
// Don't pass languages if "All" is selected or null
|
||||
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
|
||||
? undefined
|
||||
: langCodes;
|
||||
const languagesParam = getReleaseSearchLanguageParams(languageFilter, bookLanguages, defaultLanguages);
|
||||
|
||||
// Pass indexer filter only if the source supports it (empty array = search all)
|
||||
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Language } from '../types/index.js';
|
||||
import {
|
||||
LANGUAGE_OPTION_ALL,
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
getReleaseSearchLanguageParams,
|
||||
} from '../utils/languageFilters.js';
|
||||
|
||||
const supportedLanguages: Language[] = [
|
||||
{ code: 'en', language: 'English' },
|
||||
{ code: 'de', language: 'German' },
|
||||
{ code: 'hu', language: 'Hungarian' },
|
||||
];
|
||||
|
||||
describe('languageFilters release search params', () => {
|
||||
it('omits languages when only default selection is active', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
[LANGUAGE_OPTION_DEFAULT],
|
||||
supportedLanguages,
|
||||
['en'],
|
||||
);
|
||||
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it('preserves explicit all-languages selection', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
[LANGUAGE_OPTION_ALL],
|
||||
supportedLanguages,
|
||||
['en'],
|
||||
);
|
||||
|
||||
assert.deepEqual(result, [LANGUAGE_OPTION_ALL]);
|
||||
});
|
||||
|
||||
it('resolves explicit language selections to codes', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
['de', 'hu'],
|
||||
supportedLanguages,
|
||||
['en'],
|
||||
);
|
||||
|
||||
assert.deepEqual(result, ['de', 'hu']);
|
||||
});
|
||||
});
|
||||
@@ -315,6 +315,11 @@ export interface LeadingCellConfig {
|
||||
uppercase?: boolean; // Force uppercase for badge text
|
||||
}
|
||||
|
||||
export interface ExtraSortOption {
|
||||
label: string; // Display label in the sort dropdown
|
||||
sort_key: string; // Field to sort by on the Release object
|
||||
}
|
||||
|
||||
export interface SourceActionButton {
|
||||
label: string; // Button text (e.g., "Refresh search")
|
||||
action: string; // Action type: "expand" triggers expand_search
|
||||
@@ -329,6 +334,7 @@ export interface ReleaseColumnConfig {
|
||||
default_indexers?: string[]; // For Prowlarr: indexers selected in settings (pre-selected in filter)
|
||||
cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min)
|
||||
supported_filters?: string[]; // Which filters this source supports: ["format", "language", "indexer"]
|
||||
extra_sort_options?: ExtraSortOption[]; // Additional sort options not tied to a column
|
||||
action_button?: SourceActionButton; // Custom action button (replaces default expand search)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,21 @@ export const getLanguageFilterValues = (
|
||||
return resolved.size ? Array.from(resolved) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve language selection for /api/releases requests.
|
||||
* - undefined: use backend defaults
|
||||
* - ["all"]: disable language filtering
|
||||
* - ["en", ...]: explicit filter list
|
||||
*/
|
||||
export const getReleaseSearchLanguageParams = (
|
||||
selection: string[],
|
||||
supportedLanguages: Language[],
|
||||
defaultLanguageCodes: string[] = [],
|
||||
): string[] | undefined => {
|
||||
const resolved = getLanguageFilterValues(selection, supportedLanguages, defaultLanguageCodes);
|
||||
return resolved === null ? undefined : resolved;
|
||||
};
|
||||
|
||||
export const formatDefaultLanguageLabel = (
|
||||
languageCodes: string[],
|
||||
supportedLanguages: Language[],
|
||||
@@ -136,4 +151,3 @@ export const releaseLanguageMatchesFilter = (
|
||||
const selectedSet = new Set(selectedCodes.map(c => c.toLowerCase()));
|
||||
return releaseCodes.every(code => selectedSet.has(code));
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user