From df676e7e20a85bce01f59687d69483e85839c2c4 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Tue, 3 Dec 2024 18:19:27 +1030 Subject: [PATCH] feature: custom format module (#11) - improve custom formats - general tab for name, description tags - improved conditions tab - add testing system - add app footer - remove redundant data page headers --- backend/app/__init__.py | 6 - backend/app/data/__init__.py | 52 ++- backend/app/data/utils.py | 180 +++++++- backend/app/data_operations.py | 24 -- backend/app/format.py | 148 ------- backend/app/profile.py | 119 ------ backend/app/regex.py | 193 --------- backend/app/utils.py | 63 --- frontend/src/App.jsx | 8 +- frontend/src/api/data.js | 20 +- frontend/src/assets/logo/Discord.svg | 3 + frontend/src/assets/logo/GitHub.svg | 3 + .../components/condition/ConditionCard.jsx | 60 --- .../components/condition/ConditionModal.jsx | 260 ------------ .../components/format/AddFormatTestModal.jsx | 141 +++++++ frontend/src/components/format/FormatCard.jsx | 210 ++++++---- .../components/format/FormatConditionsTab.jsx | 84 ++++ .../components/format/FormatGeneralTab.jsx | 160 ++++++++ .../src/components/format/FormatModal.jsx | 385 ++++++------------ frontend/src/components/format/FormatPage.jsx | 204 +++++++--- .../components/format/FormatTestingTab.jsx | 174 ++++++++ .../src/components/format/FormatUnitTest.jsx | 153 +++++++ .../format/conditions/ConditionCard.jsx | 139 +++++++ .../format/conditions/EditionCondition.jsx | 41 ++ .../conditions/IndexerFlagCondition.jsx | 38 ++ .../format/conditions/LanguageCondition.jsx | 59 +++ .../conditions/QualityModifierCondition.jsx | 36 ++ .../conditions/ReleaseGroupCondition.jsx | 27 ++ .../conditions/ReleaseTitleCondition.jsx | 27 ++ .../conditions/ReleaseTypeCondition.jsx | 33 ++ .../format/conditions/ResolutionCondition.jsx | 35 ++ .../format/conditions/SizeCondition.jsx | 60 +++ .../format/conditions/SourceCondition.jsx | 40 ++ .../format/conditions/YearCondition.jsx | 38 ++ .../format/conditions/conditionTypes.jsx | 159 ++++++++ .../profile/ProfileLangaugesTab.jsx | 59 +-- .../src/components/profile/ProfilePage.jsx | 1 - frontend/src/components/regex/RegexPage.jsx | 1 - frontend/src/components/regex/UnitTest.jsx | 2 +- frontend/src/components/ui/BrowserSelect.jsx | 60 +++ frontend/src/components/ui/CustomDropdown.jsx | 89 ++++ .../{regex => ui}/DeleteConfirmationModal.jsx | 0 frontend/src/components/ui/Footer.jsx | 40 ++ frontend/src/constants/languages.js | 54 +++ frontend/src/hooks/useFormatModal.js | 133 ++++++ frontend/src/hooks/useFormatTesting.js | 51 +++ frontend/src/hooks/usePatterns.js | 32 ++ frontend/vite.config.js | 1 + 48 files changed, 2562 insertions(+), 1343 deletions(-) delete mode 100644 backend/app/data_operations.py delete mode 100644 backend/app/format.py delete mode 100644 backend/app/profile.py delete mode 100644 backend/app/regex.py delete mode 100644 backend/app/utils.py create mode 100644 frontend/src/assets/logo/Discord.svg create mode 100644 frontend/src/assets/logo/GitHub.svg delete mode 100644 frontend/src/components/condition/ConditionCard.jsx delete mode 100644 frontend/src/components/condition/ConditionModal.jsx create mode 100644 frontend/src/components/format/AddFormatTestModal.jsx create mode 100644 frontend/src/components/format/FormatConditionsTab.jsx create mode 100644 frontend/src/components/format/FormatGeneralTab.jsx create mode 100644 frontend/src/components/format/FormatTestingTab.jsx create mode 100644 frontend/src/components/format/FormatUnitTest.jsx create mode 100644 frontend/src/components/format/conditions/ConditionCard.jsx create mode 100644 frontend/src/components/format/conditions/EditionCondition.jsx create mode 100644 frontend/src/components/format/conditions/IndexerFlagCondition.jsx create mode 100644 frontend/src/components/format/conditions/LanguageCondition.jsx create mode 100644 frontend/src/components/format/conditions/QualityModifierCondition.jsx create mode 100644 frontend/src/components/format/conditions/ReleaseGroupCondition.jsx create mode 100644 frontend/src/components/format/conditions/ReleaseTitleCondition.jsx create mode 100644 frontend/src/components/format/conditions/ReleaseTypeCondition.jsx create mode 100644 frontend/src/components/format/conditions/ResolutionCondition.jsx create mode 100644 frontend/src/components/format/conditions/SizeCondition.jsx create mode 100644 frontend/src/components/format/conditions/SourceCondition.jsx create mode 100644 frontend/src/components/format/conditions/YearCondition.jsx create mode 100644 frontend/src/components/format/conditions/conditionTypes.jsx create mode 100644 frontend/src/components/ui/BrowserSelect.jsx create mode 100644 frontend/src/components/ui/CustomDropdown.jsx rename frontend/src/components/{regex => ui}/DeleteConfirmationModal.jsx (100%) create mode 100644 frontend/src/components/ui/Footer.jsx create mode 100644 frontend/src/constants/languages.js create mode 100644 frontend/src/hooks/useFormatModal.js create mode 100644 frontend/src/hooks/useFormatTesting.js create mode 100644 frontend/src/hooks/usePatterns.js diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 6c4cd91..d5ce935 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,9 +1,6 @@ import os from flask import Flask, jsonify from flask_cors import CORS -from .regex import bp as regex_bp -from .format import bp as format_bp -from .profile import bp as profile_bp from .git import bp as git_bp from .arr import bp as arr_bp from .data import bp as data_bp @@ -33,9 +30,6 @@ def create_app(): init_db() # Register Blueprints - app.register_blueprint(regex_bp) - app.register_blueprint(format_bp) - app.register_blueprint(profile_bp) app.register_blueprint(git_bp) app.register_blueprint(data_bp) app.register_blueprint(arr_bp) diff --git a/backend/app/data/__init__.py b/backend/app/data/__init__.py index 4515f68..9ff898b 100644 --- a/backend/app/data/__init__.py +++ b/backend/app/data/__init__.py @@ -4,7 +4,8 @@ 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, test_regex_pattern) + get_file_modified_date, test_regex_pattern, + test_format_conditions) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -133,12 +134,7 @@ def handle_item(category, name): @bp.route('//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 + logger.info(f"Received test request for category: {category}") try: data = request.get_json() @@ -146,22 +142,44 @@ def run_tests(category): 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) + if category == 'regex_pattern': + pattern = data.get('pattern') + logger.info(f"Processing regex test request - Pattern: {pattern}") + + if not pattern: + logger.warning("Rejected test request - missing pattern") + return jsonify({"error": "Pattern is required"}), 400 + + success, message, updated_tests = test_regex_pattern( + pattern, tests) + + elif category == 'custom_format': + conditions = data.get('conditions', []) + logger.info( + f"Processing format test request - Conditions: {len(conditions)}" + ) + + if not conditions: + logger.warning( + "Rejected test request - no conditions provided") + return jsonify({"error": + "At least one condition is required"}), 400 + + success, message, updated_tests = test_format_conditions( + conditions, tests) + + else: + logger.warning( + f"Rejected test request - invalid category: {category}") + return jsonify( + {"error": "Testing not supported for this category"}), 400 + logger.info(f"Test execution completed - Success: {success}") if not success: diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py index 842ce19..e1b3617 100644 --- a/backend/app/data/utils.py +++ b/backend/app/data/utils.py @@ -17,7 +17,7 @@ PROFILE_DIR = '/app/data/db/profiles' # Expected fields for each category REGEX_FIELDS = ["name", "pattern", "description", "tags", "tests"] -FORMAT_FIELDS = ["name", "format", "description"] +FORMAT_FIELDS = ["name", "description", "tags", "conditions", "tests"] PROFILE_FIELDS = [ "name", "description", @@ -196,7 +196,8 @@ def test_regex_pattern( try: # Try to compile the regex with PCRE2 compatibility try: - compiled_pattern = regex.compile(pattern, regex.V1) + compiled_pattern = regex.compile(pattern, + regex.V1 | regex.IGNORECASE) logger.info( "Pattern compiled successfully with PCRE2 compatibility") except regex.error as e: @@ -247,3 +248,178 @@ def test_regex_pattern( logger.warning(f"Unexpected error in test_regex_pattern: {str(e)}", exc_info=True) return False, str(e), tests + + +def test_format_conditions(conditions: List[Dict], + tests: List[Dict]) -> Tuple[bool, str, List[Dict]]: + """ + Test a set of format conditions against a list of test cases. + Tests only pattern-based conditions (release_title, release_group, edition). + """ + logger.info( + f"Starting format condition test - {len(conditions)} conditions") + logger.error(f"Received conditions: {conditions}") + logger.error(f"Received tests: {tests}") + + try: + # First, load all regex patterns from the patterns directory + patterns_dir = os.path.join(REPO_PATH, 'regex_patterns') + pattern_map = {} + + logger.error(f"Loading patterns from directory: {patterns_dir}") + if not os.path.exists(patterns_dir): + logger.error(f"Patterns directory not found: {patterns_dir}") + return False, "Patterns directory not found", tests + + for pattern_file in os.listdir(patterns_dir): + if pattern_file.endswith('.yml'): + pattern_path = os.path.join(patterns_dir, pattern_file) + try: + with open(pattern_path, 'r') as f: + pattern_data = yaml.safe_load(f) + if pattern_data and 'name' in pattern_data and 'pattern' in pattern_data: + pattern_map[ + pattern_data['name']] = pattern_data['pattern'] + logger.error( + f"Loaded pattern: {pattern_data['name']} = {pattern_data['pattern']}" + ) + except Exception as e: + logger.error( + f"Error loading pattern file {pattern_file}: {e}") + continue + + logger.error(f"Total patterns loaded: {len(pattern_map)}") + + # Compile all regex patterns first + compiled_patterns = {} + for condition in conditions: + if condition['type'] in [ + 'release_title', 'release_group', 'edition' + ]: + logger.error(f"Processing condition: {condition}") + try: + pattern_name = condition.get('pattern', '') + if pattern_name: + # Look up the actual pattern using the pattern name + actual_pattern = pattern_map.get(pattern_name) + if actual_pattern: + compiled_patterns[ + condition['name']] = regex.compile( + actual_pattern, + regex.V1 | regex.IGNORECASE) + logger.error( + f"Successfully compiled pattern for {condition['name']}: {actual_pattern}" + ) + else: + logger.error( + f"Pattern not found for name: {pattern_name}") + return False, f"Pattern not found: {pattern_name}", tests + except regex.error as e: + logger.error( + f"Invalid regex pattern in condition {condition['name']}: {str(e)}" + ) + return False, f"Invalid regex pattern in condition {condition['name']}: {str(e)}", tests + + logger.error(f"Total patterns compiled: {len(compiled_patterns)}") + current_time = datetime.now().isoformat() + + # Process each test + for test in tests: + test_input = test.get('input', '') + expected = test.get('expected', False) + condition_results = [] + logger.error( + f"Processing test input: {test_input}, expected: {expected}") + + # Check each condition + for condition in conditions: + if condition['type'] not in [ + 'release_title', 'release_group', 'edition' + ]: + logger.error( + f"Skipping non-pattern condition: {condition['type']}") + continue + + pattern = compiled_patterns.get(condition['name']) + if not pattern: + logger.error( + f"No compiled pattern found for condition: {condition['name']}" + ) + continue + + # Test if pattern matches input + matches = bool(pattern.search(test_input)) + logger.error( + f"Condition {condition['name']} match result: {matches}") + + # Add result + condition_results.append({ + 'name': + condition['name'], + 'type': + condition['type'], + 'pattern': + condition.get('pattern', ''), + 'required': + condition.get('required', False), + 'negate': + condition.get('negate', False), + 'matches': + matches + }) + + # Determine if format applies + format_applies = True + + # Check required conditions + for result in condition_results: + if result['required']: + logger.error( + f"Checking required condition: {result['name']}, negate: {result['negate']}, matches: {result['matches']}" + ) + if result['negate']: + if result['matches']: + format_applies = False + logger.error( + f"Required negated condition {result['name']} matched - format does not apply" + ) + break + else: + if not result['matches']: + format_applies = False + logger.error( + f"Required condition {result['name']} did not match - format does not apply" + ) + break + + # Check non-required conditions + if format_applies: + for result in condition_results: + if not result['required'] and result['negate'] and result[ + 'matches']: + format_applies = False + logger.error( + f"Non-required negated condition {result['name']} matched - format does not apply" + ) + break + + test['passes'] = format_applies == expected + test['lastRun'] = current_time + test['conditionResults'] = condition_results + + logger.error( + f"Test result - format_applies: {format_applies}, expected: {expected}, passes: {test['passes']}" + ) + + # Log final results + passed_tests = sum(1 for test in tests if test.get('passes', False)) + logger.error( + f"Final test results - {passed_tests}/{len(tests)} tests passed") + logger.error(f"Updated tests: {tests}") + + return True, "", tests + + except Exception as e: + logger.error(f"Unexpected error in test_format_conditions: {str(e)}", + exc_info=True) + return False, str(e), tests diff --git a/backend/app/data_operations.py b/backend/app/data_operations.py deleted file mode 100644 index 8b50a7b..0000000 --- a/backend/app/data_operations.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -import yaml - -DATA_DIR = '/app/data' -FORMAT_DIR = os.path.join(DATA_DIR, 'db', 'custom_formats') -PROFILE_DIR = os.path.join(DATA_DIR, 'db', 'profiles') - -def load_all_formats(): - formats = [] - for filename in os.listdir(FORMAT_DIR): - if filename.endswith('.yml'): - with open(os.path.join(FORMAT_DIR, filename), 'r') as file: - data = yaml.safe_load(file) - formats.append(data) - return formats - -def load_all_profiles(): - profiles = [] - for filename in os.listdir(PROFILE_DIR): - if filename.endswith('.yml'): - with open(os.path.join(PROFILE_DIR, filename), 'r') as file: - data = yaml.safe_load(file) - profiles.append(data) - return profiles \ No newline at end of file diff --git a/backend/app/format.py b/backend/app/format.py deleted file mode 100644 index 8d1d126..0000000 --- a/backend/app/format.py +++ /dev/null @@ -1,148 +0,0 @@ -from flask import Blueprint, request, jsonify -from collections import OrderedDict -import os -import yaml -import logging -from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input -from .data_operations import load_all_profiles, load_all_formats - -bp = Blueprint('format', __name__, url_prefix='/format') -DATA_DIR = '/app/data' -FORMAT_DIR = os.path.join(DATA_DIR, 'db', 'custom_formats') - -# Ensure the directory exists -os.makedirs(FORMAT_DIR, exist_ok=True) - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -@bp.route('', methods=['GET', 'POST']) -def handle_formats(): - if request.method == 'POST': - data = request.json - saved_data = save_format(data) - return jsonify(saved_data), 201 - else: - formats = load_all_formats() - return jsonify(formats) - - -@bp.route('/', methods=['GET', 'PUT', 'DELETE']) -def handle_format(id): - if request.method == 'GET': - format = load_format(id) - if format: - return jsonify(format) - return jsonify({"error": "Format not found"}), 404 - elif request.method == 'PUT': - data = request.json - data['id'] = id - saved_data = save_format(data) - return jsonify(saved_data) - elif request.method == 'DELETE': - logger.info("Received request to delete format with ID: %d", id) - result = delete_format(id) - if "error" in result: - logger.error("Error deleting format: %s", result['error']) - return jsonify(result), 400 - return jsonify(result), 200 - - -def is_format_used_in_profile(format_id): - profiles = load_all_profiles() - for profile in profiles: - for custom_format in profile.get('custom_formats', []): - if custom_format.get('id') == format_id and custom_format.get( - 'score', 0) != 0: - return True - return False - - -def save_format(data): - logger.info("Received data for saving format: %s", data) - - # Sanitize and extract necessary fields - name = sanitize_input(data.get('name', '')) - description = sanitize_input(data.get('description', '')) - format_id = data.get('id', None) - - # Determine if this is a new format or an existing one - if format_id == 0 or not format_id: - format_id = get_next_id(FORMAT_DIR) - logger.info("Assigned new format ID: %d", format_id) - date_created = get_current_timestamp() - else: - existing_filename = os.path.join(FORMAT_DIR, f"{format_id}.yml") - if os.path.exists(existing_filename): - existing_data = load_format(format_id) - date_created = existing_data.get('date_created', - get_current_timestamp()) - else: - raise FileNotFoundError( - f"No existing file found for ID: {format_id}") - - date_modified = get_current_timestamp() - - # Process conditions - conditions = [] - for condition in data.get('conditions', []): - logger.info("Processing condition: %s", condition) - cond_dict = OrderedDict([('type', condition['type']), - ('name', sanitize_input(condition['name'])), - ('negate', condition.get('negate', False)), - ('required', condition.get('required', - False))]) - if condition['type'] == 'regex': - cond_dict['regex_id'] = condition['regex_id'] - elif condition['type'] == 'size': - cond_dict['min'] = condition['min'] - cond_dict['max'] = condition['max'] - elif condition['type'] == 'flag': - cond_dict['flag'] = sanitize_input(condition['flag']) - conditions.append(cond_dict) - - # Process tags - tags = [sanitize_input(tag) for tag in data.get('tags', [])] - - # Construct the ordered data - ordered_data = OrderedDict([('id', format_id), ('name', name), - ('description', description), - ('date_created', str(date_created)), - ('date_modified', str(date_modified)), - ('conditions', conditions), ('tags', tags)]) - - # Generate the filename using only the ID - filename = os.path.join(FORMAT_DIR, f"{format_id}.yml") - - # Write to the file - with open(filename, 'w') as file: - yaml.dump(ordered_data, - file, - default_flow_style=False, - Dumper=yaml.SafeDumper) - - return ordered_data - - -def load_format(id): - filename = os.path.join(FORMAT_DIR, f"{id}.yml") - if os.path.exists(filename): - with open(filename, 'r') as file: - data = yaml.safe_load(file) - return data - return None - - -def delete_format(id): - if is_format_used_in_profile(id): - return { - "error": "Format in use", - "message": "This format is being used in one or more profiles." - } - - filename = os.path.join(FORMAT_DIR, f"{id}.yml") - if os.path.exists(filename): - os.remove(filename) - return {"message": f"Format with ID {id} deleted."} - return {"error": f"Format with ID {id} not found."} diff --git a/backend/app/profile.py b/backend/app/profile.py deleted file mode 100644 index a822d4d..0000000 --- a/backend/app/profile.py +++ /dev/null @@ -1,119 +0,0 @@ -from flask import Blueprint, request, jsonify -from collections import OrderedDict -import os -import yaml -import logging -from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input -from .data_operations import load_all_profiles, load_all_formats - -bp = Blueprint('profile', __name__, url_prefix='/profile') -DATA_DIR = '/app/data' -PROFILE_DIR = os.path.join(DATA_DIR, 'db', 'profiles') - -# Ensure the directory exists -os.makedirs(PROFILE_DIR, exist_ok=True) - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -@bp.route('', methods=['GET', 'POST']) -def handle_profiles(): - if request.method == 'POST': - data = request.json - saved_data = save_profile(data) - return jsonify(saved_data), 201 - else: - profiles = load_all_profiles() - return jsonify(profiles) - -@bp.route('/', methods=['GET', 'PUT', 'DELETE']) -def handle_profile(id): - if request.method == 'GET': - profile = load_profile(id) - if profile: - return jsonify(profile) - return jsonify({"error": "Profile not found"}), 404 - elif request.method == 'PUT': - data = request.json - data['id'] = id - saved_data = save_profile(data) - return jsonify(saved_data) - elif request.method == 'DELETE': - if delete_profile(id): - return jsonify({"message": f"Profile with ID {id} deleted."}), 200 - return jsonify({"error": f"Profile with ID {id} not found."}), 404 - -def save_profile(data): - logger.info("Received data for saving profile: %s", data) - - # Sanitize and extract necessary fields - name = sanitize_input(data.get('name', '')) - description = sanitize_input(data.get('description', '')) - profile_id = data.get('id', None) - - # Determine if this is a new profile or an existing one - if profile_id == 0 or not profile_id: - profile_id = get_next_id(PROFILE_DIR) - logger.info("Assigned new profile ID: %d", profile_id) - date_created = get_current_timestamp() - else: - existing_filename = os.path.join(PROFILE_DIR, f"{profile_id}.yml") - if os.path.exists(existing_filename): - existing_data = load_profile(profile_id) - date_created = existing_data.get('date_created', get_current_timestamp()) - else: - raise FileNotFoundError(f"No existing file found for ID: {profile_id}") - - date_modified = get_current_timestamp() - - # Process tags - tags = [sanitize_input(tag) for tag in data.get('tags', [])] - - # Get all existing formats - all_formats = load_all_formats() - - # Process custom formats - custom_formats = {format['id']: format['score'] for format in data.get('custom_formats', [])} - - # Ensure all formats are included with a minimum score of 0 - final_custom_formats = [] - for format in all_formats: - final_custom_formats.append({ - 'id': format['id'], - 'score': max(custom_formats.get(format['id'], 0), 0) # Ensure minimum score of 0 - }) - - # Construct the ordered data - ordered_data = OrderedDict([ - ('id', profile_id), - ('name', name), - ('description', description), - ('date_created', str(date_created)), - ('date_modified', str(date_modified)), - ('tags', tags), - ('custom_formats', final_custom_formats) - ]) - - # Generate the filename using only the ID - filename = os.path.join(PROFILE_DIR, f"{profile_id}.yml") - - # Write to the file - with open(filename, 'w') as file: - yaml.dump(ordered_data, file, default_flow_style=False, Dumper=yaml.SafeDumper) - - return ordered_data - -def load_profile(id): - filename = os.path.join(PROFILE_DIR, f"{id}.yml") - if os.path.exists(filename): - with open(filename, 'r') as file: - data = yaml.safe_load(file) - return data - return None - -def delete_profile(id): - filename = os.path.join(PROFILE_DIR, f"{id}.yml") - if os.path.exists(filename): - os.remove(filename) - return True - return False \ No newline at end of file diff --git a/backend/app/regex.py b/backend/app/regex.py deleted file mode 100644 index f6729b7..0000000 --- a/backend/app/regex.py +++ /dev/null @@ -1,193 +0,0 @@ -from flask import Blueprint, request, jsonify -from collections import OrderedDict -import os -import yaml -import json -import logging -import subprocess -from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input -from .format import load_all_formats - -bp = Blueprint('regex', __name__, url_prefix='/regex') -DATA_DIR = '/app/data' -REGEX_DIR = os.path.join(DATA_DIR, 'db', 'regex_patterns') -logging.basicConfig(level=logging.DEBUG) - -# Ensure the directory exists -os.makedirs(REGEX_DIR, exist_ok=True) - -@bp.route('/regex101', methods=['POST']) -def regex101_proxy(): - try: - logging.debug(f"Received data from frontend: {request.json}") - - required_fields = ['regex', 'delimiter', 'flavor'] - for field in required_fields: - if field not in request.json: - logging.error(f"Missing required field: {field}") - return jsonify({"error": f"Missing required field: {field}"}), 400 - - request.json['flags'] = 'gmi' - - if 'testString' not in request.json: - request.json['testString'] = "Sample test string" - - request.json['unitTests'] = [ - { - "description": "Sample DOES_MATCH test", - "testString": request.json['testString'], - "criteria": "DOES_MATCH", - "target": "REGEX" - }, - { - "description": "Sample DOES_NOT_MATCH test", - "testString": "Non-matching string", - "criteria": "DOES_NOT_MATCH", - "target": "REGEX" - } - ] - - logging.debug(f"Final payload being sent to Regex101 API: {json.dumps(request.json, indent=2)}") - - data = json.dumps(request.json) - curl_command = ["curl", "-s", "-X", "POST", "-H", "Content-Type: application/json", "-d", data, "https://regex101.com/api/regex"] - - response = subprocess.check_output(curl_command) - - logging.debug(f"Response from regex101: {response.decode('utf-8')}") - - return jsonify(json.loads(response)), 200 - - except subprocess.CalledProcessError as e: - logging.error(f"cURL command failed: {str(e)}") - return jsonify({"error": "Failed to connect to regex101"}), 500 - - except Exception as e: - logging.error(f"An unexpected error occurred: {str(e)}") - return jsonify({"error": "An unexpected error occurred"}), 500 - -@bp.route('', methods=['GET', 'POST']) -def handle_regexes(): - if request.method == 'POST': - data = request.json - saved_data = save_regex(data) - return jsonify(saved_data), 201 - else: - regexes = load_all_regexes() - return jsonify(regexes) - -@bp.route('/', methods=['GET', 'PUT', 'DELETE']) -def handle_regex(id): - if request.method == 'GET': - regex = load_regex(id) - if regex: - return jsonify(regex) - return jsonify({"error": "Regex not found"}), 404 - elif request.method == 'PUT': - data = request.json - data['id'] = id - saved_data = save_regex(data) - return jsonify(saved_data) - elif request.method == 'DELETE': - result = delete_regex(id) - if "error" in result: - return jsonify(result), 400 - return jsonify(result), 200 - -def save_regex(data): - ordered_data = OrderedDict() - - if 'id' in data and data['id'] != 0: # Editing an existing regex - ordered_data['id'] = data['id'] - existing_filename = os.path.join(REGEX_DIR, f"{ordered_data['id']}.yml") - logging.debug(f"Existing filename determined: {existing_filename}") - - # Check if the existing file actually exists - if os.path.exists(existing_filename): - existing_data = load_regex(ordered_data['id']) - if existing_data: - ordered_data['date_created'] = existing_data.get('date_created', get_current_timestamp()) - else: - raise FileNotFoundError(f"Failed to load existing data for ID: {ordered_data['id']}") - else: - raise FileNotFoundError(f"No existing file found for ID: {ordered_data['id']}") - else: # New regex - ordered_data['id'] = get_next_id(REGEX_DIR) - ordered_data['date_created'] = get_current_timestamp() - logging.debug(f"New regex being created with ID: {ordered_data['id']}") - - # Fill in other details in the desired order - ordered_data['name'] = sanitize_input(data.get('name', '')) - ordered_data['description'] = sanitize_input(data.get('description', '')) - ordered_data['tags'] = [sanitize_input(tag) for tag in data.get('tags', [])] - ordered_data['pattern'] = data.get('pattern', '') # Store pattern as-is - ordered_data['regex101Link'] = data.get('regex101Link', '') - ordered_data['date_created'] = ordered_data.get('date_created', get_current_timestamp()) - ordered_data['date_modified'] = get_current_timestamp() - - # Always use the ID as the filename - new_filename = os.path.join(REGEX_DIR, f"{ordered_data['id']}.yml") - - logging.debug(f"Filename to be used: {new_filename}") - - # Save the updated data to the file, writing each field in the specified order - with open(new_filename, 'w') as file: - file.write(f"id: {ordered_data['id']}\n") - file.write(f"name: '{ordered_data['name']}'\n") - file.write(f"description: '{ordered_data['description']}'\n") - file.write(f"tags:\n") - for tag in ordered_data['tags']: - file.write(f" - {tag}\n") - file.write(f"pattern: '{ordered_data['pattern']}'\n") - file.write(f"regex101Link: '{ordered_data['regex101Link']}'\n") - file.write(f"date_created: '{ordered_data['date_created']}'\n") - file.write(f"date_modified: '{ordered_data['date_modified']}'\n") - - logging.debug(f"File saved: {new_filename}") - - return ordered_data - -def is_regex_used_in_format(regex_id): - formats = load_all_formats() - for format in formats: - for condition in format.get('conditions', []): - if condition.get('type') == 'regex' and condition.get('regex_id') == regex_id: - return True - return False - -def find_existing_file(regex_id): - """Find the existing filename for a given regex ID.""" - files = [f for f in os.listdir(REGEX_DIR) if f.startswith(f"{regex_id}_") and f.endswith('.yml')] - if files: - logging.debug(f"Existing file found: {files[0]}") - return os.path.join(REGEX_DIR, files[0]) - logging.debug(f"No existing file found for ID: {regex_id}") - return None - -def load_regex(id): - filename = os.path.join(REGEX_DIR, f"{id}.yml") - if os.path.exists(filename): - with open(filename, 'r') as file: - data = yaml.safe_load(file) - return data - return None - - -def load_all_regexes(): - regexes = [] - for filename in os.listdir(REGEX_DIR): - if filename.endswith('.yml'): - with open(os.path.join(REGEX_DIR, filename), 'r') as file: - data = yaml.safe_load(file) - regexes.append(data) - return regexes - -def delete_regex(id): - if is_regex_used_in_format(id): - return {"error": "Regex in use", "message": "This regex is being used in one or more custom formats."} - - filename = os.path.join(REGEX_DIR, f"{id}.yml") - if os.path.exists(filename): - os.remove(filename) - return {"message": f"Regex with ID {id} deleted."} - return {"error": f"Regex with ID {id} not found."} \ No newline at end of file diff --git a/backend/app/utils.py b/backend/app/utils.py deleted file mode 100644 index 4f818df..0000000 --- a/backend/app/utils.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import git -import datetime -import re -import yaml -from yaml import safe_load -from collections import OrderedDict -import logging - -def get_next_id(directory): - max_id = 0 - for filename in os.listdir(directory): - if filename.endswith('.yml'): - file_path = os.path.join(directory, filename) - with open(file_path, 'r') as file: - content = yaml.safe_load(file) - file_id = content.get('id', None) - if isinstance(file_id, int): - if file_id > max_id: - max_id = file_id - else: - logging.warning(f"File {filename} has an invalid or missing 'id': {file_id}") - return max_id + 1 - -def generate_filename(directory, id, name): - sanitized_name = name.replace(' ', '_').lower() - return os.path.join(directory, f"{id}_{sanitized_name}.yml") - -def get_current_timestamp(): - return datetime.datetime.now().isoformat() - -def sanitize_input(input_str): - if not isinstance(input_str, str): - return input_str - - # Remove any leading/trailing whitespace - sanitized_str = input_str.strip() - - # Replace tabs with spaces - sanitized_str = sanitized_str.replace('\t', ' ') - - # Collapse multiple spaces into a single space - sanitized_str = re.sub(r'\s+', ' ', sanitized_str) - - # Escape special characters for YAML - special_chars = r'[:#&*?|<>%@`]' - sanitized_str = re.sub(special_chars, lambda m: '\\' + m.group(0), sanitized_str) - - # If the string starts with any of these characters, quote the entire string - if re.match(r'^[\'"-]', sanitized_str): - sanitized_str = yaml.dump(sanitized_str, default_style='"').strip() - - # Handle multi-line strings - if '\n' in sanitized_str: - sanitized_str = '|\n' + '\n'.join(f' {line}' for line in sanitized_str.split('\n')) - - return sanitized_str - -def represent_ordereddict(dumper, data): - return dumper.represent_mapping('tag:yaml.org,2002:map', data.items()) - -yaml.add_representer(OrderedDict, represent_ordereddict, Dumper=yaml.SafeDumper) - diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 59b1057..0865cc1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,7 +4,8 @@ import RegexPage from './components/regex/RegexPage'; import FormatPage from './components/format/FormatPage'; import ProfilePage from './components/profile/ProfilePage'; import SettingsPage from './components/settings/SettingsPage'; -import Navbar from './components/ui/Navbar'; +import Navbar from '@ui/Navbar'; +import Footer from '@ui/Footer'; import {ToastContainer} from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; @@ -21,9 +22,9 @@ function App() { return ( -
+
-
+
} /> } /> @@ -32,6 +33,7 @@ function App() { } />
+
getAllItems('custom_format'), get: name => getItem('custom_format', name), create: data => createItem('custom_format', data), - update: (name, data) => updateItem('custom_format', name, data), - delete: name => deleteItem('custom_format', name) + update: (name, data, newName) => + updateItem('custom_format', name, data, newName), + delete: name => deleteItem('custom_format', name), + runTests: async ({conditions, tests}) => { + try { + const response = await axios.post( + `${BASE_URL}/custom_format/test`, + { + conditions, + tests + } + ); + return response.data; + } catch (error) { + return handleError(error, 'run tests'); + } + } }; export const RegexPatterns = { @@ -88,7 +103,6 @@ export const RegexPatterns = { 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( diff --git a/frontend/src/assets/logo/Discord.svg b/frontend/src/assets/logo/Discord.svg new file mode 100644 index 0000000..7162f1a --- /dev/null +++ b/frontend/src/assets/logo/Discord.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/logo/GitHub.svg b/frontend/src/assets/logo/GitHub.svg new file mode 100644 index 0000000..a9dfc12 --- /dev/null +++ b/frontend/src/assets/logo/GitHub.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/condition/ConditionCard.jsx b/frontend/src/components/condition/ConditionCard.jsx deleted file mode 100644 index 6d41dcd..0000000 --- a/frontend/src/components/condition/ConditionCard.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; - -function ConditionCard({ condition, onEdit }) { - return ( -
onEdit(condition)} - className="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-4 shadow-sm cursor-pointer hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-shadow" - > -
-

{condition.name}

- - {condition.type.charAt(0).toUpperCase() + condition.type.slice(1)} - -
- {condition.type === 'regex' && ( -
-
-            Regex ID: {condition.regex_id || condition.id}  {/* Display regex_id */}
-          
-
- )} - {condition.type === 'size' && ( -

- Size: {condition.min || 'Any'} - {condition.max || 'Any'} bytes -

- )} - {condition.type === 'flag' && ( -

- Flag: {condition.flag} -

- )} -
- - {condition.required ? 'Required' : 'Optional'} - - {condition.negate && ( - - Negated - - )} -
-
- ); -} - -ConditionCard.propTypes = { - condition: PropTypes.shape({ - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - regex_id: PropTypes.number, // Updated to regex_id - min: PropTypes.number, - max: PropTypes.number, - flag: PropTypes.string, - negate: PropTypes.bool, - required: PropTypes.bool, - }).isRequired, - onEdit: PropTypes.func.isRequired, -}; - -export default ConditionCard; diff --git a/frontend/src/components/condition/ConditionModal.jsx b/frontend/src/components/condition/ConditionModal.jsx deleted file mode 100644 index a6b48ce..0000000 --- a/frontend/src/components/condition/ConditionModal.jsx +++ /dev/null @@ -1,260 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; -import Modal from '../ui/Modal'; - -function ConditionModal({ - condition = null, - isOpen, - onClose, - onSave, - onDelete = null, - regexes, - level = 0 -}) { - const [name, setName] = useState(''); - const [type, setType] = useState('regex'); - const [regexId, setRegexId] = useState(null); - const [minSize, setMinSize] = useState(''); - const [maxSize, setMaxSize] = useState(''); - const [flag, setFlag] = useState(''); - const [negate, setNegate] = useState(false); - const [required, setRequired] = useState(false); - const [error, setError] = useState(''); - const initialConditionRef = useRef(condition); - - useEffect(() => { - if (isOpen) { - initialConditionRef.current = condition; - if (condition) { - setName(condition.name); - setType(condition.type); - setRegexId(condition.regex_id || condition.id); // Read regex_id instead of id - setMinSize(condition.min?.toString() || ''); - setMaxSize(condition.max?.toString() || ''); - setFlag(condition.flag || ''); - setNegate(condition.negate || false); - setRequired(condition.required || false); - } else { - resetForm(); - } - } - }, [condition, isOpen]); - - const resetForm = () => { - setName(''); - setType('regex'); - setRegexId(null); - setMinSize(''); - setMaxSize(''); - setFlag(''); - setNegate(false); - setRequired(false); - setError(''); - }; - - const handleSave = () => { - if (!name.trim()) { - setError('Condition name is required.'); - return; - } - - if (type === 'regex' && !regexId) { - setError('Please select a regex pattern.'); - return; - } - - if (type === 'size' && (!minSize || !maxSize)) { - setError('Both minimum and maximum sizes are required.'); - return; - } - - if (type === 'flag' && !flag) { - setError('Please select a flag.'); - return; - } - - const newCondition = { - type, - name, - negate, - required, - ...(type === 'regex' ? { regex_id: regexId } : {}), // Save regex_id - ...(type === 'size' ? { min: parseInt(minSize), max: parseInt(maxSize) } : {}), - ...(type === 'flag' ? { flag } : {}), - }; - - onSave(newCondition); - onClose(); - }; - - const handleDelete = () => { - if (initialConditionRef.current && onDelete) { - onDelete(initialConditionRef.current); - onClose(); - } - }; - - return ( - - {error &&
{error}
} -
- - setName(e.target.value)} - placeholder="Enter condition name" - className="w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- - -
- {type === 'regex' && ( -
- - -
- )} - {type === 'size' && ( - <> -
- - setMinSize(e.target.value)} - placeholder="Enter minimum size" - className="w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- - setMaxSize(e.target.value)} - placeholder="Enter maximum size" - className="w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
- - )} - {type === 'flag' && ( -
- - -
- )} -
- -
-
- -
-
- - {initialConditionRef.current && onDelete && ( - - )} -
-
- ); -} - -ConditionModal.propTypes = { - condition: PropTypes.shape({ - type: PropTypes.string, - name: PropTypes.string, - regex_id: PropTypes.number, // Updated to regex_id - min: PropTypes.number, - max: PropTypes.number, - flag: PropTypes.string, - negate: PropTypes.bool, - required: PropTypes.bool, - }), - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, - onDelete: PropTypes.func, - regexes: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - })).isRequired, - level: PropTypes.number, -}; - -export default ConditionModal; diff --git a/frontend/src/components/format/AddFormatTestModal.jsx b/frontend/src/components/format/AddFormatTestModal.jsx new file mode 100644 index 0000000..f20cbe9 --- /dev/null +++ b/frontend/src/components/format/AddFormatTestModal.jsx @@ -0,0 +1,141 @@ +import React, {useState, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import Modal from '@ui/Modal'; + +const AddFormatTestModal = ({ + isOpen, + onClose, + onAdd, + tests, + editTest = null +}) => { + const [input, setInput] = useState(''); + const [expected, setExpected] = useState(true); + + // Reset form when opening modal, handling both new and edit cases + useEffect(() => { + if (isOpen) { + if (editTest) { + setInput(editTest.input); + setExpected(editTest.expected); + } else { + setInput(''); + setExpected(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, + passes: false, + lastRun: null, + conditionResults: [] // This will be populated when the test is run + }; + + onAdd(testData); + handleClose(); + }; + + const handleClose = () => { + setInput(''); + setExpected(true); + onClose(); + }; + + return ( + + + +
+ }> +
+
+ + 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 release title to test...' + autoFocus + /> +
+ +
+ +
+ + +
+
+
+ + ); +}; + +AddFormatTestModal.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 + }) +}; + +export default AddFormatTestModal; diff --git a/frontend/src/components/format/FormatCard.jsx b/frontend/src/components/format/FormatCard.jsx index dcd3740..3a028e6 100644 --- a/frontend/src/components/format/FormatCard.jsx +++ b/frontend/src/components/format/FormatCard.jsx @@ -1,78 +1,148 @@ +import React from 'react'; import PropTypes from 'prop-types'; +import {Copy} from 'lucide-react'; -function FormatCard({ format, onEdit, onClone, showDate, formatDate }) { - return ( -
onEdit(format)} - > -
-

- {format.name} -

- -
-

{format.description}

- {showDate && ( -

- Modified: {formatDate(format.date_modified)} -

- )} -
- {format.conditions.map((condition, index) => ( - - {condition.name} - - ))} -
-
- {format.tags && format.tags.map(tag => ( - - {tag} - - ))} -
-
- ); +function FormatCard({format, onEdit, onClone, sortBy}) { + const {content} = format; + const totalTests = content.tests?.length || 0; + const passedTests = content.tests?.filter(t => t.passes)?.length || 0; + const passRate = Math.round((passedTests / totalTests) * 100) || 0; + + const getConditionStyle = condition => { + if (condition.required && condition.negate) { + return 'bg-orange-100 border-orange-200 text-orange-700 dark:bg-orange-900/30 dark:border-orange-700 dark:text-orange-300'; + } + if (condition.required) { + return 'bg-green-100 border-green-200 text-green-700 dark:bg-green-900/30 dark:border-green-700 dark:text-green-300'; + } + if (condition.negate) { + return 'bg-red-100 border-red-200 text-red-700 dark:bg-red-900/30 dark:border-red-700 dark:text-red-300'; + } + return 'bg-blue-100 border-blue-200 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'; + }; + + return ( +
onEdit(format)}> +
+ {/* Header Section */} +
+
+

+ {content.name} +

+ {(sortBy === 'dateModified' || + sortBy === 'dateCreated') && ( +

+ {sortBy === 'dateModified' + ? 'Modified' + : 'Created'} + :{' '} + {new Date( + sortBy === 'dateModified' + ? format.modified_date + : format.created_date + ).toLocaleString()} +

+ )} +
+ +
+ + {/* Rest of the component remains the same */} + {content.description && ( +

+ {content.description} +

+ )} + +
+
+ {content.conditions?.map((condition, index) => ( + + {condition.name} + + ))} +
+ + {totalTests > 0 && ( +
+ = 80 + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-red-600 dark:text-red-400' + }`}> + {passRate}% Pass Rate + + + ({passedTests}/{totalTests}) + +
+ )} +
+ + {content.tags?.length > 0 && ( +
+ {content.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+
+ ); } FormatCard.propTypes = { - format: PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - description: PropTypes.string, - conditions: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string.isRequired, - negate: PropTypes.bool, - required: PropTypes.bool, - })), - date_modified: PropTypes.string.isRequired, - tags: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - onEdit: PropTypes.func.isRequired, - onClone: PropTypes.func.isRequired, - showDate: PropTypes.bool.isRequired, - formatDate: PropTypes.func.isRequired, + format: PropTypes.shape({ + file_name: PropTypes.string.isRequired, + modified_date: PropTypes.string.isRequired, + created_date: PropTypes.string.isRequired, + content: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string, + conditions: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + pattern: PropTypes.string, + required: PropTypes.bool, + negate: PropTypes.bool + }) + ), + tags: PropTypes.arrayOf(PropTypes.string), + tests: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number.isRequired, + input: PropTypes.string.isRequired, + expected: PropTypes.bool.isRequired, + passes: PropTypes.bool.isRequired + }) + ) + }).isRequired + }).isRequired, + onEdit: PropTypes.func.isRequired, + onClone: PropTypes.func.isRequired, + sortBy: PropTypes.string.isRequired }; export default FormatCard; diff --git a/frontend/src/components/format/FormatConditionsTab.jsx b/frontend/src/components/format/FormatConditionsTab.jsx new file mode 100644 index 0000000..60f6694 --- /dev/null +++ b/frontend/src/components/format/FormatConditionsTab.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Plus, InfoIcon} from 'lucide-react'; +import {usePatterns} from '@hooks/usePatterns'; +import {createCondition} from './conditions/conditionTypes'; +import ConditionCard from './conditions/ConditionCard'; + +const FormatConditionsTab = ({conditions, onConditionsChange}) => { + const {patterns, isLoading, error} = usePatterns(); + + const handleAddCondition = () => { + onConditionsChange([...conditions, createCondition()]); + }; + + const handleConditionChange = (index, updatedCondition) => { + const newConditions = [...conditions]; + newConditions[index] = updatedCondition; + onConditionsChange(newConditions); + }; + + const handleConditionDelete = index => { + onConditionsChange(conditions.filter((_, i) => i !== index)); + }; + + if (isLoading) { + return
Loading patterns...
; + } + + if (error) { + return
Error loading patterns: {error}
; + } + + return ( +
+ {/* Info Box */} +
+ +

+ Conditions define how this format matches media releases. + Each condition can be marked as required or negated. + Required conditions must match for the format to apply, + while negated conditions must not match. Use patterns to + match against release titles and groups. +

+
+ + {/* Existing Conditions */} +
+ {conditions.map((condition, index) => ( + + handleConditionChange(index, updatedCondition) + } + onDelete={() => handleConditionDelete(index)} + patterns={patterns} + /> + ))} +
+ + {/* Add New Condition Card */} +
+
+ + + Add New Condition + +
+
+
+ ); +}; + +FormatConditionsTab.propTypes = { + conditions: PropTypes.arrayOf(PropTypes.object).isRequired, + onConditionsChange: PropTypes.func.isRequired +}; + +export default FormatConditionsTab; diff --git a/frontend/src/components/format/FormatGeneralTab.jsx b/frontend/src/components/format/FormatGeneralTab.jsx new file mode 100644 index 0000000..5d0816e --- /dev/null +++ b/frontend/src/components/format/FormatGeneralTab.jsx @@ -0,0 +1,160 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import TextArea from '../ui/TextArea'; + +const FormatGeneralTab = ({ + name, + description, + tags, + error, + onNameChange, + onDescriptionChange, + onAddTag, + onRemoveTag +}) => { + 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 ( +
+ {error && ( +
+

+ {error} +

+
+ )} +
+ {/* Name Input */} +
+ + onNameChange(e.target.value)} + placeholder='Enter format 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' + /> +
+ + {/* Description */} +
+ +