feat: Implement regular expression management features

- Add server-side logic for loading, updating, and deleting regular expressions in +page.server.ts.
- Create a new Svelte component for editing regular expressions, including form handling and validation.
- Introduce a RegexPatternField component for managing regex patterns and associated unit tests.
- Develop a RegularExpressionForm component for both creating and editing regex entries.
- Implement a SearchFilterAction component for filtering regex entries.
- Add new routes for creating and managing regular expressions, including a preset feature for common patterns.
- Enhance the UI with CardView and TableView components for displaying regex entries in different formats.
- Integrate markdown parsing for descriptions in the UI.
This commit is contained in:
Sam Chau
2025-12-29 21:06:49 +10:30
parent ac0cc7d4c9
commit 7e7561e35a
22 changed files with 2026 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
/**
* Create a regular expression operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
export interface CreateRegularExpressionInput {
name: string;
pattern: string;
tags: string[];
description: string | null;
regex101Id: string | null;
}
export interface CreateRegularExpressionOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
input: CreateRegularExpressionInput;
}
/**
* Create a regular expression by writing an operation to the specified layer
*/
export async function create(options: CreateRegularExpressionOptions) {
const { databaseId, cache, layer, input } = options;
const db = cache.kb;
const queries = [];
// 1. Insert the regular expression
const insertRegex = db
.insertInto('regular_expressions')
.values({
name: input.name,
pattern: input.pattern,
description: input.description,
regex101_id: input.regex101Id
})
.compile();
queries.push(insertRegex);
// 2. Insert tags (create if not exist, then link)
for (const tagName of input.tags) {
// Insert tag if not exists
const insertTag = db
.insertInto('tags')
.values({ name: tagName })
.onConflict((oc) => oc.column('name').doNothing())
.compile();
queries.push(insertTag);
// Link tag to regular expression using helper functions
const linkTag = {
sql: `INSERT INTO regular_expression_tags (regular_expression_id, tag_id) VALUES ((SELECT id FROM regular_expressions WHERE name = '${input.name.replace(/'/g, "''")}'), tag('${tagName.replace(/'/g, "''")}'))`,
parameters: [],
query: {} as never
};
queries.push(linkTag);
}
// Write the operation
const result = await writeOperation({
databaseId,
layer,
description: `create-regular-expression-${input.name}`,
queries,
metadata: {
operation: 'create',
entity: 'regular_expression',
name: input.name
}
});
return result;
}

View File

@@ -0,0 +1,69 @@
/**
* Delete a regular expression operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import type { RegularExpressionTableRow } from './types.ts';
export interface DeleteRegularExpressionOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
/** The current regular expression data (for value guards) */
current: RegularExpressionTableRow;
}
/**
* Escape a string for SQL
*/
function esc(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Delete a regular expression by writing an operation to the specified layer
* Uses value guards to detect conflicts with upstream changes
*/
export async function remove(options: DeleteRegularExpressionOptions) {
const { databaseId, cache, layer, current } = options;
const db = cache.kb;
const queries = [];
// 1. Delete tag links first (foreign key constraint)
for (const tag of current.tags) {
const removeTagLink = {
sql: `DELETE FROM regular_expression_tags WHERE regular_expression_id = (SELECT id FROM regular_expressions WHERE name = '${esc(current.name)}') AND tag_id = tag('${esc(tag.name)}')`,
parameters: [],
query: {} as never
};
queries.push(removeTagLink);
}
// 2. Delete the regular expression with value guards
const deleteRegex = db
.deleteFrom('regular_expressions')
.where('id', '=', current.id)
// Value guards - ensure this is the regex we expect
.where('name', '=', current.name)
.where('pattern', '=', current.pattern)
.compile();
queries.push(deleteRegex);
// Write the operation
const result = await writeOperation({
databaseId,
layer,
description: `delete-regular-expression-${current.name}`,
queries,
metadata: {
operation: 'delete',
entity: 'regular_expression',
name: current.name
}
});
return result;
}

View File

@@ -0,0 +1,37 @@
/**
* Get a single regular expression by ID
*/
import type { PCDCache } from '../../cache.ts';
import type { RegularExpressionTableRow } from './types.ts';
/**
* Get a regular expression by ID with its tags
*/
export async function get(cache: PCDCache, id: number): Promise<RegularExpressionTableRow | null> {
const db = cache.kb;
// Get the regular expression
const regex = await db
.selectFrom('regular_expressions')
.select(['id', 'name', 'pattern', 'regex101_id', 'description'])
.where('id', '=', id)
.executeTakeFirst();
if (!regex) {
return null;
}
// Get tags for this regular expression
const tags = await db
.selectFrom('regular_expression_tags as ret')
.innerJoin('tags as t', 't.id', 'ret.tag_id')
.select(['t.id', 't.name'])
.where('ret.regular_expression_id', '=', id)
.execute();
return {
...regex,
tags
};
}

View File

@@ -0,0 +1,17 @@
/**
* Regular Expression queries and mutations
*/
// Export all types
export type { RegularExpressionTableRow } from './types.ts';
export type { CreateRegularExpressionInput } from './create.ts';
export type { UpdateRegularExpressionInput } from './update.ts';
// Export query functions
export { list } from './list.ts';
export { get } from './get.ts';
// Export mutation functions
export { create } from './create.ts';
export { update } from './update.ts';
export { remove } from './delete.ts';

View File

@@ -0,0 +1,63 @@
/**
* Regular expression list queries
*/
import type { PCDCache } from '../../cache.ts';
import type { Tag } from '../../types.ts';
import type { RegularExpressionTableRow } from './types.ts';
/**
* Get regular expressions with full data for table/card views
*/
export async function list(cache: PCDCache): Promise<RegularExpressionTableRow[]> {
const db = cache.kb;
// 1. Get all regular expressions
const expressions = await db
.selectFrom('regular_expressions')
.select(['id', 'name', 'pattern', 'regex101_id', 'description'])
.orderBy('name')
.execute();
if (expressions.length === 0) return [];
const expressionIds = expressions.map((e) => e.id);
// 2. Get all tags for all expressions
const allTags = await db
.selectFrom('regular_expression_tags as ret')
.innerJoin('tags as t', 't.id', 'ret.tag_id')
.select([
'ret.regular_expression_id',
't.id as tag_id',
't.name as tag_name',
't.created_at as tag_created_at'
])
.where('ret.regular_expression_id', 'in', expressionIds)
.orderBy('ret.regular_expression_id')
.orderBy('t.name')
.execute();
// Build tags map
const tagsMap = new Map<number, Tag[]>();
for (const tag of allTags) {
if (!tagsMap.has(tag.regular_expression_id)) {
tagsMap.set(tag.regular_expression_id, []);
}
tagsMap.get(tag.regular_expression_id)!.push({
id: tag.tag_id,
name: tag.tag_name,
created_at: tag.tag_created_at
});
}
// Build the final result
return expressions.map((expression) => ({
id: expression.id,
name: expression.name,
pattern: expression.pattern,
regex101_id: expression.regex101_id,
description: expression.description,
tags: tagsMap.get(expression.id) || []
}));
}

View File

@@ -0,0 +1,15 @@
/**
* Regular Expression query-specific types
*/
import type { Tag } from '../../types.ts';
/** Regular expression data for table/card views */
export interface RegularExpressionTableRow {
id: number;
name: string;
pattern: string;
regex101_id: string | null;
description: string | null;
tags: Tag[];
}

View File

@@ -0,0 +1,118 @@
/**
* Update a regular expression operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import type { RegularExpressionTableRow } from './types.ts';
export interface UpdateRegularExpressionInput {
name: string;
pattern: string;
tags: string[];
description: string | null;
regex101Id: string | null;
}
export interface UpdateRegularExpressionOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
/** The current regular expression data (for value guards) */
current: RegularExpressionTableRow;
/** The new values */
input: UpdateRegularExpressionInput;
}
/**
* Escape a string for SQL
*/
function esc(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Update a regular expression by writing an operation to the specified layer
* Uses value guards to detect conflicts with upstream changes
*/
export async function update(options: UpdateRegularExpressionOptions) {
const { databaseId, cache, layer, current, input } = options;
const db = cache.kb;
const queries = [];
// 1. Update the regular expression with value guards
const updateRegex = db
.updateTable('regular_expressions')
.set({
name: input.name,
pattern: input.pattern,
description: input.description,
regex101_id: input.regex101Id
})
.where('id', '=', current.id)
// Value guards - ensure current values match what we expect
.where('name', '=', current.name)
.where('pattern', '=', current.pattern)
.compile();
queries.push(updateRegex);
// 2. Handle tag changes
const currentTagNames = current.tags.map(t => t.name);
const newTagNames = input.tags;
// Tags to remove
const tagsToRemove = currentTagNames.filter(t => !newTagNames.includes(t));
for (const tagName of tagsToRemove) {
const removeTag = {
sql: `DELETE FROM regular_expression_tags WHERE regular_expression_id = (SELECT id FROM regular_expressions WHERE name = '${esc(current.name)}') AND tag_id = tag('${esc(tagName)}')`,
parameters: [],
query: {} as never
};
queries.push(removeTag);
}
// Tags to add
const tagsToAdd = newTagNames.filter(t => !currentTagNames.includes(t));
for (const tagName of tagsToAdd) {
// Insert tag if not exists
const insertTag = db
.insertInto('tags')
.values({ name: tagName })
.onConflict((oc) => oc.column('name').doNothing())
.compile();
queries.push(insertTag);
// Link tag to regular expression
// Use input.name for lookup since the regex might have been renamed
const regexName = input.name !== current.name ? input.name : current.name;
const linkTag = {
sql: `INSERT INTO regular_expression_tags (regular_expression_id, tag_id) VALUES ((SELECT id FROM regular_expressions WHERE name = '${esc(regexName)}'), tag('${esc(tagName)}'))`,
parameters: [],
query: {} as never
};
queries.push(linkTag);
}
// Write the operation with metadata
// Include previousName if this is a rename
const isRename = input.name !== current.name;
const result = await writeOperation({
databaseId,
layer,
description: `update-regular-expression-${input.name}`,
queries,
metadata: {
operation: 'update',
entity: 'regular_expression',
name: input.name,
...(isRename && { previousName: current.name })
}
});
return result;
}

View File

@@ -0,0 +1,47 @@
# Test regex pattern against test strings using .NET regex engine
# Usage: pwsh test.ps1 -Pattern "regex" -TestsJson '[{"testString":"test","criteria":"DOES_MATCH"}]'
param(
[Parameter(Mandatory=$true)]
[string]$Pattern,
[Parameter(Mandatory=$true)]
[string]$TestsJson
)
try {
$tests = $TestsJson | ConvertFrom-Json
$results = @()
foreach ($test in $tests) {
try {
$matched = [regex]::IsMatch(
$test.testString,
$Pattern,
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
$expectedMatch = $test.criteria -eq "DOES_MATCH"
$passed = $matched -eq $expectedMatch
$results += @{
testString = $test.testString
expected = $test.criteria
actual = $matched
passed = $passed
}
} catch {
$results += @{
testString = $test.testString
expected = $test.criteria
actual = $false
passed = $false
error = $_.Exception.Message
}
}
}
@{ success = $true; results = $results } | ConvertTo-Json -Depth 3 -Compress
} catch {
@{ success = $false; error = $_.Exception.Message } | ConvertTo-Json -Compress
}

View File

@@ -0,0 +1,146 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { logger } from '$logger/logger';
export interface Regex101UnitTest {
description: string;
testString: string;
criteria: 'DOES_MATCH' | 'DOES_NOT_MATCH';
actual?: boolean;
passed?: boolean;
}
export interface Regex101Response {
permalinkFragment: string;
version: number;
regex: string;
flags: string;
flavor: string;
unitTests: Regex101UnitTest[];
}
/**
* Run regex tests using PowerShell (.NET regex engine)
*/
async function runRegexTests(
pattern: string,
tests: Regex101UnitTest[]
): Promise<Regex101UnitTest[]> {
if (tests.length === 0) return tests;
try {
const scriptPath = `${Deno.cwd()}/src/lib/server/regex/test.ps1`;
const testsJson = JSON.stringify(tests.map(t => ({
testString: t.testString,
criteria: t.criteria
})));
const command = new Deno.Command('pwsh', {
args: ['-NoProfile', '-NonInteractive', '-File', scriptPath, '-Pattern', pattern, '-TestsJson', testsJson],
stdout: 'piped',
stderr: 'piped'
});
const { code, stdout, stderr } = await command.output();
if (code !== 0) {
const errorText = new TextDecoder().decode(stderr);
await logger.error('PowerShell regex test failed', {
source: 'Regex101API',
meta: { error: errorText, pattern }
});
return tests; // Return tests without pass/fail info
}
const outputText = new TextDecoder().decode(stdout).trim();
await logger.debug('PowerShell output', {
source: 'Regex101API',
meta: { output: outputText }
});
const result = JSON.parse(outputText);
if (!result.success) {
await logger.error('PowerShell regex test error', {
source: 'Regex101API',
meta: { error: result.error }
});
return tests;
}
// Merge results back into tests
return tests.map((test, idx) => ({
...test,
actual: result.results[idx]?.actual ?? undefined,
passed: result.results[idx]?.passed ?? undefined
}));
} catch (err) {
await logger.error('Failed to run regex tests', {
source: 'Regex101API',
meta: { error: String(err) }
});
return tests; // Return tests without pass/fail info on error
}
}
export const GET: RequestHandler = async ({ params, fetch }) => {
const { id } = params;
if (!id) {
throw error(400, 'Missing regex101 ID');
}
// Handle ID with optional version (e.g., "ABC123" or "ABC123/1")
const [regexId, version] = id.split('/');
try {
const url = version
? `https://regex101.com/api/regex/${regexId}/${version}`
: `https://regex101.com/api/regex/${regexId}`;
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Profilarr/1.0'
}
});
if (!response.ok) {
if (response.status === 404) {
throw error(404, 'Regex not found on regex101');
}
throw error(response.status, `Failed to fetch from regex101: ${response.statusText}`);
}
const data = await response.json();
await logger.debug('regex101 API response', {
source: 'Regex101API',
meta: data
});
// Extract unit tests
const unitTests: Regex101UnitTest[] = (data.unitTests || []).map((test: Record<string, unknown>) => ({
description: test.description || '',
testString: test.testString || '',
criteria: (test.criteria as string) || 'DOES_MATCH'
}));
// Run tests through PowerShell to get pass/fail results
const testedUnitTests = await runRegexTests(data.regex, unitTests);
const result: Regex101Response = {
permalinkFragment: data.permalinkFragment,
version: data.version,
regex: data.regex,
flags: data.flags || '',
flavor: data.flavor || 'pcre2',
unitTests: testedUnitTests
};
return json(result);
} catch (err) {
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
throw error(500, `Failed to fetch regex101 data: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
export const load: ServerLoad = () => {
// Get all databases
const databases = pcdManager.getAll();
// If there are databases, redirect to the first one
if (databases.length > 0) {
throw redirect(303, `/regular-expressions/${databases[0].id}`);
}
// If no databases, return empty array (page will show empty state)
return {
databases
};
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Database, Plus } from 'lucide-svelte';
import EmptyState from '$ui/state/EmptyState.svelte';
</script>
<svelte:head>
<title>Regular Expressions - Profilarr</title>
</svelte:head>
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to manage regular expressions."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
/>

View File

@@ -0,0 +1,44 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as regularExpressionQueries from '$pcd/queries/regularExpressions/index.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId } = params;
// Validate params exist
if (!databaseId) {
throw error(400, 'Missing database ID');
}
// Get all databases for tabs
const databases = pcdManager.getAll();
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Get the current database instance
const currentDatabase = databases.find((db) => db.id === currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
// Load regular expressions for the current database
const regularExpressions = await regularExpressionQueries.list(cache);
return {
databases,
currentDatabase,
regularExpressions
};
};

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ViewToggle from '$ui/actions/ViewToggle.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import TableView from './views/TableView.svelte';
import CardView from './views/CardView.svelte';
import SearchFilterAction from './components/SearchFilterAction.svelte';
import { createDataPageStore } from '$lib/client/stores/dataPage';
import { browser } from '$app/environment';
import { Info, Plus, FileText, Users } from 'lucide-svelte';
import { goto } from '$app/navigation';
import type { RegularExpressionTableRow } from '$pcd/queries/regularExpressions';
import type { PageData } from './$types';
export let data: PageData;
let infoModalOpen = false;
const SEARCH_FILTER_STORAGE_KEY = 'regularExpressionsSearchFilter';
// Default search filter options - everything except description
const defaultSearchOptions = [
{ key: 'name', label: 'Name', enabled: true },
{ key: 'tags', label: 'Tags', enabled: true },
{ key: 'pattern', label: 'Pattern', enabled: true },
{ key: 'description', label: 'Description', enabled: false },
{ key: 'regex101_id', label: 'Regex101 ID', enabled: true }
];
// Load saved preferences from localStorage or use defaults
function loadSearchOptions() {
if (!browser) return defaultSearchOptions;
try {
const saved = localStorage.getItem(SEARCH_FILTER_STORAGE_KEY);
if (saved) {
const savedMap = new Map(JSON.parse(saved) as [string, boolean][]);
return defaultSearchOptions.map((opt) => ({
...opt,
enabled: savedMap.has(opt.key) ? savedMap.get(opt.key)! : opt.enabled
}));
}
} catch {
// Ignore parse errors, use defaults
}
return defaultSearchOptions;
}
let searchOptions = loadSearchOptions();
// Save to localStorage when options change
$: if (browser) {
const enabledMap = searchOptions.map((opt) => [opt.key, opt.enabled] as [string, boolean]);
localStorage.setItem(SEARCH_FILTER_STORAGE_KEY, JSON.stringify(enabledMap));
}
// Initialize data page store (we'll use search and view, but do our own filtering)
const { search, view, setItems } = createDataPageStore(data.regularExpressions, {
storageKey: 'regularExpressionsView',
searchKeys: ['name'] // Placeholder, we do our own filtering
});
// Extract the debounced query store for reactive access
const debouncedQuery = search.debouncedQuery;
// Update items when data changes (e.g., switching databases)
$: setItems(data.regularExpressions);
// Custom filtering based on selected search options
$: filtered = filterExpressions(data.regularExpressions, $debouncedQuery, searchOptions);
function filterExpressions(
items: RegularExpressionTableRow[],
query: string,
options: typeof searchOptions
): RegularExpressionTableRow[] {
if (!query) return items;
const queryLower = query.toLowerCase();
const enabledKeys = options.filter((o) => o.enabled).map((o) => o.key);
return items.filter((item) => {
return enabledKeys.some((key) => {
if (key === 'tags') {
// Search within tag names
return item.tags.some((tag) => tag.name.toLowerCase().includes(queryLower));
}
const value = item[key as keyof RegularExpressionTableRow];
if (value == null) return false;
return String(value).toLowerCase().includes(queryLower);
});
});
}
// Map databases to tabs
$: tabs = data.databases.map((db) => ({
label: db.name,
href: `/regular-expressions/${db.id}`,
active: db.id === data.currentDatabase.id
}));
</script>
<svelte:head>
<title>Regular Expressions - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6 p-8">
<!-- Tabs -->
<Tabs {tabs} />
<!-- Actions Bar -->
<ActionsBar>
<SearchAction searchStore={search} placeholder="Search regular expressions..." />
<ActionButton icon={Plus} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown">
<Dropdown position="right">
<DropdownItem
icon={FileText}
label="Blank"
on:click={() => goto(`/regular-expressions/${data.currentDatabase.id}/new`)}
/>
<DropdownItem
icon={Users}
label="Release Group"
on:click={() => goto(`/regular-expressions/${data.currentDatabase.id}/new?preset=release-group`)}
/>
</Dropdown>
</svelte:fragment>
</ActionButton>
<SearchFilterAction bind:options={searchOptions} />
<ViewToggle bind:value={$view} />
<ActionButton icon={Info} on:click={() => (infoModalOpen = true)} />
</ActionsBar>
<!-- Regular Expressions Content -->
<div class="mt-6">
{#if data.regularExpressions.length === 0}
<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-600 dark:text-neutral-400">
No regular expressions found for {data.currentDatabase.name}
</p>
</div>
{:else if filtered.length === 0}
<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-600 dark:text-neutral-400">
No regular expressions match your search
</p>
</div>
{:else if $view === 'table'}
<TableView expressions={filtered} />
{:else}
<CardView expressions={filtered} />
{/if}
</div>
</div>
<!-- Info Modal -->
<InfoModal bind:open={infoModalOpen} header="About Regular Expressions">
<div class="space-y-4 text-sm text-neutral-700 dark:text-neutral-300">
<section>
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">How It Works</h3>
<p>
Regular expressions in Profilarr are separated from custom formats to make them reusable.
When multiple custom formats share the same pattern, you only need to update it in one place.
</p>
<p class="mt-2">
When custom formats are synced to your Arr instances, Profilarr compiles the referenced
patterns into the format each Arr expects. The regular expressions themselves are
<strong>not</strong> synced directly—only the compiled custom formats are.
</p>
</section>
<section>
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">Regex Flavor</h3>
<p>
Radarr and Sonarr use the <strong>.NET regex engine</strong> (specifically .NET 6+).
Patterns are matched case-insensitively by default.
</p>
</section>
<section>
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">Testing Patterns</h3>
<p>
Use <a
href="https://regex101.com"
target="_blank"
rel="noopener noreferrer"
class="text-accent-600 hover:underline dark:text-accent-400">regex101.com</a
> to test your patterns. Make sure to select the <strong>.NET</strong> flavor from the dropdown
for accurate results.
</p>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Tip: When saving a regex101 link, include the version number (e.g., <code class="rounded bg-neutral-100 px-1 dark:bg-neutral-800">ABC123/1</code>)
to ensure it always points to your specific version.
</p>
</section>
</div>
</InfoModal>

View File

@@ -0,0 +1,181 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as regularExpressionQueries from '$pcd/queries/regularExpressions/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
throw error(400, 'Missing parameters');
}
const currentDatabaseId = parseInt(databaseId, 10);
const regexId = parseInt(id, 10);
if (isNaN(currentDatabaseId) || isNaN(regexId)) {
throw error(400, 'Invalid parameters');
}
const currentDatabase = pcdManager.getById(currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
const regularExpression = await regularExpressionQueries.get(cache, regexId);
if (!regularExpression) {
throw error(404, 'Regular expression not found');
}
return {
currentDatabase,
regularExpression,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
const regexId = parseInt(id, 10);
if (isNaN(currentDatabaseId) || isNaN(regexId)) {
return fail(400, { error: 'Invalid parameters' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
// Get current regular expression for value guards
const current = await regularExpressionQueries.get(cache, regexId);
if (!current) {
return fail(404, { error: 'Regular expression not found' });
}
const formData = await request.formData();
// Parse form data
const name = formData.get('name') as string;
const tagsJson = formData.get('tags') as string;
const pattern = formData.get('pattern') as string;
const description = (formData.get('description') as string) || null;
const regex101Id = (formData.get('regex101Id') as string) || null;
const layer = (formData.get('layer') as OperationLayer) || 'user';
// Validate
if (!name?.trim()) {
return fail(400, { error: 'Name is required' });
}
if (!pattern?.trim()) {
return fail(400, { error: 'Pattern is required' });
}
let tags: string[] = [];
try {
tags = JSON.parse(tagsJson || '[]');
} catch {
return fail(400, { error: 'Invalid tags format' });
}
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Update the regular expression
const result = await regularExpressionQueries.update({
databaseId: currentDatabaseId,
cache,
layer,
current,
input: {
name: name.trim(),
pattern: pattern.trim(),
tags,
description: description?.trim() || null,
regex101Id: regex101Id?.trim() || null
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to update regular expression' });
}
throw redirect(303, `/regular-expressions/${databaseId}`);
},
delete: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
const regexId = parseInt(id, 10);
if (isNaN(currentDatabaseId) || isNaN(regexId)) {
return fail(400, { error: 'Invalid parameters' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
// Get current regular expression for value guards
const current = await regularExpressionQueries.get(cache, regexId);
if (!current) {
return fail(404, { error: 'Regular expression not found' });
}
const formData = await request.formData();
const layerFromForm = formData.get('layer');
const layer = (layerFromForm as OperationLayer) || 'user';
await logger.debug('Delete action received', {
source: 'RegularExpressionDelete',
meta: {
regexId,
regexName: current.name,
layerFromForm,
layerUsed: layer
}
});
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
const result = await regularExpressionQueries.remove({
databaseId: currentDatabaseId,
cache,
layer,
current
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to delete regular expression' });
}
throw redirect(303, `/regular-expressions/${databaseId}`);
}
};

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import RegularExpressionForm from '../components/RegularExpressionForm.svelte';
import type { PageData } from './$types';
export let data: PageData;
// Form state initialized from data
let name = data.regularExpression.name;
let tags = data.regularExpression.tags.map((t) => t.name);
let pattern = data.regularExpression.pattern;
let description = data.regularExpression.description ?? '';
let regex101Id = data.regularExpression.regex101_id ?? '';
function handleCancel() {
goto(`/regular-expressions/${data.currentDatabase.id}`);
}
</script>
<svelte:head>
<title>{data.regularExpression.name} - Regular Expressions - Profilarr</title>
</svelte:head>
<RegularExpressionForm
mode="edit"
databaseName={data.currentDatabase.name}
canWriteToBase={data.canWriteToBase}
actionUrl="?/update"
bind:name
bind:tags
bind:pattern
bind:description
bind:regex101Id
onCancel={handleCancel}
/>

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ExternalLink, Check, X, AlertCircle } from 'lucide-svelte';
import type { Regex101UnitTest } from '../../../api/regex101/[id]/+server';
// Props
export let pattern: string = '';
export let regex101Id: string = '';
// Internal state
let unitTests: Regex101UnitTest[] = [];
let loading = false;
let error: string | null = null;
let lastFetchedId: string | null = null;
// Build regex101 URL
$: regex101Url = regex101Id ? `https://regex101.com/r/${regex101Id}` : '';
// Fetch unit tests when regex101Id changes
$: if (regex101Id && regex101Id !== lastFetchedId) {
fetchUnitTests(regex101Id);
} else if (!regex101Id) {
unitTests = [];
error = null;
lastFetchedId = null;
}
async function fetchUnitTests(id: string) {
if (!id.trim()) {
unitTests = [];
error = null;
return;
}
loading = true;
error = null;
try {
const response = await fetch(`/api/regex101/${encodeURIComponent(id)}`);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || `Failed to fetch: ${response.statusText}`);
}
const data = await response.json();
unitTests = data.unitTests || [];
lastFetchedId = id;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fetch unit tests';
unitTests = [];
} finally {
loading = false;
}
}
// Skeleton data for loading state
const skeletonTests = Array.from({ length: 3 }, (_, i) => ({ id: i }));
</script>
<div class="space-y-4">
<!-- Regex Pattern -->
<div>
<label for="pattern" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Regular Expression <span class="text-red-500">*</span>
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Uses .NET regex flavor (case-insensitive by default)
</p>
<textarea
id="pattern"
name="pattern"
bind:value={pattern}
rows="3"
placeholder="e.g., \b(SPARKS)\b"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
></textarea>
</div>
<!-- Regex101 ID -->
<div>
<label for="regex101Id" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Regex101 ID
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Link to regex101.com for testing (include version, e.g., ABC123/1)
</p>
<div class="mt-1 flex gap-2">
<input
type="text"
id="regex101Id"
name="regex101Id"
bind:value={regex101Id}
placeholder="e.g., GMV8jd/1"
class="block flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
{#if regex101Url}
<a
href={regex101Url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-accent-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-accent-400 dark:hover:bg-neutral-700"
>
<ExternalLink size={14} />
Test
</a>
{/if}
</div>
</div>
<!-- Unit Tests Section -->
{#if regex101Id}
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-700 dark:bg-neutral-800/50">
<h4 class="mb-3 text-sm font-medium text-neutral-700 dark:text-neutral-300">
Unit Tests
{#if !loading && unitTests.length > 0}
<span class="ml-1 text-xs font-normal text-neutral-500">({unitTests.length})</span>
{/if}
</h4>
{#if loading}
<!-- Skeleton Loading -->
<div class="space-y-2">
{#each skeletonTests as skeleton (skeleton.id)}
<div class="flex items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 dark:border-neutral-700 dark:bg-neutral-900">
<div class="h-5 w-5 rounded bg-neutral-200 dark:bg-neutral-700 animate-pulse"></div>
<div class="flex-1 space-y-1.5">
<div class="h-3 w-24 rounded bg-neutral-200 dark:bg-neutral-700 animate-pulse"></div>
<div class="h-4 w-48 rounded bg-neutral-100 dark:bg-neutral-800 animate-pulse"></div>
</div>
<div class="h-5 w-16 rounded-full bg-neutral-200 dark:bg-neutral-700 animate-pulse"></div>
</div>
{/each}
</div>
{:else if error}
<!-- Error State -->
<div class="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
<AlertCircle size={16} />
<span>{error}</span>
</div>
{:else if unitTests.length === 0}
<!-- No Tests -->
<p class="text-sm text-neutral-500 dark:text-neutral-400">
No unit tests found for this regex.
</p>
{:else}
<!-- Unit Tests List -->
<div class="space-y-2">
{#each unitTests as test, idx (idx)}
<div class="flex items-start gap-3 rounded-lg border border-neutral-200 bg-white p-3 dark:border-neutral-700 dark:bg-neutral-900">
<!-- Pass/Fail indicator -->
<div class="mt-0.5 flex-shrink-0">
{#if test.passed === undefined}
<!-- No test result yet -->
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800">
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-500">?</span>
</div>
{:else if test.passed}
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check size={12} class="text-green-600 dark:text-green-400" />
</div>
{:else}
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={12} class="text-red-600 dark:text-red-400" />
</div>
{/if}
</div>
<!-- Test content -->
<div class="min-w-0 flex-1">
{#if test.description}
<p class="text-xs text-neutral-500 dark:text-neutral-400">{test.description}</p>
{/if}
<code class="mt-1 block truncate font-mono text-xs text-neutral-900 dark:text-neutral-100">
{test.testString}
</code>
</div>
<!-- Expected behavior badge -->
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium
{test.criteria === 'DOES_MATCH'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'}">
{test.criteria === 'DOES_MATCH' ? 'Should Match' : "Shouldn't Match"}
</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,265 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import TagInput from '$ui/form/TagInput.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import RegexPatternField from './RegexPatternField.svelte';
import { alertStore } from '$alerts/store';
import { Save, Trash2, Loader2 } from 'lucide-svelte';
// Props
export let mode: 'create' | 'edit';
export let databaseName: string;
export let canWriteToBase: boolean = false;
export let actionUrl: string = '';
// Form data
export let name: string = '';
export let tags: string[] = [];
export let pattern: string = '';
export let description: string = '';
export let regex101Id: string = '';
// Event handlers
export let onCancel: () => void;
// Loading states
let saving = false;
let deleting = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
// Modal states
let showSaveTargetModal = false;
let showDeleteTargetModal = false;
let mainFormElement: HTMLFormElement;
let deleteFormElement: HTMLFormElement;
// Delete layer selection
let deleteLayer: 'user' | 'base' = 'user';
// Display text based on mode
$: title = mode === 'create' ? 'New Regular Expression' : 'Edit Regular Expression';
$: description_ =
mode === 'create'
? `Create a new regular expression for ${databaseName}`
: `Update regular expression settings`;
$: submitButtonText = mode === 'create' ? 'Create' : 'Save Changes';
$: isValid = name.trim() !== '' && pattern.trim() !== '';
async function handleSaveClick() {
if (canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
mainFormElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
mainFormElement?.requestSubmit();
}
async function handleDeleteClick() {
if (canWriteToBase) {
showDeleteTargetModal = true;
} else {
deleteLayer = 'user';
await tick();
deleteFormElement?.requestSubmit();
}
}
async function handleDeleteLayerSelect(event: CustomEvent<'user' | 'base'>) {
deleteLayer = event.detail;
showDeleteTargetModal = false;
await tick();
deleteFormElement?.requestSubmit();
}
</script>
<div class="space-y-8 p-8">
<!-- Header -->
<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
bind:this={mainFormElement}
method="POST"
action={actionUrl}
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed');
} else if (result.type === 'redirect') {
alertStore.add('success', mode === 'create' ? 'Regular expression created!' : 'Regular expression updated!');
}
await update();
saving = false;
};
}}
>
<!-- Hidden fields for form data -->
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
<input type="hidden" name="layer" value={selectedLayer} />
<!-- Basic Info Section -->
<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">
Basic Info
</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}
placeholder="e.g., Release Group - SPARKS"
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-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
</div>
<!-- Tags -->
<div>
<div class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Tags
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Categorize this pattern for easier filtering
</p>
<div class="mt-2">
<TagInput bind:tags placeholder="Add tags..." />
</div>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Description
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Supports markdown formatting
</p>
<textarea
id="description"
name="description"
bind:value={description}
rows="3"
placeholder="What does this pattern match?"
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-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
></textarea>
</div>
</div>
</div>
<!-- Pattern Section -->
<div class="mt-8 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">
Pattern
</h2>
<RegexPatternField bind:pattern bind:regex101Id />
</div>
<!-- Actions -->
<div class="mt-8 flex flex-wrap items-center justify-between gap-3">
<!-- Left side: Delete (only in edit mode) -->
<div>
{#if mode === 'edit'}
<button
type="button"
on:click={handleDeleteClick}
class="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} />
Delete
</button>
{/if}
</div>
<!-- Right side: Cancel and Save -->
<div class="flex gap-3">
<button
type="button"
on:click={onCancel}
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
</button>
<button
type="button"
disabled={saving || !isValid}
on:click={handleSaveClick}
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if saving}
<Loader2 size={14} class="animate-spin" />
{mode === 'create' ? 'Creating...' : 'Saving...'}
{:else}
<Save size={14} />
{submitButtonText}
{/if}
</button>
</div>
</div>
</form>
<!-- Hidden delete form -->
{#if mode === 'edit'}
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
deleting = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to delete');
} else if (result.type === 'redirect') {
alertStore.add('success', 'Regular expression deleted');
}
await update();
deleting = false;
};
}}
>
<input type="hidden" name="layer" value={deleteLayer} />
</form>
{/if}
</div>
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
<!-- Delete Target Modal -->
<SaveTargetModal
open={showDeleteTargetModal}
mode="delete"
on:select={handleDeleteLayerSelect}
on:cancel={() => (showDeleteTargetModal = false)}
/>
{/if}

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { Binoculars, Check } from 'lucide-svelte';
import { fly } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
export let options: { key: string; label: string; enabled: boolean }[] = [];
const dispatch = createEventDispatcher<{ change: { key: string; enabled: boolean }[] }>();
let isHovered = false;
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
function handleMouseEnter() {
if (leaveTimer) {
clearTimeout(leaveTimer);
leaveTimer = null;
}
isHovered = true;
}
function handleMouseLeave() {
leaveTimer = setTimeout(() => {
isHovered = false;
}, 100);
}
function toggleOption(key: string) {
options = options.map((opt) =>
opt.key === key ? { ...opt, enabled: !opt.enabled } : opt
);
dispatch('change', options);
}
$: enabledCount = options.filter((o) => o.enabled).length;
</script>
<div
class="relative flex"
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
role="group"
>
<button
class="flex h-10 w-10 items-center justify-center border border-neutral-200 bg-white transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<div class="relative">
<Binoculars size={20} class="text-neutral-700 dark:text-neutral-300" />
{#if enabledCount < options.length}
<div
class="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-accent-600 text-[9px] font-bold text-white"
>
{enabledCount}
</div>
{/if}
</div>
</button>
{#if isHovered}
<div class="z-50" transition:fly={{ y: -8, duration: 150 }}>
<div class="absolute top-full z-40 h-3 w-full"></div>
<div
class="absolute right-0 top-full z-50 mt-3 min-w-48 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<div class="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Search in...
</div>
{#each options as option}
<button
class="flex w-full items-center gap-3 border-t border-neutral-200 px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-700"
on:click={() => toggleOption(option.key)}
>
<div
class="flex h-4 w-4 items-center justify-center rounded border {option.enabled
? 'border-accent-600 bg-accent-600 dark:border-accent-500 dark:bg-accent-500'
: 'border-neutral-300 dark:border-neutral-600'}"
>
{#if option.enabled}
<Check size={12} class="text-white" />
{/if}
</div>
<span class="text-neutral-700 dark:text-neutral-300">{option.label}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,124 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as regularExpressionQueries from '$pcd/queries/regularExpressions/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
export const load: ServerLoad = ({ params, url }) => {
const { databaseId } = params;
if (!databaseId) {
throw error(400, 'Missing database ID');
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
const currentDatabase = pcdManager.getById(currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
// Get preset from query params
const preset = url.searchParams.get('preset');
// Define preset data
let presetData = {
name: '',
tags: [] as string[],
pattern: '',
description: '',
regex101Id: ''
};
if (preset === 'release-group') {
presetData = {
name: '',
tags: ['Release Group'],
pattern: '(?<=^|[\\s.-])<group>\\b',
description: 'Matches "<group>" when preceded by whitespace, a hyphen or dot',
regex101Id: ''
};
}
return {
currentDatabase,
canWriteToBase: canWriteToBase(currentDatabaseId),
preset: presetData
};
};
export const actions: Actions = {
default: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
return fail(400, { error: 'Invalid database ID' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
const formData = await request.formData();
// Parse form data
const name = formData.get('name') as string;
const tagsJson = formData.get('tags') as string;
const pattern = formData.get('pattern') as string;
const description = (formData.get('description') as string) || null;
const regex101Id = (formData.get('regex101Id') as string) || null;
const layerFromForm = formData.get('layer');
const layer = (layerFromForm as OperationLayer) || 'user';
// Validate
if (!name?.trim()) {
return fail(400, { error: 'Name is required' });
}
if (!pattern?.trim()) {
return fail(400, { error: 'Pattern is required' });
}
let tags: string[] = [];
try {
tags = JSON.parse(tagsJson || '[]');
} catch {
return fail(400, { error: 'Invalid tags format' });
}
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Create the regular expression
const result = await regularExpressionQueries.create({
databaseId: currentDatabaseId,
cache,
layer,
input: {
name: name.trim(),
pattern: pattern.trim(),
tags,
description: description?.trim() || null,
regex101Id: regex101Id?.trim() || null
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to create regular expression' });
}
throw redirect(303, `/regular-expressions/${databaseId}`);
}
};

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { goto } from '$app/navigation';
import RegularExpressionForm from '../components/RegularExpressionForm.svelte';
import type { PageData } from './$types';
export let data: PageData;
// Form state - initialize from preset data
let name = data.preset.name;
let tags: string[] = data.preset.tags;
let pattern = data.preset.pattern;
let description = data.preset.description;
let regex101Id = data.preset.regex101Id;
function handleCancel() {
goto(`/regular-expressions/${data.currentDatabase.id}`);
}
</script>
<svelte:head>
<title>New Regular Expression - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<RegularExpressionForm
mode="create"
databaseName={data.currentDatabase.name}
canWriteToBase={data.canWriteToBase}
bind:name
bind:tags
bind:pattern
bind:description
bind:regex101Id
onCancel={handleCancel}
/>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import type { RegularExpressionTableRow } from '$pcd/queries/regularExpressions';
import { ExternalLink } from 'lucide-svelte';
import { marked } from 'marked';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let expressions: RegularExpressionTableRow[];
function handleCardClick(expression: RegularExpressionTableRow) {
const databaseId = $page.params.databaseId;
goto(`/regular-expressions/${databaseId}/${expression.id}`);
}
// Configure marked for inline parsing (no wrapping <p> tags for short text)
function parseMarkdown(text: string | null): string {
if (!text) return '';
return marked.parseInline(text) as string;
}
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each expressions as expression}
<button
type="button"
on:click={() => handleCardClick(expression)}
class="group relative flex flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-all hover:border-neutral-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700 cursor-pointer"
>
<!-- Header with name and tags -->
<div>
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{expression.name}
</h3>
{#if expression.regex101_id}
<a
href="https://regex101.com/r/{expression.regex101_id}"
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0 text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300"
title="View on regex101"
on:click|stopPropagation
>
<ExternalLink size={14} />
</a>
{/if}
</div>
{#if expression.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each expression.tags as tag}
<span
class="inline-flex items-center rounded px-1.5 py-0.5 font-mono text-[10px] bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Pattern -->
<div class="overflow-hidden rounded bg-neutral-100 p-2 dark:bg-neutral-800">
<code class="block truncate font-mono text-xs text-neutral-900 dark:text-neutral-100">
{expression.pattern}
</code>
</div>
<!-- Description -->
{#if expression.description}
<div class="text-xs text-neutral-600 dark:text-neutral-400 line-clamp-2 prose-inline">
{@html parseMarkdown(expression.description)}
</div>
{/if}
</button>
{/each}
</div>
<style>
/* Inline prose styles for markdown content */
:global(.prose-inline code) {
background-color: rgb(229 231 235);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
:global(.dark .prose-inline code) {
background-color: rgb(38 38 38);
}
:global(.prose-inline strong) {
font-weight: 600;
}
:global(.prose-inline a) {
color: rgb(var(--color-accent-600));
text-decoration: underline;
}
:global(.dark .prose-inline a) {
color: rgb(var(--color-accent-400));
}
</style>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
import type { RegularExpressionTableRow } from '$pcd/queries/regularExpressions';
import { Tag, Code, FileText, Link } from 'lucide-svelte';
import { marked } from 'marked';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let expressions: RegularExpressionTableRow[];
function handleRowClick(row: RegularExpressionTableRow) {
const databaseId = $page.params.databaseId;
goto(`/regular-expressions/${databaseId}/${row.id}`);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function parseMarkdown(text: string | null): string {
if (!text) return '';
return marked.parseInline(text) as string;
}
const columns: Column<RegularExpressionTableRow>[] = [
{
key: 'name',
header: 'Name',
headerIcon: Tag,
align: 'left',
sortable: true,
width: 'w-48',
cell: (row: RegularExpressionTableRow) => ({
html: `
<div>
<div class="font-medium">${escapeHtml(row.name)}</div>
${
row.tags.length > 0
? `
<div class="mt-1 flex flex-wrap gap-1">
${row.tags
.map(
(tag) => `
<span class="inline-flex items-center px-2 py-0.5 rounded font-mono text-[10px] bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200">
${escapeHtml(tag.name)}
</span>
`
)
.join('')}
</div>
`
: ''
}
</div>
`
})
},
{
key: 'pattern',
header: 'Pattern',
headerIcon: Code,
align: 'left',
cell: (row: RegularExpressionTableRow) => ({
html: `<code class="font-mono text-xs bg-neutral-100 dark:bg-neutral-800 px-2 py-1 rounded break-all">${escapeHtml(row.pattern)}</code>`
})
},
{
key: 'description',
header: 'Description',
headerIcon: FileText,
align: 'left',
cell: (row: RegularExpressionTableRow) => ({
html: row.description
? `<span class="text-sm text-neutral-600 dark:text-neutral-400 prose-inline">${parseMarkdown(row.description)}</span>`
: `<span class="text-neutral-400">-</span>`
})
},
{
key: 'regex101_id',
header: 'Regex101',
headerIcon: Link,
align: 'left',
width: 'w-32',
cell: (row: RegularExpressionTableRow) => ({
html: row.regex101_id
? `<a href="https://regex101.com/r/${escapeHtml(row.regex101_id)}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 font-mono text-xs text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 hover:underline">${escapeHtml(row.regex101_id)}<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></a>`
: `<span class="text-neutral-400">-</span>`
})
}
];
</script>
<Table data={expressions} {columns} emptyMessage="No regular expressions found" hoverable={true} compact={false} onRowClick={handleRowClick} />
<style>
/* Inline prose styles for markdown content */
:global(.prose-inline code) {
background-color: rgb(229 231 235);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
:global(.dark .prose-inline code) {
background-color: rgb(38 38 38);
}
:global(.prose-inline strong) {
font-weight: 600;
}
:global(.prose-inline a) {
color: rgb(var(--color-accent-600));
text-decoration: underline;
}
:global(.dark .prose-inline a) {
color: rgb(var(--color-accent-400));
}
</style>