mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat: updateLanguages, updateQualities functionality
This commit is contained in:
12
.github/workflows/notify.yml
vendored
Normal file
12
.github/workflows/notify.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Notify
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "v2"
|
||||
- "stable"
|
||||
- "dev"
|
||||
jobs:
|
||||
call-notify-commit:
|
||||
uses: Dictionarry-Hub/parrot/.github/workflows/notify-commit.yml@v1
|
||||
secrets:
|
||||
PARROT_URL: ${{ secrets.PARROT_URL }}
|
||||
@@ -33,4 +33,6 @@ export { scoring } from './scoring.ts';
|
||||
export { create } from './create.ts';
|
||||
export { updateGeneral } from './updateGeneral.ts';
|
||||
export { updateScoring } from './updateScoring.ts';
|
||||
export { updateQualities } from './updateQualities.ts';
|
||||
export { updateLanguages } from './updateLanguages.ts';
|
||||
export { remove } from './remove.ts';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Update quality profile languages
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import { writeOperation, type OperationLayer } from '../../writer.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export interface UpdateLanguagesInput {
|
||||
languageId: number | null;
|
||||
type: 'must' | 'only' | 'not' | 'simple';
|
||||
}
|
||||
|
||||
export interface UpdateLanguagesOptions {
|
||||
databaseId: number;
|
||||
cache: PCDCache;
|
||||
layer: OperationLayer;
|
||||
profileId: number;
|
||||
profileName: string;
|
||||
input: UpdateLanguagesInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quality profile language configuration
|
||||
*/
|
||||
export async function updateLanguages(options: UpdateLanguagesOptions) {
|
||||
const { databaseId, cache, layer, profileId, profileName, input } = options;
|
||||
const db = cache.kb;
|
||||
|
||||
const queries = [];
|
||||
|
||||
// 1. Delete existing languages for this profile
|
||||
const deleteLanguages = db
|
||||
.deleteFrom('quality_profile_languages')
|
||||
.where('quality_profile_id', '=', profileId)
|
||||
.compile();
|
||||
queries.push(deleteLanguages);
|
||||
|
||||
// 2. Insert new language if one is selected
|
||||
if (input.languageId !== null) {
|
||||
const insertLanguage = {
|
||||
sql: `INSERT INTO quality_profile_languages (quality_profile_id, language_id, type) VALUES (${profileId}, ${input.languageId}, '${input.type}')`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
queries.push(insertLanguage);
|
||||
}
|
||||
|
||||
await logger.info(`Save quality profile languages "${profileName}"`, {
|
||||
source: 'QualityProfile',
|
||||
meta: {
|
||||
profileId,
|
||||
languageId: input.languageId,
|
||||
type: input.type
|
||||
}
|
||||
});
|
||||
|
||||
// Write the operation
|
||||
const result = await writeOperation({
|
||||
databaseId,
|
||||
layer,
|
||||
description: `update-quality-profile-languages-${profileName}`,
|
||||
queries,
|
||||
metadata: {
|
||||
operation: 'update',
|
||||
entity: 'quality_profile',
|
||||
name: profileName
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
143
src/lib/server/pcd/queries/qualityProfiles/updateQualities.ts
Normal file
143
src/lib/server/pcd/queries/qualityProfiles/updateQualities.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Update quality profile qualities
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import { writeOperation, type OperationLayer } from '../../writer.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export interface QualityMember {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface OrderedItem {
|
||||
id: number;
|
||||
type: 'quality' | 'group';
|
||||
referenceId: number;
|
||||
name: string;
|
||||
position: number;
|
||||
enabled: boolean;
|
||||
upgradeUntil: boolean;
|
||||
members?: QualityMember[];
|
||||
}
|
||||
|
||||
export interface UpdateQualitiesInput {
|
||||
orderedItems: OrderedItem[];
|
||||
}
|
||||
|
||||
export interface UpdateQualitiesOptions {
|
||||
databaseId: number;
|
||||
cache: PCDCache;
|
||||
layer: OperationLayer;
|
||||
profileId: number;
|
||||
profileName: string;
|
||||
input: UpdateQualitiesInput;
|
||||
}
|
||||
|
||||
function esc(str: string): string {
|
||||
return str.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quality profile qualities configuration
|
||||
*/
|
||||
export async function updateQualities(options: UpdateQualitiesOptions) {
|
||||
const { databaseId, cache, layer, profileId, profileName, input } = options;
|
||||
const db = cache.kb;
|
||||
|
||||
const queries = [];
|
||||
|
||||
// 1. Delete existing quality_profile_qualities for this profile
|
||||
const deleteQPQ = db
|
||||
.deleteFrom('quality_profile_qualities')
|
||||
.where('quality_profile_id', '=', profileId)
|
||||
.compile();
|
||||
queries.push(deleteQPQ);
|
||||
|
||||
// 2. Delete existing quality_groups for this profile (cascades to quality_group_members)
|
||||
const deleteGroups = db
|
||||
.deleteFrom('quality_groups')
|
||||
.where('quality_profile_id', '=', profileId)
|
||||
.compile();
|
||||
queries.push(deleteGroups);
|
||||
|
||||
// 3. Process each ordered item
|
||||
// First pass: create groups and track their temporary IDs
|
||||
const groupNameToPosition = new Map<string, number>();
|
||||
|
||||
for (const item of input.orderedItems) {
|
||||
if (item.type === 'group') {
|
||||
// Create the group
|
||||
const createGroup = {
|
||||
sql: `INSERT INTO quality_groups (quality_profile_id, name) VALUES (${profileId}, '${esc(item.name)}')`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
queries.push(createGroup);
|
||||
|
||||
// Track position for this group name
|
||||
groupNameToPosition.set(item.name, item.position);
|
||||
|
||||
// Add group members
|
||||
if (item.members && item.members.length > 0) {
|
||||
for (const member of item.members) {
|
||||
const addMember = {
|
||||
sql: `INSERT INTO quality_group_members (quality_group_id, quality_id) VALUES ((SELECT id FROM quality_groups WHERE quality_profile_id = ${profileId} AND name = '${esc(item.name)}'), ${member.id})`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
queries.push(addMember);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Insert quality_profile_qualities entries
|
||||
for (const item of input.orderedItems) {
|
||||
const enabled = item.enabled ? 1 : 0;
|
||||
const upgradeUntil = item.upgradeUntil ? 1 : 0;
|
||||
|
||||
if (item.type === 'quality') {
|
||||
// Individual quality
|
||||
const insertQPQ = {
|
||||
sql: `INSERT INTO quality_profile_qualities (quality_profile_id, quality_id, quality_group_id, position, enabled, upgrade_until) VALUES (${profileId}, ${item.referenceId}, NULL, ${item.position}, ${enabled}, ${upgradeUntil})`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
queries.push(insertQPQ);
|
||||
} else if (item.type === 'group') {
|
||||
// Group reference
|
||||
const insertQPQ = {
|
||||
sql: `INSERT INTO quality_profile_qualities (quality_profile_id, quality_id, quality_group_id, position, enabled, upgrade_until) VALUES (${profileId}, NULL, (SELECT id FROM quality_groups WHERE quality_profile_id = ${profileId} AND name = '${esc(item.name)}'), ${item.position}, ${enabled}, ${upgradeUntil})`,
|
||||
parameters: [],
|
||||
query: {} as never
|
||||
};
|
||||
queries.push(insertQPQ);
|
||||
}
|
||||
}
|
||||
|
||||
await logger.info(`Save quality profile qualities "${profileName}"`, {
|
||||
source: 'QualityProfile',
|
||||
meta: {
|
||||
profileId,
|
||||
itemCount: input.orderedItems.length,
|
||||
groupCount: input.orderedItems.filter(i => i.type === 'group').length
|
||||
}
|
||||
});
|
||||
|
||||
// Write the operation
|
||||
const result = await writeOperation({
|
||||
databaseId,
|
||||
layer,
|
||||
description: `update-quality-profile-qualities-${profileName}`,
|
||||
queries,
|
||||
metadata: {
|
||||
operation: 'update',
|
||||
entity: 'quality_profile',
|
||||
name: profileName
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { FileText, Gauge, Layers, Earth } from 'lucide-svelte';
|
||||
import { FileText, Scale, Layers, Earth } from 'lucide-svelte';
|
||||
|
||||
$: databaseId = $page.params.databaseId;
|
||||
$: profileId = $page.params.id;
|
||||
@@ -19,7 +19,7 @@
|
||||
label: 'Scoring',
|
||||
href: `/quality-profiles/${databaseId}/${profileId}/scoring`,
|
||||
active: currentPath.includes('/scoring'),
|
||||
icon: Gauge
|
||||
icon: Scale
|
||||
},
|
||||
{
|
||||
label: 'Qualities',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { error, 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 qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
|
||||
import * as languageQueries from '$pcd/queries/languages.ts';
|
||||
import type { OperationLayer } from '$pcd/writer.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId, id } = params;
|
||||
@@ -38,6 +40,76 @@ export const load: ServerLoad = async ({ params }) => {
|
||||
|
||||
return {
|
||||
languages: languagesData.languages,
|
||||
availableLanguages
|
||||
availableLanguages,
|
||||
canWriteToBase: canWriteToBase(currentDatabaseId)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, params }) => {
|
||||
const { databaseId, id } = params;
|
||||
|
||||
if (!databaseId || !id) {
|
||||
return fail(400, { error: 'Missing required parameters' });
|
||||
}
|
||||
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
if (isNaN(currentDatabaseId)) {
|
||||
return fail(400, { error: 'Invalid database ID' });
|
||||
}
|
||||
|
||||
const profileId = parseInt(id, 10);
|
||||
if (isNaN(profileId)) {
|
||||
return fail(400, { error: 'Invalid profile 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 layer = (formData.get('layer') as OperationLayer) || 'user';
|
||||
const languageIdStr = formData.get('languageId') as string;
|
||||
const type = (formData.get('type') as 'must' | 'only' | 'not' | 'simple') || 'simple';
|
||||
|
||||
const languageId = languageIdStr ? parseInt(languageIdStr, 10) : null;
|
||||
|
||||
// Check layer permission
|
||||
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
|
||||
return fail(403, { error: 'Cannot write to base layer without personal access token' });
|
||||
}
|
||||
|
||||
// Get profile name for metadata
|
||||
const profile = await cache.kb
|
||||
.selectFrom('quality_profiles')
|
||||
.select('name')
|
||||
.where('id', '=', profileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!profile) {
|
||||
return fail(404, { error: 'Quality profile not found' });
|
||||
}
|
||||
|
||||
// Update the languages
|
||||
const result = await qualityProfileQueries.updateLanguages({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer,
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
input: {
|
||||
languageId,
|
||||
type
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to update languages' });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Info } from 'lucide-svelte';
|
||||
import { ChevronDown, Info, Save } from 'lucide-svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { initEdit, current, isDirty, update } from '$lib/client/stores/dirty';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { OperationLayer } from '$pcd/writer';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
|
||||
let showInfoModal = false;
|
||||
let showSaveModal = false;
|
||||
let isSaving = false;
|
||||
let formElement: HTMLFormElement;
|
||||
|
||||
const typeOptions: Array<{ value: 'simple' | 'must' | 'only' | 'not'; label: string }> = [
|
||||
{ value: 'simple', label: 'Preferred' },
|
||||
@@ -14,9 +22,20 @@
|
||||
{ value: 'not', label: 'Does Not Include' }
|
||||
];
|
||||
|
||||
// Initialize with existing language or defaults
|
||||
let selectedType: 'must' | 'only' | 'not' | 'simple' = data.languages[0]?.type || 'simple';
|
||||
let selectedLanguageId: number | null = data.languages[0]?.id || null;
|
||||
// Build initial data from server
|
||||
$: initialData = {
|
||||
type: data.languages[0]?.type || 'simple',
|
||||
languageId: data.languages[0]?.id || null
|
||||
};
|
||||
|
||||
// Initialize dirty tracking
|
||||
$: initEdit(initialData);
|
||||
|
||||
// Reactive getters for current values
|
||||
$: selectedType = ($current.type ?? 'simple') as 'must' | 'only' | 'not' | 'simple';
|
||||
$: selectedLanguageId = ($current.languageId ?? null) as number | null;
|
||||
|
||||
// Search query tracks the display name
|
||||
let searchQuery = data.languages[0]?.name || '';
|
||||
let showTypeDropdown = false;
|
||||
let showLanguageDropdown = false;
|
||||
@@ -31,12 +50,12 @@
|
||||
$: showValidationError = searchQuery !== '' && !isValidLanguage;
|
||||
|
||||
function selectType(type: 'must' | 'only' | 'not' | 'simple') {
|
||||
selectedType = type;
|
||||
update('type', type);
|
||||
showTypeDropdown = false;
|
||||
}
|
||||
|
||||
function selectLanguage(language: { id: number; name: string }) {
|
||||
selectedLanguageId = language.id;
|
||||
update('languageId', language.id);
|
||||
searchQuery = language.name;
|
||||
showLanguageDropdown = false;
|
||||
}
|
||||
@@ -50,9 +69,9 @@
|
||||
l => l.name.toLowerCase() === searchQuery.toLowerCase()
|
||||
);
|
||||
if (!exactMatch) {
|
||||
selectedLanguageId = null;
|
||||
update('languageId', null);
|
||||
} else {
|
||||
selectedLanguageId = exactMatch.id;
|
||||
update('languageId', exactMatch.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +87,47 @@
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleSaveClick() {
|
||||
if (data.canWriteToBase) {
|
||||
showSaveModal = true;
|
||||
} else {
|
||||
submitForm('user');
|
||||
}
|
||||
}
|
||||
|
||||
function submitForm(layer: OperationLayer) {
|
||||
showSaveModal = false;
|
||||
const layerInput = formElement.querySelector('input[name="layer"]') as HTMLInputElement;
|
||||
if (layerInput) layerInput.value = layer;
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Languages - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
return async ({ update: formUpdate }) => {
|
||||
await formUpdate();
|
||||
isSaving = false;
|
||||
if (form?.success) {
|
||||
initEdit(initialData);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="layer" value="user" />
|
||||
<input type="hidden" name="languageId" value={selectedLanguageId ?? ''} />
|
||||
<input type="hidden" name="type" value={selectedType} />
|
||||
</form>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -84,14 +138,27 @@
|
||||
Configure the language preference for this profile
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => showInfoModal = true}
|
||||
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"
|
||||
>
|
||||
<Info size={14} />
|
||||
Info
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $isDirty}
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleSaveClick}
|
||||
disabled={isSaving || showValidationError}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-accent-500 bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => showInfoModal = true}
|
||||
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"
|
||||
>
|
||||
<Info size={14} />
|
||||
Info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@@ -187,3 +254,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<SaveTargetModal
|
||||
bind:open={showSaveModal}
|
||||
onSelect={submitForm}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { error, 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 qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
|
||||
import type { OperationLayer } from '$pcd/writer.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId, id } = params;
|
||||
@@ -32,6 +34,95 @@ export const load: ServerLoad = async ({ params }) => {
|
||||
const qualitiesData = await qualityProfileQueries.qualities(cache, currentDatabaseId, profileId);
|
||||
|
||||
return {
|
||||
qualities: qualitiesData
|
||||
qualities: qualitiesData,
|
||||
canWriteToBase: canWriteToBase(currentDatabaseId)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, params }) => {
|
||||
const { databaseId, id } = params;
|
||||
|
||||
if (!databaseId || !id) {
|
||||
return fail(400, { error: 'Missing required parameters' });
|
||||
}
|
||||
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
if (isNaN(currentDatabaseId)) {
|
||||
return fail(400, { error: 'Invalid database ID' });
|
||||
}
|
||||
|
||||
const profileId = parseInt(id, 10);
|
||||
if (isNaN(profileId)) {
|
||||
return fail(400, { error: 'Invalid profile 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 layer = (formData.get('layer') as OperationLayer) || 'user';
|
||||
const orderedItemsJson = formData.get('orderedItems') as string;
|
||||
|
||||
// Check layer permission
|
||||
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
|
||||
return fail(403, { error: 'Cannot write to base layer without personal access token' });
|
||||
}
|
||||
|
||||
// Parse ordered items
|
||||
let orderedItems: Array<{
|
||||
id: number;
|
||||
type: 'quality' | 'group';
|
||||
referenceId: number;
|
||||
name: string;
|
||||
position: number;
|
||||
enabled: boolean;
|
||||
upgradeUntil: boolean;
|
||||
members?: Array<{ id: number; name: string }>;
|
||||
}> = [];
|
||||
try {
|
||||
orderedItems = JSON.parse(orderedItemsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Invalid ordered items format' });
|
||||
}
|
||||
|
||||
// Validate: only one item can have upgradeUntil set to true
|
||||
const upgradeUntilCount = orderedItems.filter(item => item.upgradeUntil).length;
|
||||
if (upgradeUntilCount > 1) {
|
||||
return fail(400, { error: 'Only one quality can be marked as "upgrade until"' });
|
||||
}
|
||||
|
||||
// Get profile name for metadata
|
||||
const profile = await cache.kb
|
||||
.selectFrom('quality_profiles')
|
||||
.select('name')
|
||||
.where('id', '=', profileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!profile) {
|
||||
return fail(404, { error: 'Quality profile not found' });
|
||||
}
|
||||
|
||||
// Update the qualities
|
||||
const result = await qualityProfileQueries.updateQualities({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer,
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
input: {
|
||||
orderedItems
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to update qualities' });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { X, Check, ArrowUp, Info, Eye, EyeOff } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { X, Check, ArrowUp, Info, Eye, EyeOff, Save, Loader2 } from 'lucide-svelte';
|
||||
import IconCheckbox from '$lib/client/ui/form/IconCheckbox.svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
current,
|
||||
isDirty,
|
||||
initEdit,
|
||||
update
|
||||
} from '$lib/client/stores/dirty';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -15,9 +25,37 @@
|
||||
type OrderedItem = PageData['qualities']['orderedItems'][number];
|
||||
type QualityMember = PageData['qualities']['availableQualities'][number];
|
||||
|
||||
let mainBucket: OrderedItem[] = structuredClone(data.qualities.orderedItems);
|
||||
let legacyBucket: QualityMember[] = structuredClone(data.qualities.availableQualities);
|
||||
// Form data shape
|
||||
interface QualitiesFormData {
|
||||
orderedItems: OrderedItem[];
|
||||
availableQualities: QualityMember[];
|
||||
}
|
||||
|
||||
// Build initial data from server
|
||||
$: initialData = buildInitialData(data.qualities);
|
||||
|
||||
function buildInitialData(qualities: typeof data.qualities): QualitiesFormData {
|
||||
return {
|
||||
orderedItems: structuredClone(qualities.orderedItems),
|
||||
availableQualities: structuredClone(qualities.availableQualities)
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize dirty tracking
|
||||
$: initEdit(initialData);
|
||||
|
||||
// Reactive getters for current values
|
||||
$: mainBucket = ($current.orderedItems ?? []) as OrderedItem[];
|
||||
$: legacyBucket = ($current.availableQualities ?? []) as QualityMember[];
|
||||
|
||||
// Save state
|
||||
let isSaving = false;
|
||||
let saveError: string | null = null;
|
||||
let selectedLayer: 'user' | 'base' = 'user';
|
||||
let showSaveTargetModal = false;
|
||||
let formElement: HTMLFormElement;
|
||||
|
||||
// Drag state (local, not tracked for dirty)
|
||||
let draggedFromLegacy: QualityMember | null = null;
|
||||
let draggedQualityFromMain: { item: OrderedItem; index: number } | null = null;
|
||||
let lastTargetIndex: number | null = null;
|
||||
@@ -26,7 +64,17 @@
|
||||
let willAddToGroup: boolean = false;
|
||||
let editingGroupIndex: number | null = null;
|
||||
let editingGroupName: string = '';
|
||||
let groupingMode: boolean = false; // For mobile toggle
|
||||
let groupingMode: boolean = false;
|
||||
|
||||
// Helper to update mainBucket in the store
|
||||
function updateMainBucket(newBucket: OrderedItem[]) {
|
||||
update('orderedItems', newBucket);
|
||||
}
|
||||
|
||||
// Helper to update legacyBucket in the store
|
||||
function updateLegacyBucket(newBucket: QualityMember[]) {
|
||||
update('availableQualities', newBucket);
|
||||
}
|
||||
|
||||
function handleQualityDragStart(item: OrderedItem, index: number) {
|
||||
draggedQualityFromMain = { item, index };
|
||||
@@ -47,30 +95,22 @@
|
||||
|
||||
hoverTargetIndex = targetIndex;
|
||||
|
||||
// Grouping mode (Ctrl/Cmd held or mobile toggle enabled)
|
||||
if (isGroupingMode && draggedItem.type === 'quality') {
|
||||
// Check if we're hovering over a quality with a quality (create group)
|
||||
if (targetItem.type === 'quality') {
|
||||
willCreateGroup = true;
|
||||
willAddToGroup = false;
|
||||
}
|
||||
// Check if we're hovering over a group with a quality (add to group)
|
||||
else if (targetItem.type === 'group') {
|
||||
} else if (targetItem.type === 'group') {
|
||||
willCreateGroup = false;
|
||||
willAddToGroup = true;
|
||||
} else {
|
||||
willCreateGroup = false;
|
||||
willAddToGroup = false;
|
||||
}
|
||||
}
|
||||
// Reordering mode (default)
|
||||
else {
|
||||
} else {
|
||||
willCreateGroup = false;
|
||||
willAddToGroup = false;
|
||||
|
||||
// Only reorder if we've moved to a different item
|
||||
if (lastTargetIndex === targetIndex) return;
|
||||
|
||||
lastTargetIndex = targetIndex;
|
||||
|
||||
const newBucket = [...mainBucket];
|
||||
@@ -78,13 +118,12 @@
|
||||
const [movedItem] = newBucket.splice(sourceIndex, 1);
|
||||
newBucket.splice(targetIndex, 0, movedItem);
|
||||
|
||||
// Recalculate positions
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
|
||||
mainBucket = newBucket;
|
||||
draggedQualityFromMain.index = targetIndex; // Update the dragged index
|
||||
updateMainBucket(newBucket);
|
||||
draggedQualityFromMain.index = targetIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +146,12 @@
|
||||
const sourceIndex = draggedQualityFromMain.index;
|
||||
const isGroupingMode = e.ctrlKey || e.metaKey || groupingMode;
|
||||
|
||||
// Grouping actions (only if Ctrl/Cmd held or grouping mode enabled)
|
||||
if (isGroupingMode && draggedItem.type === 'quality') {
|
||||
// Create a new group from two qualities
|
||||
if (targetItem.type === 'quality') {
|
||||
const newGroup: OrderedItem = {
|
||||
id: -1,
|
||||
type: 'group',
|
||||
referenceId: -1, // Will be assigned on save
|
||||
referenceId: -1,
|
||||
name: `${draggedItem.name} + ${targetItem.name}`,
|
||||
position: targetIndex,
|
||||
enabled: draggedItem.enabled && targetItem.enabled,
|
||||
@@ -126,42 +163,34 @@
|
||||
};
|
||||
|
||||
const newBucket = [...mainBucket];
|
||||
// Remove both items
|
||||
const indicesToRemove = [sourceIndex, targetIndex].sort((a, b) => b - a);
|
||||
indicesToRemove.forEach(idx => newBucket.splice(idx, 1));
|
||||
// Insert the new group at the target position
|
||||
const insertPos = Math.min(sourceIndex, targetIndex);
|
||||
newBucket.splice(insertPos, 0, newGroup);
|
||||
|
||||
// Recalculate positions
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
mainBucket = newBucket;
|
||||
}
|
||||
// Add quality to existing group
|
||||
else if (targetItem.type === 'group') {
|
||||
updateMainBucket(newBucket);
|
||||
} else if (targetItem.type === 'group') {
|
||||
const newBucket = [...mainBucket];
|
||||
const targetGroup = newBucket[targetIndex];
|
||||
const targetGroup = { ...newBucket[targetIndex] };
|
||||
|
||||
// Add the quality to the group's members
|
||||
if (!targetGroup.members) targetGroup.members = [];
|
||||
targetGroup.members.push({
|
||||
targetGroup.members = [...targetGroup.members, {
|
||||
id: draggedItem.referenceId,
|
||||
name: draggedItem.name
|
||||
});
|
||||
}];
|
||||
|
||||
// Remove the dragged quality
|
||||
newBucket[targetIndex] = targetGroup;
|
||||
newBucket.splice(sourceIndex, 1);
|
||||
|
||||
// Recalculate positions
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
mainBucket = newBucket;
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
}
|
||||
// Reordering was already handled in dragover, just need to clean up
|
||||
|
||||
resetDragState();
|
||||
}
|
||||
@@ -183,8 +212,9 @@
|
||||
|
||||
function saveGroupName() {
|
||||
if (editingGroupIndex !== null && editingGroupName.trim()) {
|
||||
mainBucket[editingGroupIndex].name = editingGroupName.trim();
|
||||
mainBucket = [...mainBucket]; // Trigger reactivity
|
||||
const newBucket = [...mainBucket];
|
||||
newBucket[editingGroupIndex] = { ...newBucket[editingGroupIndex], name: editingGroupName.trim() };
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
editingGroupIndex = null;
|
||||
editingGroupName = '';
|
||||
@@ -196,28 +226,28 @@
|
||||
}
|
||||
|
||||
function toggleEnabled(index: number) {
|
||||
// Block disabling if the item has upgradeUntil set
|
||||
if (mainBucket[index].enabled && mainBucket[index].upgradeUntil) {
|
||||
alertStore.add('warning', 'Cannot disable an item that is set as "Upgrade Until". Please remove the "Upgrade Until" flag first.');
|
||||
return;
|
||||
}
|
||||
|
||||
mainBucket[index].enabled = !mainBucket[index].enabled;
|
||||
mainBucket = [...mainBucket]; // Trigger reactivity
|
||||
const newBucket = [...mainBucket];
|
||||
newBucket[index] = { ...newBucket[index], enabled: !newBucket[index].enabled };
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
|
||||
function toggleUpgradeUntil(index: number) {
|
||||
// Check if the item is enabled, if not enable it and alert
|
||||
if (!mainBucket[index].enabled) {
|
||||
mainBucket[index].enabled = true;
|
||||
const newBucket = [...mainBucket];
|
||||
|
||||
if (!newBucket[index].enabled) {
|
||||
newBucket[index] = { ...newBucket[index], enabled: true };
|
||||
alertStore.add('info', 'Item was automatically enabled because "Upgrade Until" requires it to be enabled.');
|
||||
}
|
||||
|
||||
// Set this one and unset all others
|
||||
mainBucket.forEach((item, i) => {
|
||||
item.upgradeUntil = i === index;
|
||||
newBucket.forEach((item, i) => {
|
||||
newBucket[i] = { ...item, upgradeUntil: i === index };
|
||||
});
|
||||
mainBucket = [...mainBucket]; // Trigger reactivity
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
|
||||
function handleDragStart(item: QualityMember) {
|
||||
@@ -232,10 +262,9 @@
|
||||
e.preventDefault();
|
||||
if (!draggedFromLegacy) return;
|
||||
|
||||
// Remove from legacy bucket
|
||||
legacyBucket = legacyBucket.filter(q => q.id !== draggedFromLegacy!.id);
|
||||
const newLegacyBucket = legacyBucket.filter(q => q.id !== draggedFromLegacy!.id);
|
||||
updateLegacyBucket(newLegacyBucket);
|
||||
|
||||
// Add to main bucket at the end
|
||||
const newItem: OrderedItem = {
|
||||
id: -1,
|
||||
type: 'quality',
|
||||
@@ -247,11 +276,10 @@
|
||||
};
|
||||
|
||||
const newBucket = [...mainBucket, newItem];
|
||||
// Recalculate positions
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
mainBucket = newBucket;
|
||||
updateMainBucket(newBucket);
|
||||
|
||||
draggedFromLegacy = null;
|
||||
}
|
||||
@@ -263,31 +291,44 @@
|
||||
function collapseGroup(group: OrderedItem) {
|
||||
if (group.type !== 'group' || !group.members) return;
|
||||
|
||||
// Find the index of the group
|
||||
const groupIndex = mainBucket.findIndex(item => item.id === group.id);
|
||||
const groupIndex = mainBucket.findIndex(item => item.id === group.id && item.type === 'group' && item.name === group.name);
|
||||
if (groupIndex === -1) return;
|
||||
|
||||
// Create individual quality items from group members
|
||||
const newQualities: OrderedItem[] = group.members.map((member, index) => ({
|
||||
id: -1, // Temporary ID
|
||||
id: -1,
|
||||
type: 'quality' as const,
|
||||
referenceId: member.id,
|
||||
name: member.name,
|
||||
position: groupIndex + index,
|
||||
enabled: group.enabled,
|
||||
upgradeUntil: index === 0 ? group.upgradeUntil : false // Only first gets upgradeUntil
|
||||
upgradeUntil: index === 0 ? group.upgradeUntil : false
|
||||
}));
|
||||
|
||||
// Remove the group and insert individual qualities
|
||||
const newBucket = [...mainBucket];
|
||||
newBucket.splice(groupIndex, 1, ...newQualities);
|
||||
|
||||
// Recalculate positions
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
|
||||
mainBucket = newBucket;
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
|
||||
async function handleSaveClick() {
|
||||
if (data.canWriteToBase) {
|
||||
showSaveTargetModal = true;
|
||||
} else {
|
||||
selectedLayer = 'user';
|
||||
await tick();
|
||||
formElement?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
|
||||
selectedLayer = event.detail;
|
||||
showSaveTargetModal = false;
|
||||
await tick();
|
||||
formElement?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -295,6 +336,59 @@
|
||||
<title>Qualities - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Save Bar -->
|
||||
{#if $isDirty}
|
||||
<div class="sticky top-0 z-40 -mx-8 mb-6 flex items-center justify-between border-b border-neutral-200 bg-white/95 px-8 py-3 backdrop-blur-sm dark:border-neutral-700 dark:bg-neutral-900/95">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-amber-600 dark:text-amber-400">Unsaved changes</span>
|
||||
{#if saveError}
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{saveError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
on:click={handleSaveClick}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-4 py-1.5 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 isSaving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden form for submission -->
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
alertStore.add('success', 'Qualities saved!');
|
||||
initEdit(initialData);
|
||||
await formUpdate();
|
||||
} else if (result.type === 'failure') {
|
||||
saveError = (result.data as { error?: string })?.error || 'Failed to save';
|
||||
alertStore.add('error', saveError);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="orderedItems" value={JSON.stringify(mainBucket)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<ActionsBar>
|
||||
<ActionButton icon={Info} on:click={() => (showInfoModal = true)} />
|
||||
@@ -323,7 +417,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.referenceId}` : `group-${item.id}`)}
|
||||
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.referenceId}-${index}` : `group-${item.name}-${index}`)}
|
||||
<div
|
||||
draggable={true}
|
||||
on:dragstart={() => handleQualityDragStart(item, index)}
|
||||
@@ -414,7 +508,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<IconCheckbox
|
||||
bind:checked={item.upgradeUntil}
|
||||
checked={item.upgradeUntil}
|
||||
icon={ArrowUp}
|
||||
color="#07CA07"
|
||||
shape="circle"
|
||||
@@ -424,7 +518,7 @@
|
||||
}}
|
||||
/>
|
||||
<IconCheckbox
|
||||
bind:checked={item.enabled}
|
||||
checked={item.enabled}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
@@ -539,3 +633,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<!-- 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