diff --git a/src/lib/server/pcd/queries/customFormats/create.ts b/src/lib/server/pcd/queries/customFormats/create.ts
new file mode 100644
index 0000000..beeb7b5
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/create.ts
@@ -0,0 +1,78 @@
+/**
+ * Create a custom format operation
+ */
+
+import type { PCDCache } from '../../cache.ts';
+import { writeOperation, type OperationLayer } from '../../writer.ts';
+
+export interface CreateCustomFormatInput {
+ name: string;
+ description: string | null;
+ includeInRename: boolean;
+ tags: string[];
+}
+
+export interface CreateCustomFormatOptions {
+ databaseId: number;
+ cache: PCDCache;
+ layer: OperationLayer;
+ input: CreateCustomFormatInput;
+}
+
+/**
+ * Create a custom format by writing an operation to the specified layer
+ */
+export async function create(options: CreateCustomFormatOptions) {
+ const { databaseId, cache, layer, input } = options;
+ const db = cache.kb;
+
+ const queries = [];
+
+ // 1. Insert the custom format
+ const insertFormat = db
+ .insertInto('custom_formats')
+ .values({
+ name: input.name,
+ description: input.description,
+ include_in_rename: input.includeInRename ? 1 : 0
+ })
+ .compile();
+
+ queries.push(insertFormat);
+
+ // 2. Insert tags (create if not exist, then link)
+ for (const tagName of input.tags) {
+ // Insert tag if not exists
+ const insertTag = db
+ .insertInto('tags')
+ .values({ name: tagName })
+ .onConflict((oc) => oc.column('name').doNothing())
+ .compile();
+
+ queries.push(insertTag);
+
+ // Link tag to custom format
+ const linkTag = {
+ sql: `INSERT INTO custom_format_tags (custom_format_id, tag_id) VALUES ((SELECT id FROM custom_formats WHERE name = '${input.name.replace(/'/g, "''")}'), tag('${tagName.replace(/'/g, "''")}'))`,
+ parameters: [],
+ query: {} as never
+ };
+
+ queries.push(linkTag);
+ }
+
+ // Write the operation
+ const result = await writeOperation({
+ databaseId,
+ layer,
+ description: `create-custom-format-${input.name}`,
+ queries,
+ metadata: {
+ operation: 'create',
+ entity: 'custom_format',
+ name: input.name
+ }
+ });
+
+ return result;
+}
diff --git a/src/lib/server/pcd/queries/customFormats/delete.ts b/src/lib/server/pcd/queries/customFormats/delete.ts
new file mode 100644
index 0000000..218bf2c
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/delete.ts
@@ -0,0 +1,63 @@
+/**
+ * Delete a custom format operation
+ */
+
+import type { PCDCache } from '../../cache.ts';
+import { writeOperation, type OperationLayer } from '../../writer.ts';
+
+export interface DeleteCustomFormatOptions {
+ databaseId: number;
+ cache: PCDCache;
+ layer: OperationLayer;
+ /** The custom format ID */
+ formatId: number;
+ /** The custom format name (for metadata and value guards) */
+ formatName: string;
+}
+
+/**
+ * Escape a string for SQL
+ */
+function esc(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+/**
+ * Delete a custom format by writing an operation to the specified layer
+ * Cascading deletes handle conditions, tests, and tag links
+ */
+export async function remove(options: DeleteCustomFormatOptions) {
+ const { databaseId, cache, layer, formatId, formatName } = options;
+ const db = cache.kb;
+
+ const queries = [];
+
+ // Delete the custom format with value guards
+ // Foreign key cascades will handle:
+ // - custom_format_tags
+ // - custom_format_conditions (and their type-specific tables)
+ // - custom_format_tests
+ const deleteFormat = db
+ .deleteFrom('custom_formats')
+ .where('id', '=', formatId)
+ // Value guard - ensure this is the format we expect
+ .where('name', '=', formatName)
+ .compile();
+
+ queries.push(deleteFormat);
+
+ // Write the operation
+ const result = await writeOperation({
+ databaseId,
+ layer,
+ description: `delete-custom-format-${formatName}`,
+ queries,
+ metadata: {
+ operation: 'delete',
+ entity: 'custom_format',
+ name: formatName
+ }
+ });
+
+ return result;
+}
diff --git a/src/lib/server/pcd/queries/customFormats/index.ts b/src/lib/server/pcd/queries/customFormats/index.ts
index acbdbf3..5de5774 100644
--- a/src/lib/server/pcd/queries/customFormats/index.ts
+++ b/src/lib/server/pcd/queries/customFormats/index.ts
@@ -12,6 +12,8 @@ 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 type { CreateCustomFormatInput, CreateCustomFormatOptions } from './create.ts';
+export type { DeleteCustomFormatOptions } from './delete.ts';
// Export query functions (reads)
export { list } from './list.ts';
@@ -22,6 +24,8 @@ export { listConditions } from './listConditions.ts';
export { evaluateCustomFormat, getParsedInfo } from './evaluator.ts';
// Export mutation functions (writes via PCD operations)
+export { create } from './create.ts';
+export { remove } from './delete.ts';
export { createTest } from './testCreate.ts';
export { updateTest } from './testUpdate.ts';
export { deleteTest } from './testDelete.ts';
diff --git a/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts
index e16c6bd..374c85d 100644
--- a/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts
+++ b/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts
@@ -121,5 +121,54 @@ export const actions: Actions = {
}
throw redirect(303, `/custom-formats/${databaseId}/${id}/general`);
+ },
+
+ delete: 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 for value guards
+ const current = await customFormatQueries.general(cache, formatId);
+ if (!current) {
+ return fail(404, { error: 'Custom format not found' });
+ }
+
+ const formData = await request.formData();
+ const layer = (formData.get('layer') as OperationLayer) || 'user';
+
+ // Check layer permission
+ if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
+ return fail(403, { error: 'Cannot write to base layer without personal access token' });
+ }
+
+ // Delete the custom format
+ const result = await customFormatQueries.remove({
+ databaseId: currentDatabaseId,
+ cache,
+ layer,
+ formatId,
+ formatName: current.name
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to delete custom format' });
+ }
+
+ throw redirect(303, `/custom-formats/${databaseId}`);
}
};
diff --git a/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte
index e269a5a..1e8c37c 100644
--- a/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte
+++ b/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte
@@ -1,18 +1,5 @@
{data.format.name} - General - Profilarr
-
-
-
-{#if data.canWriteToBase}
- (showSaveTargetModal = false)}
+
+
-{/if}
+
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte
index d45d8fe..8549ee9 100644
--- a/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte
@@ -2,15 +2,11 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import TestForm from '../components/TestForm.svelte';
+ import DirtyModal from '$ui/modal/DirtyModal.svelte';
import type { PageData } from './$types';
export let data: PageData;
- let title = data.test.title;
- let type: 'movie' | 'series' = data.test.type as 'movie' | 'series';
- let shouldMatch = data.test.should_match;
- let description = data.test.description ?? '';
-
function handleCancel() {
goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing`);
}
@@ -25,9 +21,13 @@
formatName={data.format.name}
canWriteToBase={data.canWriteToBase}
actionUrl="?/update"
- bind:title
- bind:type
- bind:shouldMatch
- bind:description
+ initialData={{
+ title: data.test.title,
+ type: data.test.type as 'movie' | 'series',
+ shouldMatch: data.test.should_match,
+ description: data.test.description ?? ''
+ }}
onCancel={handleCancel}
/>
+
+
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte
index c466060..3f18ee1 100644
--- a/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte
@@ -5,23 +5,47 @@
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
- import { Save, Trash2, Loader2, Check, X } from 'lucide-svelte';
+ import { Trash2, Loader2, Check, X } from 'lucide-svelte';
+ import {
+ current,
+ isDirty,
+ initEdit,
+ initCreate,
+ update
+ } from '$lib/client/stores/dirty';
+
+ // Form data shape
+ interface TestFormData {
+ title: string;
+ type: 'movie' | 'series';
+ shouldMatch: boolean;
+ description: string;
+ }
// Props
export let mode: 'create' | 'edit';
export let formatName: string;
export let canWriteToBase: boolean = false;
export let actionUrl: string = '';
-
- // Form data
- export let title: string = '';
- export let type: 'movie' | 'series' = 'movie';
- export let shouldMatch: boolean = true;
- export let description: string = '';
+ export let initialData: TestFormData;
// Event handlers
export let onCancel: () => void;
+ // Initialize dirty tracking
+ const defaults: TestFormData = {
+ title: '',
+ type: 'movie',
+ shouldMatch: true,
+ description: ''
+ };
+
+ if (mode === 'create') {
+ initCreate(initialData ?? defaults);
+ } else {
+ initEdit(initialData);
+ }
+
// Loading states
let saving = false;
let deleting = false;
@@ -38,6 +62,12 @@
let mainFormElement: HTMLFormElement;
let deleteFormElement: HTMLFormElement;
+ // Reactive getters for current values
+ $: title = ($current.title ?? '') as string;
+ $: type = ($current.type ?? 'movie') as 'movie' | 'series';
+ $: shouldMatch = ($current.shouldMatch ?? true) as boolean;
+ $: description = ($current.description ?? '') as string;
+
// Display text based on mode
$: pageTitle = mode === 'create' ? 'New Test Case' : 'Edit Test Case';
$: pageDescription = mode === 'create'
@@ -108,13 +138,15 @@
action={actionUrl}
use:enhance={() => {
saving = true;
- return async ({ result, update }) => {
+ 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', mode === 'create' ? 'Test case created!' : 'Test case updated!');
+ // Mark as clean so navigation guard doesn't trigger
+ initEdit($current as TestFormData);
}
- await update();
+ await formUpdate();
saving = false;
};
}}
@@ -124,6 +156,7 @@
+
@@ -136,7 +169,8 @@
type="text"
id="title"
name="title"
- bind:value={title}
+ value={title}
+ oninput={(e) => update('title', e.currentTarget.value)}
placeholder="e.g., Movie.Name.2024.1080p.BluRay.x264-GROUP"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono 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"
/>
@@ -151,7 +185,7 @@
{#each typeOptions as option}
@@ -218,7 +253,7 @@
{#if mode === 'edit'}