From 7e7561e35a70563cd8e44e328359b77deb4439b0 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 29 Dec 2025 21:06:49 +1030 Subject: [PATCH] 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. --- .../pcd/queries/regularExpressions/create.ts | 80 ++++++ .../pcd/queries/regularExpressions/delete.ts | 69 +++++ .../pcd/queries/regularExpressions/get.ts | 37 +++ .../pcd/queries/regularExpressions/index.ts | 17 ++ .../pcd/queries/regularExpressions/list.ts | 63 +++++ .../pcd/queries/regularExpressions/types.ts | 15 + .../pcd/queries/regularExpressions/update.ts | 118 ++++++++ src/lib/server/regex/test.ps1 | 47 ++++ src/routes/api/regex101/[id]/+server.ts | 146 ++++++++++ .../regular-expressions/+page.server.ts | 18 ++ src/routes/regular-expressions/+page.svelte | 17 ++ .../[databaseId]/+page.server.ts | 44 +++ .../[databaseId]/+page.svelte | 206 ++++++++++++++ .../[databaseId]/[id]/+page.server.ts | 181 ++++++++++++ .../[databaseId]/[id]/+page.svelte | 35 +++ .../components/RegexPatternField.svelte | 192 +++++++++++++ .../components/RegularExpressionForm.svelte | 265 ++++++++++++++++++ .../components/SearchFilterAction.svelte | 87 ++++++ .../[databaseId]/new/+page.server.ts | 124 ++++++++ .../[databaseId]/new/+page.svelte | 34 +++ .../[databaseId]/views/CardView.svelte | 104 +++++++ .../[databaseId]/views/TableView.svelte | 127 +++++++++ 22 files changed, 2026 insertions(+) create mode 100644 src/lib/server/pcd/queries/regularExpressions/create.ts create mode 100644 src/lib/server/pcd/queries/regularExpressions/delete.ts create mode 100644 src/lib/server/pcd/queries/regularExpressions/get.ts create mode 100644 src/lib/server/pcd/queries/regularExpressions/index.ts create mode 100644 src/lib/server/pcd/queries/regularExpressions/list.ts create mode 100644 src/lib/server/pcd/queries/regularExpressions/types.ts create mode 100644 src/lib/server/pcd/queries/regularExpressions/update.ts create mode 100644 src/lib/server/regex/test.ps1 create mode 100644 src/routes/api/regex101/[id]/+server.ts create mode 100644 src/routes/regular-expressions/+page.server.ts create mode 100644 src/routes/regular-expressions/+page.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/+page.server.ts create mode 100644 src/routes/regular-expressions/[databaseId]/+page.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/[id]/+page.server.ts create mode 100644 src/routes/regular-expressions/[databaseId]/[id]/+page.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/components/RegexPatternField.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/components/RegularExpressionForm.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/components/SearchFilterAction.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/new/+page.server.ts create mode 100644 src/routes/regular-expressions/[databaseId]/new/+page.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/views/CardView.svelte create mode 100644 src/routes/regular-expressions/[databaseId]/views/TableView.svelte diff --git a/src/lib/server/pcd/queries/regularExpressions/create.ts b/src/lib/server/pcd/queries/regularExpressions/create.ts new file mode 100644 index 0000000..22d64e2 --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/create.ts @@ -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; +} diff --git a/src/lib/server/pcd/queries/regularExpressions/delete.ts b/src/lib/server/pcd/queries/regularExpressions/delete.ts new file mode 100644 index 0000000..ad5b6ac --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/delete.ts @@ -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; +} diff --git a/src/lib/server/pcd/queries/regularExpressions/get.ts b/src/lib/server/pcd/queries/regularExpressions/get.ts new file mode 100644 index 0000000..072af88 --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/get.ts @@ -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 { + 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 + }; +} diff --git a/src/lib/server/pcd/queries/regularExpressions/index.ts b/src/lib/server/pcd/queries/regularExpressions/index.ts new file mode 100644 index 0000000..c49fcff --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/index.ts @@ -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'; diff --git a/src/lib/server/pcd/queries/regularExpressions/list.ts b/src/lib/server/pcd/queries/regularExpressions/list.ts new file mode 100644 index 0000000..156e0b2 --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/list.ts @@ -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 { + 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(); + 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) || [] + })); +} diff --git a/src/lib/server/pcd/queries/regularExpressions/types.ts b/src/lib/server/pcd/queries/regularExpressions/types.ts new file mode 100644 index 0000000..bd4e3a5 --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/types.ts @@ -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[]; +} diff --git a/src/lib/server/pcd/queries/regularExpressions/update.ts b/src/lib/server/pcd/queries/regularExpressions/update.ts new file mode 100644 index 0000000..b6c158c --- /dev/null +++ b/src/lib/server/pcd/queries/regularExpressions/update.ts @@ -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; +} diff --git a/src/lib/server/regex/test.ps1 b/src/lib/server/regex/test.ps1 new file mode 100644 index 0000000..d2fd42f --- /dev/null +++ b/src/lib/server/regex/test.ps1 @@ -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 +} diff --git a/src/routes/api/regex101/[id]/+server.ts b/src/routes/api/regex101/[id]/+server.ts new file mode 100644 index 0000000..cbd06ab --- /dev/null +++ b/src/routes/api/regex101/[id]/+server.ts @@ -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 { + 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) => ({ + 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'}`); + } +}; diff --git a/src/routes/regular-expressions/+page.server.ts b/src/routes/regular-expressions/+page.server.ts new file mode 100644 index 0000000..ab41433 --- /dev/null +++ b/src/routes/regular-expressions/+page.server.ts @@ -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 + }; +}; diff --git a/src/routes/regular-expressions/+page.svelte b/src/routes/regular-expressions/+page.svelte new file mode 100644 index 0000000..262f530 --- /dev/null +++ b/src/routes/regular-expressions/+page.svelte @@ -0,0 +1,17 @@ + + + + Regular Expressions - Profilarr + + + diff --git a/src/routes/regular-expressions/[databaseId]/+page.server.ts b/src/routes/regular-expressions/[databaseId]/+page.server.ts new file mode 100644 index 0000000..b6a4137 --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/+page.server.ts @@ -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 + }; +}; diff --git a/src/routes/regular-expressions/[databaseId]/+page.svelte b/src/routes/regular-expressions/[databaseId]/+page.svelte new file mode 100644 index 0000000..7106c12 --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/+page.svelte @@ -0,0 +1,206 @@ + + + + Regular Expressions - {data.currentDatabase.name} - Profilarr + + +
+ + + + + + + + + + goto(`/regular-expressions/${data.currentDatabase.id}/new`)} + /> + goto(`/regular-expressions/${data.currentDatabase.id}/new?preset=release-group`)} + /> + + + + + + (infoModalOpen = true)} /> + + + +
+ {#if data.regularExpressions.length === 0} +
+

+ No regular expressions found for {data.currentDatabase.name} +

+
+ {:else if filtered.length === 0} +
+

+ No regular expressions match your search +

+
+ {:else if $view === 'table'} + + {:else} + + {/if} +
+
+ + + +
+
+

How It Works

+

+ 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. +

+

+ 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 + not synced directly—only the compiled custom formats are. +

+
+ +
+

Regex Flavor

+

+ Radarr and Sonarr use the .NET regex engine (specifically .NET 6+). + Patterns are matched case-insensitively by default. +

+
+ +
+

Testing Patterns

+

+ Use regex101.com to test your patterns. Make sure to select the .NET flavor from the dropdown + for accurate results. +

+

+ Tip: When saving a regex101 link, include the version number (e.g., ABC123/1) + to ensure it always points to your specific version. +

+
+
+
diff --git a/src/routes/regular-expressions/[databaseId]/[id]/+page.server.ts b/src/routes/regular-expressions/[databaseId]/[id]/+page.server.ts new file mode 100644 index 0000000..75534a1 --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/[id]/+page.server.ts @@ -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}`); + } +}; diff --git a/src/routes/regular-expressions/[databaseId]/[id]/+page.svelte b/src/routes/regular-expressions/[databaseId]/[id]/+page.svelte new file mode 100644 index 0000000..557cdb2 --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/[id]/+page.svelte @@ -0,0 +1,35 @@ + + + + {data.regularExpression.name} - Regular Expressions - Profilarr + + + diff --git a/src/routes/regular-expressions/[databaseId]/components/RegexPatternField.svelte b/src/routes/regular-expressions/[databaseId]/components/RegexPatternField.svelte new file mode 100644 index 0000000..4737806 --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/components/RegexPatternField.svelte @@ -0,0 +1,192 @@ + + +
+ +
+ +

+ Uses .NET regex flavor (case-insensitive by default) +

+ +
+ + +
+ +

+ Link to regex101.com for testing (include version, e.g., ABC123/1) +

+
+ + {#if regex101Url} + + + Test + + {/if} +
+
+ + + {#if regex101Id} +
+

+ Unit Tests + {#if !loading && unitTests.length > 0} + ({unitTests.length}) + {/if} +

+ + {#if loading} + +
+ {#each skeletonTests as skeleton (skeleton.id)} +
+
+
+
+
+
+
+
+ {/each} +
+ {:else if error} + +
+ + {error} +
+ {:else if unitTests.length === 0} + +

+ No unit tests found for this regex. +

+ {:else} + +
+ {#each unitTests as test, idx (idx)} +
+ +
+ {#if test.passed === undefined} + +
+ ? +
+ {:else if test.passed} +
+ +
+ {:else} +
+ +
+ {/if} +
+ + +
+ {#if test.description} +

{test.description}

+ {/if} + + {test.testString} + +
+ + + + {test.criteria === 'DOES_MATCH' ? 'Should Match' : "Shouldn't Match"} + +
+ {/each} +
+ {/if} +
+ {/if} +
diff --git a/src/routes/regular-expressions/[databaseId]/components/RegularExpressionForm.svelte b/src/routes/regular-expressions/[databaseId]/components/RegularExpressionForm.svelte new file mode 100644 index 0000000..5854ffd --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/components/RegularExpressionForm.svelte @@ -0,0 +1,265 @@ + + +
+ +
+

{title}

+

+ {description_} +

+
+ +
{ + 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; + }; + }} + > + + + + + +
+

+ Basic Info +

+ +
+ +
+ + +
+ + +
+
+ Tags +
+

+ Categorize this pattern for easier filtering +

+
+ +
+
+ + +
+ +

+ Supports markdown formatting +

+ +
+
+
+ + +
+

+ Pattern +

+ + +
+ + +
+ +
+ {#if mode === 'edit'} + + {/if} +
+ + +
+ + +
+
+
+ + + {#if mode === 'edit'} + + {/if} +
+ + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> + + + (showDeleteTargetModal = false)} + /> +{/if} diff --git a/src/routes/regular-expressions/[databaseId]/components/SearchFilterAction.svelte b/src/routes/regular-expressions/[databaseId]/components/SearchFilterAction.svelte new file mode 100644 index 0000000..e1c59a7 --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/components/SearchFilterAction.svelte @@ -0,0 +1,87 @@ + + +
+ + + {#if isHovered} +
+
+
+
+ Search in... +
+ {#each options as option} + + {/each} +
+
+ {/if} +
diff --git a/src/routes/regular-expressions/[databaseId]/new/+page.server.ts b/src/routes/regular-expressions/[databaseId]/new/+page.server.ts new file mode 100644 index 0000000..c78ac0e --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/new/+page.server.ts @@ -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.-])\\b', + description: 'Matches "" 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}`); + } +}; diff --git a/src/routes/regular-expressions/[databaseId]/new/+page.svelte b/src/routes/regular-expressions/[databaseId]/new/+page.svelte new file mode 100644 index 0000000..3a9f19c --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/new/+page.svelte @@ -0,0 +1,34 @@ + + + + New Regular Expression - {data.currentDatabase.name} - Profilarr + + + diff --git a/src/routes/regular-expressions/[databaseId]/views/CardView.svelte b/src/routes/regular-expressions/[databaseId]/views/CardView.svelte new file mode 100644 index 0000000..1897dde --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/views/CardView.svelte @@ -0,0 +1,104 @@ + + +
+ {#each expressions as expression} + + {/each} +
+ + diff --git a/src/routes/regular-expressions/[databaseId]/views/TableView.svelte b/src/routes/regular-expressions/[databaseId]/views/TableView.svelte new file mode 100644 index 0000000..0b7699b --- /dev/null +++ b/src/routes/regular-expressions/[databaseId]/views/TableView.svelte @@ -0,0 +1,127 @@ + + + + +