feature: regex patterns (#10)

- add new regex patterns, matched using PCRE2, with case insensitivity
- name, description, pattern, tags
- add unit tests, attempt to highlight matches
This commit is contained in:
Sam Chau
2024-11-30 03:09:59 +10:30
committed by Sam Chau
parent 9b1d69014a
commit 6ff0e79a28
20 changed files with 1681 additions and 546 deletions

View File

@@ -4,7 +4,7 @@ import os
import yaml
from .utils import (get_category_directory, load_yaml_file, validate,
save_yaml_file, update_yaml_file, get_file_created_date,
get_file_modified_date)
get_file_modified_date, test_regex_pattern)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@@ -129,3 +129,48 @@ def handle_item(category, name):
except Exception as e:
logger.exception("Unexpected error occurred")
return jsonify({"error": "An unexpected error occurred"}), 500
@bp.route('/<string:category>/test', methods=['POST'])
def run_tests(category):
logger.info(f"Received regex test request for category: {category}")
if category != 'regex_pattern':
logger.warning(f"Rejected test request - invalid category: {category}")
return jsonify({"error":
"Testing only supported for regex patterns"}), 400
try:
data = request.get_json()
if not data:
logger.warning("Rejected test request - no JSON data provided")
return jsonify({"error": "No JSON data provided"}), 400
pattern = data.get('pattern')
tests = data.get('tests', [])
logger.info(f"Processing test request - Pattern: {pattern}")
logger.info(f"Number of test cases: {len(tests)}")
if not pattern:
logger.warning("Rejected test request - missing pattern")
return jsonify({"error": "Pattern is required"}), 400
if not tests:
logger.warning("Rejected test request - no test cases provided")
return jsonify({"error":
"At least one test case is required"}), 400
success, message, updated_tests = test_regex_pattern(pattern, tests)
logger.info(f"Test execution completed - Success: {success}")
if not success:
logger.warning(f"Test execution failed - {message}")
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": True, "tests": updated_tests}), 200
except Exception as e:
logger.warning(f"Unexpected error in test endpoint: {str(e)}",
exc_info=True)
return jsonify({"success": False, "message": str(e)}), 500

View File

@@ -2,9 +2,10 @@ import os
import yaml
import shutil
import logging
from typing import Dict, Any, Tuple
from datetime import datetime
from typing import Dict, List, Any, Tuple, Union
import git
import regex
logger = logging.getLogger(__name__)
@@ -15,7 +16,7 @@ FORMAT_DIR = '/app/data/db/custom_formats'
PROFILE_DIR = '/app/data/db/profiles'
# Expected fields for each category
REGEX_FIELDS = ["name", "pattern", "flags"]
REGEX_FIELDS = ["name", "pattern", "description", "tags", "tests"]
FORMAT_FIELDS = ["name", "format", "description"]
PROFILE_FIELDS = [
"name",
@@ -138,17 +139,29 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
# First save the updated content to the current file
save_yaml_file(file_path, data_to_save, category)
# Then use git mv for the rename
# Check if file is being tracked by git
repo = git.Repo(REPO_PATH)
# Convert to relative paths for git
rel_old_path = os.path.relpath(file_path, REPO_PATH)
rel_new_path = os.path.relpath(new_file_path, REPO_PATH)
try:
repo.git.mv(rel_old_path, rel_new_path)
# Check if file is tracked by git
tracked_files = repo.git.ls_files().splitlines()
is_tracked = rel_old_path in tracked_files
if is_tracked:
# Use git mv for tracked files
repo.git.mv(rel_old_path, rel_new_path)
else:
# For untracked files, manually move
os.rename(file_path, new_file_path)
except git.GitCommandError as e:
logger.error(f"Git mv failed: {e}")
raise Exception("Failed to rename file using git mv")
logger.error(f"Git operation failed: {e}")
raise Exception("Failed to rename file")
except OSError as e:
logger.error(f"File operation failed: {e}")
raise Exception("Failed to rename file")
else:
# Normal update without rename
@@ -163,3 +176,74 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
except Exception as e:
raise
def test_regex_pattern(
pattern: str,
tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]:
"""
Test a regex pattern against a list of test cases using PCRE2 compatible engine.
Args:
pattern: The regex pattern to test
tests: List of test dictionaries with 'input', 'expected', 'id', and 'passes' fields
Returns:
Tuple of (success, message, updated_tests)
"""
logger.info(f"Starting regex pattern test - Pattern: {pattern}")
try:
# Try to compile the regex with PCRE2 compatibility
try:
compiled_pattern = regex.compile(pattern, regex.V1)
logger.info(
"Pattern compiled successfully with PCRE2 compatibility")
except regex.error as e:
logger.warning(f"Invalid regex pattern: {str(e)}")
return False, f"Invalid regex pattern: {str(e)}", tests
current_time = datetime.now().isoformat()
logger.info(f"Processing {len(tests)} test cases")
# Run each test
for test in tests:
test_id = test.get('id', 'unknown')
test_input = test.get('input', '')
expected = test.get('expected', False)
logger.info(
f"Running test {test_id} - Input: {test_input}, Expected: {expected}"
)
try:
# Test if pattern matches input
matches = bool(compiled_pattern.search(test_input))
# Update test result
test['passes'] = matches == expected
test['lastRun'] = current_time
if test['passes']:
logger.info(
f"Test {test_id} passed - Match result: {matches}")
else:
logger.warning(
f"Test {test_id} failed - Expected {expected}, got {matches}"
)
except Exception as e:
logger.warning(f"Error running test {test_id}: {str(e)}")
test['passes'] = False
test['lastRun'] = current_time
# Log overall results
passed_tests = sum(1 for test in tests if test.get('passes', False))
logger.info(
f"Test execution complete - {passed_tests}/{len(tests)} tests passed"
)
return True, "", tests
except Exception as e:
logger.warning(f"Unexpected error in test_regex_pattern: {str(e)}",
exc_info=True)
return False, str(e), tests

View File

@@ -386,27 +386,84 @@ def compare_upgrade_until(local_upgrade, remote_upgrade):
return changes
def compare_generic(local_data, remote_data):
"""Process changes for non-profile files"""
if local_data is None and remote_data is not None:
def process_generic(old_data, new_data):
if old_data is None and new_data is not None:
return [{'key': 'File', 'change': 'added'}]
if local_data is not None and remote_data is None:
if old_data is not None and new_data is None:
return [{'key': 'File', 'change': 'deleted'}]
changes = []
all_keys = set(local_data.keys()).union(set(remote_data.keys()))
all_keys = set(old_data.keys()).union(set(new_data.keys()))
for key in all_keys:
local_value = local_data.get(key)
remote_value = remote_data.get(key)
old_value = old_data.get(key)
new_value = new_data.get(key)
if local_value != remote_value:
changes.append({
'key': key.title(), # Capitalize generic keys
'change': 'modified',
'from': local_value,
'to': remote_value
})
if old_value != new_value:
if key == 'tests':
old_tests = {t['id']: t for t in old_value or []}
new_tests = {t['id']: t for t in new_value or []}
# Handle deleted tests
for test_id in set(old_tests) - set(new_tests):
test = old_tests[test_id]
changes.append({
'key': 'Test',
'change': 'deleted',
'from':
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})',
'to': None
})
# Handle added tests
for test_id in set(new_tests) - set(old_tests):
test = new_tests[test_id]
changes.append({
'key':
'Test',
'change':
'added',
'from':
None,
'to':
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})'
})
# Handle modified tests
for test_id in set(old_tests) & set(new_tests):
if old_tests[test_id] != new_tests[test_id]:
old_test = old_tests[test_id]
new_test = new_tests[test_id]
if old_test['input'] != new_test['input']:
changes.append({
'key':
f'Test #{test_id}',
'change':
'modified',
'from':
f'Input: "{old_test["input"]}"',
'to':
f'Input: "{new_test["input"]}"'
})
if old_test['expected'] != new_test['expected']:
changes.append({
'key':
f'Test #{test_id}',
'change':
'modified',
'from':
f'Expected: {old_test["expected"]}',
'to':
f'Expected: {new_test["expected"]}'
})
else:
changes.append({
'key': key.title(),
'change': 'modified',
'from': old_value,
'to': new_value
})
return changes

View File

@@ -399,7 +399,6 @@ def compare_upgrade_until(ours_upgrade, theirs_upgrade):
def compare_generic(ours_data, theirs_data):
"""Compare generic files for conflicts"""
conflicts = []
all_keys = set(ours_data.keys()).union(set(theirs_data.keys()))
@@ -411,11 +410,63 @@ def compare_generic(ours_data, theirs_data):
theirs_value = theirs_data.get(key)
if ours_value != theirs_value:
conflicts.append({
'parameter': key.title(),
'local_value': ours_value,
'incoming_value': theirs_value
})
if key == 'tests':
ours_tests = {t['id']: t for t in ours_value or []}
theirs_tests = {t['id']: t for t in theirs_value or []}
# Handle deleted tests
for test_id in set(ours_tests) - set(theirs_tests):
conflicts.append({
'parameter': f'Test {test_id}',
'local_value': {
'input': ours_tests[test_id]['input'],
'expected': ours_tests[test_id]['expected']
},
'incoming_value': None
})
# Handle added tests
for test_id in set(theirs_tests) - set(ours_tests):
conflicts.append({
'parameter': f'Test {test_id}',
'local_value': None,
'incoming_value': {
'input': theirs_tests[test_id]['input'],
'expected': theirs_tests[test_id]['expected']
}
})
# Handle modified tests
for test_id in set(ours_tests) & set(theirs_tests):
if ours_tests[test_id] != theirs_tests[test_id]:
ours_test = ours_tests[test_id]
theirs_test = theirs_tests[test_id]
if ours_test['input'] != theirs_test['input']:
conflicts.append({
'parameter':
f'Test {test_id} Input',
'local_value':
ours_test['input'],
'incoming_value':
theirs_test['input']
})
if ours_test['expected'] != theirs_test['expected']:
conflicts.append({
'parameter':
f'Test {test_id} Expected',
'local_value':
ours_test['expected'],
'incoming_value':
theirs_test['expected']
})
else:
conflicts.append({
'parameter': key.title(),
'local_value': ours_value,
'incoming_value': theirs_value
})
return conflicts

View File

@@ -418,10 +418,8 @@ def compare_upgrade_until(old_upgrade, new_upgrade):
def process_generic(old_data, new_data):
"""Process changes for non-profile files"""
if old_data is None and new_data is not None:
return [{'key': 'File', 'change': 'added'}]
if old_data is not None and new_data is None:
return [{'key': 'File', 'change': 'deleted'}]
@@ -433,11 +431,70 @@ def process_generic(old_data, new_data):
new_value = new_data.get(key)
if old_value != new_value:
changes.append({
'key': key.title(), # Capitalize generic keys
'change': 'modified',
'from': old_value,
'to': new_value
})
if key == 'tests':
old_tests = {t['id']: t for t in old_value or []}
new_tests = {t['id']: t for t in new_value or []}
# Handle deleted tests
for test_id in set(old_tests) - set(new_tests):
test = old_tests[test_id]
changes.append({
'key': 'Test',
'change': 'deleted',
'from':
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})',
'to': None
})
# Handle added tests
for test_id in set(new_tests) - set(old_tests):
test = new_tests[test_id]
changes.append({
'key':
'Test',
'change':
'added',
'from':
None,
'to':
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})'
})
# Handle modified tests
for test_id in set(old_tests) & set(new_tests):
if old_tests[test_id] != new_tests[test_id]:
old_test = old_tests[test_id]
new_test = new_tests[test_id]
if old_test['input'] != new_test['input']:
changes.append({
'key':
f'Test #{test_id}',
'change':
'modified',
'from':
f'Input: "{old_test["input"]}"',
'to':
f'Input: "{new_test["input"]}"'
})
if old_test['expected'] != new_test['expected']:
changes.append({
'key':
f'Test #{test_id}',
'change':
'modified',
'from':
f'Expected: {old_test["expected"]}',
'to':
f'Expected: {new_test["expected"]}'
})
else:
changes.append({
'key': key.title(),
'change': 'modified',
'from': old_value,
'to': new_value
})
return changes

View File

@@ -3,4 +3,5 @@ Flask-CORS==3.0.10
PyYAML==5.4.1
requests==2.26.0
Werkzeug==2.0.1
GitPython==3.1.24
GitPython==3.1.24
regex==2023.10.3

View File

@@ -19,8 +19,10 @@ services:
- backend_data:/app/data
environment:
- FLASK_ENV=development
- TZ=Australia/Adelaide # Add this line
env_file:
- .env.1
restart: always
volumes:
backend_data:

View File

@@ -4,16 +4,9 @@ const BASE_URL = '/api/data';
const handleError = (error, operation) => {
console.error(`Error ${operation}:`, error);
if (error.response?.data) {
return {
success: false,
message: error.response.data.error
};
}
return {
success: false,
message: `Failed to ${operation}`
};
const errorMessage =
error.response?.data?.error || `Failed to ${operation}`;
throw new Error(errorMessage); // Throw instead of returning an error object
};
// Get all items for a category
@@ -92,6 +85,22 @@ export const RegexPatterns = {
getAll: () => getAllItems('regex_pattern'),
get: name => getItem('regex_pattern', name),
create: data => createItem('regex_pattern', data),
update: (name, data) => updateItem('regex_pattern', name, data),
delete: name => deleteItem('regex_pattern', name)
update: (name, data, newName) =>
updateItem('regex_pattern', name, data, newName),
delete: name => deleteItem('regex_pattern', name),
// Add this new method
runTests: async (pattern, tests) => {
try {
const response = await axios.post(
`${BASE_URL}/regex_pattern/test`,
{
pattern,
tests
}
);
return response.data;
} catch (error) {
return handleError(error, 'run tests');
}
}
};

View File

@@ -0,0 +1,138 @@
// AddUnitTestModal.jsx
import React, {useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import Modal from '../ui/Modal';
const AddUnitTestModal = ({isOpen, onClose, onAdd, tests, editTest = null}) => {
const [input, setInput] = useState('');
const [shouldMatch, setShouldMatch] = useState(true);
// Reset form when opening modal, handling both new and edit cases
useEffect(() => {
if (isOpen) {
if (editTest) {
setInput(editTest.input);
setShouldMatch(editTest.expected);
} else {
setInput('');
setShouldMatch(true);
}
}
}, [isOpen, editTest]);
const handleSubmit = () => {
const getNextTestId = testArray => {
if (!testArray || testArray.length === 0) return 1;
return Math.max(...testArray.map(test => test.id)) + 1;
};
const testData = {
id: editTest ? editTest.id : getNextTestId(tests),
input,
expected: shouldMatch,
passes: false,
lastRun: null
};
onAdd(testData);
handleClose();
};
const handleClose = () => {
setInput('');
setShouldMatch(true);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={editTest ? 'Edit Test Case' : 'Add Test Case'}
width='3xl'
footer={
<div className='flex justify-end space-x-3'>
<button
onClick={handleClose}
className='px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200
bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md
hover:bg-gray-50 dark:hover:bg-gray-700'>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!input.trim()}
className='px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-md
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed'>
{editTest ? 'Save Changes' : 'Add Test'}
</button>
</div>
}>
{/* Rest of the modal content remains the same */}
<div className='space-y-4'>
<div className='space-y-2'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Test String
</label>
<input
type='text'
value={input}
onChange={e => setInput(e.target.value)}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600
rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
placeholder-gray-500 dark:placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='Enter string to test against pattern...'
autoFocus
/>
</div>
<div className='space-y-2'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Expected Result
</label>
<div className='flex space-x-4'>
<label className='flex items-center space-x-2'>
<input
type='radio'
checked={shouldMatch}
onChange={() => setShouldMatch(true)}
className='text-blue-600 focus:ring-blue-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
Should Match
</span>
</label>
<label className='flex items-center space-x-2'>
<input
type='radio'
checked={!shouldMatch}
onChange={() => setShouldMatch(false)}
className='text-blue-600 focus:ring-blue-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
Should Not Match
</span>
</label>
</div>
</div>
</div>
</Modal>
);
};
AddUnitTestModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
tests: PropTypes.array.isRequired,
editTest: PropTypes.shape({
id: PropTypes.number.isRequired,
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired,
lastRun: PropTypes.string
})
};
export default AddUnitTestModal;

View File

@@ -0,0 +1,48 @@
// DeleteConfirmationModal.jsx
import React from 'react';
import PropTypes from 'prop-types';
import Modal from '@ui/Modal';
import {AlertTriangle} from 'lucide-react';
const DeleteConfirmationModal = ({isOpen, onClose, onConfirm}) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='Delete Test'
width='sm'
footer={
<div className='flex justify-end space-x-3'>
<button
onClick={onClose}
className='px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200
bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md
hover:bg-gray-50 dark:hover:bg-gray-700'>
Cancel
</button>
<button
onClick={onConfirm}
className='px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md
hover:bg-red-700'>
Delete
</button>
</div>
}>
<div className='flex items-center gap-3 text-gray-700 dark:text-gray-200'>
<AlertTriangle className='w-5 h-5 text-red-500' />
<p>
Are you sure you want to delete this test case? This action
cannot be undone.
</p>
</div>
</Modal>
);
};
DeleteConfirmationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired
};
export default DeleteConfirmationModal;

View File

@@ -1,71 +1,125 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Copy} from 'lucide-react';
function unsanitize(text) {
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
}
const RegexCard = ({pattern, onEdit, onClone, formatDate, sortBy}) => {
const totalTests = pattern.tests.length;
const passedTests = pattern.tests.filter(t => t.passes).length;
const passRate = Math.round((passedTests / totalTests) * 100);
function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
return (
<div
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm cursor-pointer hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-shadow"
onClick={() => onEdit(regex)}
>
<div className="flex justify-between items-center">
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-200">
{unsanitize(regex.name)}
</h3>
<button
onClick={(e) => {
e.stopPropagation();
onClone(regex);
}}
className="relative group"
>
<img
src="/clone.svg"
alt="Clone"
className="w-5 h-5 transition-transform transform group-hover:scale-125 group-hover:rotate-12 group-hover:-translate-y-1 group-hover:translate-x-1"
/>
<span className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"></span>
</button>
</div>
<div className="mt-2 mb-2 bg-gray-100 dark:bg-gray-700 rounded p-2">
<pre className="text-sm font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
{regex.pattern}
</pre>
</div>
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
{unsanitize(regex.description)}
</p>
{showDate && (
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
Modified: {formatDate(regex.date_modified)}
</p>
)}
<div className="flex flex-wrap">
{regex.tags && regex.tags.map(tag => (
<span key={tag} className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 mb-1 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300">
{tag}
</span>
))}
</div>
</div>
);
}
return (
<div
className='w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-all cursor-pointer max-h-96'
onClick={() => onEdit(pattern)}>
<div className='flex flex-col p-6 gap-3'>
{/* Header Section */}
<div className='flex justify-between items-center gap-4'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100 truncate'>
{pattern.name}
</h3>
<button
onClick={e => {
e.stopPropagation();
onClone(pattern);
}}
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors shrink-0'>
<Copy className='w-5 h-5 text-gray-500 dark:text-gray-400' />
</button>
</div>
{/* Pattern Display with line clamp */}
<div className='bg-gray-50 dark:bg-gray-900/50 rounded-md p-3 font-mono text-sm'>
<code className='text-gray-800 dark:text-gray-200 break-all line-clamp-3'>
{pattern.pattern}
</code>
</div>
{/* Description if exists - with line clamp */}
{pattern.description && (
<p className='text-gray-600 dark:text-gray-300 text-sm line-clamp-2'>
{pattern.description}
</p>
)}
{/* Bottom Metadata */}
<div className='flex flex-wrap items-center gap-4 text-sm'>
{/* Test Results */}
<div className='flex items-center gap-2 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-md'>
{totalTests > 0 ? (
<>
<span
className={`text-sm font-medium ${
passRate === 100
? 'text-green-600 dark:text-green-400'
: passRate >= 80
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
}`}>
{passRate}% Pass Rate
</span>
<span className='text-gray-500 dark:text-gray-400 text-xs'>
({passedTests}/{totalTests} tests)
</span>
</>
) : (
<span className='text-gray-500 dark:text-gray-400 text-sm'>
No tests
</span>
)}
</div>
{/* Tags */}
{pattern.tags && pattern.tags.length > 0 && (
<div className='flex flex-wrap gap-2 max-h-20 overflow-y-auto'>
{pattern.tags.map(tag => (
<span
key={tag}
className='bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 px-2.5 py-0.5 rounded text-xs'>
{tag}
</span>
))}
</div>
)}
{/* Date Modified/Created */}
{(sortBy === 'dateModified' ||
sortBy === 'dateCreated') && (
<span className='text-xs text-gray-500 dark:text-gray-400 text-left'>
{sortBy === 'dateModified' ? 'Modified' : 'Created'}
:{' '}
{formatDate(
sortBy === 'dateModified'
? pattern.modified_date
: pattern.created_date
)}
</span>
)}
</div>
</div>
</div>
);
};
RegexCard.propTypes = {
regex: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
pattern: PropTypes.string.isRequired,
description: PropTypes.string,
date_modified: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.string),
}).isRequired,
onEdit: PropTypes.func.isRequired,
onClone: PropTypes.func.isRequired, // Added clone handler prop
showDate: PropTypes.bool.isRequired,
formatDate: PropTypes.func.isRequired,
pattern: PropTypes.shape({
name: PropTypes.string.isRequired,
pattern: PropTypes.string.isRequired,
description: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
tests: PropTypes.arrayOf(
PropTypes.shape({
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired
})
).isRequired,
created_date: PropTypes.string.isRequired,
modified_date: PropTypes.string.isRequired
}).isRequired,
onEdit: PropTypes.func.isRequired,
onClone: PropTypes.func.isRequired,
formatDate: PropTypes.func.isRequired,
sortBy: PropTypes.string.isRequired
};
export default RegexCard;

View File

@@ -0,0 +1,194 @@
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import TextArea from '../ui/TextArea';
import {InfoIcon} from 'lucide-react';
const RegexGeneralTab = ({
name,
description,
pattern,
tags,
onNameChange,
onDescriptionChange,
onPatternChange,
onAddTag,
onRemoveTag,
error,
patternError
}) => {
const [newTag, setNewTag] = useState('');
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
onAddTag(newTag.trim());
setNewTag('');
}
};
const handleKeyPress = e => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
};
return (
<div className='w-full'>
{error && (
<div className='bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-4 mb-6'>
<p className='text-sm text-red-600 dark:text-red-400'>
{error}
</p>
</div>
)}
<div className='space-y-6'>
{/* Name Input */}
<div className='space-y-2'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Pattern Name
</label>
<input
type='text'
value={name}
onChange={e => onNameChange(e.target.value)}
placeholder='Enter pattern name'
className='w-full rounded-md border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 px-3 py-2 text-sm
text-gray-900 dark:text-gray-100
placeholder-gray-500 dark:placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
transition-colors duration-200'
/>
</div>
{/* Description */}
<div className='space-y-2'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Description
</label>
<TextArea
value={description}
onChange={e => onDescriptionChange(e.target.value)}
placeholder='Describe what this pattern matches...'
rows={3}
className='w-full rounded-md border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700
text-gray-900 dark:text-gray-100
placeholder-gray-500 dark:placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
transition-colors duration-200'
/>
</div>
{/* Pattern Input */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Pattern
</label>
<div className='flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400'>
<InfoIcon className='h-4 w-4' />
<span>Case insensitive PCRE2</span>
</div>
</div>
{patternError && (
<p className='text-sm text-red-600 dark:text-red-400'>
{patternError}
</p>
)}
<textarea
value={pattern}
onChange={e => onPatternChange(e.target.value)}
className='w-full h-24 rounded-md border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 px-3 py-2
text-gray-900 dark:text-gray-100 font-mono text-sm
focus:border-blue-500 dark:focus:border-blue-400
focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'
placeholder='Enter your regex pattern here...'
/>
</div>
{/* Tags */}
<div className='space-y-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Tags
</label>
<div className='flex space-x-2'>
<input
type='text'
value={newTag}
onChange={e => setNewTag(e.target.value)}
onKeyPress={handleKeyPress}
placeholder='Add a tag'
className='w-full rounded-md border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 px-3 py-2 text-sm
text-gray-900 dark:text-gray-100
placeholder-gray-500 dark:placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
transition-colors duration-200'
/>
<button
onClick={handleAddTag}
disabled={!newTag.trim()}
className='px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-400
text-white rounded-md text-sm font-medium transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
dark:focus:ring-offset-gray-800'>
Add
</button>
</div>
{tags.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{tags.map(tag => (
<span
key={tag}
className='inline-flex items-center p-1.5 rounded-md text-xs
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 group'>
{tag}
<button
onClick={() => onRemoveTag(tag)}
className='ml-1.5 hover:text-blue-900 dark:hover:text-blue-200 focus:outline-none'>
<svg
className='w-3.5 h-3.5 opacity-60 group-hover:opacity-100 transition-opacity'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</span>
))}
</div>
) : (
<div
className='flex items-center justify-center h-[2.5rem] text-sm
text-gray-500 dark:text-gray-400 rounded-md border border-dashed border-gray-300 dark:border-gray-600'>
No tags added yet
</div>
)}
</div>
</div>
</div>
);
};
RegexGeneralTab.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
pattern: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onNameChange: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onPatternChange: PropTypes.func.isRequired,
onAddTag: PropTypes.func.isRequired,
onRemoveTag: PropTypes.func.isRequired,
error: PropTypes.string,
patternError: PropTypes.string
};
export default RegexGeneralTab;

View File

@@ -1,189 +1,74 @@
import {useState, useEffect, useRef} from 'react';
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {saveRegex, deleteRegex, createRegex101Link} from '../../api/api';
import Modal from '../ui/Modal';
import Alert from '../ui/Alert';
import Modal from '@ui/Modal';
import RegexGeneralTab from './RegexGeneralTab';
import RegexTestingTab from './RegexTestingTab';
import {useRegexModal} from '@hooks/useRegexModal';
import {RegexPatterns} from '@api/data';
import Alert from '@ui/Alert';
import {Loader, Play} from 'lucide-react';
function unsanitize(text) {
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
}
function RegexModal({
regex: initialRegex,
const RegexModal = ({
pattern: initialPattern,
isOpen,
onClose,
onSave,
isCloning = false
}) {
const [name, setName] = useState('');
const [pattern, setPattern] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState([]);
const [newTag, setNewTag] = useState('');
const [regex101Link, setRegex101Link] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [modalTitle, setModalTitle] = useState('');
}) => {
const {
// Form state
name,
description,
patternValue,
tags,
tests,
// UI state
error,
patternError,
activeTab,
isDeleting,
isRunningTests,
// Actions
setName,
setDescription,
setPatternValue,
setTags,
setTests,
setActiveTab,
setIsDeleting,
// Main handlers
initializeForm,
handleSave,
handleRunTests
} = useRegexModal(initialPattern, onSave);
const tabs = [
{id: 'general', label: 'General'},
{id: 'testing', label: 'Testing'}
];
useEffect(() => {
if (isOpen) {
// Set the modal title
if (isCloning) {
setModalTitle('Clone Regex Pattern');
} else if (initialRegex && initialRegex.id !== 0) {
setModalTitle('Edit Regex Pattern');
} else {
setModalTitle('Add Regex Pattern');
}
if (initialRegex && (initialRegex.id !== 0 || isCloning)) {
setName(unsanitize(initialRegex.name));
setPattern(initialRegex.pattern);
setDescription(unsanitize(initialRegex.description));
setTags(
initialRegex.tags ? initialRegex.tags.map(unsanitize) : []
);
setRegex101Link(initialRegex.regex101Link || '');
} else {
setName('');
setPattern('');
setDescription('');
setTags([]);
setRegex101Link('');
}
setError('');
setNewTag('');
setIsLoading(false);
setIsDeleting(false);
initializeForm(initialPattern, isCloning);
}
}, [initialRegex, isOpen, isCloning]);
const handleCreateRegex101Link = async () => {
if (!pattern.trim()) {
setError('Please provide a regex pattern before creating tests.');
return;
}
const unitTests = [
{
description: "Test if 'D-Z0N3' is detected correctly",
testString: 'Test D-Z0N3 pattern',
criteria: 'DOES_MATCH',
target: 'REGEX'
},
{
description: "Test if 'random text' does not match",
testString: 'random text',
criteria: 'DOES_NOT_MATCH',
target: 'REGEX'
}
];
setIsLoading(true);
try {
const response = await createRegex101Link({
regex: pattern,
flavor: 'pcre',
flags: 'gmi',
delimiter: '/',
unitTests: unitTests
});
const permalinkFragment = response.permalinkFragment;
const regex101Link = `https://regex101.com/r/${permalinkFragment}`;
setRegex101Link(regex101Link);
await saveRegex({
id: regex ? regex.id : 0,
name,
pattern,
description,
tags,
regex101Link
});
window.open(regex101Link, '_blank');
onSave();
setError('');
} catch (error) {
console.error('Error creating regex101 link:', error);
setError('Failed to create regex101 link. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleRemoveRegex101Link = async () => {
const confirmRemoval = window.confirm(
'Are you sure you want to remove this Regex101 link?'
);
if (!confirmRemoval) return;
setIsLoading(true);
setRegex101Link('');
try {
await saveRegex({
id: regex ? regex.id : 0,
name,
pattern,
description,
tags,
regex101Link: ''
});
onSave();
setError('');
} catch (error) {
console.error('Error removing regex101 link:', error);
setError('Failed to remove regex101 link. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!name.trim() || !pattern.trim()) {
setError('Name and pattern are required.');
return;
}
try {
await saveRegex({
id: initialRegex ? initialRegex.id : 0,
name,
pattern,
description,
tags,
regex101Link
});
onSave();
onClose();
} catch (error) {
console.error('Error saving regex:', error);
setError('Failed to save regex. Please try again.');
}
};
}, [initialPattern, isOpen, isCloning, initializeForm]);
const handleDelete = async () => {
if (!initialPattern) return;
if (isDeleting) {
try {
console.log(
'Attempting to delete regex with ID:',
initialRegex.id
await RegexPatterns.delete(
initialPattern.file_name.replace('.yml', '')
);
const response = await deleteRegex(initialRegex.id);
console.log('Delete response:', response);
if (response.error) {
Alert.error(`Cannot delete: ${response.message}`);
} else {
Alert.success('Regex deleted successfully');
onSave();
onClose();
}
onSave();
onClose();
} catch (error) {
console.error('Error deleting regex:', error);
Alert.error('Failed to delete regex. Please try again.');
console.error('Error deleting pattern:', error);
Alert.error('Failed to delete pattern. Please try again.');
} finally {
setIsDeleting(false);
}
@@ -192,152 +77,116 @@ function RegexModal({
}
};
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags([...tags, newTag.trim()]);
setNewTag('');
}
};
const handleRemoveTag = tagToRemove => {
setTags(tags.filter(tag => tag !== tagToRemove));
};
const footerContent = (
<div className='flex justify-between'>
{initialPattern && !isCloning && (
<button
onClick={handleDelete}
className={`px-4 py-2 text-white rounded transition-colors ${
isDeleting
? 'bg-red-600 hover:bg-red-700'
: 'bg-red-500 hover:bg-red-600'
}`}>
{isDeleting ? 'Confirm Delete' : 'Delete'}
</button>
)}
<div className='flex gap-2'>
{activeTab === 'testing' && tests?.length > 0 && (
<button
onClick={() => handleRunTests(patternValue, tests)}
disabled={isRunningTests}
className='inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700
disabled:bg-green-600/50 text-white rounded transition-colors'>
{isRunningTests ? (
<Loader className='w-4 h-4 mr-2 animate-spin' />
) : (
<Play className='w-4 h-4 mr-2' />
)}
Run Tests
</button>
)}
<button
onClick={handleSave}
className='bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded transition-colors'>
Save
</button>
</div>
</div>
);
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
height='auto'
width='xl'>
{error && <div className='text-red-500 mb-4'>{error}</div>}
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Regex Name
</label>
<input
type='text'
value={name}
onChange={e => setName(e.target.value)}
placeholder='Enter regex name'
className='w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
/>
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Regex Pattern
</label>
<input
type='text'
value={pattern}
onChange={e => setPattern(e.target.value)}
placeholder='Enter regex pattern'
className='w-full p-2 border rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
/>
</div>
<div className='mb-4'>
{regex101Link ? (
<div className='flex items-center space-x-2'>
<a
href={regex101Link}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 transition-colors'>
View in Regex101
</a>
<button
onClick={handleRemoveRegex101Link}
className='text-red-500 hover:text-red-600 transition-colors'
disabled={isLoading}>
{isLoading ? 'Removing...' : 'Remove Link'}
</button>
</div>
) : (
<button
onClick={handleCreateRegex101Link}
className='bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors'
disabled={isLoading}>
{isLoading
? 'Creating Tests...'
: 'Create Tests in Regex101'}
</button>
)}
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Description (Optional)
</label>
<input
type='text'
value={description}
onChange={e => setDescription(e.target.value)}
placeholder='Enter description'
className='w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
/>
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Tags
</label>
<div className='flex flex-wrap mb-2'>
{tags.map(tag => (
<span
key={tag}
className='bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300'>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className='ml-1 text-xs'>
&times;
</button>
</span>
))}
<Modal
isOpen={isOpen}
onClose={onClose}
title={
isCloning
? 'Clone Pattern'
: initialPattern
? 'Edit Pattern'
: 'Add Pattern'
}
height='6xl'
width='4xl'
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={footerContent}>
{activeTab => {
return (
<div className='h-full'>
{activeTab === 'general' && (
<RegexGeneralTab
name={name}
description={description}
pattern={patternValue}
error={error}
patternError={patternError}
tags={tags}
onNameChange={setName}
onDescriptionChange={setDescription}
onPatternChange={newPattern => {
setPatternValue(newPattern);
setPatternError('');
}}
onAddTag={tag => setTags([...tags, tag])}
onRemoveTag={tag =>
setTags(tags.filter(t => t !== tag))
}
/>
)}
{activeTab === 'testing' && (
<RegexTestingTab
pattern={patternValue}
tests={tests || []}
onTestsChange={setTests}
isRunningTests={isRunningTests}
onRunTests={handleRunTests}
/>
)}
</div>
<div className='flex'>
<input
type='text'
value={newTag}
onChange={e => setNewTag(e.target.value)}
placeholder='Add a tag'
className='flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
/>
<button
onClick={handleAddTag}
className='bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors'>
Add
</button>
</div>
</div>
<div className='flex justify-between'>
{initialRegex && (
<button
onClick={handleDelete}
className={`bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors ${
isDeleting ? 'bg-red-600' : ''
}`}>
{isDeleting ? 'Confirm Delete' : 'Delete'}
</button>
)}
<button
onClick={handleSave}
className='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors'>
Save
</button>
</div>
</Modal>
</>
);
}}
</Modal>
);
}
};
RegexModal.propTypes = {
regex: PropTypes.shape({
id: PropTypes.number,
pattern: PropTypes.shape({
name: PropTypes.string.isRequired,
pattern: PropTypes.string.isRequired,
description: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
regex101Link: PropTypes.string
tests: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired,
lastRun: PropTypes.string
})
),
created_date: PropTypes.string,
modified_date: PropTypes.string
}),
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,

View File

@@ -1,168 +1,124 @@
import React, {useState, useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import RegexCard from './RegexCard';
import RegexModal from './RegexModal';
import AddNewCard from '../ui/AddNewCard';
import {getRegexes} from '../../api/api';
import {RegexPatterns} from '@api/data';
import FilterMenu from '../ui/FilterMenu';
import SortMenu from '../ui/SortMenu';
import {Loader} from 'lucide-react';
import {getGitStatus} from '../../api/api';
import Alert from '@ui/Alert';
function RegexPage() {
const [regexes, setRegexes] = useState([]);
const [patterns, setPatterns] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedRegex, setSelectedRegex] = useState(null);
const [selectedPattern, setSelectedPattern] = useState(null);
const [sortBy, setSortBy] = useState('title');
const [filterType, setFilterType] = useState('none');
const [filterValue, setFilterValue] = useState('');
const [allTags, setAllTags] = useState([]);
const [isCloning, setIsCloning] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [mergeConflicts, setMergeConflicts] = useState([]);
const navigate = useNavigate();
const loadingMessages = [
'Compiling complex patterns...',
'Analyzing regex efficiency...',
'Optimizing search algorithms...',
'Testing pattern boundaries...',
'Loading regex libraries...',
'Parsing intricate expressions...',
'Detecting pattern conflicts...',
'Refactoring nested groups...'
];
// Load initial data
useEffect(() => {
fetchGitStatus();
loadPatterns();
}, []);
const fetchRegexes = async () => {
const loadPatterns = async () => {
setIsLoading(true);
try {
const fetchedRegexes = await getRegexes();
setRegexes(fetchedRegexes);
const tags = [
...new Set(fetchedRegexes.flatMap(regex => regex.tags || []))
];
setAllTags(tags);
const response = await RegexPatterns.getAll();
if (Array.isArray(response)) {
const patternsData = response.map(item => ({
...item.content,
file_name: item.file_name,
created_date: item.created_date,
modified_date: item.modified_date
}));
setPatterns(patternsData);
// Extract all unique tags
const tags = new Set();
patternsData.forEach(pattern => {
pattern.tags?.forEach(tag => tags.add(tag));
});
setAllTags(Array.from(tags));
} else {
Alert.error('Failed to load patterns');
}
} catch (error) {
console.error('Error fetching regexes:', error);
console.error('Error loading patterns:', error);
Alert.error('Failed to load patterns');
} finally {
setIsLoading(false);
}
};
const fetchGitStatus = async () => {
try {
const result = await getGitStatus();
if (result.success) {
setMergeConflicts(result.data.merge_conflicts || []);
if (result.data.merge_conflicts.length === 0) {
fetchRegexes();
} else {
setIsLoading(false);
}
}
} catch (error) {
console.error('Error fetching Git status:', error);
setIsLoading(false);
}
};
const handleOpenModal = (regex = null) => {
setSelectedRegex(regex);
const handleOpenModal = (pattern = null) => {
setSelectedPattern(pattern);
setIsModalOpen(true);
setIsCloning(false);
};
const handleCloseModal = () => {
setSelectedRegex(null);
setSelectedPattern(null);
setIsModalOpen(false);
setIsCloning(false);
};
const handleCloneRegex = regex => {
const clonedRegex = {
...regex,
id: 0,
name: `${regex.name} [COPY]`,
regex101Link: ''
const handleClonePattern = pattern => {
const clonedPattern = {
...pattern,
name: `${pattern.name} [COPY]`
};
setSelectedRegex(clonedRegex);
setSelectedPattern(clonedPattern);
setIsModalOpen(true);
setIsCloning(true);
};
const handleSaveRegex = () => {
fetchRegexes();
const handleSavePattern = async () => {
await loadPatterns();
handleCloseModal();
};
const getFilteredAndSortedPatterns = () => {
let filtered = [...patterns];
// Apply filters
if (filterType === 'tag' && filterValue) {
filtered = filtered.filter(pattern =>
pattern.tags?.includes(filterValue)
);
} else if (filterType === 'name' && filterValue) {
filtered = filtered.filter(pattern =>
pattern.name.toLowerCase().includes(filterValue.toLowerCase())
);
}
// Apply sorting
return filtered.sort((a, b) => {
switch (sortBy) {
case 'dateModified':
return (
new Date(b.modified_date) - new Date(a.modified_date)
);
case 'dateCreated':
return new Date(b.created_date) - new Date(a.created_date);
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
};
const formatDate = dateString => {
return new Date(dateString).toLocaleString();
};
const sortedAndFilteredRegexes = regexes
.filter(regex => {
if (filterType === 'tag') {
return regex.tags && regex.tags.includes(filterValue);
}
if (filterType === 'date') {
const regexDate = new Date(regex.date_modified);
const filterDate = new Date(filterValue);
return regexDate.toDateString() === filterDate.toDateString();
}
return true;
})
.sort((a, b) => {
if (sortBy === 'title') return a.name.localeCompare(b.name);
if (sortBy === 'dateCreated')
return new Date(b.date_created) - new Date(a.date_created);
if (sortBy === 'dateModified')
return new Date(b.date_modified) - new Date(a.date_modified);
return 0;
});
const hasConflicts = mergeConflicts.length > 0;
if (isLoading) {
return (
<div className='flex flex-col items-center justify-center h-screen'>
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
{
loadingMessages[
Math.floor(Math.random() * loadingMessages.length)
]
}
</p>
</div>
);
}
if (hasConflicts) {
return (
<div className='bg-gray-900 text-white'>
<div className='mt-8 flex justify-between items-center'>
<h4 className='text-xl font-extrabold'>
Merge Conflicts Detected
</h4>
<button
onClick={() => navigate('/settings')}
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
Resolve Conflicts
</button>
</div>
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
<h3 className='text-xl font-semibold'>What Happened?</h3>
<p className='mt-2 text-gray-300'>
This page is locked because there are unresolved merge
conflicts. You need to address these conflicts in the
settings page before continuing.
</p>
</div>
<div className='flex justify-center items-center h-64'>
<Loader className='w-8 h-8 animate-spin text-blue-500' />
</div>
);
}
@@ -180,25 +136,24 @@ function RegexPage() {
allTags={allTags}
/>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
{sortedAndFilteredRegexes.map(regex => (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4'>
{getFilteredAndSortedPatterns().map(pattern => (
<RegexCard
key={regex.id}
regex={regex}
onEdit={() => handleOpenModal(regex)}
onClone={handleCloneRegex} // Pass the clone handler
showDate={sortBy !== 'title'}
key={pattern.name}
pattern={pattern}
onEdit={() => handleOpenModal(pattern)}
onClone={handleClonePattern}
formatDate={formatDate}
sortBy={sortBy}
/>
))}
<AddNewCard onAdd={() => handleOpenModal()} />
</div>
<RegexModal
regex={selectedRegex}
pattern={selectedPattern}
isOpen={isModalOpen}
onClose={handleCloseModal}
onSave={handleSaveRegex}
allTags={allTags}
onSave={handleSavePattern}
isCloning={isCloning}
/>
</div>

View File

@@ -0,0 +1,181 @@
// RegexTestingTab.jsx
import React, {useState, useCallback} from 'react';
import PropTypes from 'prop-types';
import {Plus, Loader, Play} from 'lucide-react';
import UnitTest from './UnitTest';
import AddUnitTestModal from './AddUnitTestModal';
const formatTestDate = dateString => {
if (!dateString) return null;
try {
return new Date(dateString).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch (error) {
console.error('Error formatting date:', error);
return dateString;
}
};
const RegexTestingTab = ({
pattern,
tests,
onTestsChange,
isRunningTests,
onRunTests
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTest, setEditingTest] = useState(null);
const handleAddOrUpdateTest = useCallback(
testData => {
let updatedTests;
if (editingTest) {
// Update existing test
updatedTests = tests.map(test =>
test.id === testData.id ? testData : test
);
} else {
// Add new test
updatedTests = [...tests, testData];
}
onTestsChange(updatedTests);
onRunTests(pattern, updatedTests);
setEditingTest(null);
},
[tests, onTestsChange, onRunTests, pattern, editingTest]
);
const handleEditTest = useCallback(test => {
setEditingTest(test);
setIsModalOpen(true);
}, []);
const handleDeleteTest = useCallback(
testId => {
const updatedTests = tests.filter(test => test.id !== testId);
onTestsChange(updatedTests);
},
[tests, onTestsChange]
);
const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
setEditingTest(null);
}, []);
// Calculate test statistics
const totalTests = tests?.length || 0;
const passedTests = tests?.filter(test => test.passes)?.length || 0;
return (
<div className='flex flex-col h-full'>
{/* Header Section with Progress Bar */}
<div className='flex items-center justify-between pb-4 pr-2'>
<div>
<h2 className='text-xl font-semibold text-gray-900 dark:text-white mb-3'>
Unit Tests
</h2>
<div className='flex items-center gap-3'>
<div className='h-1.5 w-32 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
<div
className='h-full bg-emerald-500 rounded-full transition-all duration-300'
style={{
width: `${
totalTests
? (passedTests / totalTests) * 100
: 0
}%`
}}
/>
</div>
<span className='text-sm text-gray-600 dark:text-gray-300'>
{totalTests > 0
? `${passedTests}/${totalTests} tests passing`
: 'No tests added yet'}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
{tests?.length > 0 && (
<button
onClick={() => onRunTests(pattern, tests)}
disabled={isRunningTests}
className='inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-green-600/50 text-white'>
{isRunningTests ? (
<Loader className='w-4 h-4 mr-2 animate-spin' />
) : (
<Play className='w-4 h-4 mr-2' />
)}
Run Tests
</button>
)}
<button
onClick={() => setIsModalOpen(true)}
className='inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 text-white'>
<Plus className='w-4 h-4 mr-2' />
Add Test
</button>
</div>
</div>
{/* Test List */}
<div className='flex-1 overflow-y-auto pr-2'>
{tests?.length > 0 ? (
<div className='space-y-3'>
{tests.map(test => (
<UnitTest
key={test.id}
test={{
...test,
lastRun: formatTestDate(test.lastRun)
}}
pattern={pattern}
onDelete={() => handleDeleteTest(test.id)}
onEdit={() => handleEditTest(test)}
/>
))}
</div>
) : (
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
<p className='text-gray-500 dark:text-gray-400'>
No tests added yet
</p>
</div>
)}
</div>
<AddUnitTestModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onAdd={handleAddOrUpdateTest}
editTest={editingTest}
tests={tests}
/>
</div>
);
};
RegexTestingTab.propTypes = {
pattern: PropTypes.string.isRequired,
tests: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired,
lastRun: PropTypes.string
})
),
onTestsChange: PropTypes.func.isRequired,
isRunningTests: PropTypes.bool.isRequired,
onRunTests: PropTypes.func.isRequired
};
export default RegexTestingTab;

View File

@@ -0,0 +1,222 @@
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {Trash2, Pencil} from 'lucide-react';
import DeleteConfirmationModal from './DeleteConfirmationModal';
const MatchHighlight = ({input, pattern, test}) => {
if (!pattern) return <span className='font-mono'>{input}</span>;
try {
const regex = new RegExp(pattern, 'g');
const matches = [];
let match;
while ((match = regex.exec(input)) !== null) {
// Avoid infinite loops with zero-length matches
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
matches.push(match);
}
if (!matches.length) {
return <span className='font-mono text-gray-100'>{input}</span>;
}
let segments = [];
let lastIndex = 0;
matches.forEach(match => {
let matchText = '';
let matchStart = match.index;
// Use capturing groups if they exist
let capturingGroupIndex = 1;
while (
capturingGroupIndex < match.length &&
!match[capturingGroupIndex]
) {
capturingGroupIndex++;
}
if (capturingGroupIndex < match.length) {
matchText = match[capturingGroupIndex];
// Find the position of matchText in the input, starting from match.index
matchStart = input.indexOf(matchText, match.index);
if (matchStart === -1) {
// If not found, skip this match
return;
}
} else {
// No capturing group match, use full match
matchText = match[0];
}
// Add non-highlighted segment before the match
if (matchStart > lastIndex) {
segments.push({
text: input.slice(lastIndex, matchStart),
highlight: false
});
}
// Add the highlighted match
if (matchText.length > 0) {
segments.push({
text: matchText,
highlight: true
});
lastIndex = matchStart + matchText.length;
} else {
// Handle zero-length matches
lastIndex = matchStart;
}
});
// Add any remaining non-highlighted text
if (lastIndex < input.length) {
segments.push({
text: input.slice(lastIndex),
highlight: false
});
}
return (
<span className='font-mono'>
<span className='bg-green-900/20 rounded px-0.5'>
{segments.map((segment, idx) => (
<span
key={idx}
className={
segment.highlight
? test.passes
? 'bg-emerald-200 dark:bg-emerald-600 text-emerald-900 dark:text-emerald-100 px-0.5 rounded'
: 'bg-red-200 dark:bg-red-600 text-red-900 dark:text-red-100 px-0.5 rounded'
: 'text-gray-100'
}>
{segment.text}
</span>
))}
</span>
</span>
);
} catch (error) {
console.error('Regex error:', error);
return <span className='font-mono text-gray-100'>{input}</span>;
}
};
const UnitTest = ({test, pattern, onDelete, onEdit}) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleDeleteClick = () => {
setShowDeleteModal(true);
};
const handleConfirmDelete = () => {
onDelete();
setShowDeleteModal(false);
};
return (
<>
<div
className={`
relative rounded-lg border group
${
test.passes
? 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
}
`}>
{/* Header */}
<div className='px-4 py-2 pr-2 flex items-center justify-between border-b border-inherit'>
<div className='flex items-center gap-2'>
<div
className={`
w-2 h-2 rounded-full
${
test.passes
? 'bg-emerald-500 shadow-sm shadow-emerald-500/50'
: 'bg-red-500 shadow-sm shadow-red-500/50'
}
`}
/>
<span
className={`text-xs font-medium
${
test.passes
? 'text-emerald-700 dark:text-emerald-300'
: 'text-red-700 dark:text-red-300'
}
`}>
{test.expected
? 'Should Match'
: 'Should Not Match'}
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-xs text-gray-500 dark:text-gray-400'>
Last run: {test.lastRun}
</span>
<div className='flex gap-2'>
<button
onClick={onEdit}
className='p-1 rounded shrink-0 transition-transform transform hover:scale-110'>
<Pencil className='w-4 h-4 text-gray-500 dark:text-gray-400' />
</button>
<button
onClick={handleDeleteClick}
className='p-1 rounded shrink-0 transition-transform transform hover:scale-110'>
<Trash2 className='w-4 h-4 text-gray-500 dark:text-gray-400' />
</button>
</div>
</div>
</div>
{/* Content */}
<div className='p-2 flex items-start gap-3'>
<div className='flex-1 min-w-0'>
<div className='rounded bg-white/75 dark:bg-black/25 px-2 py-1.5 text-xs'>
<MatchHighlight
input={test.input}
pattern={pattern}
test={test}
/>
</div>
</div>
</div>
</div>
<DeleteConfirmationModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleConfirmDelete}
/>
</>
);
};
UnitTest.propTypes = {
test: PropTypes.shape({
id: PropTypes.number.isRequired,
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired,
lastRun: PropTypes.string
}).isRequired,
pattern: PropTypes.string.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired
};
MatchHighlight.propTypes = {
input: PropTypes.string.isRequired,
pattern: PropTypes.string.isRequired,
test: PropTypes.shape({
passes: PropTypes.bool.isRequired
}).isRequired
};
export default UnitTest;

View File

@@ -18,7 +18,6 @@ const Modal = ({
}) => {
const modalRef = useRef();
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id);
useEffect(() => {
if (isOpen && !disableCloseOnEscape) {
const handleEscape = event => {
@@ -34,10 +33,15 @@ const Modal = ({
}, [isOpen, onClose, disableCloseOnEscape]);
const handleClickOutside = e => {
// Get the current selection
const selection = window.getSelection();
const hasSelection = selection && selection.toString().length > 0;
if (
modalRef.current &&
!modalRef.current.contains(e.target) &&
!disableCloseOnOutsideClick
!disableCloseOnOutsideClick &&
!hasSelection // Don't close if there's text selected
) {
onClose();
}

View File

@@ -0,0 +1,125 @@
import {useState, useCallback} from 'react';
import {RegexPatterns} from '@api/data';
import Alert from '@ui/Alert';
import {useRegexTesting} from './useRegexTesting';
export const useRegexModal = (initialPattern, onSave) => {
// Form state
const [name, setName] = useState('');
const [originalName, setOriginalName] = useState('');
const [description, setDescription] = useState('');
const [patternValue, setPatternValue] = useState('');
const [tags, setTags] = useState([]);
const [tests, setTests] = useState([]);
const [isCloning, setIsCloning] = useState(false);
// UI state
const [error, setError] = useState('');
const [patternError, setPatternError] = useState('');
const [activeTab, setActiveTab] = useState('general');
const [isDeleting, setIsDeleting] = useState(false);
// Initialize testing functionality
const {isRunningTests, runTests} = useRegexTesting();
const initializeForm = useCallback((pattern, cloning) => {
setIsCloning(cloning || false);
if (pattern) {
const initialName = cloning ? `${pattern.name}` : pattern.name;
setName(initialName);
setOriginalName(cloning ? '' : pattern.name);
setDescription(pattern.description || '');
setPatternValue(pattern.pattern || '');
setTags(pattern.tags || []);
setTests(pattern.tests || []);
} else {
setName('');
setOriginalName('');
setDescription('');
setPatternValue('');
setTags([]);
setTests([]);
}
setError('');
setPatternError('');
setIsDeleting(false);
}, []);
const handleSave = async () => {
try {
const data = {
name,
pattern: patternValue,
description,
tags,
tests
};
// Validation checks
if (!name.trim()) {
Alert.error('Name is required');
return;
}
if (!patternValue.trim()) {
Alert.error('Pattern is required');
return;
}
if (initialPattern && !isCloning) {
// Check if name has changed
const hasNameChanged = name !== originalName;
await RegexPatterns.update(
initialPattern.file_name.replace('.yml', ''),
data,
hasNameChanged ? name : undefined // Only pass new name if it changed
);
Alert.success('Pattern updated successfully');
} else {
await RegexPatterns.create(data);
Alert.success('Pattern created successfully');
}
onSave();
} catch (error) {
console.error('Error saving pattern:', error);
Alert.error('Failed to save pattern. Please try again.');
}
};
const handleRunTests = useCallback(
async (pattern, tests) => {
const updatedTests = await runTests(pattern, tests);
if (updatedTests) {
setTests(updatedTests);
}
},
[runTests]
);
return {
// Form state
name,
description,
patternValue,
tags,
tests,
// UI state
error,
patternError,
activeTab,
isDeleting,
isRunningTests,
isCloning,
// Actions
setName,
setDescription,
setPatternValue,
setTags,
setTests,
setActiveTab,
setIsDeleting,
// Main handlers
initializeForm,
handleSave,
handleRunTests
};
};

View File

@@ -0,0 +1,58 @@
// useRegexTesting.js
import {useState, useCallback} from 'react';
import {RegexPatterns} from '@api/data';
import Alert from '@ui/Alert';
export const useRegexTesting = onUpdateTests => {
const [isRunningTests, setIsRunningTests] = useState(false);
const runTests = useCallback(
async (pattern, tests) => {
if (!pattern?.trim() || !tests?.length) {
return tests;
}
setIsRunningTests(true);
try {
const result = await RegexPatterns.runTests(pattern, tests);
if (result.success) {
// Calculate test statistics
const totalTests = result.tests.length;
const passedTests = result.tests.filter(
test => test.passes
).length;
// Show success message with statistics
Alert.success(
`Tests completed: ${passedTests}/${totalTests} passed`,
{
autoClose: 3000,
hideProgressBar: false
}
);
// Update tests through the callback
if (onUpdateTests) {
onUpdateTests(result.tests);
}
return result.tests;
} else {
Alert.error(result.message || 'Failed to run tests');
return tests;
}
} catch (error) {
console.error('Error running tests:', error);
Alert.error('An error occurred while running tests');
return tests;
} finally {
setIsRunningTests(false);
}
},
[onUpdateTests]
);
return {
isRunningTests,
runTests
};
};

View File

@@ -23,6 +23,7 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@ui': path.resolve(__dirname, './src/components/ui'),
'@assets': path.resolve(__dirname, './src/assets'),
'@logo': path.resolve(__dirname, './src/assets/logo'),