From 08710ffcb4ab24fb5b45d470fa034dc5bbca99dd Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Sat, 3 Jan 2026 03:22:29 +1030 Subject: [PATCH] feat: implement condition management with draft support and layer permissions - Added server-side actions for updating conditions with layer permissions. - Enhanced the conditions page to handle draft conditions and validation. - Introduced a modal for selecting save targets based on user permissions. - Refactored condition and draft condition components to emit changes immutably. - Updated general page to manage form data more reactively and validate inputs. --- .../server/pcd/queries/customFormats/index.ts | 2 + .../queries/customFormats/updateConditions.ts | 411 ++++++++++++++++++ .../[id]/conditions/+page.server.ts | 82 +++- .../[databaseId]/[id]/conditions/+page.svelte | 284 +++++++++--- .../components/ConditionCard.svelte | 111 +++-- .../components/DraftConditionCard.svelte | 104 +++-- .../[databaseId]/[id]/general/+page.svelte | 34 +- 7 files changed, 871 insertions(+), 157 deletions(-) create mode 100644 src/lib/server/pcd/queries/customFormats/updateConditions.ts diff --git a/src/lib/server/pcd/queries/customFormats/index.ts b/src/lib/server/pcd/queries/customFormats/index.ts index f64406f..acbdbf3 100644 --- a/src/lib/server/pcd/queries/customFormats/index.ts +++ b/src/lib/server/pcd/queries/customFormats/index.ts @@ -11,6 +11,7 @@ export type { ConditionData } from './conditions.ts'; export type { ConditionListItem } from './listConditions.ts'; export type { ConditionResult, EvaluationResult, ParsedInfo } from './evaluator.ts'; export type { UpdateGeneralInput, UpdateGeneralOptions } from './updateGeneral.ts'; +export type { UpdateConditionsOptions } from './updateConditions.ts'; // Export query functions (reads) export { list } from './list.ts'; @@ -25,3 +26,4 @@ export { createTest } from './testCreate.ts'; export { updateTest } from './testUpdate.ts'; export { deleteTest } from './testDelete.ts'; export { updateGeneral } from './updateGeneral.ts'; +export { updateConditions } from './updateConditions.ts'; diff --git a/src/lib/server/pcd/queries/customFormats/updateConditions.ts b/src/lib/server/pcd/queries/customFormats/updateConditions.ts new file mode 100644 index 0000000..fe0e60a --- /dev/null +++ b/src/lib/server/pcd/queries/customFormats/updateConditions.ts @@ -0,0 +1,411 @@ +/** + * Update custom format conditions + * + * This mutation handles: + * - Deleting removed conditions + * - Inserting new conditions (from drafts with negative IDs) + * - Updating existing conditions + */ + +import type { PCDCache } from '../../cache.ts'; +import { writeOperation, type OperationLayer } from '../../writer.ts'; +import type { ConditionData } from './conditions.ts'; +import { logger } from '$logger/logger.ts'; + +export interface UpdateConditionsOptions { + databaseId: number; + cache: PCDCache; + layer: OperationLayer; + /** The custom format ID */ + formatId: number; + /** The custom format name (for metadata) */ + formatName: string; + /** Current conditions from the database (for comparison) */ + originalConditions: ConditionData[]; + /** The new/modified conditions from the client */ + conditions: ConditionData[]; +} + +/** + * Escape a string for SQL + */ +function esc(value: string): string { + return value.replace(/'/g, "''"); +} + +/** + * Generate SQL to insert a condition's type-specific data + */ +function generateConditionValueSql(conditionName: string, formatName: string, condition: ConditionData): string[] { + const conditionIdLookup = `(SELECT id FROM custom_format_conditions WHERE name = '${esc(conditionName)}' AND custom_format_id = (SELECT id FROM custom_formats WHERE name = '${esc(formatName)}'))`; + const sqls: string[] = []; + + switch (condition.type) { + case 'release_title': + case 'release_group': + case 'edition': + if (condition.patterns && condition.patterns.length > 0) { + for (const pattern of condition.patterns) { + sqls.push(`INSERT INTO condition_patterns (custom_format_condition_id, regular_expression_id) VALUES (${conditionIdLookup}, ${pattern.id})`); + } + } + break; + + case 'language': + if (condition.languages && condition.languages.length > 0) { + for (const lang of condition.languages) { + sqls.push(`INSERT INTO condition_languages (custom_format_condition_id, language_id, except_language) VALUES (${conditionIdLookup}, ${lang.id}, ${lang.except ? 1 : 0})`); + } + } + break; + + case 'source': + if (condition.sources && condition.sources.length > 0) { + for (const source of condition.sources) { + sqls.push(`INSERT INTO condition_sources (custom_format_condition_id, source) VALUES (${conditionIdLookup}, '${esc(source)}')`); + } + } + break; + + case 'resolution': + if (condition.resolutions && condition.resolutions.length > 0) { + for (const res of condition.resolutions) { + sqls.push(`INSERT INTO condition_resolutions (custom_format_condition_id, resolution) VALUES (${conditionIdLookup}, '${esc(res)}')`); + } + } + break; + + case 'quality_modifier': + if (condition.qualityModifiers && condition.qualityModifiers.length > 0) { + for (const qm of condition.qualityModifiers) { + sqls.push(`INSERT INTO condition_quality_modifiers (custom_format_condition_id, quality_modifier) VALUES (${conditionIdLookup}, '${esc(qm)}')`); + } + } + break; + + case 'release_type': + if (condition.releaseTypes && condition.releaseTypes.length > 0) { + for (const rt of condition.releaseTypes) { + sqls.push(`INSERT INTO condition_release_types (custom_format_condition_id, release_type) VALUES (${conditionIdLookup}, '${esc(rt)}')`); + } + } + break; + + case 'indexer_flag': + if (condition.indexerFlags && condition.indexerFlags.length > 0) { + for (const flag of condition.indexerFlags) { + sqls.push(`INSERT INTO condition_indexer_flags (custom_format_condition_id, flag) VALUES (${conditionIdLookup}, '${esc(flag)}')`); + } + } + break; + + case 'size': + if (condition.size) { + const minBytes = condition.size.minBytes ?? 'NULL'; + const maxBytes = condition.size.maxBytes ?? 'NULL'; + sqls.push(`INSERT INTO condition_sizes (custom_format_condition_id, min_bytes, max_bytes) VALUES (${conditionIdLookup}, ${minBytes}, ${maxBytes})`); + } + break; + + case 'year': + if (condition.years) { + const minYear = condition.years.minYear ?? 'NULL'; + const maxYear = condition.years.maxYear ?? 'NULL'; + sqls.push(`INSERT INTO condition_years (custom_format_condition_id, min_year, max_year) VALUES (${conditionIdLookup}, ${minYear}, ${maxYear})`); + } + break; + } + + return sqls; +} + +/** + * Update conditions for a custom format + * + * Strategy: + * 1. Find conditions to delete (in original but not in new) + * 2. Find conditions to add (new with negative IDs, these are drafts) + * 3. Find conditions to update (positive IDs that exist in both) + */ +export async function updateConditions(options: UpdateConditionsOptions) { + const { databaseId, layer, formatId, formatName, originalConditions, conditions } = options; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queries: any[] = []; + + // Get IDs of conditions to keep + const newConditionIds = new Set(conditions.filter(c => c.id > 0).map(c => c.id)); + + // 1. Delete removed conditions (cascade will handle type-specific tables) + const conditionsToDelete = originalConditions.filter(c => !newConditionIds.has(c.id)); + for (const condition of conditionsToDelete) { + queries.push({ + sql: `DELETE FROM custom_format_conditions WHERE id = ${condition.id}`, + parameters: [], + query: {} as never + }); + } + + // 2. Handle new conditions (negative IDs from drafts) + const newConditions = conditions.filter(c => c.id < 0); + for (const condition of newConditions) { + // Insert the base condition + queries.push({ + sql: `INSERT INTO custom_format_conditions (custom_format_id, name, type, arr_type, negate, required) VALUES (${formatId}, '${esc(condition.name)}', '${esc(condition.type)}', 'all', ${condition.negate ? 1 : 0}, ${condition.required ? 1 : 0})`, + parameters: [], + query: {} as never + }); + + // Insert type-specific data + const valueSqls = generateConditionValueSql(condition.name, formatName, condition); + for (const sql of valueSqls) { + queries.push({ + sql, + parameters: [], + query: {} as never + }); + } + } + + // 3. Handle updated conditions (positive IDs) + const existingConditions = conditions.filter(c => c.id > 0); + for (const condition of existingConditions) { + const original = originalConditions.find(c => c.id === condition.id); + if (!original) continue; + + // Check if base condition changed + const baseChanged = + original.name !== condition.name || + original.type !== condition.type || + original.negate !== condition.negate || + original.required !== condition.required; + + if (baseChanged) { + // Update base condition + queries.push({ + sql: `UPDATE custom_format_conditions SET name = '${esc(condition.name)}', type = '${esc(condition.type)}', negate = ${condition.negate ? 1 : 0}, required = ${condition.required ? 1 : 0} WHERE id = ${condition.id}`, + parameters: [], + query: {} as never + }); + } + + // For type-specific data, if type changed, delete old and insert new + // If type same but values changed, also delete and insert + const typeChanged = original.type !== condition.type; + const valuesChanged = !deepEquals( + getConditionValues(original), + getConditionValues(condition) + ); + + if (typeChanged || valuesChanged) { + // Delete old type-specific data based on original type + const deleteTable = getTypeTable(original.type); + if (deleteTable) { + queries.push({ + sql: `DELETE FROM ${deleteTable} WHERE custom_format_condition_id = ${condition.id}`, + parameters: [], + query: {} as never + }); + } + + // Insert new type-specific data + // Use a direct ID lookup since this is an existing condition + const valueSqls = generateConditionValueSqlById(condition.id, condition); + for (const sql of valueSqls) { + queries.push({ + sql, + parameters: [], + query: {} as never + }); + } + } + } + + // If no changes, return success without writing + if (queries.length === 0) { + return { success: true }; + } + + // Log what's being changed + await logger.info(`Save conditions for custom format "${formatName}"`, { + source: 'CustomFormat', + meta: { + formatId, + deleted: conditionsToDelete.length, + added: newConditions.length, + updated: existingConditions.length + } + }); + + // Write the operation + const result = await writeOperation({ + databaseId, + layer, + description: `update-conditions-${formatName}`, + queries, + metadata: { + operation: 'update', + entity: 'custom_format_conditions', + name: formatName + } + }); + + return result; +} + +/** + * Get the type-specific table name for a condition type + */ +function getTypeTable(type: string): string | null { + switch (type) { + case 'release_title': + case 'release_group': + case 'edition': + return 'condition_patterns'; + case 'language': + return 'condition_languages'; + case 'source': + return 'condition_sources'; + case 'resolution': + return 'condition_resolutions'; + case 'quality_modifier': + return 'condition_quality_modifiers'; + case 'release_type': + return 'condition_release_types'; + case 'indexer_flag': + return 'condition_indexer_flags'; + case 'size': + return 'condition_sizes'; + case 'year': + return 'condition_years'; + default: + return null; + } +} + +/** + * Get condition values for comparison + */ +function getConditionValues(condition: ConditionData): unknown { + return { + patterns: condition.patterns, + languages: condition.languages, + sources: condition.sources, + resolutions: condition.resolutions, + qualityModifiers: condition.qualityModifiers, + releaseTypes: condition.releaseTypes, + indexerFlags: condition.indexerFlags, + size: condition.size, + years: condition.years + }; +} + +/** + * Generate SQL for condition values using direct ID (for existing conditions) + */ +function generateConditionValueSqlById(conditionId: number, condition: ConditionData): string[] { + const sqls: string[] = []; + + switch (condition.type) { + case 'release_title': + case 'release_group': + case 'edition': + if (condition.patterns && condition.patterns.length > 0) { + for (const pattern of condition.patterns) { + sqls.push(`INSERT INTO condition_patterns (custom_format_condition_id, regular_expression_id) VALUES (${conditionId}, ${pattern.id})`); + } + } + break; + + case 'language': + if (condition.languages && condition.languages.length > 0) { + for (const lang of condition.languages) { + sqls.push(`INSERT INTO condition_languages (custom_format_condition_id, language_id, except_language) VALUES (${conditionId}, ${lang.id}, ${lang.except ? 1 : 0})`); + } + } + break; + + case 'source': + if (condition.sources && condition.sources.length > 0) { + for (const source of condition.sources) { + sqls.push(`INSERT INTO condition_sources (custom_format_condition_id, source) VALUES (${conditionId}, '${esc(source)}')`); + } + } + break; + + case 'resolution': + if (condition.resolutions && condition.resolutions.length > 0) { + for (const res of condition.resolutions) { + sqls.push(`INSERT INTO condition_resolutions (custom_format_condition_id, resolution) VALUES (${conditionId}, '${esc(res)}')`); + } + } + break; + + case 'quality_modifier': + if (condition.qualityModifiers && condition.qualityModifiers.length > 0) { + for (const qm of condition.qualityModifiers) { + sqls.push(`INSERT INTO condition_quality_modifiers (custom_format_condition_id, quality_modifier) VALUES (${conditionId}, '${esc(qm)}')`); + } + } + break; + + case 'release_type': + if (condition.releaseTypes && condition.releaseTypes.length > 0) { + for (const rt of condition.releaseTypes) { + sqls.push(`INSERT INTO condition_release_types (custom_format_condition_id, release_type) VALUES (${conditionId}, '${esc(rt)}')`); + } + } + break; + + case 'indexer_flag': + if (condition.indexerFlags && condition.indexerFlags.length > 0) { + for (const flag of condition.indexerFlags) { + sqls.push(`INSERT INTO condition_indexer_flags (custom_format_condition_id, flag) VALUES (${conditionId}, '${esc(flag)}')`); + } + } + break; + + case 'size': + if (condition.size) { + const minBytes = condition.size.minBytes ?? 'NULL'; + const maxBytes = condition.size.maxBytes ?? 'NULL'; + sqls.push(`INSERT INTO condition_sizes (custom_format_condition_id, min_bytes, max_bytes) VALUES (${conditionId}, ${minBytes}, ${maxBytes})`); + } + break; + + case 'year': + if (condition.years) { + const minYear = condition.years.minYear ?? 'NULL'; + const maxYear = condition.years.maxYear ?? 'NULL'; + sqls.push(`INSERT INTO condition_years (custom_format_condition_id, min_year, max_year) VALUES (${conditionId}, ${minYear}, ${maxYear})`); + } + break; + } + + return sqls; +} + +/** + * Deep equality check + */ +function deepEquals(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return a === b; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => deepEquals(item, b[i])); + } + + if (typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => deepEquals(aObj[key], bObj[key])); + } + + return false; +} diff --git a/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.server.ts index 8abb723..f743546 100644 --- a/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.server.ts +++ b/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.server.ts @@ -1,9 +1,12 @@ -import { error } from '@sveltejs/kit'; -import type { ServerLoad } from '@sveltejs/kit'; +import { error, redirect, fail } from '@sveltejs/kit'; +import type { ServerLoad, Actions } from '@sveltejs/kit'; import { pcdManager } from '$pcd/pcd.ts'; +import { canWriteToBase } from '$pcd/writer.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'; +import type { OperationLayer } from '$pcd/writer.ts'; +import type { ConditionData } from '$pcd/queries/customFormats/index.ts'; export const load: ServerLoad = async ({ params }) => { const { databaseId, id } = params; @@ -25,6 +28,12 @@ export const load: ServerLoad = async ({ params }) => { throw error(400, 'Invalid format ID'); } + // Get current database + const currentDatabase = pcdManager.getById(currentDatabaseId); + if (!currentDatabase) { + throw error(404, 'Database not found'); + } + // Get the cache for the database const cache = pcdManager.getCache(currentDatabaseId); if (!cache) { @@ -44,9 +53,76 @@ export const load: ServerLoad = async ({ params }) => { } return { + currentDatabase, format, conditions, availablePatterns: patterns.map((p) => ({ id: p.id, name: p.name, pattern: p.pattern })), - availableLanguages: languages + availableLanguages: languages, + canWriteToBase: canWriteToBase(currentDatabaseId) }; }; + +export const actions: Actions = { + update: async ({ request, params }) => { + const { databaseId, id } = params; + + if (!databaseId || !id) { + return fail(400, { error: 'Missing parameters' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + const formatId = parseInt(id, 10); + + if (isNaN(currentDatabaseId) || isNaN(formatId)) { + return fail(400, { error: 'Invalid parameters' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + // Get current format and conditions + const format = await customFormatQueries.getById(cache, formatId); + if (!format) { + return fail(404, { error: 'Custom format not found' }); + } + + const originalConditions = await customFormatQueries.getConditionsForEvaluation(cache, formatId); + + const formData = await request.formData(); + + // Parse form data + const conditionsJson = formData.get('conditions') as string; + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + let conditions: ConditionData[] = []; + try { + conditions = JSON.parse(conditionsJson || '[]'); + } catch { + return fail(400, { error: 'Invalid conditions format' }); + } + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Update conditions + const result = await customFormatQueries.updateConditions({ + databaseId: currentDatabaseId, + cache, + layer, + formatId, + formatName: format.name, + originalConditions, + conditions + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to update conditions' }); + } + + throw redirect(303, `/custom-formats/${databaseId}/${id}/conditions`); + } +}; diff --git a/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.svelte index 7055803..ca65d02 100644 --- a/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.svelte +++ b/src/routes/custom-formats/[databaseId]/[id]/conditions/+page.svelte @@ -1,27 +1,95 @@ @@ -63,69 +155,127 @@ {data.format.name} - Conditions - Profilarr -
- -
-
-

Conditions

-

- Define the conditions that must be met for this custom format to match a release. -

-
- -
+
{ + saving = true; + return async ({ result, update: formUpdate }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed'); + } else if (result.type === 'redirect') { + alertStore.add('success', 'Conditions updated!'); + // Mark as clean so navigation guard doesn't trigger + initEdit(initialData); + } + await formUpdate(); + saving = false; + }; + }} +> + + + - - {#if draftConditions.length > 0} -
-
- Drafts - {draftConditions.length} +
+ +
+
+

Conditions

+

+ Define the conditions that must be met for this custom format to match a release. +

+
+
+ +
- - {#each draftConditions as draft (draft.id)} - confirmDraft(draft)} - on:discard={() => discardDraft(draft.id)} - /> - {/each}
- {/if} - - {#if data.conditions.length === 0 && draftConditions.length === 0} -

No conditions defined

- {:else} - {#each orderedTypes as group (group.value)} + + {#if draftConditions.length > 0}
-
- - {group.label} - - {group.conditions.length} + Drafts + {draftConditions.length}
- - {#each group.conditions as condition (condition.id)} - handleRemove(condition.id)} + on:confirm={(e) => confirmDraft(e.detail)} + on:discard={() => discardDraft(draft.id)} + on:change={(e) => handleDraftChange(e.detail)} /> {/each}
- {/each} - {/if} -
+ {/if} + + + {#if conditions.length === 0 && draftConditions.length === 0} +

No conditions defined

+ {:else} + {#each orderedTypes as group (group.value)} +
+ +
+ + {group.label} + + {group.conditions.length} +
+ + + {#each group.conditions as condition (condition.id)} + handleRemove(condition.id)} + on:change={(e) => handleConditionChange(e.detail)} + /> + {/each} +
+ {/each} + {/if} +
+ + + +{#if data.canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if} diff --git a/src/routes/custom-formats/[databaseId]/[id]/conditions/components/ConditionCard.svelte b/src/routes/custom-formats/[databaseId]/[id]/conditions/components/ConditionCard.svelte index 206e4b6..b34ab0d 100644 --- a/src/routes/custom-formats/[databaseId]/[id]/conditions/components/ConditionCard.svelte +++ b/src/routes/custom-formats/[databaseId]/[id]/conditions/components/ConditionCard.svelte @@ -4,8 +4,6 @@ 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, @@ -18,13 +16,27 @@ } from '$lib/shared/conditionTypes'; import type { ConditionData } from '$pcd/queries/customFormats/index'; + const dispatch = createEventDispatcher<{ + remove: void; + change: ConditionData; + }>(); + export let condition: ConditionData; export let arrType: ArrType = 'all'; + export let invalid = false; + + // Reference invalid to avoid unused export warning (used in template) + $: isInvalid = invalid; // Available patterns and languages from database (passed in) export let availablePatterns: { id: number; name: string; pattern: string }[] = []; export let availableLanguages: { id: number; name: string }[] = []; + // Helper to emit changes - creates new object to maintain immutability + function emitChange(updates: Partial) { + dispatch('change', { ...condition, ...updates }); + } + // Filter condition types based on arrType $: filteredConditionTypes = CONDITION_TYPES.filter( (t) => t.arrType === 'all' || t.arrType === arrType @@ -66,9 +78,9 @@ 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 }] : []; + emitChange({ patterns: pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [] }); } else { - condition.patterns = []; + emitChange({ patterns: [] }); } } @@ -99,24 +111,24 @@ function handleSelectChange(value: string) { switch (condition.type) { case 'source': - condition.sources = value ? [value] : []; + emitChange({ sources: value ? [value] : [] }); break; case 'resolution': - condition.resolutions = value ? [value] : []; + emitChange({ resolutions: value ? [value] : [] }); break; case 'quality_modifier': - condition.qualityModifiers = value ? [value] : []; + emitChange({ qualityModifiers: value ? [value] : [] }); break; case 'release_type': - condition.releaseTypes = value ? [value] : []; + emitChange({ releaseTypes: value ? [value] : [] }); break; case 'indexer_flag': - condition.indexerFlags = value ? [value] : []; + emitChange({ 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 }] : []; + emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] }); break; } } @@ -142,25 +154,27 @@ 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 }] : []; + emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] }); } else { - condition.languages = []; + emitChange({ 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; + emitChange({ + type: newType, + // Reset all value fields + patterns: undefined, + languages: undefined, + sources: undefined, + resolutions: undefined, + qualityModifiers: undefined, + releaseTypes: undefined, + indexerFlags: undefined, + size: undefined, + years: undefined + }); } // Type options for Select @@ -172,26 +186,50 @@ 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); + const currentSize = condition.size ?? { minBytes: null, maxBytes: null }; + emitChange({ + size: { + ...currentSize, + 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); + const currentSize = condition.size ?? { minBytes: null, maxBytes: null }; + emitChange({ + size: { + ...currentSize, + 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; + const currentYears = condition.years ?? { minYear: null, maxYear: null }; + emitChange({ + years: { + ...currentYears, + 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 currentYears = condition.years ?? { minYear: null, maxYear: null }; + emitChange({ + years: { + ...currentYears, + maxYear: isNaN(value) ? null : value + } + }); + } + + function handleNameChange(event: Event) { + emitChange({ name: (event.target as HTMLInputElement).value }); } const inputClass = @@ -199,13 +237,16 @@
@@ -299,7 +340,7 @@ icon={X} checked={condition.negate} color="red" - on:click={() => (condition.negate = !condition.negate)} + on:click={() => emitChange({ negate: !condition.negate })} /> Negate
@@ -310,7 +351,7 @@ icon={Check} checked={condition.required} color="green" - on:click={() => (condition.required = !condition.required)} + on:click={() => emitChange({ required: !condition.required })} /> Required
diff --git a/src/routes/custom-formats/[databaseId]/[id]/conditions/components/DraftConditionCard.svelte b/src/routes/custom-formats/[databaseId]/[id]/conditions/components/DraftConditionCard.svelte index 5ddf121..c494652 100644 --- a/src/routes/custom-formats/[databaseId]/[id]/conditions/components/DraftConditionCard.svelte +++ b/src/routes/custom-formats/[databaseId]/[id]/conditions/components/DraftConditionCard.svelte @@ -16,7 +16,11 @@ } from '$lib/shared/conditionTypes'; import type { ConditionData } from '$pcd/queries/customFormats/index'; - const dispatch = createEventDispatcher<{ confirm: void; discard: void }>(); + const dispatch = createEventDispatcher<{ + confirm: ConditionData; + discard: void; + change: ConditionData; + }>(); export let condition: ConditionData; export let arrType: ArrType = 'all'; @@ -25,6 +29,11 @@ export let availablePatterns: { id: number; name: string; pattern: string }[] = []; export let availableLanguages: { id: number; name: string }[] = []; + // Helper to emit changes - creates new object to maintain immutability + function emitChange(updates: Partial) { + dispatch('change', { ...condition, ...updates }); + } + // Filter condition types based on arrType $: filteredConditionTypes = CONDITION_TYPES.filter( (t) => t.arrType === 'all' || t.arrType === arrType @@ -69,9 +78,9 @@ 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 }] : []; + emitChange({ patterns: pattern ? [{ id: pattern.id, pattern: pattern.pattern }] : [] }); } else { - condition.patterns = []; + emitChange({ patterns: [] }); } } @@ -102,24 +111,24 @@ function handleSelectChange(value: string) { switch (condition.type) { case 'source': - condition.sources = value ? [value] : []; + emitChange({ sources: value ? [value] : [] }); break; case 'resolution': - condition.resolutions = value ? [value] : []; + emitChange({ resolutions: value ? [value] : [] }); break; case 'quality_modifier': - condition.qualityModifiers = value ? [value] : []; + emitChange({ qualityModifiers: value ? [value] : [] }); break; case 'release_type': - condition.releaseTypes = value ? [value] : []; + emitChange({ releaseTypes: value ? [value] : [] }); break; case 'indexer_flag': - condition.indexerFlags = value ? [value] : []; + emitChange({ 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 }] : []; + emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] }); break; } } @@ -145,25 +154,27 @@ 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 }] : []; + emitChange({ languages: lang ? [{ id: lang.id, name: lang.name, except: false }] : [] }); } else { - condition.languages = []; + emitChange({ 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; + emitChange({ + type: newType, + // Reset all value fields + patterns: undefined, + languages: undefined, + sources: undefined, + resolutions: undefined, + qualityModifiers: undefined, + releaseTypes: undefined, + indexerFlags: undefined, + size: undefined, + years: undefined + }); } // Type options for Select @@ -175,26 +186,50 @@ 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); + const currentSize = condition.size ?? { minBytes: null, maxBytes: null }; + emitChange({ + size: { + ...currentSize, + 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); + const currentSize = condition.size ?? { minBytes: null, maxBytes: null }; + emitChange({ + size: { + ...currentSize, + 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; + const currentYears = condition.years ?? { minYear: null, maxYear: null }; + emitChange({ + years: { + ...currentYears, + 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 currentYears = condition.years ?? { minYear: null, maxYear: null }; + emitChange({ + years: { + ...currentYears, + maxYear: isNaN(value) ? null : value + } + }); + } + + function handleNameChange(event: Event) { + emitChange({ name: (event.target as HTMLInputElement).value }); } const inputClass = @@ -208,7 +243,8 @@
@@ -302,7 +338,7 @@ icon={X} checked={condition.negate} color="red" - on:click={() => (condition.negate = !condition.negate)} + on:click={() => emitChange({ negate: !condition.negate })} /> Negate
@@ -313,7 +349,7 @@ icon={Check} checked={condition.required} color="green" - on:click={() => (condition.required = !condition.required)} + on:click={() => emitChange({ required: !condition.required })} /> Required
@@ -321,7 +357,7 @@