feat(pcd): add database linking functionality

This commit is contained in:
Sam Chau
2025-11-04 06:58:54 +10:30
parent a7d9685ed9
commit 37ae5164e6
35 changed files with 2790 additions and 63 deletions

View File

@@ -10,6 +10,7 @@
"$server/": "./src/server/",
"$db/": "./src/lib/server/db/",
"$jobs/": "./src/lib/server/jobs/",
"$pcd/": "./src/lib/server/pcd/",
"$arr/": "./src/lib/server/utils/arr/",
"$http/": "./src/lib/server/utils/http/",
"$utils/": "./src/lib/server/utils/",

View File

@@ -0,0 +1,126 @@
<script lang="ts" generics="T extends Record<string, any>">
import type { ComponentType } from 'svelte';
/**
* Column definition for table
*/
export interface Column<T> {
/** Unique key for the column */
key: string;
/** Header text to display */
header: string;
/** Optional width class (e.g., 'w-32', 'w-1/4') */
width?: string;
/** Text alignment */
align?: 'left' | 'center' | 'right';
/** Whether column is sortable */
sortable?: boolean;
/** Custom cell renderer - receives the full row object */
cell?: (row: T) => string | ComponentType;
}
/**
* Props
*/
export let columns: Column<T>[];
export let data: T[];
export let hoverable: boolean = true;
export let compact: boolean = false;
export let emptyMessage: string = 'No data available';
/**
* Get cell value by key path (supports nested properties like 'user.name')
*/
function getCellValue(row: T, key: string): any {
return key.split('.').reduce((obj, k) => obj?.[k], row);
}
/**
* Get alignment class
*/
function getAlignClass(align?: 'left' | 'center' | 'right'): string {
switch (align) {
case 'center':
return 'text-center';
case 'right':
return 'text-right';
default:
return 'text-left';
}
}
</script>
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<table class="w-full">
<!-- Header -->
<thead
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
>
<tr>
{#each columns as column}
<th
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 ${getAlignClass(column.align)} ${column.width || ''}`}
>
{column.header}
</th>
{/each}
<!-- Actions column slot -->
{#if $$slots.actions}
<th
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 text-right`}
>
Actions
</th>
{/if}
</tr>
</thead>
<!-- Body -->
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#if data.length === 0}
<tr>
<td
colspan={columns.length + ($$slots.actions ? 1 : 0)}
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
>
{emptyMessage}
</td>
</tr>
{:else}
{#each data as row, rowIndex}
<tr
class={hoverable
? 'transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900'
: ''}
>
{#each columns as column}
<td
class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 ${getAlignClass(column.align)} ${column.width || ''}`}
>
{#if column.cell}
{@const rendered = column.cell(row)}
{#if typeof rendered === 'string'}
{rendered}
{:else}
<svelte:component this={rendered} {row} />
{/if}
{:else}
<slot name="cell" {row} {column} {rowIndex}>
{getCellValue(row, column.key)}
</slot>
{/if}
</td>
{/each}
<!-- Actions slot -->
{#if $$slots.actions}
<td class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-right`}>
<slot name="actions" {row} {rowIndex} />
</td>
{/if}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>

View File

@@ -9,6 +9,9 @@ import { migration as migration004 } from './migrations/004_create_jobs_tables.t
import { migration as migration005 } from './migrations/005_create_backup_settings.ts';
import { migration as migration006 } from './migrations/006_simplify_log_settings.ts';
import { migration as migration007 } from './migrations/007_create_notification_tables.ts';
import { migration as migration008 } from './migrations/008_create_database_instances.ts';
import { migration as migration009 } from './migrations/009_add_personal_access_token.ts';
import { migration as migration010 } from './migrations/010_add_is_private.ts';
export interface Migration {
version: number;
@@ -233,7 +236,10 @@ export function loadMigrations(): Migration[] {
migration004,
migration005,
migration006,
migration007
migration007,
migration008,
migration009,
migration010
];
// Sort by version number

View File

@@ -0,0 +1,62 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 008: Create database_instances table
*
* Creates the table for storing linked Profilarr Compliant Database (PCD) repositories.
* These databases contain configuration profiles that can be synced to arr instances.
*
* Fields:
* - id: Auto-incrementing primary key
* - uuid: Unique identifier used for filesystem storage path
* - name: User-friendly name (unique)
* - repository_url: Git repository URL
* - local_path: Path where the repository is cloned (data/databases/{uuid})
* - sync_strategy: 0 = manual check, >0 = auto-check every X minutes
* - auto_pull: 0 = notify only, 1 = auto-pull updates
* - enabled: Boolean flag (1=enabled, 0=disabled)
* - last_synced_at: Timestamp of last successful sync
* - created_at: Timestamp of creation
* - updated_at: Timestamp of last update
*/
export const migration: Migration = {
version: 8,
name: 'Create database_instances table',
up: `
CREATE TABLE database_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Instance identification
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
-- Repository connection
repository_url TEXT NOT NULL,
-- Local storage
local_path TEXT NOT NULL,
-- Sync settings
sync_strategy INTEGER NOT NULL DEFAULT 0,
auto_pull INTEGER NOT NULL DEFAULT 0,
-- Status
enabled INTEGER NOT NULL DEFAULT 1,
last_synced_at DATETIME,
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Index for looking up by UUID
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
`,
down: `
DROP INDEX IF EXISTS idx_database_instances_uuid;
DROP TABLE IF EXISTS database_instances;
`
};

View File

@@ -0,0 +1,47 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 009: Add personal_access_token to database_instances
*
* Adds support for Personal Access Tokens (PAT) to enable:
* - Cloning private repositories
* - Push access for developers working on database content
*/
export const migration: Migration = {
version: 9,
name: 'Add personal_access_token to database_instances',
up: `
ALTER TABLE database_instances
ADD COLUMN personal_access_token TEXT;
`,
down: `
-- SQLite doesn't support DROP COLUMN easily, so we recreate the table
CREATE TABLE database_instances_backup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
repository_url TEXT NOT NULL,
local_path TEXT NOT NULL,
sync_strategy INTEGER NOT NULL DEFAULT 0,
auto_pull INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
last_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO database_instances_backup
SELECT id, uuid, name, repository_url, local_path, sync_strategy,
auto_pull, enabled, last_synced_at, created_at, updated_at
FROM database_instances;
DROP TABLE database_instances;
ALTER TABLE database_instances_backup RENAME TO database_instances;
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
`
};

View File

@@ -0,0 +1,48 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 010: Add is_private to database_instances
*
* Adds auto-detected flag to indicate if a repository is private.
* This is determined during the initial clone by attempting to access
* the repository with and without authentication.
*/
export const migration: Migration = {
version: 10,
name: 'Add is_private to database_instances',
up: `
ALTER TABLE database_instances
ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;
`,
down: `
-- SQLite doesn't support DROP COLUMN easily, so we recreate the table
CREATE TABLE database_instances_backup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
repository_url TEXT NOT NULL,
local_path TEXT NOT NULL,
sync_strategy INTEGER NOT NULL DEFAULT 0,
auto_pull INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
personal_access_token TEXT,
last_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO database_instances_backup
SELECT id, uuid, name, repository_url, local_path, sync_strategy,
auto_pull, enabled, personal_access_token, last_synced_at, created_at, updated_at
FROM database_instances;
DROP TABLE database_instances;
ALTER TABLE database_instances_backup RENAME TO database_instances;
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
`
};

View File

@@ -0,0 +1,211 @@
import { db } from '../db.ts';
/**
* Types for database_instances table
*/
export interface DatabaseInstance {
id: number;
uuid: string;
name: string;
repository_url: string;
local_path: string;
sync_strategy: number;
auto_pull: number;
enabled: number;
personal_access_token: string | null;
is_private: number;
last_synced_at: string | null;
created_at: string;
updated_at: string;
}
export interface CreateDatabaseInstanceInput {
uuid: string;
name: string;
repositoryUrl: string;
localPath: string;
syncStrategy?: number;
autoPull?: boolean;
enabled?: boolean;
personalAccessToken?: string;
isPrivate?: boolean;
}
export interface UpdateDatabaseInstanceInput {
name?: string;
repositoryUrl?: string;
syncStrategy?: number;
autoPull?: boolean;
enabled?: boolean;
personalAccessToken?: string;
}
/**
* All queries for database_instances table
*/
export const databaseInstancesQueries = {
/**
* Create a new database instance
*/
create(input: CreateDatabaseInstanceInput): number {
const syncStrategy = input.syncStrategy ?? 0;
const autoPull = input.autoPull !== false ? 1 : 0;
const enabled = input.enabled !== false ? 1 : 0;
const personalAccessToken = input.personalAccessToken || null;
const isPrivate = input.isPrivate ? 1 : 0;
db.execute(
`INSERT INTO database_instances (uuid, name, repository_url, local_path, sync_strategy, auto_pull, enabled, personal_access_token, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
input.uuid,
input.name,
input.repositoryUrl,
input.localPath,
syncStrategy,
autoPull,
enabled,
personalAccessToken,
isPrivate
);
// Get the last inserted ID
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
return result?.id ?? 0;
},
/**
* Get a database instance by ID
*/
getById(id: number): DatabaseInstance | undefined {
return db.queryFirst<DatabaseInstance>('SELECT * FROM database_instances WHERE id = ?', id);
},
/**
* Get a database instance by UUID
*/
getByUuid(uuid: string): DatabaseInstance | undefined {
return db.queryFirst<DatabaseInstance>(
'SELECT * FROM database_instances WHERE uuid = ?',
uuid
);
},
/**
* Get all database instances
*/
getAll(): DatabaseInstance[] {
return db.query<DatabaseInstance>('SELECT * FROM database_instances ORDER BY name');
},
/**
* Get enabled database instances
*/
getEnabled(): DatabaseInstance[] {
return db.query<DatabaseInstance>(
'SELECT * FROM database_instances WHERE enabled = 1 ORDER BY name'
);
},
/**
* Get databases that need auto-sync check
*/
getDueForSync(): DatabaseInstance[] {
return db.query<DatabaseInstance>(
`SELECT * FROM database_instances
WHERE enabled = 1
AND sync_strategy > 0
AND (
last_synced_at IS NULL
OR datetime(last_synced_at, '+' || sync_strategy || ' minutes') <= datetime('now')
)
ORDER BY last_synced_at ASC NULLS FIRST`
);
},
/**
* Update a database instance
*/
update(id: number, input: UpdateDatabaseInstanceInput): boolean {
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (input.name !== undefined) {
updates.push('name = ?');
params.push(input.name);
}
if (input.repositoryUrl !== undefined) {
updates.push('repository_url = ?');
params.push(input.repositoryUrl);
}
if (input.syncStrategy !== undefined) {
updates.push('sync_strategy = ?');
params.push(input.syncStrategy);
}
if (input.autoPull !== undefined) {
updates.push('auto_pull = ?');
params.push(input.autoPull ? 1 : 0);
}
if (input.enabled !== undefined) {
updates.push('enabled = ?');
params.push(input.enabled ? 1 : 0);
}
if (input.personalAccessToken !== undefined) {
updates.push('personal_access_token = ?');
params.push(input.personalAccessToken || null);
}
if (updates.length === 0) {
return false;
}
// Add updated_at
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(id);
const affected = db.execute(
`UPDATE database_instances SET ${updates.join(', ')} WHERE id = ?`,
...params
);
return affected > 0;
},
/**
* Update last_synced_at timestamp
*/
updateSyncedAt(id: number): boolean {
const affected = db.execute(
'UPDATE database_instances SET last_synced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
id
);
return affected > 0;
},
/**
* Delete a database instance
*/
delete(id: number): boolean {
const affected = db.execute('DELETE FROM database_instances WHERE id = ?', id);
return affected > 0;
},
/**
* Check if a database name already exists
*/
nameExists(name: string, excludeId?: number): boolean {
if (excludeId !== undefined) {
const result = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM database_instances WHERE name = ? AND id != ?',
name,
excludeId
);
return (result?.count ?? 0) > 0;
}
const result = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM database_instances WHERE name = ?',
name
);
return (result?.count ?? 0) > 0;
}
};

View File

@@ -1,7 +1,7 @@
-- Profilarr Database Schema
-- This file documents the current database schema after all migrations
-- DO NOT execute this file directly - use migrations instead
-- Last updated: 2025-10-22
-- Last updated: 2025-11-04
-- ==============================================================================
-- TABLE: migrations
@@ -191,6 +191,40 @@ CREATE TABLE notification_history (
FOREIGN KEY (service_id) REFERENCES notification_services(id) ON DELETE CASCADE
);
-- ==============================================================================
-- TABLE: database_instances
-- Purpose: Store linked Profilarr Compliant Database (PCD) repositories
-- Migration: 008_create_database_instances.ts, 009_add_personal_access_token.ts, 010_add_is_private.ts
-- ==============================================================================
CREATE TABLE database_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Instance identification
uuid TEXT NOT NULL UNIQUE, -- UUID for filesystem storage path
name TEXT NOT NULL UNIQUE, -- User-friendly name (e.g., "Dictionarry DB")
-- Repository connection
repository_url TEXT NOT NULL, -- Git repository URL
personal_access_token TEXT, -- PAT for private repos and push access (Migration 009)
is_private INTEGER NOT NULL DEFAULT 0, -- 1=private repo, 0=public (auto-detected, Migration 010)
-- Local storage
local_path TEXT NOT NULL, -- Path where repo is cloned (data/databases/{uuid})
-- Sync settings
sync_strategy INTEGER NOT NULL DEFAULT 0, -- 0=manual check, >0=auto-check every X minutes
auto_pull INTEGER NOT NULL DEFAULT 0, -- 0=notify only, 1=auto-pull updates
-- Status
enabled INTEGER NOT NULL DEFAULT 1, -- 1=enabled, 0=disabled
last_synced_at DATETIME, -- Timestamp of last successful sync
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ==============================================================================
-- INDEXES
-- Purpose: Improve query performance
@@ -212,3 +246,6 @@ CREATE INDEX idx_notification_services_type ON notification_services(service_typ
CREATE INDEX idx_notification_history_service_id ON notification_history(service_id);
CREATE INDEX idx_notification_history_sent_at ON notification_history(sent_at);
CREATE INDEX idx_notification_history_status ON notification_history(status);
-- Database instances indexes (Migration: 008_create_database_instances.ts)
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);

View File

@@ -0,0 +1,60 @@
import { logger } from '$logger/logger.ts';
import { syncDatabases } from '../logic/syncDatabases.ts';
import type { JobDefinition, JobResult } from '../types.ts';
/**
* Sync PCD databases job
* Checks for databases that need syncing and pulls updates if auto_pull is enabled
*/
export const syncDatabasesJob: JobDefinition = {
name: 'sync_databases',
description: 'Auto-sync PCD databases with remote repositories',
schedule: '*/5 minutes',
handler: async (): Promise<JobResult> => {
try {
await logger.info('Starting database sync job', {
source: 'SyncDatabasesJob'
});
// Run sync
const result = await syncDatabases();
// Log results for each database
for (const db of result.databases) {
if (db.success) {
if (db.updatesPulled > 0) {
await logger.info(`Synced database: ${db.name}`, {
source: 'SyncDatabasesJob',
meta: { databaseId: db.id, updatesPulled: db.updatesPulled }
});
}
} else {
await logger.error(`Failed to sync database: ${db.name}`, {
source: 'SyncDatabasesJob',
meta: { databaseId: db.id, error: db.error }
});
}
}
const message = `Sync completed: ${result.successCount} successful, ${result.failureCount} failed (${result.totalChecked} total)`;
if (result.failureCount > 0 && result.successCount === 0) {
return {
success: false,
error: message
};
}
return {
success: true,
output: message
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
};

View File

@@ -6,6 +6,7 @@ import { logger } from '$logger/logger.ts';
import { cleanupLogsJob } from './definitions/cleanupLogs.ts';
import { createBackupJob } from './definitions/createBackup.ts';
import { cleanupBackupsJob } from './definitions/cleanupBackups.ts';
import { syncDatabasesJob } from './definitions/syncDatabases.ts';
/**
* Register all job definitions
@@ -15,6 +16,7 @@ function registerAllJobs(): void {
jobRegistry.register(cleanupLogsJob);
jobRegistry.register(createBackupJob);
jobRegistry.register(cleanupBackupsJob);
jobRegistry.register(syncDatabasesJob);
}
/**

View File

@@ -54,7 +54,7 @@ export async function createBackup(
error: `Source path is not a directory: ${sourceDir}`,
};
}
} catch (error) {
} catch (_error) {
return {
success: false,
error: `Source directory does not exist: ${sourceDir}`,

View File

@@ -0,0 +1,139 @@
/**
* Core sync logic for PCD auto-sync
* Checks for databases that need syncing and pulls updates if auto_pull is enabled
*/
import { pcdManager } from '$pcd/pcd.ts';
import { notificationManager } from '$notifications/NotificationManager.ts';
export interface DatabaseSyncStatus {
id: number;
name: string;
success: boolean;
updatesPulled: number;
error?: string;
}
export interface SyncDatabasesResult {
totalChecked: number;
successCount: number;
failureCount: number;
databases: DatabaseSyncStatus[];
}
/**
* Sync all databases that are due for auto-sync
* Checks databases with sync_strategy > 0 that haven't been synced within their interval
*/
export async function syncDatabases(): Promise<SyncDatabasesResult> {
const databases = pcdManager.getDueForSync();
const totalChecked = databases.length;
let successCount = 0;
let failureCount = 0;
const statuses: DatabaseSyncStatus[] = [];
for (const db of databases) {
try {
// Check for updates
const updateInfo = await pcdManager.checkForUpdates(db.id);
if (!updateInfo.hasUpdates) {
// No updates available, just mark as checked
statuses.push({
id: db.id,
name: db.name,
success: true,
updatesPulled: 0
});
successCount++;
continue;
}
// Updates are available
if (db.auto_pull === 1) {
// Auto-pull is enabled, sync the database
const syncResult = await pcdManager.sync(db.id);
if (syncResult.success) {
// Send success notification
await notificationManager.notify({
type: 'pcd.sync_success',
title: 'Database Synced Successfully',
message: `Database "${db.name}" has been updated (${syncResult.commitsBehind} commit${syncResult.commitsBehind === 1 ? '' : 's'} pulled)`,
metadata: {
databaseId: db.id,
databaseName: db.name,
commitsPulled: syncResult.commitsBehind
}
});
statuses.push({
id: db.id,
name: db.name,
success: true,
updatesPulled: syncResult.commitsBehind
});
successCount++;
} else {
// Send failure notification
await notificationManager.notify({
type: 'pcd.sync_failed',
title: 'Database Sync Failed',
message: `Failed to sync database "${db.name}": ${syncResult.error}`,
metadata: {
databaseId: db.id,
databaseName: db.name,
error: syncResult.error
}
});
statuses.push({
id: db.id,
name: db.name,
success: false,
updatesPulled: 0,
error: syncResult.error
});
failureCount++;
}
} else {
// Auto-pull is disabled, send notification
await notificationManager.notify({
type: 'pcd.updates_available',
title: 'Database Updates Available',
message: `Updates are available for database "${db.name}" (${updateInfo.commitsBehind} commit${updateInfo.commitsBehind === 1 ? '' : 's'} behind)`,
metadata: {
databaseId: db.id,
databaseName: db.name,
commitsBehind: updateInfo.commitsBehind
}
});
statuses.push({
id: db.id,
name: db.name,
success: true,
updatesPulled: 0
});
successCount++;
}
} catch (error) {
statuses.push({
id: db.id,
name: db.name,
success: false,
updatesPulled: 0,
error: error instanceof Error ? error.message : 'Unknown error'
});
failureCount++;
}
}
return {
totalChecked,
successCount,
failureCount,
databases: statuses
};
}

106
src/lib/server/pcd/deps.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* PCD Dependency Resolution
* Handles cloning and managing PCD dependencies
*/
import * as git from '$utils/git/git.ts';
import { loadManifest } from './manifest.ts';
/**
* Extract repository name from GitHub URL
* https://github.com/Dictionarry-Hub/schema -> schema
*/
function getRepoName(repoUrl: string): string {
const parts = repoUrl.split('/');
return parts[parts.length - 1];
}
/**
* Get dependency path
*/
function getDependencyPath(pcdPath: string, repoName: string): string {
return `${pcdPath}/deps/${repoName}`;
}
/**
* Clone and checkout a single dependency
*/
async function cloneDependency(
pcdPath: string,
repoUrl: string,
version: string
): Promise<void> {
const repoName = getRepoName(repoUrl);
const depPath = getDependencyPath(pcdPath, repoName);
// Clone the dependency repository
await git.clone(repoUrl, depPath);
// Checkout the specific version tag
await git.checkout(depPath, version);
}
/**
* Process all dependencies for a PCD
* Clones dependencies and validates their manifests
*/
export async function processDependencies(pcdPath: string): Promise<void> {
// Load the PCD's manifest
const manifest = await loadManifest(pcdPath);
// Skip if no dependencies
if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) {
return;
}
// Create deps directory
const depsDir = `${pcdPath}/deps`;
await Deno.mkdir(depsDir, { recursive: true });
// Process each dependency
for (const [repoUrl, version] of Object.entries(manifest.dependencies)) {
// Clone and checkout the dependency
await cloneDependency(pcdPath, repoUrl, version);
// Validate the dependency's manifest
const repoName = getRepoName(repoUrl);
const depPath = getDependencyPath(pcdPath, repoName);
await loadManifest(depPath);
// TODO (post-2.0): Recursively process nested dependencies
// For now, we only support one level of dependencies
}
}
/**
* Check if all dependencies are present and valid
*/
export async function validateDependencies(pcdPath: string): Promise<boolean> {
try {
const manifest = await loadManifest(pcdPath);
// If no dependencies, validation passes
if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) {
return true;
}
for (const [repoUrl] of Object.entries(manifest.dependencies)) {
const repoName = getRepoName(repoUrl);
const depPath = getDependencyPath(pcdPath, repoName);
// Check if dependency directory exists
try {
await Deno.stat(depPath);
} catch {
return false;
}
// Validate dependency manifest
await loadManifest(depPath);
}
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,152 @@
/**
* PCD Manifest Parser and Validator
* Handles reading and validating pcd.json files
*/
export interface Manifest {
name: string;
version: string;
description: string;
dependencies?: Record<string, string>;
arr_types?: string[];
authors?: Array<{ name: string; email?: string }>;
license?: string;
repository?: string;
tags?: string[];
links?: {
homepage?: string;
documentation?: string;
issues?: string;
};
profilarr: {
minimum_version: string;
};
}
export class ManifestValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ManifestValidationError';
}
}
/**
* Read manifest from a PCD repository
*/
export async function readManifest(pcdPath: string): Promise<Manifest> {
const manifestPath = `${pcdPath}/pcd.json`;
try {
const manifestContent = await Deno.readTextFile(manifestPath);
const manifest = JSON.parse(manifestContent);
return manifest;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
throw new ManifestValidationError('pcd.json not found in repository');
}
if (error instanceof SyntaxError) {
throw new ManifestValidationError('pcd.json contains invalid JSON');
}
throw error;
}
}
/**
* Validate a manifest object
*/
export function validateManifest(manifest: unknown): asserts manifest is Manifest {
if (!manifest || typeof manifest !== 'object') {
throw new ManifestValidationError('Manifest must be an object');
}
const m = manifest as Record<string, unknown>;
// Required fields
if (typeof m.name !== 'string' || !m.name) {
throw new ManifestValidationError('Manifest missing required field: name');
}
if (typeof m.version !== 'string' || !m.version) {
throw new ManifestValidationError('Manifest missing required field: version');
}
if (typeof m.description !== 'string' || !m.description) {
throw new ManifestValidationError('Manifest missing required field: description');
}
// Validate dependencies if present
if (m.dependencies !== undefined) {
if (typeof m.dependencies !== 'object' || m.dependencies === null) {
throw new ManifestValidationError('Manifest field dependencies must be an object');
}
// Validate dependencies includes schema (only check for non-empty dependencies)
const deps = m.dependencies as Record<string, unknown>;
if (Object.keys(deps).length > 0) {
const hasSchema = Object.keys(deps).some((url) => url.includes('/schema'));
if (!hasSchema) {
throw new ManifestValidationError('Manifest dependencies must include schema repository');
}
}
}
// Validate profilarr section
if (!m.profilarr || typeof m.profilarr !== 'object') {
throw new ManifestValidationError('Manifest missing required field: profilarr');
}
const profilarr = m.profilarr as Record<string, unknown>;
if (typeof profilarr.minimum_version !== 'string' || !profilarr.minimum_version) {
throw new ManifestValidationError('Manifest missing required field: profilarr.minimum_version');
}
// Optional fields validation
if (m.arr_types !== undefined) {
if (!Array.isArray(m.arr_types)) {
throw new ManifestValidationError('Manifest field arr_types must be an array');
}
const validTypes = ['radarr', 'sonarr', 'readarr', 'lidarr', 'prowlarr', 'whisparr'];
for (const type of m.arr_types) {
if (typeof type !== 'string' || !validTypes.includes(type)) {
throw new ManifestValidationError(
`Invalid arr_type: ${type}. Must be one of: ${validTypes.join(', ')}`
);
}
}
}
if (m.authors !== undefined) {
if (!Array.isArray(m.authors)) {
throw new ManifestValidationError('Manifest field authors must be an array');
}
for (const author of m.authors) {
if (!author || typeof author !== 'object') {
throw new ManifestValidationError('Each author must be an object');
}
const a = author as Record<string, unknown>;
if (typeof a.name !== 'string' || !a.name) {
throw new ManifestValidationError('Each author must have a name');
}
}
}
if (m.tags !== undefined) {
if (!Array.isArray(m.tags)) {
throw new ManifestValidationError('Manifest field tags must be an array');
}
for (const tag of m.tags) {
if (typeof tag !== 'string') {
throw new ManifestValidationError('Each tag must be a string');
}
}
}
}
/**
* Read and validate manifest from a PCD repository
*/
export async function loadManifest(pcdPath: string): Promise<Manifest> {
const manifest = await readManifest(pcdPath);
validateManifest(manifest);
return manifest;
}

View File

@@ -0,0 +1,19 @@
/**
* Helper functions for PCD paths
*/
import { config } from '$config';
/**
* Get the filesystem path for a PCD repository
*/
export function getPCDPath(uuid: string): string {
return `${config.paths.databases}/${uuid}`;
}
/**
* Get the manifest file path for a PCD repository
*/
export function getManifestPath(uuid: string): string {
return `${getPCDPath(uuid)}/pcd.json`;
}

242
src/lib/server/pcd/pcd.ts Normal file
View File

@@ -0,0 +1,242 @@
/**
* PCD Manager - High-level orchestration for PCD lifecycle
*/
import * as git from '$utils/git/git.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
import { loadManifest, type Manifest } from './manifest.ts';
import { getPCDPath } from './paths.ts';
import { processDependencies } from './deps.ts';
import { notificationManager } from '$notifications/NotificationManager.ts';
export interface LinkOptions {
repositoryUrl: string;
name: string;
branch?: string;
syncStrategy?: number;
autoPull?: boolean;
personalAccessToken?: string;
}
export interface SyncResult {
success: boolean;
commitsBehind: number;
error?: string;
}
/**
* PCD Manager - Manages the lifecycle of Profilarr Compliant Databases
*/
class PCDManager {
/**
* Link a new PCD repository
*/
async link(options: LinkOptions): Promise<DatabaseInstance> {
// Generate UUID for storage
const uuid = crypto.randomUUID();
const localPath = getPCDPath(uuid);
try {
// Clone the repository and detect if it's private
const isPrivate = await git.clone(options.repositoryUrl, localPath, options.branch, options.personalAccessToken);
// Validate manifest (loadManifest throws if invalid)
await loadManifest(localPath);
// Process dependencies (clone and validate)
await processDependencies(localPath);
// Insert into database
const id = databaseInstancesQueries.create({
uuid,
name: options.name,
repositoryUrl: options.repositoryUrl,
localPath,
syncStrategy: options.syncStrategy,
autoPull: options.autoPull,
personalAccessToken: options.personalAccessToken,
isPrivate
});
// Get and return the created instance
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error('Failed to retrieve created database instance');
}
// Send notification
await notificationManager.notify({
type: 'pcd.linked',
title: 'Database Linked',
message: `Database "${options.name}" has been linked successfully`,
metadata: {
databaseId: id,
databaseName: options.name,
repositoryUrl: options.repositoryUrl
}
});
return instance;
} catch (error) {
// Cleanup on failure - remove cloned directory
try {
await Deno.remove(localPath, { recursive: true });
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Unlink a PCD repository
*/
async unlink(id: number): Promise<void> {
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error(`Database instance ${id} not found`);
}
// Store name and URL for notification
const { name, repository_url } = instance;
// Delete from database first
databaseInstancesQueries.delete(id);
// Then cleanup filesystem
try {
await Deno.remove(instance.local_path, { recursive: true });
} catch (error) {
// Log but don't throw - database entry is already deleted
console.error(`Failed to remove PCD directory ${instance.local_path}:`, error);
}
// Send notification
await notificationManager.notify({
type: 'pcd.unlinked',
title: 'Database Unlinked',
message: `Database "${name}" has been removed`,
metadata: {
databaseId: id,
databaseName: name,
repositoryUrl: repository_url
}
});
}
/**
* Sync a PCD repository (pull updates)
*/
async sync(id: number): Promise<SyncResult> {
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error(`Database instance ${id} not found`);
}
try {
// Check for updates first
const updateInfo = await git.checkForUpdates(instance.local_path);
if (!updateInfo.hasUpdates) {
// Already up to date
databaseInstancesQueries.updateSyncedAt(id);
return {
success: true,
commitsBehind: 0
};
}
// Pull updates
await git.pull(instance.local_path);
// Update last_synced_at
databaseInstancesQueries.updateSyncedAt(id);
return {
success: true,
commitsBehind: updateInfo.commitsBehind
};
} catch (error) {
return {
success: false,
commitsBehind: 0,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Check for available updates without pulling
*/
async checkForUpdates(id: number): Promise<git.UpdateInfo> {
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error(`Database instance ${id} not found`);
}
return await git.checkForUpdates(instance.local_path);
}
/**
* Get parsed manifest for a PCD
*/
async getManifest(id: number): Promise<Manifest> {
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error(`Database instance ${id} not found`);
}
return await loadManifest(instance.local_path);
}
/**
* Switch branch for a PCD
*/
async switchBranch(id: number, branch: string): Promise<void> {
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error(`Database instance ${id} not found`);
}
await git.checkout(instance.local_path, branch);
await git.pull(instance.local_path);
databaseInstancesQueries.updateSyncedAt(id);
}
/**
* Get git status for a PCD
*/
async getStatus(id: number): Promise<git.GitStatus> {
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
throw new Error(`Database instance ${id} not found`);
}
return await git.getStatus(instance.local_path);
}
/**
* Get all PCDs
*/
getAll(): DatabaseInstance[] {
return databaseInstancesQueries.getAll();
}
/**
* Get PCD by ID
*/
getById(id: number): DatabaseInstance | undefined {
return databaseInstancesQueries.getById(id);
}
/**
* Get PCDs that need auto-sync
*/
getDueForSync(): DatabaseInstance[] {
return databaseInstancesQueries.getDueForSync();
}
}
// Export singleton instance
export const pcdManager = new PCDManager();

View File

@@ -26,6 +26,7 @@ class Config {
await Deno.mkdir(this.paths.logs, { recursive: true });
await Deno.mkdir(this.paths.data, { recursive: true });
await Deno.mkdir(this.paths.backups, { recursive: true });
await Deno.mkdir(this.paths.databases, { recursive: true });
}
/**
@@ -54,6 +55,9 @@ class Config {
get database(): string {
return `${config.basePath}/data/profilarr.db`;
},
get databases(): string {
return `${config.basePath}/data/databases`;
},
get backups(): string {
return `${config.basePath}/backups`;
}

View File

@@ -0,0 +1,299 @@
/**
* Git utility functions for managing database repositories
*/
export interface GitStatus {
currentBranch: string;
isDirty: boolean;
untracked: string[];
modified: string[];
staged: string[];
}
export interface UpdateInfo {
hasUpdates: boolean;
commitsBehind: number;
commitsAhead: number;
latestRemoteCommit: string;
currentLocalCommit: string;
}
/**
* Execute a git command with sandboxed environment (no system credentials)
*/
async function execGit(args: string[], cwd?: string): Promise<string> {
const command = new Deno.Command('git', {
args,
cwd,
stdout: 'piped',
stderr: 'piped',
env: {
// Disable all credential helpers and interactive prompts
GIT_TERMINAL_PROMPT: '0', // Fail instead of prompting (git 2.3+)
GIT_ASKPASS: 'echo', // Return empty on credential requests
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', // Disable SSH password prompts
// Clear credential helpers via environment config
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'credential.helper',
GIT_CONFIG_VALUE_0: ''
}
});
const { code, stdout, stderr } = await command.output();
if (code !== 0) {
const errorMessage = new TextDecoder().decode(stderr);
throw new Error(`Git command failed: ${errorMessage}`);
}
return new TextDecoder().decode(stdout).trim();
}
/**
* Validate that a repository URL is accessible and detect if it's private using GitHub API
* Returns true if the repository is private, false if public
*/
async function validateRepository(repositoryUrl: string, personalAccessToken?: string): Promise<boolean> {
// Validate GitHub URL format and extract owner/repo
const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/;
const normalizedUrl = repositoryUrl.replace(/\.git$/, '');
const match = normalizedUrl.match(githubPattern);
if (!match) {
throw new Error('Repository URL must be a valid GitHub repository (https://github.com/username/repo)');
}
const [, owner, repo] = match;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
// First try without authentication to check if it's public
try {
const response = await globalThis.fetch(apiUrl, {
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Profilarr'
}
});
if (response.ok) {
const data = await response.json();
// Repository is accessible without auth
return data.private === true;
}
// 404 or 403 means repo doesn't exist or is private
if (response.status === 404 || response.status === 403) {
// If we have a PAT, try with authentication
if (personalAccessToken) {
const authResponse = await globalThis.fetch(apiUrl, {
headers: {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${personalAccessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Profilarr'
}
});
if (authResponse.ok) {
const data = await authResponse.json();
return data.private === true;
}
if (authResponse.status === 404) {
throw new Error('Repository not found. Please check the URL.');
}
if (authResponse.status === 401 || authResponse.status === 403) {
throw new Error('Unable to access repository. Please check your Personal Access Token has the correct permissions (repo scope required).');
}
throw new Error(`GitHub API error: ${authResponse.status} ${authResponse.statusText}`);
}
throw new Error('Repository not found or is private. Please provide a Personal Access Token if this is a private repository.');
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} catch (error) {
if (error instanceof Error && (
error.message.includes('Repository not found') ||
error.message.includes('Unable to access') ||
error.message.includes('GitHub API error')
)) {
throw error;
}
throw new Error(`Failed to validate repository: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Clone a git repository
* Returns true if the repository is private, false if public
*/
export async function clone(
repositoryUrl: string,
targetPath: string,
branch?: string,
personalAccessToken?: string
): Promise<boolean> {
// Validate repository exists and detect if it's private
const isPrivate = await validateRepository(repositoryUrl, personalAccessToken);
const args = ['clone'];
if (branch) {
args.push('--branch', branch);
}
// Inject personal access token into URL if provided (for private repos or push access)
let authUrl = repositoryUrl;
if (personalAccessToken) {
// Format: https://TOKEN@github.com/username/repo
authUrl = repositoryUrl.replace('https://github.com', `https://${personalAccessToken}@github.com`);
}
args.push(authUrl, targetPath);
await execGit(args);
return isPrivate;
}
/**
* Pull latest changes from remote
*/
export async function pull(repoPath: string): Promise<void> {
await execGit(['pull'], repoPath);
}
/**
* Fetch from remote without merging
*/
export async function fetchRemote(repoPath: string): Promise<void> {
await execGit(['fetch'], repoPath);
}
/**
* Get current branch name
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
return await execGit(['branch', '--show-current'], repoPath);
}
/**
* Checkout a branch
*/
export async function checkout(repoPath: string, branch: string): Promise<void> {
await execGit(['checkout', branch], repoPath);
}
/**
* Get repository status
*/
export async function getStatus(repoPath: string): Promise<GitStatus> {
const currentBranch = await getCurrentBranch(repoPath);
// Get short status
const statusOutput = await execGit(['status', '--short'], repoPath);
const untracked: string[] = [];
const modified: string[] = [];
const staged: string[] = [];
for (const line of statusOutput.split('\n')) {
if (!line.trim()) continue;
const status = line.substring(0, 2);
const file = line.substring(3);
if (status.startsWith('??')) {
untracked.push(file);
} else if (status[1] === 'M' || status[1] === 'D') {
modified.push(file);
} else if (status[0] === 'M' || status[0] === 'A' || status[0] === 'D') {
staged.push(file);
}
}
const isDirty = untracked.length > 0 || modified.length > 0 || staged.length > 0;
return {
currentBranch,
isDirty,
untracked,
modified,
staged
};
}
/**
* Check for updates from remote
*/
export async function checkForUpdates(repoPath: string): Promise<UpdateInfo> {
// Fetch latest from remote
await fetchRemote(repoPath);
const currentBranch = await getCurrentBranch(repoPath);
const remoteBranch = `origin/${currentBranch}`;
// Get current commit
const currentLocalCommit = await execGit(['rev-parse', 'HEAD'], repoPath);
// Get remote commit
let latestRemoteCommit: string;
try {
latestRemoteCommit = await execGit(['rev-parse', remoteBranch], repoPath);
} catch {
// Remote branch doesn't exist or hasn't been fetched
return {
hasUpdates: false,
commitsBehind: 0,
commitsAhead: 0,
latestRemoteCommit: currentLocalCommit,
currentLocalCommit
};
}
// Count commits behind
let commitsBehind = 0;
try {
const behindOutput = await execGit(
['rev-list', '--count', `HEAD..${remoteBranch}`],
repoPath
);
commitsBehind = parseInt(behindOutput) || 0;
} catch {
commitsBehind = 0;
}
// Count commits ahead
let commitsAhead = 0;
try {
const aheadOutput = await execGit(
['rev-list', '--count', `${remoteBranch}..HEAD`],
repoPath
);
commitsAhead = parseInt(aheadOutput) || 0;
} catch {
commitsAhead = 0;
}
return {
hasUpdates: commitsBehind > 0,
commitsBehind,
commitsAhead,
latestRemoteCommit,
currentLocalCommit
};
}
/**
* Reset repository to match remote (discards local changes)
*/
export async function resetToRemote(repoPath: string): Promise<void> {
const currentBranch = await getCurrentBranch(repoPath);
const remoteBranch = `origin/${currentBranch}`;
await execGit(['reset', '--hard', remoteBranch], repoPath);
}

View File

@@ -54,21 +54,24 @@ class Logger {
* Check if logging is enabled
*/
private isEnabled(): boolean {
return this.config.enabled;
const currentSettings = logSettings.get();
return currentSettings.enabled === 1;
}
/**
* Check if file logging is enabled
*/
private isFileLoggingEnabled(): boolean {
return this.config.fileLogging;
const currentSettings = logSettings.get();
return currentSettings.file_logging === 1;
}
/**
* Check if console logging is enabled
*/
private isConsoleLoggingEnabled(): boolean {
return this.config.consoleLogging;
const currentSettings = logSettings.get();
return currentSettings.console_logging === 1;
}
/**
@@ -79,8 +82,12 @@ class Logger {
return false;
}
// Get fresh settings from database instead of using cached config
const currentSettings = logSettings.get();
const currentMinLevel = currentSettings.min_level;
const levels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const minIndex = levels.indexOf(this.config.minLevel);
const minIndex = levels.indexOf(currentMinLevel);
const levelIndex = levels.indexOf(level);
return levelIndex >= minIndex;

View File

@@ -0,0 +1,118 @@
/**
* Shared notification types for both backend and frontend
* Defines all available notification types and their metadata
*/
export interface NotificationType {
id: string;
label: string;
category: string;
description?: string;
}
/**
* All available notification types
*/
export const notificationTypes: NotificationType[] = [
// Backups
{
id: 'job.create_backup.success',
label: 'Backup Created (Success)',
category: 'Backups',
description: 'Notification when a backup is created successfully'
},
{
id: 'job.create_backup.failed',
label: 'Backup Created (Failed)',
category: 'Backups',
description: 'Notification when backup creation fails'
},
{
id: 'job.cleanup_backups.success',
label: 'Backup Cleanup (Success)',
category: 'Backups',
description: 'Notification when old backups are cleaned up successfully'
},
{
id: 'job.cleanup_backups.failed',
label: 'Backup Cleanup (Failed)',
category: 'Backups',
description: 'Notification when backup cleanup fails'
},
// Logs
{
id: 'job.cleanup_logs.success',
label: 'Log Cleanup (Success)',
category: 'Logs',
description: 'Notification when old logs are cleaned up successfully'
},
{
id: 'job.cleanup_logs.failed',
label: 'Log Cleanup (Failed)',
category: 'Logs',
description: 'Notification when log cleanup fails'
},
// Database Sync
{
id: 'pcd.linked',
label: 'Database Linked',
category: 'Databases',
description: 'Notification when a new database is linked'
},
{
id: 'pcd.unlinked',
label: 'Database Unlinked',
category: 'Databases',
description: 'Notification when a database is removed'
},
{
id: 'pcd.updates_available',
label: 'Database Updates Available',
category: 'Databases',
description: 'Notification when database updates are available but auto-pull is disabled'
},
{
id: 'pcd.sync_success',
label: 'Database Synced (Success)',
category: 'Databases',
description: 'Notification when a database is synced successfully'
},
{
id: 'pcd.sync_failed',
label: 'Database Sync (Failed)',
category: 'Databases',
description: 'Notification when database sync fails'
}
];
/**
* Group notification types by category
*/
export function groupNotificationTypesByCategory(): Record<string, NotificationType[]> {
return notificationTypes.reduce(
(acc, type) => {
if (!acc[type.category]) {
acc[type.category] = [];
}
acc[type.category].push(type);
return acc;
},
{} as Record<string, NotificationType[]>
);
}
/**
* Get all notification type IDs
*/
export function getAllNotificationTypeIds(): string[] {
return notificationTypes.map((type) => type.id);
}
/**
* Validate if a notification type ID exists
*/
export function isValidNotificationType(typeId: string): boolean {
return notificationTypes.some((type) => type.id === typeId);
}

View File

@@ -0,0 +1,54 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = () => {
const databases = pcdManager.getAll();
return {
databases
};
};
export const actions = {
delete: async ({ request }) => {
const formData = await request.formData();
const id = parseInt(formData.get('id')?.toString() || '0', 10);
if (!id) {
await logger.warn('Attempted to unlink database without ID', {
source: 'databases'
});
return fail(400, {
error: 'Database ID is required'
});
}
try {
await pcdManager.unlink(id);
await logger.info(`Unlinked database: ${id}`, {
source: 'databases',
meta: { id }
});
redirect(303, '/databases');
} catch (error) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
await logger.error('Failed to unlink database', {
source: 'databases',
meta: { error: error instanceof Error ? error.message : String(error) }
});
return fail(500, {
error: error instanceof Error ? error.message : 'Failed to unlink database'
});
}
}
} satisfies Actions;

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { Database, Plus, Lock, Code, Trash2, Pencil, ExternalLink } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import EmptyState from '$ui/state/EmptyState.svelte';
import Table from '$ui/table/Table.svelte';
import Modal from '$ui/modal/Modal.svelte';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { Column } from '$ui/table/Table.svelte';
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
export let data: PageData;
// Modal state
let showUnlinkModal = false;
let selectedDatabase: DatabaseInstance | null = null;
let unlinkFormElement: HTMLFormElement;
// Extract GitHub username/org from repository URL
function getGitHubAvatar(repoUrl: string): string {
const match = repoUrl.match(/github\.com\/([^\/]+)\//);
if (match) {
return `https://github.com/${match[1]}.png?size=40`;
}
return '';
}
// Format sync strategy for display
function formatSyncStrategy(minutes: number): string {
if (minutes === 0) return 'Manual';
if (minutes < 60) return `Every ${minutes} min`;
if (minutes === 60) return 'Hourly';
if (minutes < 1440) return `Every ${minutes / 60}h`;
return `Every ${minutes / 1440}d`;
}
// Format last synced date
function formatLastSynced(date: string | null): string {
if (!date) return 'Never';
return new Date(date).toLocaleDateString();
}
// Handle row click
function handleRowClick(database: DatabaseInstance) {
goto(`/databases/${database.id}`);
}
// Handle unlink click
function handleUnlinkClick(e: MouseEvent, database: DatabaseInstance) {
e.stopPropagation(); // Prevent row click
selectedDatabase = database;
showUnlinkModal = true;
}
// Define table columns
const columns: Column<DatabaseInstance>[] = [
{ key: 'name', header: 'Name', align: 'left' },
{ key: 'repository_url', header: 'Repository', align: 'left' },
{ key: 'sync_strategy', header: 'Sync', align: 'left', width: 'w-32' },
{ key: 'last_synced_at', header: 'Last Synced', align: 'left', width: 'w-40' }
];
</script>
<svelte:head>
<title>Databases - Profilarr</title>
</svelte:head>
{#if data.databases.length === 0}
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to get started with profile management."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
/>
{:else}
<div class="space-y-6 p-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Databases</h1>
<p class="mt-1 text-neutral-600 dark:text-neutral-400">
Manage your linked Profilarr Compliant Databases
</p>
</div>
<a
href="/databases/new"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Plus size={16} />
Link Database
</a>
</div>
<!-- Database Table -->
<Table {columns} data={data.databases} hoverable={true}>
<svelte:fragment slot="cell" let:row let:column>
<div on:click={() => handleRowClick(row)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)} class="cursor-pointer">
{#if column.key === 'name'}
<div class="flex items-center gap-3">
<img
src={getGitHubAvatar(row.repository_url)}
alt="{row.name} avatar"
class="h-8 w-8 rounded-lg"
/>
<div class="flex items-center gap-2">
<div class="font-medium text-neutral-900 dark:text-neutral-50">
{row.name}
</div>
{#if row.is_private}
<span class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<Lock size={10} />
Private
</span>
{/if}
{#if row.personal_access_token}
<span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
<Code size={10} />
Dev
</span>
{/if}
</div>
</div>
{:else if column.key === 'repository_url'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.repository_url.replace('https://github.com/', '')}
</code>
{:else if column.key === 'sync_strategy'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{formatSyncStrategy(row.sync_strategy)}
</code>
{:else if column.key === 'last_synced_at'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{formatLastSynced(row.last_synced_at)}
</code>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-2">
<!-- GitHub Link Button -->
<a
href={row.repository_url}
target="_blank"
rel="noopener noreferrer"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-blue-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-blue-400 dark:hover:bg-neutral-700"
title="View on GitHub"
>
<ExternalLink size={14} />
</a>
<!-- Edit Button -->
<a
href="/databases/{row.id}/edit"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit database"
>
<Pencil size={14} />
</a>
<!-- Unlink Button -->
<button
type="button"
on:click={(e) => handleUnlinkClick(e, row)}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
title="Unlink database"
>
<Trash2 size={14} />
</button>
</div>
</svelte:fragment>
</Table>
</div>
{/if}
<!-- Unlink Confirmation Modal -->
<Modal
open={showUnlinkModal}
header="Unlink Database"
bodyMessage={`Are you sure you want to unlink "${selectedDatabase?.name}"? This action cannot be undone and all local data will be permanently removed.`}
confirmText="Unlink"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showUnlinkModal = false;
if (selectedDatabase) {
unlinkFormElement?.requestSubmit();
}
}}
on:cancel={() => {
showUnlinkModal = false;
selectedDatabase = null;
}}
/>
<!-- Hidden unlink form -->
<form
bind:this={unlinkFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to unlink database'
);
} else if (result.type === 'redirect') {
alertStore.add('success', 'Database unlinked successfully');
}
await update();
selectedDatabase = null;
};
}}
>
<input type="hidden" name="id" value={selectedDatabase?.id || ''} />
</form>

View File

@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
error(400, 'Invalid database ID');
}
const database = databaseInstancesQueries.getById(id);
if (!database) {
error(404, 'Database not found');
}
return {
database
};
};

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { PageData } from './$types';
import { Pencil } from 'lucide-svelte';
export let data: PageData;
</script>
<svelte:head>
<title>{data.database.name} - Profilarr</title>
</svelte:head>
<!-- Floating Edit Button -->
<a
href="/databases/{data.database.id}/edit"
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
aria-label="Edit database"
>
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
</a>

View File

@@ -0,0 +1,172 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { pcdManager } from '$pcd/pcd.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
error(404, `Invalid database ID: ${params.id}`);
}
// Fetch the specific instance
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
error(404, `Database not found: ${id}`);
}
return {
instance
};
};
export const actions: Actions = {
default: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
await logger.warn('Update failed: Invalid database ID', {
source: 'databases/[id]/edit',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid database ID' });
}
// Fetch the instance to verify it exists
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
await logger.warn('Update failed: Database not found', {
source: 'databases/[id]/edit',
meta: { id }
});
return fail(404, { error: 'Database not found' });
}
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const syncStrategy = parseInt(formData.get('sync_strategy')?.toString() || '0', 10);
const autoPull = formData.get('auto_pull') === '1';
// Validation
if (!name) {
await logger.warn('Attempted to update database with missing required fields', {
source: 'databases/[id]/edit',
meta: { id, name }
});
return fail(400, {
error: 'Name is required',
values: { name }
});
}
// Check if name already exists (excluding current instance)
const existingWithName = databaseInstancesQueries.getAll().find(
(db) => db.name === name && db.id !== id
);
if (existingWithName) {
await logger.warn('Attempted to update database with duplicate name', {
source: 'databases/[id]/edit',
meta: { id, name }
});
return fail(400, {
error: 'A database with this name already exists',
values: { name }
});
}
try {
// Update the database
const updated = databaseInstancesQueries.update(id, {
name,
syncStrategy,
autoPull
});
if (!updated) {
throw new Error('Update returned false');
}
await logger.info(`Updated database: ${name}`, {
source: 'databases/[id]/edit',
meta: { id, name }
});
// Redirect to database detail page
redirect(303, `/databases/${id}`);
} catch (error) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
await logger.error('Failed to update database', {
source: 'databases/[id]/edit',
meta: { error: error instanceof Error ? error.message : String(error) }
});
return fail(500, {
error: 'Failed to update database',
values: { name }
});
}
},
delete: async ({ params }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
await logger.warn('Delete failed: Invalid database ID', {
source: 'databases/[id]/edit',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid database ID' });
}
// Fetch the instance to verify it exists
const instance = databaseInstancesQueries.getById(id);
if (!instance) {
await logger.warn('Delete failed: Database not found', {
source: 'databases/[id]/edit',
meta: { id }
});
return fail(404, { error: 'Database not found' });
}
try {
// Unlink the database
await pcdManager.unlink(id);
await logger.info(`Unlinked database: ${instance.name}`, {
source: 'databases/[id]/edit',
meta: { id, name: instance.name, repositoryUrl: instance.repository_url }
});
// Redirect to databases list
redirect(303, '/databases');
} catch (error) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
await logger.error('Failed to unlink database', {
source: 'databases/[id]/edit',
meta: { error: error instanceof Error ? error.message : String(error) }
});
return fail(500, { error: 'Failed to unlink database' });
}
}
};

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import InstanceForm from '../../components/InstanceForm.svelte';
import type { ActionData, PageData } from './$types';
export let form: ActionData;
export let data: PageData;
</script>
<InstanceForm mode="edit" {form} instance={data.instance} />

View File

@@ -0,0 +1,21 @@
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = ({ url }) => {
const urlParam = url.searchParams.get('url') || '';
const type = url.searchParams.get('type') || 'unknown';
const name = url.searchParams.get('name') || '';
const branch = url.searchParams.get('branch') || '';
const syncStrategy = url.searchParams.get('sync_strategy') || '';
const autoPull = url.searchParams.get('auto_pull') || '';
return {
url: urlParam,
type,
formData: {
name,
branch,
syncStrategy,
autoPull
}
};
};

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { RotateCcw } from 'lucide-svelte';
import { alertStore } from '$alerts/store';
import { onMount } from 'svelte';
import type { PageData } from './$types';
export let data: PageData;
onMount(() => {
alertStore.add('error', 'bruh', 8000);
});
// Extract YouTube video ID from URL and add autoplay
function getYouTubeEmbedUrl(url: string): string {
const videoIdMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/);
if (videoIdMatch) {
return `https://www.youtube.com/embed/${videoIdMatch[1]}?autoplay=1`;
}
return '';
}
$: embedUrl = data.type === 'youtube' ? getYouTubeEmbedUrl(data.url) : '';
</script>
<svelte:head>
<title>Bruh - Profilarr</title>
</svelte:head>
<div class="flex min-h-screen items-center bg-white p-8 dark:bg-neutral-950">
<div class="mx-auto w-full max-w-4xl space-y-6">
<!-- Cheeky Message -->
<div class="text-center">
<p class="text-2xl font-medium text-neutral-900 dark:text-neutral-50">
If you insist...
</p>
</div>
<!-- Embed Container -->
<div class="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-800 dark:bg-neutral-900">
{#if data.type === 'youtube' && embedUrl}
<div class="aspect-video w-full">
<iframe
src={embedUrl}
title="YouTube video"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="h-full w-full"
></iframe>
</div>
{:else if data.type === 'twitter'}
<div class="p-12 text-center">
<p class="mb-4 text-2xl text-neutral-700 dark:text-neutral-300">
Here's your tweet:
</p>
<a
href={data.url}
target="_blank"
rel="noopener noreferrer"
class="text-xl text-blue-600 hover:underline dark:text-blue-400"
>
{data.url}
</a>
</div>
{:else if data.type === 'reddit'}
<div class="p-12 text-center">
<p class="mb-4 text-2xl text-neutral-700 dark:text-neutral-300">
Here's your Reddit post:
</p>
<a
href={data.url}
target="_blank"
rel="noopener noreferrer"
class="text-xl text-blue-600 hover:underline dark:text-blue-400"
>
{data.url}
</a>
</div>
{:else}
<div class="p-12 text-center">
<p class="mb-4 text-2xl text-neutral-700 dark:text-neutral-300">
Here's what you tried to link:
</p>
<a
href={data.url}
target="_blank"
rel="noopener noreferrer"
class="text-xl text-blue-600 hover:underline dark:text-blue-400"
>
{data.url}
</a>
</div>
{/if}
</div>
<!-- Footer Message with Button -->
<div class="flex flex-wrap items-center justify-center gap-4 text-center text-neutral-600 dark:text-neutral-400">
<p>
You need to link a <strong class="text-neutral-900 dark:text-neutral-50">GitHub repository</strong>
</p>
<a
href="/databases/new?name={encodeURIComponent(data.formData.name)}&branch={encodeURIComponent(data.formData.branch)}&sync_strategy={encodeURIComponent(data.formData.syncStrategy)}&auto_pull={encodeURIComponent(data.formData.autoPull)}"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<RotateCcw size={16} />
Try Again
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,335 @@
<script lang="ts">
import { Save, Trash2, Loader2 } from 'lucide-svelte';
import Modal from '$ui/modal/Modal.svelte';
import { enhance } from '$app/forms';
import { alertStore } from '$alerts/store';
import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
// Props
export let mode: 'create' | 'edit';
export let instance: DatabaseInstance | undefined = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let form: any = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let data: any = undefined;
// Loading state
let isLoading = false;
// Form values
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let name = (form as any)?.values?.name ?? (mode === 'edit' ? instance?.name : data?.formData?.name) ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let repositoryUrl =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form as any)?.values?.repository_url ??
(mode === 'edit' ? instance?.repository_url : '') ??
'';
let branch = (form as any)?.values?.branch ?? (mode === 'create' ? data?.formData?.branch : '') ?? '';
let personalAccessToken = (form as any)?.values?.personal_access_token ?? (mode === 'edit' ? instance?.personal_access_token : data?.formData?.personalAccessToken) ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let syncStrategy =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form as any)?.values?.sync_strategy ??
(mode === 'edit' ? instance?.sync_strategy : (data?.formData?.syncStrategy ? parseInt(data.formData.syncStrategy) : 60)) ??
60;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let autoPull = (form as any)?.values?.auto_pull ?? (mode === 'edit' ? instance?.auto_pull : (data?.formData?.autoPull === '1' ? 1 : (data?.formData?.autoPull === '0' ? 0 : 1))) ?? 1;
// Delete modal state
let showDeleteModal = false;
let deleteFormElement: HTMLFormElement;
// Display text based on mode
$: title = mode === 'create' ? 'Link Database' : 'Edit Database';
$: description =
mode === 'create'
? 'Link a Profilarr Compliant Database from a Git repository'
: `Update the configuration for ${instance?.name || 'this database'}`;
$: submitButtonText = mode === 'create' ? 'Link Database' : 'Save Changes';
$: successMessage =
mode === 'create' ? 'Database linked successfully!' : 'Database updated successfully!';
$: errorMessage = mode === 'create' ? 'Failed to link database' : 'Failed to update database';
</script>
<div class="space-y-8 p-8">
<div class="space-y-3">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
<p class="text-lg text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
<form
method="POST"
class="space-y-6"
use:enhance={() => {
isLoading = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || errorMessage);
} else if (result.type === 'redirect') {
// Don't show success message if redirecting to bruh page
if (result.location && !result.location.includes('/databases/bruh')) {
alertStore.add('success', successMessage);
}
}
await update();
isLoading = false;
};
}}
>
<!-- Database Details -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Database Details
</h2>
<div class="space-y-4">
<!-- Name -->
<div>
<label
for="name"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
required
placeholder="e.g., Main Database, 4K Profiles"
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 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
A friendly name to identify this database
</p>
</div>
<!-- Repository URL -->
<div>
<label
for="repository_url"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Repository URL <span class="text-red-500">*</span>
</label>
<input
type="url"
id="repository_url"
name="repository_url"
bind:value={repositoryUrl}
required
disabled={mode === 'edit'}
placeholder="https://github.com/username/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 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900"
/>
{#if mode === 'edit'}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Repository URL cannot be changed after linking
</p>
{:else}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Git repository URL containing the PCD manifest
</p>
{/if}
</div>
<!-- Branch -->
{#if mode === 'create'}
<div>
<label
for="branch"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Branch
</label>
<input
type="text"
id="branch"
name="branch"
bind:value={branch}
placeholder="main"
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 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Branch to checkout on link. Leave empty to use the default branch. You can change this later.
</p>
</div>
{/if}
<!-- Personal Access Token -->
<div>
<label
for="personal_access_token"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Personal Access Token (Optional)
</label>
<input
type="password"
id="personal_access_token"
name="personal_access_token"
bind:value={personalAccessToken}
placeholder="ghp_..."
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 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Required for private repositories to clone and for developers to push back to GitHub.
</p>
</div>
</div>
</div>
<!-- Sync Settings -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Sync Settings
</h2>
<div class="space-y-4">
<!-- Sync Strategy -->
<div>
<label
for="sync_strategy"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Sync Strategy
</label>
<select
id="sync_strategy"
name="sync_strategy"
bind:value={syncStrategy}
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option value={0}>Manual (no auto-sync)</option>
<option value={5}>Every 5 minutes</option>
<option value={15}>Every 15 minutes</option>
<option value={30}>Every 30 minutes</option>
<option value={60}>Every hour</option>
<option value={360}>Every 6 hours</option>
<option value={720}>Every 12 hours</option>
<option value={1440}>Every 24 hours</option>
</select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
How often to check for updates from the remote repository
</p>
</div>
<!-- Auto Pull -->
<div class="flex items-start gap-3">
<input
type="checkbox"
id="auto_pull"
name="auto_pull"
bind:checked={autoPull}
value="1"
class="mt-0.5 h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800"
/>
<div>
<label
for="auto_pull"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Automatically pull updates
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
If enabled, updates will be pulled automatically. If disabled, you'll only receive
notifications when updates are available.
</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center justify-end gap-3">
{#if mode === 'edit'}
<a
href="/databases/{instance?.id}"
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Cancel
</a>
{/if}
<button
type="submit"
disabled={isLoading}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{#if isLoading}
<Loader2 size={14} class="animate-spin" />
{mode === 'create' ? 'Linking...' : 'Saving...'}
{:else}
<Save size={14} />
{submitButtonText}
{/if}
</button>
</div>
</form>
<!-- Delete Section (Edit Mode Only) -->
{#if mode === 'edit'}
<div
class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-950/40"
>
<h2 class="text-lg font-semibold text-red-700 dark:text-red-300">Danger Zone</h2>
<p class="mt-2 text-sm text-red-600 dark:text-red-400">
Once you unlink this database, there is no going back. All local data will be removed.
</p>
<button
type="button"
on:click={() => (showDeleteModal = true)}
class="mt-4 flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
>
<Trash2 size={14} />
Unlink Database
</button>
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to unlink database'
);
} else if (result.type === 'redirect') {
alertStore.add('success', 'Database unlinked successfully');
}
await update();
};
}}
>
<!-- Empty form, just for submission -->
</form>
</div>
{/if}
</div>
<!-- Delete Confirmation Modal -->
{#if mode === 'edit'}
<Modal
open={showDeleteModal}
header="Unlink Database"
bodyMessage={`Are you sure you want to unlink "${instance?.name}"? This action cannot be undone and all local data will be permanently removed.`}
confirmText="Unlink"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}

View File

@@ -0,0 +1,113 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = ({ url }) => {
const name = url.searchParams.get('name') || '';
const branch = url.searchParams.get('branch') || '';
const syncStrategy = url.searchParams.get('sync_strategy') || '';
const autoPull = url.searchParams.get('auto_pull') || '';
return {
formData: {
name,
branch,
syncStrategy,
autoPull
}
};
};
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const repositoryUrl = formData.get('repository_url')?.toString().trim();
const branch = formData.get('branch')?.toString().trim() || undefined;
const syncStrategy = parseInt(formData.get('sync_strategy')?.toString() || '0', 10);
const autoPull = formData.get('auto_pull') === '1';
const personalAccessToken = formData.get('personal_access_token')?.toString().trim() || undefined;
// Validation
if (!name || !repositoryUrl) {
await logger.warn('Attempted to link database with missing required fields', {
source: 'databases/new',
meta: { name, repositoryUrl }
});
return fail(400, {
error: 'Name and repository URL are required',
values: { name, repository_url: repositoryUrl }
});
}
// Check for common non-GitHub URLs and redirect to bruh page
const bruhParams = new URLSearchParams({
name: name,
branch: branch || '',
sync_strategy: syncStrategy.toString(),
auto_pull: autoPull ? '1' : '0'
});
if (repositoryUrl.includes('youtube.com') || repositoryUrl.includes('youtu.be')) {
redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=youtube&${bruhParams.toString()}`);
}
if (repositoryUrl.includes('twitter.com') || repositoryUrl.includes('x.com')) {
redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=twitter&${bruhParams.toString()}`);
}
if (repositoryUrl.includes('reddit.com')) {
redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=reddit&${bruhParams.toString()}`);
}
// Check if name already exists
if (databaseInstancesQueries.nameExists(name)) {
await logger.warn('Attempted to link database with duplicate name', {
source: 'databases/new',
meta: { name }
});
return fail(400, {
error: 'A database with this name already exists',
values: { name, repository_url: repositoryUrl }
});
}
try {
// Link the database
const instance = await pcdManager.link({
name,
repositoryUrl,
branch,
syncStrategy,
autoPull,
personalAccessToken
});
await logger.info(`Linked new database: ${name}`, {
source: 'databases/new',
meta: { id: instance.id, name, repositoryUrl }
});
// Redirect to databases list
redirect(303, '/databases');
} catch (error) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
await logger.error('Failed to link database', {
source: 'databases/new',
meta: { error: error instanceof Error ? error.message : String(error) }
});
return fail(500, {
error: error instanceof Error ? error.message : 'Failed to link database',
values: { name, repository_url: repositoryUrl }
});
}
}
} satisfies Actions;

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import InstanceForm from '../components/InstanceForm.svelte';
import type { ActionData, PageData } from './$types';
export let form: ActionData;
export let data: PageData;
</script>
<InstanceForm mode="create" {form} {data} />

View File

@@ -3,6 +3,7 @@
import { alertStore } from '$alerts/store';
import DiscordConfiguration from './DiscordConfiguration.svelte';
import { siDiscord } from 'simple-icons';
import { groupNotificationTypesByCategory } from '$shared/notificationTypes';
export let mode: 'create' | 'edit' = 'create';
export let initialData: {
@@ -15,31 +16,8 @@
let selectedType: 'discord' | 'slack' | 'email' = (initialData.serviceType as 'discord') || 'discord';
let serviceName = initialData.name || '';
// Available notification types
const notificationTypes = [
{ id: 'job.create_backup.success', label: 'Backup Created (Success)', category: 'Backups' },
{ id: 'job.create_backup.failed', label: 'Backup Created (Failed)', category: 'Backups' },
{
id: 'job.cleanup_backups.success',
label: 'Backup Cleanup (Success)',
category: 'Backups'
},
{ id: 'job.cleanup_backups.failed', label: 'Backup Cleanup (Failed)', category: 'Backups' },
{ id: 'job.cleanup_logs.success', label: 'Log Cleanup (Success)', category: 'Logs' },
{ id: 'job.cleanup_logs.failed', label: 'Log Cleanup (Failed)', category: 'Logs' }
];
// Group notification types by category
const groupedTypes = notificationTypes.reduce(
(acc, type) => {
if (!acc[type.category]) {
acc[type.category] = [];
}
acc[type.category].push(type);
return acc;
},
{} as Record<string, typeof notificationTypes>
);
const groupedTypes = groupNotificationTypesByCategory();
// Check if a notification type should be checked by default
function isTypeEnabled(typeId: string): boolean {

View File

@@ -2,6 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { logger } from '$logger/logger.ts';
import { notificationServicesQueries } from '$db/queries/notificationServices.ts';
import { getAllNotificationTypeIds } from '$shared/notificationTypes.ts';
export const load = ({ params }: { params: { id: string } }) => {
const { id } = params;
@@ -84,22 +85,9 @@ export const actions: Actions = {
enable_mentions: enableMentions
};
// Get enabled notification types
const jobBackupSuccess = formData.get('job.create_backup.success') === 'on';
const jobBackupFailed = formData.get('job.create_backup.failed') === 'on';
const jobCleanupBackupsSuccess = formData.get('job.cleanup_backups.success') === 'on';
const jobCleanupBackupsFailed = formData.get('job.cleanup_backups.failed') === 'on';
const jobCleanupLogsSuccess = formData.get('job.cleanup_logs.success') === 'on';
const jobCleanupLogsFailed = formData.get('job.cleanup_logs.failed') === 'on';
enabledTypes = [
...(jobBackupSuccess ? ['job.create_backup.success'] : []),
...(jobBackupFailed ? ['job.create_backup.failed'] : []),
...(jobCleanupBackupsSuccess ? ['job.cleanup_backups.success'] : []),
...(jobCleanupBackupsFailed ? ['job.cleanup_backups.failed'] : []),
...(jobCleanupLogsSuccess ? ['job.cleanup_logs.success'] : []),
...(jobCleanupLogsFailed ? ['job.cleanup_logs.failed'] : [])
];
// Get enabled notification types dynamically from all available types
const allTypeIds = getAllNotificationTypeIds();
enabledTypes = allTypeIds.filter((typeId) => formData.get(typeId) === 'on');
}
// Update the service

View File

@@ -2,6 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import { logger } from '$logger/logger.ts';
import { notificationServicesQueries } from '$db/queries/notificationServices.ts';
import { getAllNotificationTypeIds } from '$shared/notificationTypes.ts';
export const actions: Actions = {
create: async ({ request }: RequestEvent) => {
@@ -39,22 +40,9 @@ export const actions: Actions = {
enable_mentions: enableMentions
};
// Get enabled notification types
const jobBackupSuccess = formData.get('job.create_backup.success') === 'on';
const jobBackupFailed = formData.get('job.create_backup.failed') === 'on';
const jobCleanupBackupsSuccess = formData.get('job.cleanup_backups.success') === 'on';
const jobCleanupBackupsFailed = formData.get('job.cleanup_backups.failed') === 'on';
const jobCleanupLogsSuccess = formData.get('job.cleanup_logs.success') === 'on';
const jobCleanupLogsFailed = formData.get('job.cleanup_logs.failed') === 'on';
enabledTypes = [
...(jobBackupSuccess ? ['job.create_backup.success'] : []),
...(jobBackupFailed ? ['job.create_backup.failed'] : []),
...(jobCleanupBackupsSuccess ? ['job.cleanup_backups.success'] : []),
...(jobCleanupBackupsFailed ? ['job.cleanup_backups.failed'] : []),
...(jobCleanupLogsSuccess ? ['job.cleanup_logs.success'] : []),
...(jobCleanupLogsFailed ? ['job.cleanup_logs.failed'] : [])
];
// Get enabled notification types dynamically from all available types
const allTypeIds = getAllNotificationTypeIds();
enabledTypes = allTypeIds.filter((typeId) => formData.get(typeId) === 'on');
}
// Generate UUID for the service

View File

@@ -22,6 +22,7 @@ const config = {
$server: './src/server',
$db: './src/lib/server/db',
$jobs: './src/lib/server/jobs',
$pcd: './src/lib/server/pcd',
$arr: './src/lib/server/utils/arr',
$http: './src/lib/server/utils/http',
$utils: './src/lib/server/utils',