mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-26 12:52:00 +01:00
feat(custom-formats): add updateGeneral functionality and integrate with the general page
This commit is contained in:
@@ -10,6 +10,7 @@ export type { DeleteTestOptions } from './testDelete.ts';
|
||||
export type { ConditionData } from './conditions.ts';
|
||||
export type { ConditionListItem } from './listConditions.ts';
|
||||
export type { ConditionResult, EvaluationResult, ParsedInfo } from './evaluator.ts';
|
||||
export type { UpdateGeneralInput, UpdateGeneralOptions } from './updateGeneral.ts';
|
||||
|
||||
// Export query functions (reads)
|
||||
export { list } from './list.ts';
|
||||
@@ -23,3 +24,4 @@ export { evaluateCustomFormat, getParsedInfo } from './evaluator.ts';
|
||||
export { createTest } from './testCreate.ts';
|
||||
export { updateTest } from './testUpdate.ts';
|
||||
export { deleteTest } from './testDelete.ts';
|
||||
export { updateGeneral } from './updateGeneral.ts';
|
||||
|
||||
138
src/lib/server/pcd/queries/customFormats/updateGeneral.ts
Normal file
138
src/lib/server/pcd/queries/customFormats/updateGeneral.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Update custom format general information
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import { writeOperation, type OperationLayer } from '../../writer.ts';
|
||||
import type { CustomFormatGeneral } from './types.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export interface UpdateGeneralInput {
|
||||
name: string;
|
||||
description: string;
|
||||
includeInRename: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface UpdateGeneralOptions {
|
||||
databaseId: number;
|
||||
cache: PCDCache;
|
||||
layer: OperationLayer;
|
||||
/** The current custom format data (for value guards) */
|
||||
current: CustomFormatGeneral;
|
||||
/** The new values */
|
||||
input: UpdateGeneralInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for SQL
|
||||
*/
|
||||
function esc(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom format general information
|
||||
*/
|
||||
export async function updateGeneral(options: UpdateGeneralOptions) {
|
||||
const { databaseId, cache, layer, current, input } = options;
|
||||
const db = cache.kb;
|
||||
|
||||
const queries = [];
|
||||
|
||||
// 1. Update the custom format with value guards
|
||||
const updateFormat = db
|
||||
.updateTable('custom_formats')
|
||||
.set({
|
||||
name: input.name,
|
||||
description: input.description || null,
|
||||
include_in_rename: input.includeInRename ? 1 : 0
|
||||
})
|
||||
.where('id', '=', current.id)
|
||||
// Value guards - ensure current values match what we expect
|
||||
.where('name', '=', current.name)
|
||||
.compile();
|
||||
|
||||
queries.push(updateFormat);
|
||||
|
||||
// 2. Handle tag changes
|
||||
const currentTagNames = current.tags.map(t => t.name);
|
||||
const newTagNames = input.tags;
|
||||
|
||||
// Tags to remove
|
||||
const tagsToRemove = currentTagNames.filter(t => !newTagNames.includes(t));
|
||||
for (const tagName of tagsToRemove) {
|
||||
const removeTag = {
|
||||
sql: `DELETE FROM custom_format_tags WHERE custom_format_id = (SELECT id FROM custom_formats WHERE name = '${esc(current.name)}') AND tag_id = tag('${esc(tagName)}')`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
queries.push(removeTag);
|
||||
}
|
||||
|
||||
// Tags to add
|
||||
const tagsToAdd = newTagNames.filter(t => !currentTagNames.includes(t));
|
||||
for (const tagName of tagsToAdd) {
|
||||
// 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
|
||||
// Use input.name for lookup since the format might have been renamed
|
||||
const formatName = input.name !== current.name ? input.name : current.name;
|
||||
const linkTag = {
|
||||
sql: `INSERT INTO custom_format_tags (custom_format_id, tag_id) VALUES ((SELECT id FROM custom_formats WHERE name = '${esc(formatName)}'), tag('${esc(tagName)}'))`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
|
||||
queries.push(linkTag);
|
||||
}
|
||||
|
||||
// Log what's being changed
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||
|
||||
if (current.name !== input.name) {
|
||||
changes.name = { from: current.name, to: input.name };
|
||||
}
|
||||
if (current.description !== input.description) {
|
||||
changes.description = { from: current.description, to: input.description };
|
||||
}
|
||||
if (current.include_in_rename !== input.includeInRename) {
|
||||
changes.includeInRename = { from: current.include_in_rename, to: input.includeInRename };
|
||||
}
|
||||
if (tagsToAdd.length > 0 || tagsToRemove.length > 0) {
|
||||
changes.tags = { from: currentTagNames, to: input.tags };
|
||||
}
|
||||
|
||||
await logger.info(`Save custom format "${input.name}"`, {
|
||||
source: 'CustomFormat',
|
||||
meta: {
|
||||
id: current.id,
|
||||
changes
|
||||
}
|
||||
});
|
||||
|
||||
// Write the operation with metadata
|
||||
const isRename = input.name !== current.name;
|
||||
|
||||
const result = await writeOperation({
|
||||
databaseId,
|
||||
layer,
|
||||
description: `update-custom-format-${input.name}`,
|
||||
queries,
|
||||
metadata: {
|
||||
operation: 'update',
|
||||
entity: 'custom_format',
|
||||
name: input.name,
|
||||
...(isRename && { previousName: current.name })
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { FileText, Filter, FlaskConical } from 'lucide-svelte';
|
||||
|
||||
@@ -38,3 +39,5 @@
|
||||
<Tabs {tabs} {backButton} />
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<DirtyModal />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 type { OperationLayer } from '$pcd/writer.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId, id } = params;
|
||||
@@ -23,6 +25,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) {
|
||||
@@ -36,6 +44,82 @@ export const load: ServerLoad = async ({ params }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
format
|
||||
currentDatabase,
|
||||
format,
|
||||
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 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();
|
||||
|
||||
// Parse form data
|
||||
const name = formData.get('name') as string;
|
||||
const description = (formData.get('description') as string) || '';
|
||||
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' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// Update the custom format
|
||||
const result = await customFormatQueries.updateGeneral({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer,
|
||||
current,
|
||||
input: {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
includeInRename,
|
||||
tags
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to update custom format' });
|
||||
}
|
||||
|
||||
throw redirect(303, `/custom-formats/${databaseId}/${id}/general`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,29 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { Check } from 'lucide-svelte';
|
||||
import FormInput from '$ui/form/FormInput.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import { Check, Save, Loader2 } from 'lucide-svelte';
|
||||
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
|
||||
import TagInput from '$ui/form/TagInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import UnsavedChangesModal from '$ui/modal/UnsavedChangesModal.svelte';
|
||||
import { useUnsavedChanges } from '$lib/client/utils/unsavedChanges.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import {
|
||||
current,
|
||||
isDirty,
|
||||
initEdit,
|
||||
update
|
||||
} from '$lib/client/stores/dirty';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const unsavedChanges = useUnsavedChanges();
|
||||
// Form data shape
|
||||
interface GeneralFormData {
|
||||
name: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
includeInRename: boolean;
|
||||
}
|
||||
|
||||
let name = data.format.name;
|
||||
let description = data.format.description;
|
||||
let tags: string[] = data.format.tags.map((t) => t.name);
|
||||
let includeInRename = data.format.include_in_rename;
|
||||
// Build initial data from server
|
||||
$: initialData = {
|
||||
name: data.format.name,
|
||||
tags: data.format.tags.map((t) => t.name),
|
||||
description: data.format.description ?? '',
|
||||
includeInRename: data.format.include_in_rename
|
||||
};
|
||||
|
||||
// Mark as dirty when any field changes
|
||||
$: if (
|
||||
name !== data.format.name ||
|
||||
description !== data.format.description ||
|
||||
includeInRename !== data.format.include_in_rename
|
||||
) {
|
||||
unsavedChanges.markDirty();
|
||||
// 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;
|
||||
|
||||
// Validation
|
||||
$: isValid = ($current as GeneralFormData).name?.trim() !== '';
|
||||
|
||||
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>
|
||||
|
||||
@@ -31,47 +71,118 @@
|
||||
<title>{data.format.name} - General - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<UnsavedChangesModal />
|
||||
<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', 'Custom format updated!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit($current as GeneralFormData);
|
||||
}
|
||||
await formUpdate();
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Hidden fields for form data -->
|
||||
<input type="hidden" name="tags" value={JSON.stringify(($current as GeneralFormData).tags)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
<input type="hidden" name="includeInRename" value={($current as GeneralFormData).includeInRename} />
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<FormInput
|
||||
label="Name"
|
||||
description="The name of this custom format"
|
||||
placeholder="Enter custom format name"
|
||||
bind:value={name}
|
||||
/>
|
||||
|
||||
<MarkdownInput
|
||||
label="Description"
|
||||
description="Add any notes or details about this custom format's purpose and configuration."
|
||||
bind:value={description}
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">Tags</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Add tags to organize and categorize this custom format.
|
||||
</p>
|
||||
<TagInput bind:tags />
|
||||
<input type="hidden" name="tags" value={tags.length > 0 ? JSON.stringify(tags) : ''} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Include In Rename
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
When enabled, this custom format's name will be included in the renamed filename.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
icon={Check}
|
||||
bind:checked={includeInRename}
|
||||
on:click={() => (includeInRename = !includeInRename)}
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
The name of this custom format
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={($current as GeneralFormData).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"
|
||||
/>
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{includeInRename ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<MarkdownInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
description="Add any notes or details about this custom format's purpose and configuration."
|
||||
value={($current as GeneralFormData).description}
|
||||
onchange={(v) => update('description', v)}
|
||||
/>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">Tags</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Add tags to organize and categorize this custom format.
|
||||
</p>
|
||||
<TagInput
|
||||
tags={($current as GeneralFormData).tags}
|
||||
onchange={(newTags) => update('tags', newTags)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Include In Rename -->
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Include In Rename
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
When enabled, this custom format's name will be included in the renamed filename.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
icon={Check}
|
||||
checked={($current as GeneralFormData).includeInRename}
|
||||
on:click={() => update('includeInRename', !($current as GeneralFormData).includeInRename)}
|
||||
/>
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{($current as GeneralFormData).includeInRename ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !isValid || !$isDirty}
|
||||
onclick={handleSaveClick}
|
||||
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Save Target Modal -->
|
||||
{#if data.canWriteToBase}
|
||||
<SaveTargetModal
|
||||
open={showSaveTargetModal}
|
||||
mode="save"
|
||||
on:select={handleLayerSelect}
|
||||
on:cancel={() => (showSaveTargetModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user