mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
80
src/lib/server/pcd/queries/regularExpressions/create.ts
Normal file
80
src/lib/server/pcd/queries/regularExpressions/create.ts
Normal 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;
|
||||
}
|
||||
69
src/lib/server/pcd/queries/regularExpressions/delete.ts
Normal file
69
src/lib/server/pcd/queries/regularExpressions/delete.ts
Normal 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;
|
||||
}
|
||||
37
src/lib/server/pcd/queries/regularExpressions/get.ts
Normal file
37
src/lib/server/pcd/queries/regularExpressions/get.ts
Normal 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
|
||||
};
|
||||
}
|
||||
17
src/lib/server/pcd/queries/regularExpressions/index.ts
Normal file
17
src/lib/server/pcd/queries/regularExpressions/index.ts
Normal 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';
|
||||
63
src/lib/server/pcd/queries/regularExpressions/list.ts
Normal file
63
src/lib/server/pcd/queries/regularExpressions/list.ts
Normal 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) || []
|
||||
}));
|
||||
}
|
||||
15
src/lib/server/pcd/queries/regularExpressions/types.ts
Normal file
15
src/lib/server/pcd/queries/regularExpressions/types.ts
Normal 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[];
|
||||
}
|
||||
118
src/lib/server/pcd/queries/regularExpressions/update.ts
Normal file
118
src/lib/server/pcd/queries/regularExpressions/update.ts
Normal 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;
|
||||
}
|
||||
47
src/lib/server/regex/test.ps1
Normal file
47
src/lib/server/regex/test.ps1
Normal 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
|
||||
}
|
||||
146
src/routes/api/regex101/[id]/+server.ts
Normal file
146
src/routes/api/regex101/[id]/+server.ts
Normal 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'}`);
|
||||
}
|
||||
};
|
||||
18
src/routes/regular-expressions/+page.server.ts
Normal file
18
src/routes/regular-expressions/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
17
src/routes/regular-expressions/+page.svelte
Normal file
17
src/routes/regular-expressions/+page.svelte
Normal 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}
|
||||
/>
|
||||
44
src/routes/regular-expressions/[databaseId]/+page.server.ts
Normal file
44
src/routes/regular-expressions/[databaseId]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
206
src/routes/regular-expressions/[databaseId]/+page.svelte
Normal file
206
src/routes/regular-expressions/[databaseId]/+page.svelte
Normal 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>
|
||||
181
src/routes/regular-expressions/[databaseId]/[id]/+page.server.ts
Normal file
181
src/routes/regular-expressions/[databaseId]/[id]/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
124
src/routes/regular-expressions/[databaseId]/new/+page.server.ts
Normal file
124
src/routes/regular-expressions/[databaseId]/new/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
34
src/routes/regular-expressions/[databaseId]/new/+page.svelte
Normal file
34
src/routes/regular-expressions/[databaseId]/new/+page.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user