feat: add conditions management for custom formats

- Introduced a new `listConditions` query to fetch conditions for custom formats.
- Created a new `ConditionListItem` type for better type safety.
- Added a new route for managing conditions under custom formats.
- Implemented UI components for displaying and managing conditions, including `ConditionCard` and `DraftConditionCard`.
- Enhanced the layout to include a new tab for conditions.
- Added support for various condition types and their respective options.
This commit is contained in:
Sam Chau
2025-12-31 16:40:41 +10:30
parent 56cf061a4b
commit 95795d5f0e
10 changed files with 1276 additions and 1 deletions

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
type Option = {
value: string;
label: string;
};
export let options: Option[] = [];
export let selected: Option[] = [];
export let max: number = 1;
export let placeholder: string = 'Type to search...';
export let mono: boolean = false;
const dispatch = createEventDispatcher<{
change: Option[];
}>();
let inputValue = '';
let isOpen = false;
let highlightedIndex = 0;
let inputElement: HTMLInputElement;
// Filter options based on input and exclude already selected
$: filteredOptions = options.filter(
(opt) =>
opt.label.toLowerCase().includes(inputValue.toLowerCase()) &&
!selected.some((s) => s.value === opt.value)
);
// Reset highlight when filtered options change
$: if (filteredOptions.length > 0) {
highlightedIndex = Math.min(highlightedIndex, filteredOptions.length - 1);
}
function handleInput() {
isOpen = true;
highlightedIndex = 0;
}
function handleFocus() {
isOpen = true;
}
function handleBlur(event: FocusEvent) {
// Delay closing to allow click on option
setTimeout(() => {
isOpen = false;
inputValue = '';
}, 150);
}
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
isOpen = true;
event.preventDefault();
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, 0);
break;
case 'Enter':
event.preventDefault();
if (filteredOptions[highlightedIndex]) {
selectOption(filteredOptions[highlightedIndex]);
}
break;
case 'Escape':
event.preventDefault();
isOpen = false;
inputValue = '';
break;
case 'Backspace':
if (inputValue === '' && selected.length > 0) {
removeOption(selected[selected.length - 1]);
}
break;
}
}
function selectOption(option: Option) {
if (selected.length >= max) {
// Replace the last one if at max
selected = max === 1 ? [option] : [...selected.slice(0, -1), option];
} else {
selected = [...selected, option];
}
dispatch('change', selected);
inputValue = '';
isOpen = false;
inputElement?.focus();
}
function removeOption(option: Option) {
selected = selected.filter((s) => s.value !== option.value);
dispatch('change', selected);
inputElement?.focus();
}
</script>
<div class="relative">
<!-- Input container with selected tags -->
<div
class="flex min-h-[2.25rem] flex-wrap items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-2 py-1.5 dark:border-neutral-700 dark:bg-neutral-800"
>
<!-- Selected items as tags -->
{#each selected as item (item.value)}
<div
class="flex items-center gap-1 rounded bg-accent-100 px-2 py-0.5 text-sm text-accent-800 dark:bg-accent-900/30 dark:text-accent-300"
>
<span class={mono ? 'font-mono' : ''}>{item.label}</span>
<button
type="button"
on:click={() => removeOption(item)}
class="cursor-pointer hover:text-accent-900 dark:hover:text-accent-100"
aria-label="Remove {item.label}"
>
<X size={14} />
</button>
</div>
{/each}
<!-- Input field -->
{#if selected.length < max}
<input
bind:this={inputElement}
type="text"
bind:value={inputValue}
on:input={handleInput}
on:focus={handleFocus}
on:blur={handleBlur}
on:keydown={handleKeydown}
{placeholder}
class="min-w-[120px] flex-1 border-0 bg-transparent text-sm text-neutral-900 outline-none placeholder:text-neutral-400 focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-500 {mono ? 'font-mono' : ''}"
/>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen && filteredOptions.length > 0}
<div
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each filteredOptions as option, index (option.value)}
<button
type="button"
on:mousedown|preventDefault={() => selectOption(option)}
on:mouseenter={() => (highlightedIndex = index)}
class="w-full px-3 py-2 text-left text-sm transition-colors {mono ? 'font-mono' : ''} {highlightedIndex === index
? 'bg-accent-100 text-accent-900 dark:bg-accent-900/30 dark:text-accent-100'
: 'text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700'}"
>
{option.label}
</button>
{/each}
</div>
{/if}
<!-- No results message -->
{#if isOpen && inputValue && filteredOptions.length === 0}
<div
class="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400"
>
No matches found
</div>
{/if}
</div>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
type Option = {
value: string;
label: string;
};
export let options: Option[] = [];
export let value: string = '';
export let placeholder: string = 'Select...';
export let mono: boolean = false;
const dispatch = createEventDispatcher<{
change: string;
}>();
let isOpen = false;
let highlightedIndex = -1;
let containerElement: HTMLDivElement;
$: selectedOption = options.find((opt) => opt.value === value);
$: if (isOpen) {
highlightedIndex = options.findIndex((opt) => opt.value === value);
}
function toggle() {
isOpen = !isOpen;
}
function handleBlur(event: FocusEvent) {
// Check if focus moved outside the container
setTimeout(() => {
if (!containerElement?.contains(document.activeElement)) {
isOpen = false;
}
}, 0);
}
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
isOpen = true;
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, options.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, 0);
break;
case 'Enter':
case ' ':
event.preventDefault();
if (options[highlightedIndex]) {
selectOption(options[highlightedIndex]);
}
break;
case 'Escape':
event.preventDefault();
isOpen = false;
break;
}
}
function selectOption(option: Option) {
value = option.value;
dispatch('change', value);
isOpen = false;
}
</script>
<div class="relative" bind:this={containerElement}>
<!-- Trigger button -->
<button
type="button"
on:click={toggle}
on:blur={handleBlur}
on:keydown={handleKeydown}
class="flex min-h-[2.25rem] w-full items-center justify-between gap-2 rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-left text-sm dark:border-neutral-700 dark:bg-neutral-800"
>
<span class="{mono ? 'font-mono' : ''} {selectedOption ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-400 dark:text-neutral-500'}">
{selectedOption?.label ?? placeholder}
</span>
<ChevronDown
size={16}
class="text-neutral-400 transition-transform dark:text-neutral-500 {isOpen ? 'rotate-180' : ''}"
/>
</button>
<!-- Dropdown -->
{#if isOpen}
<div
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each options as option, index (option.value)}
<button
type="button"
on:mousedown|preventDefault={() => selectOption(option)}
on:mouseenter={() => (highlightedIndex = index)}
class="w-full px-3 py-2 text-left text-sm transition-colors {mono ? 'font-mono' : ''} {highlightedIndex === index
? 'bg-accent-100 text-accent-900 dark:bg-accent-900/30 dark:text-accent-100'
: 'text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700'}"
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -8,6 +8,7 @@ export type { CreateTestInput, CreateTestOptions } from './testCreate.ts';
export type { UpdateTestInput, UpdateTestOptions } from './testUpdate.ts';
export type { DeleteTestOptions } from './testDelete.ts';
export type { ConditionData } from './conditions.ts';
export type { ConditionListItem } from './listConditions.ts';
export type { ConditionResult, EvaluationResult, ParsedInfo } from './evaluator.ts';
// Export query functions (reads)
@@ -15,6 +16,7 @@ export { list } from './list.ts';
export { general } from './general.ts';
export { getById, listTests, getTestById } from './tests.ts';
export { getConditionsForEvaluation } from './conditions.ts';
export { listConditions } from './listConditions.ts';
export { evaluateCustomFormat, getParsedInfo } from './evaluator.ts';
// Export mutation functions (writes via PCD operations)

View File

@@ -0,0 +1,39 @@
/**
* Custom format condition list query
*/
import type { PCDCache } from '../../cache.ts';
/** Condition item for list display */
export interface ConditionListItem {
id: number;
name: string;
type: string;
negate: boolean;
required: boolean;
}
/**
* Get all conditions for a custom format (basic info for list display)
*/
export async function listConditions(
cache: PCDCache,
formatId: number
): Promise<ConditionListItem[]> {
const db = cache.kb;
const conditions = await db
.selectFrom('custom_format_conditions')
.select(['id', 'name', 'type', 'negate', 'required'])
.where('custom_format_id', '=', formatId)
.orderBy('id')
.execute();
return conditions.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
negate: c.negate === 1,
required: c.required === 1
}));
}

View File

@@ -0,0 +1,84 @@
/**
* Condition types and their valid values for custom formats
*/
// Arr type for filtering
export type ArrType = 'all' | 'radarr' | 'sonarr';
// Condition type definitions
export const CONDITION_TYPES = [
{ value: 'release_title', label: 'Release Title', arrType: 'all' as ArrType },
{ value: 'release_group', label: 'Release Group', arrType: 'all' as ArrType },
{ value: 'edition', label: 'Edition', arrType: 'radarr' as ArrType },
{ value: 'language', label: 'Language', arrType: 'all' as ArrType },
{ value: 'source', label: 'Source', arrType: 'all' as ArrType },
{ value: 'resolution', label: 'Resolution', arrType: 'all' as ArrType },
{ value: 'quality_modifier', label: 'Quality Modifier', arrType: 'radarr' as ArrType },
{ value: 'release_type', label: 'Release Type', arrType: 'sonarr' as ArrType },
{ value: 'indexer_flag', label: 'Indexer Flag', arrType: 'all' as ArrType },
{ value: 'size', label: 'Size', arrType: 'all' as ArrType },
{ value: 'year', label: 'Year', arrType: 'all' as ArrType }
] as const;
// Pattern-based types (use regex patterns as values)
export const PATTERN_TYPES = ['release_title', 'release_group', 'edition'] as const;
export type ConditionType = (typeof CONDITION_TYPES)[number]['value'];
// Source values
export const SOURCE_VALUES = [
{ value: 'unknown', label: 'Unknown', arrType: 'all' as ArrType },
{ value: 'television', label: 'Television', arrType: 'all' as ArrType },
{ value: 'television_raw', label: 'Television Raw', arrType: 'sonarr' as ArrType },
{ value: 'webdl', label: 'WEB-DL', arrType: 'all' as ArrType },
{ value: 'webrip', label: 'WEBRip', arrType: 'all' as ArrType },
{ value: 'dvd', label: 'DVD', arrType: 'all' as ArrType },
{ value: 'bluray', label: 'Bluray', arrType: 'all' as ArrType },
{ value: 'bluray_raw', label: 'Bluray Raw', arrType: 'sonarr' as ArrType },
{ value: 'cam', label: 'CAM', arrType: 'all' as ArrType },
{ value: 'telesync', label: 'Telesync', arrType: 'all' as ArrType },
{ value: 'telecine', label: 'Telecine', arrType: 'all' as ArrType },
{ value: 'workprint', label: 'Workprint', arrType: 'all' as ArrType }
] as const;
// Resolution values
export const RESOLUTION_VALUES = [
{ value: '360p', label: '360p', arrType: 'all' as ArrType },
{ value: '480p', label: '480p', arrType: 'all' as ArrType },
{ value: '540p', label: '540p', arrType: 'all' as ArrType },
{ value: '576p', label: '576p', arrType: 'all' as ArrType },
{ value: '720p', label: '720p', arrType: 'all' as ArrType },
{ value: '1080p', label: '1080p', arrType: 'all' as ArrType },
{ value: '2160p', label: '2160p', arrType: 'all' as ArrType }
] as const;
// Quality modifier values (Radarr only)
export const QUALITY_MODIFIER_VALUES = [
{ value: 'none', label: 'None', arrType: 'radarr' as ArrType },
{ value: 'regional', label: 'Regional', arrType: 'radarr' as ArrType },
{ value: 'screener', label: 'Screener', arrType: 'radarr' as ArrType },
{ value: 'rawhd', label: 'RawHD', arrType: 'radarr' as ArrType },
{ value: 'brdisk', label: 'BRDISK', arrType: 'radarr' as ArrType },
{ value: 'remux', label: 'REMUX', arrType: 'radarr' as ArrType }
] as const;
// Release type values (Sonarr only)
export const RELEASE_TYPE_VALUES = [
{ value: 'single_episode', label: 'Single Episode', arrType: 'sonarr' as ArrType },
{ value: 'multi_episode', label: 'Multi Episode', arrType: 'sonarr' as ArrType },
{ value: 'season_pack', label: 'Season Pack', arrType: 'sonarr' as ArrType }
] as const;
// Indexer flag values
export const INDEXER_FLAG_VALUES = [
{ value: 'freeleech', label: 'Freeleech', arrType: 'all' as ArrType },
{ value: 'halfleech', label: 'Halfleech', arrType: 'all' as ArrType },
{ value: 'double_upload', label: 'Double Upload', arrType: 'all' as ArrType },
{ value: 'internal', label: 'Internal', arrType: 'all' as ArrType },
{ value: 'scene', label: 'Scene', arrType: 'all' as ArrType },
{ value: 'freeleech_75', label: 'Freeleech 75%', arrType: 'all' as ArrType },
{ value: 'freeleech_25', label: 'Freeleech 25%', arrType: 'all' as ArrType },
{ value: 'nuked', label: 'Nuked', arrType: 'all' as ArrType },
{ value: 'ptp_golden', label: 'PTP Golden', arrType: 'radarr' as ArrType },
{ value: 'ptp_approved', label: 'PTP Approved', arrType: 'radarr' as ArrType }
] as const;

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import { FileText, FlaskConical } from 'lucide-svelte';
import { FileText, Filter, FlaskConical } from 'lucide-svelte';
$: databaseId = $page.params.databaseId;
$: formatId = $page.params.id;
@@ -14,6 +14,12 @@
active: currentPath.includes('/general'),
icon: FileText
},
{
label: 'Conditions',
href: `/custom-formats/${databaseId}/${formatId}/conditions`,
active: currentPath.includes('/conditions'),
icon: Filter
},
{
label: 'Testing',
href: `/custom-formats/${databaseId}/${formatId}/testing`,

View File

@@ -0,0 +1,52 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
import * as regularExpressionQueries from '$pcd/queries/regularExpressions/index.ts';
import * as languageQueries from '$pcd/queries/languages.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
// Validate params exist
if (!databaseId || !id) {
throw error(400, 'Missing required parameters');
}
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Parse and validate the format ID
const formatId = parseInt(id, 10);
if (isNaN(formatId)) {
throw error(400, 'Invalid format ID');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
// Get custom format basic info, conditions, and available options
const [format, conditions, patterns, languages] = await Promise.all([
customFormatQueries.getById(cache, formatId),
customFormatQueries.getConditionsForEvaluation(cache, formatId),
regularExpressionQueries.list(cache),
languageQueries.list(cache)
]);
if (!format) {
throw error(404, 'Custom format not found');
}
return {
format,
conditions,
availablePatterns: patterns.map((p) => ({ id: p.id, name: p.name, pattern: p.pattern })),
availableLanguages: languages
};
};

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import ConditionCard from './components/ConditionCard.svelte';
import DraftConditionCard from './components/DraftConditionCard.svelte';
import Badge from '$ui/badge/Badge.svelte';
import { CONDITION_TYPES } from '$lib/shared/conditionTypes';
import type { PageData } from './$types';
import type { ConditionData } from '$pcd/queries/customFormats/index';
export let data: PageData;
// Draft conditions (not yet confirmed)
let draftConditions: ConditionData[] = [];
let nextDraftId = -1; // Use negative IDs for drafts
// Group conditions by type
$: groupedConditions = data.conditions.reduce(
(acc, condition) => {
const type = condition.type;
if (!acc[type]) acc[type] = [];
acc[type].push(condition);
return acc;
},
{} as Record<string, typeof data.conditions>
);
// Get ordered types (only those that have conditions)
$: orderedTypes = CONDITION_TYPES.filter((t) => groupedConditions[t.value]).map((t) => ({
value: t.value,
label: t.label,
conditions: groupedConditions[t.value]
}));
function handleRemove(conditionId: number) {
data.conditions = data.conditions.filter((c) => c.id !== conditionId);
}
function addDraftCondition() {
const draft: ConditionData = {
id: nextDraftId--,
name: 'New Condition',
type: 'release_title',
negate: false,
required: false
};
draftConditions = [...draftConditions, draft];
}
function confirmDraft(draft: ConditionData) {
// Remove from drafts
draftConditions = draftConditions.filter((d) => d.id !== draft.id);
// Add to main conditions with a new positive ID
const newId = Math.max(0, ...data.conditions.map((c) => c.id)) + 1;
data.conditions = [...data.conditions, { ...draft, id: newId }];
}
function discardDraft(draftId: number) {
draftConditions = draftConditions.filter((d) => d.id !== draftId);
}
</script>
<svelte:head>
<title>{data.format.name} - Conditions - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">Conditions</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Define the conditions that must be met for this custom format to match a release.
</p>
</div>
<button
type="button"
on:click={addDraftCondition}
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700"
>
<Plus size={16} />
Add Condition
</button>
</div>
<!-- Draft conditions -->
{#if draftConditions.length > 0}
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">Drafts</span>
<Badge variant="neutral" size="sm">{draftConditions.length}</Badge>
</div>
{#each draftConditions as draft (draft.id)}
<DraftConditionCard
condition={draft}
availablePatterns={data.availablePatterns}
availableLanguages={data.availableLanguages}
on:confirm={() => confirmDraft(draft)}
on:discard={() => discardDraft(draft.id)}
/>
{/each}
</div>
{/if}
<!-- Existing conditions grouped by type -->
{#if data.conditions.length === 0 && draftConditions.length === 0}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No conditions defined</p>
{:else}
{#each orderedTypes as group (group.value)}
<div class="space-y-2">
<!-- Group header -->
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">
{group.label}
</span>
<Badge variant="neutral" size="sm">{group.conditions.length}</Badge>
</div>
<!-- Conditions -->
{#each group.conditions as condition (condition.id)}
<ConditionCard
{condition}
availablePatterns={data.availablePatterns}
availableLanguages={data.availableLanguages}
on:remove={() => handleRemove(condition.id)}
/>
{/each}
</div>
{/each}
{/if}
</div>

View File

@@ -0,0 +1,327 @@
<script lang="ts">
import { Check, X, Trash2 } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import Autocomplete from '$ui/form/Autocomplete.svelte';
import Select from '$ui/form/Select.svelte';
const dispatch = createEventDispatcher<{ remove: void }>();
import {
CONDITION_TYPES,
PATTERN_TYPES,
SOURCE_VALUES,
RESOLUTION_VALUES,
QUALITY_MODIFIER_VALUES,
RELEASE_TYPE_VALUES,
INDEXER_FLAG_VALUES,
type ArrType
} from '$lib/shared/conditionTypes';
import type { ConditionData } from '$pcd/queries/customFormats/index';
export let condition: ConditionData;
export let arrType: ArrType = 'all';
// Available patterns and languages from database (passed in)
export let availablePatterns: { id: number; name: string; pattern: string }[] = [];
export let availableLanguages: { id: number; name: string }[] = [];
// Filter condition types based on arrType
$: filteredConditionTypes = CONDITION_TYPES.filter(
(t) => t.arrType === 'all' || t.arrType === arrType
);
// Get value options based on current type
$: valueOptions = getValueOptions(condition.type);
function getValueOptions(type: string) {
switch (type) {
case 'source':
return SOURCE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'resolution':
return RESOLUTION_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'quality_modifier':
return QUALITY_MODIFIER_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'release_type':
return RELEASE_TYPE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'indexer_flag':
return INDEXER_FLAG_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
default:
return [];
}
}
// Check if type is pattern-based
$: isPatternType = PATTERN_TYPES.includes(condition.type as typeof PATTERN_TYPES[number]);
// Autocomplete options for patterns
$: patternOptions = availablePatterns.map((p) => ({ value: p.id.toString(), label: p.name }));
// Currently selected pattern for Autocomplete
$: selectedPatterns = condition.patterns
? condition.patterns.map((p) => ({ value: p.id.toString(), label: availablePatterns.find((ap) => ap.id === p.id)?.name ?? p.pattern }))
: [];
function handlePatternChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const patternId = parseInt(selected[0].value);
const pattern = availablePatterns.find((p) => p.id === patternId);
condition.patterns = pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [];
} else {
condition.patterns = [];
}
}
// Reactive selected value based on condition type
$: selectedValue = (() => {
if (isPatternType) {
return condition.patterns?.[0]?.id?.toString() ?? '';
}
switch (condition.type) {
case 'source':
return condition.sources?.[0] ?? '';
case 'resolution':
return condition.resolutions?.[0] ?? '';
case 'quality_modifier':
return condition.qualityModifiers?.[0] ?? '';
case 'release_type':
return condition.releaseTypes?.[0] ?? '';
case 'indexer_flag':
return condition.indexerFlags?.[0] ?? '';
case 'language':
return condition.languages?.[0]?.id?.toString() ?? '';
default:
return '';
}
})();
// Update value when Select changes
function handleSelectChange(value: string) {
switch (condition.type) {
case 'source':
condition.sources = value ? [value] : [];
break;
case 'resolution':
condition.resolutions = value ? [value] : [];
break;
case 'quality_modifier':
condition.qualityModifiers = value ? [value] : [];
break;
case 'release_type':
condition.releaseTypes = value ? [value] : [];
break;
case 'indexer_flag':
condition.indexerFlags = value ? [value] : [];
break;
case 'language':
const langId = parseInt(value);
const lang = availableLanguages.find((l) => l.id === langId);
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
break;
}
}
// Language options for Autocomplete (Any first, Original second, then alphabetical)
$: languageOptions = availableLanguages
.map((l) => ({ value: l.id.toString(), label: l.name }))
.sort((a, b) => {
if (a.label === 'Any') return -1;
if (b.label === 'Any') return 1;
if (a.label === 'Original') return -1;
if (b.label === 'Original') return 1;
return a.label.localeCompare(b.label);
});
// Currently selected language for Autocomplete
$: selectedLanguages = condition.languages
? condition.languages.map((l) => ({ value: l.id.toString(), label: l.name }))
: [];
function handleLanguageChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const langId = parseInt(selected[0].value);
const lang = availableLanguages.find((l) => l.id === langId);
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
} else {
condition.languages = [];
}
}
// Handle type change - reset values
function handleTypeChange(newType: string) {
condition.type = newType;
// Reset all value fields
condition.patterns = undefined;
condition.languages = undefined;
condition.sources = undefined;
condition.resolutions = undefined;
condition.qualityModifiers = undefined;
condition.releaseTypes = undefined;
condition.indexerFlags = undefined;
condition.size = undefined;
condition.years = undefined;
}
// Type options for Select
$: typeOptions = filteredConditionTypes.map((t) => ({ value: t.value, label: t.label }));
// Size helpers (convert between bytes and GB for display)
$: minSizeGB = condition.size?.minBytes ? condition.size.minBytes / 1024 / 1024 / 1024 : null;
$: maxSizeGB = condition.size?.maxBytes ? condition.size.maxBytes / 1024 / 1024 / 1024 : null;
function handleMinSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
condition.size.minBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
}
function handleMaxSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
condition.size.maxBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
}
function handleMinYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
if (!condition.years) condition.years = { minYear: null, maxYear: null };
condition.years.minYear = isNaN(value) ? null : value;
}
function handleMaxYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
if (!condition.years) condition.years = { minYear: null, maxYear: null };
condition.years.maxYear = isNaN(value) ? null : value;
}
const inputClass =
'w-full rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm font-mono text-neutral-900 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100';
</script>
<div
class="flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800"
>
<!-- Name -->
<div class="w-48 shrink-0">
<input
type="text"
bind:value={condition.name}
placeholder="Condition name"
class={inputClass}
/>
</div>
<!-- Type dropdown -->
<div class="w-44 shrink-0">
<Select
options={typeOptions}
value={condition.type}
placeholder="Select type..."
mono
on:change={(e) => handleTypeChange(e.detail)}
/>
</div>
<!-- Value input - changes based on type -->
<div class="min-w-0 flex-1">
{#if isPatternType}
<Autocomplete
options={patternOptions}
selected={selectedPatterns}
max={1}
placeholder="Search patterns..."
mono
on:change={handlePatternChange}
/>
{:else if condition.type === 'language'}
<Autocomplete
options={languageOptions}
selected={selectedLanguages}
max={1}
placeholder="Search languages..."
mono
on:change={handleLanguageChange}
/>
{:else if condition.type === 'size'}
<div class="flex items-center gap-2">
<input
type="number"
step="0.1"
placeholder="Min"
value={minSizeGB ?? ''}
on:change={handleMinSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
step="0.1"
placeholder="Max"
value={maxSizeGB ?? ''}
on:change={handleMaxSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500 dark:text-neutral-400">GB</span>
</div>
{:else if condition.type === 'year'}
<div class="flex items-center gap-2">
<input
type="number"
placeholder="Min"
value={condition.years?.minYear ?? ''}
on:change={handleMinYearChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
placeholder="Max"
value={condition.years?.maxYear ?? ''}
on:change={handleMaxYearChange}
class="{inputClass} w-20 font-mono"
/>
</div>
{:else}
<!-- Enum-based types -->
<Select
options={valueOptions}
value={selectedValue}
placeholder="Select value..."
mono
on:change={(e) => handleSelectChange(e.detail)}
/>
{/if}
</div>
<!-- Negate -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={X}
checked={condition.negate}
color="red"
on:click={() => (condition.negate = !condition.negate)}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
</div>
<!-- Required -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={condition.required}
color="green"
on:click={() => (condition.required = !condition.required)}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
</div>
<!-- Remove -->
<button
type="button"
on:click={() => dispatch('remove')}
class="shrink-0 cursor-pointer p-1 text-neutral-400 transition-colors hover:text-red-500 dark:text-neutral-500 dark:hover:text-red-400"
title="Remove condition"
>
<Trash2 size={16} />
</button>
</div>

View File

@@ -0,0 +1,340 @@
<script lang="ts">
import { Check, X, Trash2 } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import Autocomplete from '$ui/form/Autocomplete.svelte';
import Select from '$ui/form/Select.svelte';
import {
CONDITION_TYPES,
PATTERN_TYPES,
SOURCE_VALUES,
RESOLUTION_VALUES,
QUALITY_MODIFIER_VALUES,
RELEASE_TYPE_VALUES,
INDEXER_FLAG_VALUES,
type ArrType
} from '$lib/shared/conditionTypes';
import type { ConditionData } from '$pcd/queries/customFormats/index';
const dispatch = createEventDispatcher<{ confirm: void; discard: void }>();
export let condition: ConditionData;
export let arrType: ArrType = 'all';
// Available patterns and languages from database (passed in)
export let availablePatterns: { id: number; name: string; pattern: string }[] = [];
export let availableLanguages: { id: number; name: string }[] = [];
// Filter condition types based on arrType
$: filteredConditionTypes = CONDITION_TYPES.filter(
(t) => t.arrType === 'all' || t.arrType === arrType
);
// Get value options based on current type
$: valueOptions = getValueOptions(condition.type);
function getValueOptions(type: string) {
switch (type) {
case 'source':
return SOURCE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'resolution':
return RESOLUTION_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'quality_modifier':
return QUALITY_MODIFIER_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'release_type':
return RELEASE_TYPE_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
case 'indexer_flag':
return INDEXER_FLAG_VALUES.filter((v) => v.arrType === 'all' || v.arrType === arrType);
default:
return [];
}
}
// Check if type is pattern-based
$: isPatternType = PATTERN_TYPES.includes(condition.type as (typeof PATTERN_TYPES)[number]);
// Autocomplete options for patterns
$: patternOptions = availablePatterns.map((p) => ({ value: p.id.toString(), label: p.name }));
// Currently selected pattern for Autocomplete
$: selectedPatterns = condition.patterns
? condition.patterns.map((p) => ({
value: p.id.toString(),
label: availablePatterns.find((ap) => ap.id === p.id)?.name ?? p.pattern
}))
: [];
function handlePatternChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const patternId = parseInt(selected[0].value);
const pattern = availablePatterns.find((p) => p.id === patternId);
condition.patterns = pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [];
} else {
condition.patterns = [];
}
}
// Reactive selected value based on condition type
$: selectedValue = (() => {
if (isPatternType) {
return condition.patterns?.[0]?.id?.toString() ?? '';
}
switch (condition.type) {
case 'source':
return condition.sources?.[0] ?? '';
case 'resolution':
return condition.resolutions?.[0] ?? '';
case 'quality_modifier':
return condition.qualityModifiers?.[0] ?? '';
case 'release_type':
return condition.releaseTypes?.[0] ?? '';
case 'indexer_flag':
return condition.indexerFlags?.[0] ?? '';
case 'language':
return condition.languages?.[0]?.id?.toString() ?? '';
default:
return '';
}
})();
// Update value when Select changes
function handleSelectChange(value: string) {
switch (condition.type) {
case 'source':
condition.sources = value ? [value] : [];
break;
case 'resolution':
condition.resolutions = value ? [value] : [];
break;
case 'quality_modifier':
condition.qualityModifiers = value ? [value] : [];
break;
case 'release_type':
condition.releaseTypes = value ? [value] : [];
break;
case 'indexer_flag':
condition.indexerFlags = value ? [value] : [];
break;
case 'language':
const langId = parseInt(value);
const lang = availableLanguages.find((l) => l.id === langId);
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
break;
}
}
// Language options for Autocomplete (Any first, Original second, then alphabetical)
$: languageOptions = availableLanguages
.map((l) => ({ value: l.id.toString(), label: l.name }))
.sort((a, b) => {
if (a.label === 'Any') return -1;
if (b.label === 'Any') return 1;
if (a.label === 'Original') return -1;
if (b.label === 'Original') return 1;
return a.label.localeCompare(b.label);
});
// Currently selected language for Autocomplete
$: selectedLanguages = condition.languages
? condition.languages.map((l) => ({ value: l.id.toString(), label: l.name }))
: [];
function handleLanguageChange(event: CustomEvent<{ value: string; label: string }[]>) {
const selected = event.detail;
if (selected.length > 0) {
const langId = parseInt(selected[0].value);
const lang = availableLanguages.find((l) => l.id === langId);
condition.languages = lang ? [{ id: lang.id, name: lang.name, except: false }] : [];
} else {
condition.languages = [];
}
}
// Handle type change - reset values
function handleTypeChange(newType: string) {
condition.type = newType;
// Reset all value fields
condition.patterns = undefined;
condition.languages = undefined;
condition.sources = undefined;
condition.resolutions = undefined;
condition.qualityModifiers = undefined;
condition.releaseTypes = undefined;
condition.indexerFlags = undefined;
condition.size = undefined;
condition.years = undefined;
}
// Type options for Select
$: typeOptions = filteredConditionTypes.map((t) => ({ value: t.value, label: t.label }));
// Size helpers (convert between bytes and GB for display)
$: minSizeGB = condition.size?.minBytes ? condition.size.minBytes / 1024 / 1024 / 1024 : null;
$: maxSizeGB = condition.size?.maxBytes ? condition.size.maxBytes / 1024 / 1024 / 1024 : null;
function handleMinSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
condition.size.minBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
}
function handleMaxSizeChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
if (!condition.size) condition.size = { minBytes: null, maxBytes: null };
condition.size.maxBytes = isNaN(value) ? null : Math.round(value * 1024 * 1024 * 1024);
}
function handleMinYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
if (!condition.years) condition.years = { minYear: null, maxYear: null };
condition.years.minYear = isNaN(value) ? null : value;
}
function handleMaxYearChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value);
if (!condition.years) condition.years = { minYear: null, maxYear: null };
condition.years.maxYear = isNaN(value) ? null : value;
}
const inputClass =
'w-full rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm font-mono text-neutral-900 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100';
</script>
<div
class="flex items-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-800"
>
<!-- Name -->
<div class="w-48 shrink-0">
<input
type="text"
bind:value={condition.name}
placeholder="Condition name"
class={inputClass}
/>
</div>
<!-- Type dropdown -->
<div class="w-44 shrink-0">
<Select
options={typeOptions}
value={condition.type}
placeholder="Select type..."
mono
on:change={(e) => handleTypeChange(e.detail)}
/>
</div>
<!-- Value input - changes based on type -->
<div class="min-w-0 flex-1">
{#if isPatternType}
<Autocomplete
options={patternOptions}
selected={selectedPatterns}
max={1}
placeholder="Search patterns..."
mono
on:change={handlePatternChange}
/>
{:else if condition.type === 'language'}
<Autocomplete
options={languageOptions}
selected={selectedLanguages}
max={1}
placeholder="Search languages..."
mono
on:change={handleLanguageChange}
/>
{:else if condition.type === 'size'}
<div class="flex items-center gap-2">
<input
type="number"
step="0.1"
placeholder="Min"
value={minSizeGB ?? ''}
on:change={handleMinSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
step="0.1"
placeholder="Max"
value={maxSizeGB ?? ''}
on:change={handleMaxSizeChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500 dark:text-neutral-400">GB</span>
</div>
{:else if condition.type === 'year'}
<div class="flex items-center gap-2">
<input
type="number"
placeholder="Min"
value={condition.years?.minYear ?? ''}
on:change={handleMinYearChange}
class="{inputClass} w-20 font-mono"
/>
<span class="text-sm text-neutral-500">-</span>
<input
type="number"
placeholder="Max"
value={condition.years?.maxYear ?? ''}
on:change={handleMaxYearChange}
class="{inputClass} w-20 font-mono"
/>
</div>
{:else}
<!-- Enum-based types -->
<Select
options={valueOptions}
value={selectedValue}
placeholder="Select value..."
mono
on:change={(e) => handleSelectChange(e.detail)}
/>
{/if}
</div>
<!-- Negate -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={X}
checked={condition.negate}
color="red"
on:click={() => (condition.negate = !condition.negate)}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
</div>
<!-- Required -->
<div class="flex shrink-0 items-center gap-1.5">
<IconCheckbox
icon={Check}
checked={condition.required}
color="green"
on:click={() => (condition.required = !condition.required)}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
</div>
<!-- Confirm -->
<button
type="button"
on:click={() => dispatch('confirm')}
class="shrink-0 cursor-pointer p-1 text-neutral-400 transition-colors hover:text-emerald-500 dark:text-neutral-500 dark:hover:text-emerald-400"
title="Confirm condition"
>
<Check size={16} />
</button>
<!-- Discard -->
<button
type="button"
on:click={() => dispatch('discard')}
class="shrink-0 cursor-pointer p-1 text-neutral-400 transition-colors hover:text-red-500 dark:text-neutral-500 dark:hover:text-red-400"
title="Discard condition"
>
<Trash2 size={16} />
</button>
</div>