mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat: backend support for manifest/readme updates
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' = 'accent';
|
||||
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' | 'info' = 'accent';
|
||||
export let size: 'sm' | 'md' = 'sm';
|
||||
export let icon: ComponentType | null = null;
|
||||
export let mono: boolean = false;
|
||||
@@ -11,7 +11,8 @@
|
||||
neutral: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
success: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
|
||||
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
danger: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
danger: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
};
|
||||
|
||||
const sizeClasses: Record<typeof size, string> = {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Handles reading and validating pcd.json files
|
||||
*/
|
||||
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export interface Manifest {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -150,3 +152,16 @@ export async function loadManifest(pcdPath: string): Promise<Manifest> {
|
||||
validateManifest(manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write manifest to a PCD repository
|
||||
*/
|
||||
export async function writeManifest(pcdPath: string, manifest: Manifest): Promise<void> {
|
||||
validateManifest(manifest);
|
||||
const manifestPath = `${pcdPath}/pcd.json`;
|
||||
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
await logger.info(`Wrote manifest: ${manifest.name}`, {
|
||||
source: 'PCDManifest',
|
||||
meta: { path: pcdPath, manifest }
|
||||
});
|
||||
}
|
||||
|
||||
28
src/lib/server/pcd/readme.ts
Normal file
28
src/lib/server/pcd/readme.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* PCD README Handler
|
||||
* Handles reading and writing README.md files for PCD repositories
|
||||
*/
|
||||
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
/**
|
||||
* Read README from a PCD repository
|
||||
*/
|
||||
export async function readReadme(pcdPath: string): Promise<string | null> {
|
||||
try {
|
||||
return await Deno.readTextFile(`${pcdPath}/README.md`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write README to a PCD repository
|
||||
*/
|
||||
export async function writeReadme(pcdPath: string, content: string): Promise<void> {
|
||||
await Deno.writeTextFile(`${pcdPath}/README.md`, content);
|
||||
await logger.info('Wrote README', {
|
||||
source: 'PCDReadme',
|
||||
meta: { path: pcdPath, content }
|
||||
});
|
||||
}
|
||||
@@ -43,27 +43,60 @@ function extractOpNumber(filename: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uncommitted operation files from ops/ directory
|
||||
* Get metadata for known config files
|
||||
*/
|
||||
function getConfigFileMetadata(filename: string, status: string): Partial<OperationFile> {
|
||||
const isNew = status.includes('?') || status.includes('A');
|
||||
const operation = isNew ? 'create' : 'update';
|
||||
|
||||
if (filename === 'pcd.json') {
|
||||
return { operation, entity: 'manifest', name: 'pcd.json' };
|
||||
}
|
||||
if (filename === 'README.md') {
|
||||
return { operation, entity: 'readme', name: 'README.md' };
|
||||
}
|
||||
if (filename.startsWith('tweaks/') && filename.endsWith('.sql')) {
|
||||
return { operation, entity: 'tweak', name: filename.replace('tweaks/', '') };
|
||||
}
|
||||
|
||||
return { operation, entity: null, name: filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uncommitted files from the repository (excludes user_ops/ and deps/)
|
||||
*/
|
||||
export async function getUncommittedOps(repoPath: string): Promise<OperationFile[]> {
|
||||
const files: OperationFile[] = [];
|
||||
|
||||
const output = await execGitSafe(['status', '--porcelain', 'ops/'], repoPath);
|
||||
const output = await execGitSafe(['status', '--porcelain'], repoPath);
|
||||
if (!output) return files;
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const status = line.substring(0, 2);
|
||||
const filename = line.substring(3).trim();
|
||||
// Git porcelain format: XY PATH (2 char status + variable whitespace + path)
|
||||
const rawFilename = line.substring(2).trimStart();
|
||||
|
||||
// Only include new/modified .sql files
|
||||
if ((status.includes('?') || status.includes('A') || status.includes('M')) && filename.endsWith('.sql')) {
|
||||
const filepath = `${repoPath}/${filename}`;
|
||||
const metadata = await parseOperationMetadata(filepath);
|
||||
// Skip user_ops and deps directories
|
||||
if (rawFilename.startsWith('user_ops/') || rawFilename.startsWith('deps/')) continue;
|
||||
|
||||
// Only include new/modified files
|
||||
if (status.includes('?') || status.includes('A') || status.includes('M')) {
|
||||
const filepath = `${repoPath}/${rawFilename}`;
|
||||
|
||||
let metadata: Partial<OperationFile> = {};
|
||||
|
||||
if (rawFilename.startsWith('ops/') && rawFilename.endsWith('.sql')) {
|
||||
// Parse metadata from SQL file header
|
||||
metadata = await parseOperationMetadata(filepath);
|
||||
} else {
|
||||
// Get metadata for config files
|
||||
metadata = getConfigFileMetadata(rawFilename, status);
|
||||
}
|
||||
|
||||
files.push({
|
||||
filename: filename.replace('ops/', ''),
|
||||
filename: rawFilename.startsWith('ops/') ? rawFilename.replace('ops/', '') : rawFilename,
|
||||
filepath,
|
||||
operation: metadata.operation || null,
|
||||
entity: metadata.entity || null,
|
||||
@@ -73,8 +106,18 @@ export async function getUncommittedOps(repoPath: string): Promise<OperationFile
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by operation number (natural sort)
|
||||
files.sort((a, b) => extractOpNumber(a.filename) - extractOpNumber(b.filename));
|
||||
// Sort: ops/ files by number first, then other files alphabetically
|
||||
files.sort((a, b) => {
|
||||
const aIsOps = a.filepath.includes('/ops/');
|
||||
const bIsOps = b.filepath.includes('/ops/');
|
||||
|
||||
if (aIsOps && bIsOps) {
|
||||
return extractOpNumber(a.filename) - extractOpNumber(b.filename);
|
||||
}
|
||||
if (aIsOps) return -1;
|
||||
if (bIsOps) return 1;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
@@ -103,25 +146,37 @@ export async function getMaxOpNumber(repoPath: string): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard operation files (delete them)
|
||||
* Discard uncommitted files (restore or delete them)
|
||||
*/
|
||||
export async function discardOps(repoPath: string, filepaths: string[]): Promise<void> {
|
||||
for (const filepath of filepaths) {
|
||||
// Security: ensure file is within ops directory
|
||||
if (!filepath.startsWith(repoPath + '/ops/')) {
|
||||
// Security: ensure file is within repo and not in user_ops or deps
|
||||
if (!filepath.startsWith(repoPath + '/') ||
|
||||
filepath.startsWith(repoPath + '/user_ops/') ||
|
||||
filepath.startsWith(repoPath + '/deps/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await Deno.remove(filepath);
|
||||
// Check if file is tracked by git
|
||||
const relativePath = filepath.replace(repoPath + '/', '');
|
||||
const isTracked = await execGitSafe(['ls-files', relativePath], repoPath);
|
||||
|
||||
if (isTracked?.trim()) {
|
||||
// Tracked file - restore from git
|
||||
await execGitSafe(['checkout', 'HEAD', '--', relativePath], repoPath);
|
||||
} else {
|
||||
// Untracked file - delete it
|
||||
await Deno.remove(filepath);
|
||||
}
|
||||
} catch {
|
||||
// File might already be deleted
|
||||
// File might already be deleted or restored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add operation files: pull, renumber if needed, commit, push
|
||||
* Add files: pull, renumber ops if needed, commit, push
|
||||
*
|
||||
* TODO: This functionality needs to be redesigned. The current approach of
|
||||
* pull -> renumber -> commit -> push has race conditions and edge cases that
|
||||
@@ -148,32 +203,43 @@ export async function addOps(
|
||||
// 2. Get max op number after pull
|
||||
let maxNum = await getMaxOpNumber(repoPath);
|
||||
|
||||
// 3. Renumber and collect files to stage
|
||||
// 3. Process files - renumber ops/, keep others as-is
|
||||
const filesToStage: string[] = [];
|
||||
const opsPath = `${repoPath}/ops`;
|
||||
|
||||
for (const filepath of filepaths) {
|
||||
if (!filepath.startsWith(repoPath + '/ops/')) continue;
|
||||
// Security: ensure file is within repo and not in user_ops or deps
|
||||
if (!filepath.startsWith(repoPath + '/') ||
|
||||
filepath.startsWith(repoPath + '/user_ops/') ||
|
||||
filepath.startsWith(repoPath + '/deps/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filename = filepath.split('/').pop()!;
|
||||
const match = filename.match(/^(\d+)\.(.+)$/);
|
||||
// Handle ops/ files with renumbering
|
||||
if (filepath.startsWith(repoPath + '/ops/')) {
|
||||
const filename = filepath.split('/').pop()!;
|
||||
const match = filename.match(/^(\d+)\.(.+)$/);
|
||||
|
||||
if (match) {
|
||||
const currentNum = parseInt(match[1], 10);
|
||||
const rest = match[2];
|
||||
if (match) {
|
||||
const currentNum = parseInt(match[1], 10);
|
||||
const rest = match[2];
|
||||
|
||||
if (currentNum <= maxNum) {
|
||||
// Need to renumber
|
||||
maxNum++;
|
||||
const newFilename = `${maxNum}.${rest}`;
|
||||
const newFilepath = `${opsPath}/${newFilename}`;
|
||||
await Deno.rename(filepath, newFilepath);
|
||||
filesToStage.push(newFilepath);
|
||||
if (currentNum <= maxNum) {
|
||||
// Need to renumber
|
||||
maxNum++;
|
||||
const newFilename = `${maxNum}.${rest}`;
|
||||
const newFilepath = `${opsPath}/${newFilename}`;
|
||||
await Deno.rename(filepath, newFilepath);
|
||||
filesToStage.push(newFilepath);
|
||||
} else {
|
||||
maxNum = Math.max(maxNum, currentNum);
|
||||
filesToStage.push(filepath);
|
||||
}
|
||||
} else {
|
||||
maxNum = Math.max(maxNum, currentNum);
|
||||
filesToStage.push(filepath);
|
||||
}
|
||||
} else {
|
||||
// Non-ops files - stage as-is
|
||||
filesToStage.push(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +135,10 @@
|
||||
{row.name}
|
||||
</div>
|
||||
{#if row.is_private}
|
||||
<Badge variant="warning" icon={Lock} mono>Private</Badge>
|
||||
<Badge variant="neutral" icon={Lock} mono>Private</Badge>
|
||||
{/if}
|
||||
{#if row.personal_access_token}
|
||||
<Badge variant="success" icon={Code} mono>Dev</Badge>
|
||||
<Badge variant="info" icon={Code} mono>Dev</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { readManifest, type Manifest } from '$lib/server/pcd/manifest.ts';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { readManifest, writeManifest, validateManifest, type Manifest } from '$lib/server/pcd/manifest.ts';
|
||||
import { readReadme, writeReadme } from '$lib/server/pcd/readme.ts';
|
||||
import { parseMarkdown } from '$utils/markdown/markdown.ts';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { database } = await parent();
|
||||
@@ -20,11 +22,9 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
// Manifest might not exist yet
|
||||
}
|
||||
|
||||
try {
|
||||
readmeRaw = await Deno.readTextFile(`${database.local_path}/README.md`);
|
||||
readmeRaw = await readReadme(database.local_path);
|
||||
if (readmeRaw) {
|
||||
readmeHtml = parseMarkdown(readmeRaw);
|
||||
} catch {
|
||||
// README might not exist
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -33,3 +33,34 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
readmeHtml
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, params }) => {
|
||||
const id = parseInt(params.id, 10);
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
return fail(404, { error: 'Database not found' });
|
||||
}
|
||||
|
||||
if (!database.personal_access_token) {
|
||||
return fail(403, { error: 'Personal access token required' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const manifestJson = formData.get('manifest') as string;
|
||||
const readme = formData.get('readme') as string;
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(manifestJson);
|
||||
validateManifest(manifest);
|
||||
await writeManifest(database.local_path, manifest);
|
||||
await writeReadme(database.local_path, readme);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return fail(400, { error: message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import NumberInput from '$ui/form/NumberInput.svelte';
|
||||
import KeyValueList from '$ui/form/KeyValueList.svelte';
|
||||
import TagInput from '$ui/form/TagInput.svelte';
|
||||
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store';
|
||||
import {
|
||||
isDirty,
|
||||
initEdit,
|
||||
update as dirtyUpdate,
|
||||
resetFromServer,
|
||||
clear as clearDirty
|
||||
} from '$lib/client/stores/dirty';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let manifest = data.manifest;
|
||||
let readme = data.readmeRaw ?? '';
|
||||
let saving = false;
|
||||
|
||||
function update<K extends keyof NonNullable<typeof manifest>>(key: K, value: NonNullable<typeof manifest>[K]) {
|
||||
// Initialize dirty tracking
|
||||
onMount(() => {
|
||||
if (manifest) {
|
||||
initEdit({ manifest, readme });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearDirty();
|
||||
});
|
||||
|
||||
function updateManifest<K extends keyof NonNullable<typeof manifest>>(key: K, value: NonNullable<typeof manifest>[K]) {
|
||||
if (!manifest) return;
|
||||
manifest = { ...manifest, [key]: value };
|
||||
dirtyUpdate('manifest', manifest);
|
||||
}
|
||||
|
||||
function updateProfilarr(key: 'minimum_version', value: string) {
|
||||
@@ -22,6 +45,12 @@
|
||||
...manifest,
|
||||
profilarr: { ...manifest.profilarr, [key]: value }
|
||||
};
|
||||
dirtyUpdate('manifest', manifest);
|
||||
}
|
||||
|
||||
function updateReadme(value: string) {
|
||||
readme = value;
|
||||
dirtyUpdate('readme', readme);
|
||||
}
|
||||
|
||||
function parseVersion(v: string): [number, number, number] {
|
||||
@@ -43,7 +72,26 @@
|
||||
<title>Config - {data.database.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mt-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result }) => {
|
||||
saving = false;
|
||||
if (result.type === 'success') {
|
||||
alertStore.add('success', 'Config saved successfully');
|
||||
resetFromServer({ manifest, readme });
|
||||
} else if (result.type === 'failure') {
|
||||
alertStore.add('error', (result.data as { error?: string })?.error || 'Failed to save config');
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-6"
|
||||
>
|
||||
<input type="hidden" name="manifest" value={JSON.stringify(manifest)} />
|
||||
<input type="hidden" name="readme" value={readme} />
|
||||
|
||||
{#if manifest}
|
||||
<div class="space-y-5">
|
||||
<!-- Name -->
|
||||
@@ -58,7 +106,7 @@
|
||||
type="text"
|
||||
id="name"
|
||||
value={manifest.name}
|
||||
oninput={(e) => update('name', (e.target as HTMLInputElement).value)}
|
||||
oninput={(e) => updateManifest('name', (e.target as HTMLInputElement).value)}
|
||||
placeholder="my-database"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
|
||||
/>
|
||||
@@ -76,7 +124,7 @@
|
||||
type="text"
|
||||
id="description"
|
||||
value={manifest.description}
|
||||
oninput={(e) => update('description', (e.target as HTMLInputElement).value)}
|
||||
oninput={(e) => updateManifest('description', (e.target as HTMLInputElement).value)}
|
||||
placeholder="My custom Arr configurations"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
|
||||
/>
|
||||
@@ -97,7 +145,7 @@
|
||||
value={vMajor}
|
||||
min={1}
|
||||
font="mono"
|
||||
onchange={(v) => update('version', updateVersionPart(manifest.version, 0, v))}
|
||||
onchange={(v) => updateManifest('version', updateVersionPart(manifest.version, 0, v))}
|
||||
onMinBlocked={() => alertStore.add('warning', 'Database version must be at least 1.0.0')}
|
||||
/>
|
||||
</div>
|
||||
@@ -108,7 +156,7 @@
|
||||
value={vMinor}
|
||||
min={0}
|
||||
font="mono"
|
||||
onchange={(v) => update('version', updateVersionPart(manifest.version, 1, v))}
|
||||
onchange={(v) => updateManifest('version', updateVersionPart(manifest.version, 1, v))}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
|
||||
@@ -118,7 +166,7 @@
|
||||
value={vPatch}
|
||||
min={0}
|
||||
font="mono"
|
||||
onchange={(v) => update('version', updateVersionPart(manifest.version, 2, v))}
|
||||
onchange={(v) => updateManifest('version', updateVersionPart(manifest.version, 2, v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +226,7 @@
|
||||
<TagInput
|
||||
tags={manifest.arr_types ?? []}
|
||||
placeholder="Add arr type (radarr, sonarr, etc.)"
|
||||
onchange={(tags) => update('arr_types', tags)}
|
||||
onchange={(tags) => updateManifest('arr_types', tags)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +241,7 @@
|
||||
<TagInput
|
||||
tags={manifest.tags ?? []}
|
||||
placeholder="Add tags..."
|
||||
onchange={(tags) => update('tags', tags)}
|
||||
onchange={(tags) => updateManifest('tags', tags)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +258,7 @@
|
||||
type="text"
|
||||
id="license"
|
||||
value={manifest.license ?? ''}
|
||||
oninput={(e) => update('license', (e.target as HTMLInputElement).value || undefined)}
|
||||
oninput={(e) => updateManifest('license', (e.target as HTMLInputElement).value || undefined)}
|
||||
placeholder="MIT"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
|
||||
/>
|
||||
@@ -228,7 +276,7 @@
|
||||
type="url"
|
||||
id="repository"
|
||||
value={manifest.repository ?? ''}
|
||||
oninput={(e) => update('repository', (e.target as HTMLInputElement).value || undefined)}
|
||||
oninput={(e) => updateManifest('repository', (e.target as HTMLInputElement).value || undefined)}
|
||||
placeholder="https://github.com/user/repo"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
|
||||
/>
|
||||
@@ -244,7 +292,7 @@
|
||||
valueType="version"
|
||||
versionMinMajor={1}
|
||||
value={manifest.dependencies ?? {}}
|
||||
onchange={(v) => update('dependencies', v)}
|
||||
onchange={(v) => updateManifest('dependencies', v)}
|
||||
lockedFirst={{ key: 'https://github.com/Dictionarry-Hub/schema', value: '1.0.0', minMajor: 1 }}
|
||||
onLockedEditAttempt={() => alertStore.add('warning', 'The schema package URL cannot be changed')}
|
||||
onLockedDeleteAttempt={() => alertStore.add('warning', 'The schema dependency is required and cannot be removed')}
|
||||
@@ -261,15 +309,28 @@
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<MarkdownInput
|
||||
bind:value={readme}
|
||||
value={readme}
|
||||
onchange={updateReadme}
|
||||
placeholder="Write your README here..."
|
||||
rows={12}
|
||||
autoResize={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Button -->
|
||||
<div class="sticky bottom-0 -mx-6 border-t border-neutral-200 bg-neutral-50 px-6 py-4 dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!$isDirty || saving}
|
||||
class="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-600 dark:hover:bg-accent-500"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No manifest found</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DirtyModal />
|
||||
|
||||
@@ -1,167 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Save, Check, Code } from 'lucide-svelte';
|
||||
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Mock tweak data
|
||||
type Tweak = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sql: string;
|
||||
};
|
||||
|
||||
const mockTweaks: Tweak[] = [
|
||||
{
|
||||
id: 'enable-prereleases',
|
||||
name: 'Enable Prereleases',
|
||||
description: 'Adds a Prereleases quality group (CAM, Telesync, DVD Screener, etc.) to the bottom of all quality profiles, allowing early access to new releases.',
|
||||
sql: `-- Enable Prereleases
|
||||
-- Creates a Prereleases quality group for each profile and places it
|
||||
-- below the last enabled quality/group
|
||||
|
||||
-- Step 1: Create Prereleases group for each quality profile
|
||||
INSERT INTO quality_groups (quality_profile_id, name)
|
||||
SELECT id, 'Prereleases'
|
||||
FROM quality_profiles
|
||||
WHERE id NOT IN (
|
||||
SELECT quality_profile_id FROM quality_groups WHERE name = 'Prereleases'
|
||||
);
|
||||
|
||||
-- Step 2: Add prerelease qualities to the group
|
||||
INSERT INTO quality_group_members (quality_group_id, quality_id)
|
||||
SELECT qg.id, q.id
|
||||
FROM quality_groups qg
|
||||
CROSS JOIN qualities q
|
||||
WHERE qg.name = 'Prereleases'
|
||||
AND q.name IN ('CAM', 'TELESYNC', 'TELECINE', 'DVDSCR', 'REGIONAL', 'WORKPRINT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM quality_group_members qgm
|
||||
WHERE qgm.quality_group_id = qg.id AND qgm.quality_id = q.id
|
||||
);
|
||||
|
||||
-- Step 3: Add the group to each profile's quality list
|
||||
-- Position it below the last enabled quality/group
|
||||
INSERT INTO quality_profile_qualities (quality_profile_id, quality_group_id, position, enabled)
|
||||
SELECT
|
||||
qg.quality_profile_id,
|
||||
qg.id,
|
||||
COALESCE(
|
||||
(SELECT MAX(position) + 1
|
||||
FROM quality_profile_qualities
|
||||
WHERE quality_profile_id = qg.quality_profile_id AND enabled = 1),
|
||||
1
|
||||
),
|
||||
1
|
||||
FROM quality_groups qg
|
||||
WHERE qg.name = 'Prereleases'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM quality_profile_qualities qpq
|
||||
WHERE qpq.quality_group_id = qg.id
|
||||
);`
|
||||
}
|
||||
];
|
||||
|
||||
// Track enabled state (not persisted)
|
||||
let enabledTweaks: Set<string> = new Set();
|
||||
|
||||
function toggleTweak(id: string) {
|
||||
if (enabledTweaks.has(id)) {
|
||||
enabledTweaks.delete(id);
|
||||
} else {
|
||||
enabledTweaks.add(id);
|
||||
}
|
||||
enabledTweaks = enabledTweaks;
|
||||
}
|
||||
|
||||
const columns: Column<Tweak>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description'
|
||||
}
|
||||
];
|
||||
|
||||
function getRowId(row: Tweak): string {
|
||||
return row.id;
|
||||
}
|
||||
|
||||
$: hasChanges = enabledTweaks.size > 0;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tweaks - {data.database.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<!-- Header with Save -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Tweaks</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Optional modifications curated by databases. Enable tweaks to adjust quality profile behaviour.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasChanges}
|
||||
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"
|
||||
>
|
||||
<Save size={16} />
|
||||
Save
|
||||
</button>
|
||||
<div class="mt-6">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<p class="text-neutral-500 dark:text-neutral-400">
|
||||
No tweaks available for this database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tweaks Table -->
|
||||
{#if mockTweaks.length > 0}
|
||||
<ExpandableTable
|
||||
{columns}
|
||||
data={mockTweaks}
|
||||
{getRowId}
|
||||
compact={true}
|
||||
flushExpanded={true}
|
||||
emptyMessage="No tweaks available"
|
||||
chevronPosition="right"
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'name'}
|
||||
<span class="font-medium text-neutral-900 dark:text-neutral-100">{row.name}</span>
|
||||
{:else if column.key === 'description'}
|
||||
<span class="text-sm text-neutral-600 dark:text-neutral-400">{row.description}</span>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
<IconCheckbox
|
||||
icon={Check}
|
||||
checked={enabledTweaks.has(row.id)}
|
||||
on:click={() => toggleTweak(row.id)}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="expanded" let:row>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
<Code size={12} />
|
||||
SQL
|
||||
</div>
|
||||
<pre class="rounded-lg bg-neutral-100 p-3 text-xs font-mono text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200 overflow-x-auto">{row.sql}</pre>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ExpandableTable>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<p class="text-neutral-500 dark:text-neutral-400">
|
||||
No tweaks available for this database.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user