From aec6d79695b3ce022432364e49d53fef045c4470 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Wed, 14 Jan 2026 16:03:14 +1030 Subject: [PATCH] feat: updateLanguages, updateQualities functionality --- .github/workflows/notify.yml | 12 + .../pcd/queries/qualityProfiles/index.ts | 2 + .../qualityProfiles/updateLanguages.ts | 72 ++++++ .../qualityProfiles/updateQualities.ts | 143 +++++++++++ .../[databaseId]/[id]/+layout.svelte | 4 +- .../[id]/languages/+page.server.ts | 78 +++++- .../[databaseId]/[id]/languages/+page.svelte | 106 ++++++-- .../[id]/qualities/+page.server.ts | 97 +++++++- .../[databaseId]/[id]/qualities/+page.svelte | 230 +++++++++++++----- 9 files changed, 656 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/notify.yml create mode 100644 src/lib/server/pcd/queries/qualityProfiles/updateLanguages.ts create mode 100644 src/lib/server/pcd/queries/qualityProfiles/updateQualities.ts diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml new file mode 100644 index 0000000..0adeade --- /dev/null +++ b/.github/workflows/notify.yml @@ -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 }} diff --git a/src/lib/server/pcd/queries/qualityProfiles/index.ts b/src/lib/server/pcd/queries/qualityProfiles/index.ts index 9761740..2246623 100644 --- a/src/lib/server/pcd/queries/qualityProfiles/index.ts +++ b/src/lib/server/pcd/queries/qualityProfiles/index.ts @@ -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'; diff --git a/src/lib/server/pcd/queries/qualityProfiles/updateLanguages.ts b/src/lib/server/pcd/queries/qualityProfiles/updateLanguages.ts new file mode 100644 index 0000000..8b0d9fa --- /dev/null +++ b/src/lib/server/pcd/queries/qualityProfiles/updateLanguages.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; +} diff --git a/src/lib/server/pcd/queries/qualityProfiles/updateQualities.ts b/src/lib/server/pcd/queries/qualityProfiles/updateQualities.ts new file mode 100644 index 0000000..073e196 --- /dev/null +++ b/src/lib/server/pcd/queries/qualityProfiles/updateQualities.ts @@ -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(); + + 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; +} diff --git a/src/routes/quality-profiles/[databaseId]/[id]/+layout.svelte b/src/routes/quality-profiles/[databaseId]/[id]/+layout.svelte index 1077f16..5d9ee07 100644 --- a/src/routes/quality-profiles/[databaseId]/[id]/+layout.svelte +++ b/src/routes/quality-profiles/[databaseId]/[id]/+layout.svelte @@ -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', diff --git a/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.server.ts b/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.server.ts index 7a56ee9..4e74cfc 100644 --- a/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.server.ts +++ b/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.svelte b/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.svelte index 3607a4d..7e65f26 100644 --- a/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.svelte +++ b/src/routes/quality-profiles/[databaseId]/[id]/languages/+page.svelte @@ -1,11 +1,19 @@ Languages - Profilarr +
{ + isSaving = true; + return async ({ update: formUpdate }) => { + await formUpdate(); + isSaving = false; + if (form?.success) { + initEdit(initialData); + } + }; + }} +> + + + +
+
@@ -84,14 +138,27 @@ Configure the language preference for this profile

- +
+ {#if $isDirty} + + {/if} + +
@@ -187,3 +254,8 @@
+ + diff --git a/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts index c3d7219..36b7e04 100644 --- a/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts +++ b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte index e70f510..54371a4 100644 --- a/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte +++ b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte @@ -1,10 +1,20 @@ @@ -295,6 +336,59 @@ Qualities - Profilarr + +{#if $isDirty} +
+
+ Unsaved changes + {#if saveError} + {saveError} + {/if} +
+ + +
+ + + +{/if} +
(showInfoModal = true)} /> @@ -323,7 +417,7 @@
{:else}
- {#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}`)}
handleQualityDragStart(item, index)} @@ -414,7 +508,7 @@ {/if}
+ + +{#if data.canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if}