From 8deef25c9e8005bfb6a7b2c7ac4bad6728a077dd Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Sat, 3 Jan 2026 04:07:08 +1030 Subject: [PATCH] feat: add create and delete custom format functionality - Implemented `create.ts` for creating custom formats with associated tags. - Added `delete.ts` for deleting custom formats with cascading deletes for related entities. - Updated `index.ts` to export new create and delete functions. - Enhanced the server-side logic in `+page.server.ts` for handling new custom format creation. - Created a new Svelte component `GeneralForm.svelte` for managing custom format details. - Updated the UI in `+page.svelte` for creating new custom formats and handling form submissions. - Integrated dirty state management for form inputs in `TestForm.svelte` and `GeneralForm.svelte`. - Added delete functionality in the UI for custom formats with confirmation modals. --- .../pcd/queries/customFormats/create.ts | 78 +++++ .../pcd/queries/customFormats/delete.ts | 63 ++++ .../server/pcd/queries/customFormats/index.ts | 4 + .../[databaseId]/[id]/general/+page.server.ts | 49 +++ .../[databaseId]/[id]/general/+page.svelte | 176 +--------- .../[id]/testing/[testId]/+page.svelte | 18 +- .../[id]/testing/components/TestForm.svelte | 73 +++-- .../[id]/testing/new/+page.svelte | 18 +- .../components/GeneralForm.svelte | 305 ++++++++++++++++++ .../[databaseId]/new/+page.server.ts | 118 +++++++ .../[databaseId]/new/+page.svelte | 36 +++ 11 files changed, 734 insertions(+), 204 deletions(-) create mode 100644 src/lib/server/pcd/queries/customFormats/create.ts create mode 100644 src/lib/server/pcd/queries/customFormats/delete.ts create mode 100644 src/routes/custom-formats/[databaseId]/components/GeneralForm.svelte create mode 100644 src/routes/custom-formats/[databaseId]/new/+page.server.ts create mode 100644 src/routes/custom-formats/[databaseId]/new/+page.svelte 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 -
{ - 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', 'Custom format updated!'); - // Mark as clean so navigation guard doesn't trigger - initEdit(initialData); - } - await formUpdate(); - saving = false; - }; - }} -> - - - - - -
- -
- -

- The name of this custom format -

- 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" - /> -
- - - update('description', v)} - /> - - -
-
Tags
-

- Add tags to organize and categorize this custom format. -

- update('tags', newTags)} - /> -
- - -
-
- Include In Rename -
-

- When enabled, this custom format's name will be included in the renamed filename. -

-
- update('includeInRename', !includeInRename)} - /> - - {includeInRename ? 'Enabled' : 'Disabled'} - -
-
- - -
- -
-
-
- - -{#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'} + {/if} +
+ + +
+ {#if onCancel} + + {/if} + +
+ + + + + + {#if mode === 'edit'} + + {/if} + + + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> + + + (showDeleteTargetModal = false)} + /> +{/if} diff --git a/src/routes/custom-formats/[databaseId]/new/+page.server.ts b/src/routes/custom-formats/[databaseId]/new/+page.server.ts new file mode 100644 index 0000000..cb14d9e --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/new/+page.server.ts @@ -0,0 +1,118 @@ +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 type { OperationLayer } from '$pcd/writer.ts'; + +export const load: ServerLoad = ({ params }) => { + const { databaseId } = params; + + if (!databaseId) { + throw error(400, 'Missing database ID'); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + throw error(400, 'Invalid database ID'); + } + + const currentDatabase = pcdManager.getById(currentDatabaseId); + if (!currentDatabase) { + throw error(404, 'Database not found'); + } + + return { + currentDatabase, + canWriteToBase: canWriteToBase(currentDatabaseId) + }; +}; + +export const actions: Actions = { + default: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Parse form data + const name = formData.get('name') as string; + const description = (formData.get('description') as string) || null; + const tagsJson = formData.get('tags') as string; + const includeInRename = formData.get('includeInRename') === 'true'; + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Validate + if (!name?.trim()) { + return fail(400, { error: 'Name is required' }); + } + + // Check for duplicate name + const existingFormats = await customFormatQueries.list(cache); + const duplicate = existingFormats.find(f => f.name.toLowerCase() === name.trim().toLowerCase()); + if (duplicate) { + return fail(400, { error: `A custom format named "${name.trim()}" already exists` }); + } + + let tags: string[] = []; + try { + tags = JSON.parse(tagsJson || '[]'); + } catch { + return fail(400, { error: 'Invalid tags format' }); + } + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Create the custom format + const result = await customFormatQueries.create({ + databaseId: currentDatabaseId, + cache, + layer, + input: { + name: name.trim(), + description: description?.trim() || null, + includeInRename, + tags + } + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to create custom format' }); + } + + // Get fresh cache after create (compile creates a new cache instance) + const freshCache = pcdManager.getCache(currentDatabaseId); + if (!freshCache) { + // Fallback to list page if cache isn't ready + throw redirect(303, `/custom-formats/${databaseId}`); + } + + // Get the new format ID by looking it up by name + const formats = await customFormatQueries.list(freshCache); + const newFormat = formats.find(f => f.name === name.trim()); + + if (newFormat) { + // Redirect to conditions page so user can add conditions + throw redirect(303, `/custom-formats/${databaseId}/${newFormat.id}/conditions`); + } + + // Fallback to list page if we can't find the new format + throw redirect(303, `/custom-formats/${databaseId}`); + } +}; diff --git a/src/routes/custom-formats/[databaseId]/new/+page.svelte b/src/routes/custom-formats/[databaseId]/new/+page.svelte new file mode 100644 index 0000000..8f90fd5 --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/new/+page.svelte @@ -0,0 +1,36 @@ + + + + New Custom Format - {data.currentDatabase.name} - Profilarr + + +
+ +
+ +