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.
This commit is contained in:
Sam Chau
2026-01-03 03:22:29 +10:30
parent 3462c8b84d
commit 08710ffcb4
7 changed files with 871 additions and 157 deletions

View File

@@ -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';

View File

@@ -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<string, unknown>;
const bObj = b as Record<string, unknown>;
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;
}

View File

@@ -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`);
}
};

View File

@@ -1,27 +1,95 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { Plus, Save, Loader2 } from 'lucide-svelte';
import ConditionCard from './components/ConditionCard.svelte';
import DraftConditionCard from './components/DraftConditionCard.svelte';
import Badge from '$ui/badge/Badge.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import { alertStore } from '$alerts/store';
import { CONDITION_TYPES } from '$lib/shared/conditionTypes';
import {
current,
isDirty,
initEdit,
update
} from '$lib/client/stores/dirty';
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
// Track next draft ID locally (negative IDs for drafts)
let nextDraftId = -1;
// Build initial data from server
$: initialData = {
conditions: structuredClone(data.conditions),
draftConditions: [] as ConditionData[]
};
// Initialize dirty tracking
$: initEdit(initialData);
// Loading state
let saving = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
// Modal state
let showSaveTargetModal = false;
let mainFormElement: HTMLFormElement;
// Reactive getters for current values
$: conditions = ($current.conditions ?? []) as ConditionData[];
$: draftConditions = ($current.draftConditions ?? []) as ConditionData[];
// Check if there are draft conditions (blocks saving)
$: hasDrafts = draftConditions.length > 0;
// Validation - check all conditions have required values
$: invalidConditions = conditions.filter((c) => !isConditionValid(c));
$: hasInvalidConditions = invalidConditions.length > 0;
function isConditionValid(condition: ConditionData): boolean {
switch (condition.type) {
case 'release_title':
case 'release_group':
case 'edition':
return (condition.patterns?.length ?? 0) > 0;
case 'language':
return (condition.languages?.length ?? 0) > 0;
case 'source':
return (condition.sources?.length ?? 0) > 0;
case 'resolution':
return (condition.resolutions?.length ?? 0) > 0;
case 'quality_modifier':
return (condition.qualityModifiers?.length ?? 0) > 0;
case 'release_type':
return (condition.releaseTypes?.length ?? 0) > 0;
case 'indexer_flag':
return (condition.indexerFlags?.length ?? 0) > 0;
case 'size':
// At least one of min or max must be set
return condition.size?.minBytes != null || condition.size?.maxBytes != null;
case 'year':
// At least one of min or max must be set
return condition.years?.minYear != null || condition.years?.maxYear != null;
default:
return true;
}
}
// Group conditions by type
$: groupedConditions = data.conditions.reduce(
$: groupedConditions = 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>
{} as Record<string, ConditionData[]>
);
// Get ordered types (only those that have conditions)
@@ -32,7 +100,11 @@
}));
function handleRemove(conditionId: number) {
data.conditions = data.conditions.filter((c) => c.id !== conditionId);
update('conditions', conditions.filter((c) => c.id !== conditionId));
}
function handleConditionChange(updatedCondition: ConditionData) {
update('conditions', conditions.map((c) => c.id === updatedCondition.id ? updatedCondition : c));
}
function addDraftCondition() {
@@ -43,19 +115,39 @@
negate: false,
required: false
};
draftConditions = [...draftConditions, draft];
update('draftConditions', [...draftConditions, draft]);
}
function handleDraftChange(updatedDraft: ConditionData) {
update('draftConditions', draftConditions.map((d) => d.id === updatedDraft.id ? updatedDraft : d));
}
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 }];
// Remove from drafts and add to main conditions
// Keep the negative ID - the server will handle assigning real IDs
update('draftConditions', draftConditions.filter((d) => d.id !== draft.id));
update('conditions', [...conditions, draft]);
}
function discardDraft(draftId: number) {
draftConditions = draftConditions.filter((d) => d.id !== draftId);
update('draftConditions', draftConditions.filter((d) => d.id !== draftId));
}
async function handleSaveClick() {
if (data.canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
mainFormElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
mainFormElement?.requestSubmit();
}
</script>
@@ -63,69 +155,127 @@
<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>
<form
bind:this={mainFormElement}
method="POST"
action="?/update"
use:enhance={() => {
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;
};
}}
>
<!-- Hidden fields for form data -->
<input type="hidden" name="conditions" value={JSON.stringify(conditions)} />
<input type="hidden" name="layer" value={selectedLayer} />
<!-- 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 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>
<div class="flex items-center gap-2">
<button
type="button"
onclick={addDraftCondition}
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<Plus size={16} />
Add Condition
</button>
<button
type="button"
disabled={saving || !$isDirty || hasDrafts || hasInvalidConditions}
onclick={handleSaveClick}
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 disabled:cursor-not-allowed disabled:opacity-50"
title={hasDrafts
? 'Confirm or discard all draft conditions before saving'
: hasInvalidConditions
? 'Some conditions are missing required values'
: ''}
>
{#if saving}
<Loader2 size={16} class="animate-spin" />
Saving...
{:else}
<Save size={16} />
Save Changes
{/if}
</button>
</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)}
<!-- Draft conditions -->
{#if draftConditions.length > 0}
<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>
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">Drafts</span>
<Badge variant="neutral" size="sm">{draftConditions.length}</Badge>
</div>
<!-- Conditions -->
{#each group.conditions as condition (condition.id)}
<ConditionCard
{condition}
{#each draftConditions as draft (draft.id)}
<DraftConditionCard
condition={draft}
availablePatterns={data.availablePatterns}
availableLanguages={data.availableLanguages}
on:remove={() => handleRemove(condition.id)}
on:confirm={(e) => confirmDraft(e.detail)}
on:discard={() => discardDraft(draft.id)}
on:change={(e) => handleDraftChange(e.detail)}
/>
{/each}
</div>
{/each}
{/if}
</div>
{/if}
<!-- Existing conditions grouped by type -->
{#if 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}
invalid={!isConditionValid(condition)}
on:remove={() => handleRemove(condition.id)}
on:change={(e) => handleConditionChange(e.detail)}
/>
{/each}
</div>
{/each}
{/if}
</div>
</form>
<!-- Save Target Modal -->
{#if data.canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
{/if}

View File

@@ -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<ConditionData>) {
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 @@
</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"
class="flex items-center gap-3 rounded-lg border px-3 py-2 {isInvalid
? 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20'
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}"
>
<!-- Name -->
<div class="w-48 shrink-0">
<input
type="text"
bind:value={condition.name}
value={condition.name}
on:input={handleNameChange}
placeholder="Condition name"
class={inputClass}
/>
@@ -299,7 +340,7 @@
icon={X}
checked={condition.negate}
color="red"
on:click={() => (condition.negate = !condition.negate)}
on:click={() => emitChange({ negate: !condition.negate })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
</div>
@@ -310,7 +351,7 @@
icon={Check}
checked={condition.required}
color="green"
on:click={() => (condition.required = !condition.required)}
on:click={() => emitChange({ required: !condition.required })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
</div>

View File

@@ -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<ConditionData>) {
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 @@
<div class="w-48 shrink-0">
<input
type="text"
bind:value={condition.name}
value={condition.name}
on:input={handleNameChange}
placeholder="Condition name"
class={inputClass}
/>
@@ -302,7 +338,7 @@
icon={X}
checked={condition.negate}
color="red"
on:click={() => (condition.negate = !condition.negate)}
on:click={() => emitChange({ negate: !condition.negate })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Negate</span>
</div>
@@ -313,7 +349,7 @@
icon={Check}
checked={condition.required}
color="green"
on:click={() => (condition.required = !condition.required)}
on:click={() => emitChange({ required: !condition.required })}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">Required</span>
</div>
@@ -321,7 +357,7 @@
<!-- Confirm -->
<button
type="button"
on:click={() => dispatch('confirm')}
on:click={() => dispatch('confirm', condition)}
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"
>

View File

@@ -17,14 +17,6 @@
export let data: PageData;
// Form data shape
interface GeneralFormData {
name: string;
tags: string[];
description: string;
includeInRename: boolean;
}
// Build initial data from server
$: initialData = {
name: data.format.name,
@@ -46,8 +38,14 @@
let showSaveTargetModal = false;
let mainFormElement: HTMLFormElement;
// Reactive getters for current values
$: name = ($current.name ?? '') as string;
$: tags = ($current.tags ?? []) as string[];
$: description = ($current.description ?? '') as string;
$: includeInRename = ($current.includeInRename ?? false) as boolean;
// Validation
$: isValid = ($current as GeneralFormData).name?.trim() !== '';
$: isValid = name.trim() !== '';
async function handleSaveClick() {
if (data.canWriteToBase) {
@@ -83,7 +81,7 @@
} else if (result.type === 'redirect') {
alertStore.add('success', 'Custom format updated!');
// Mark as clean so navigation guard doesn't trigger
initEdit($current as GeneralFormData);
initEdit(initialData);
}
await formUpdate();
saving = false;
@@ -91,9 +89,9 @@
}}
>
<!-- Hidden fields for form data -->
<input type="hidden" name="tags" value={JSON.stringify(($current as GeneralFormData).tags)} />
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
<input type="hidden" name="layer" value={selectedLayer} />
<input type="hidden" name="includeInRename" value={($current as GeneralFormData).includeInRename} />
<input type="hidden" name="includeInRename" value={includeInRename} />
<div class="mt-6 space-y-6">
<!-- Name -->
@@ -108,7 +106,7 @@
type="text"
id="name"
name="name"
value={($current as GeneralFormData).name}
value={name}
oninput={(e) => update('name', e.currentTarget.value)}
placeholder="Enter custom format name"
class="mt-2 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
@@ -121,7 +119,7 @@
name="description"
label="Description"
description="Add any notes or details about this custom format's purpose and configuration."
value={($current as GeneralFormData).description}
value={description}
onchange={(v) => update('description', v)}
/>
@@ -132,7 +130,7 @@
Add tags to organize and categorize this custom format.
</p>
<TagInput
tags={($current as GeneralFormData).tags}
{tags}
onchange={(newTags) => update('tags', newTags)}
/>
</div>
@@ -148,11 +146,11 @@
<div class="flex items-center gap-2">
<IconCheckbox
icon={Check}
checked={($current as GeneralFormData).includeInRename}
on:click={() => update('includeInRename', !($current as GeneralFormData).includeInRename)}
checked={includeInRename}
on:click={() => update('includeInRename', !includeInRename)}
/>
<span class="text-sm text-neutral-700 dark:text-neutral-300">
{($current as GeneralFormData).includeInRename ? 'Enabled' : 'Disabled'}
{includeInRename ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>