From ae75baca26760dc2b7f259c5efa2784a5670b012 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 19 Aug 2024 00:29:17 +0930 Subject: [PATCH] feat(backend): Major overhaul of backend structure, git integration, and settings management - **Backend Refactor:** - Merged route and operation files for regex and format. - Updated directory structure and consolidated utility functions. - Removed unnecessary app.py, using `__init__.py` for app creation. - **Git Integration:** - Enhanced git cloning and merging methods, ensuring accurate local file updates. - Implemented comprehensive git status fetching with improved file status display and error handling. - Added branch management features, including branch creation, checkout, deletion, and associated UI improvements. - Integrated loading indicators and fun messages for better user feedback during git operations. - **Settings Manager Enhancements:** - Expanded Git status display, including connected repository link, branch information, and detailed change listings. - Added revert functionality for individual files and all changes, with conditional UI updates based on file statuses. - Integrated `react-toastify` for alert notifications with improved styling. - Improved file name parsing, handling of file paths, and consistent API request structure. - Added UI components for a smooth tab transition and enhanced settings layout. - **General Improvements:** - Revised sanitization logic for less aggressive handling, particularly for regex101 links. - Refactored backend logic to improve performance, specifically optimizing git status checks. - Implemented dynamic retrieval of default branches and enhanced handling of IDs in files. **fixes, refactors, and additional features included**: - Bug fixes for branch handling, git status accuracy, and file name adjustments. - Improved error handling, logging, and user feedback across various components. --- backend/Dockerfile | 2 +- backend/app.py | 14 - backend/app/__init__.py | 27 + .../{utils/format_operations.py => format.py} | 89 ++- backend/app/regex.py | 178 +++++ backend/app/routes/__init__.py | 0 backend/app/routes/format_routes.py | 31 - backend/app/routes/regex_routes.py | 103 --- backend/app/settings.py | 712 ++++++++++++++++++ backend/app/utils.py | 59 ++ backend/app/utils/__init__.py | 0 backend/app/utils/file_utils.py | 24 - backend/app/utils/regex_operations.py | 71 -- .../custom_formats/2_release_group_tier_1.yml | 32 - .../custom_formats/3_release_group_tier_2.yml | 32 - backend/regex_patterns/1_ebp.yml | 10 - backend/regex_patterns/2_don.yml | 10 - backend/regex_patterns/3_d-z0n3.yml | 10 - backend/regex_patterns/4_geek.yml | 10 - backend/regex_patterns/5_tayto.yml | 10 - backend/regex_patterns/6_zq.yml | 10 - backend/requirements.txt | 3 +- backend/run.py | 5 + frontend/.gitignore | 2 + frontend/package-lock.json | 101 ++- frontend/package.json | 8 +- frontend/src/App.jsx | 36 +- frontend/src/api/api.js | 140 +++- frontend/src/components/regex/RegexCard.jsx | 8 +- frontend/src/components/regex/RegexModal.jsx | 32 +- .../src/components/settings/CommitSection.jsx | 87 +++ .../src/components/settings/DiffViewer.jsx | 59 ++ .../settings/SettingsBranchModal.jsx | 262 +++++++ .../components/settings/SettingsManager.jsx | 438 +++++++++++ .../src/components/settings/SettingsModal.jsx | 64 ++ frontend/src/components/ui/Alert.jsx | 50 ++ frontend/src/components/ui/Navbar.jsx | 47 +- frontend/src/components/ui/TextArea.jsx | 15 + frontend/src/components/ui/Tooltip.jsx | 17 + frontend/src/main.jsx | 6 +- frontend/tailwind.config.js | 10 + 41 files changed, 2368 insertions(+), 456 deletions(-) delete mode 100644 backend/app.py rename backend/app/{utils/format_operations.py => format.py} (52%) create mode 100644 backend/app/regex.py delete mode 100644 backend/app/routes/__init__.py delete mode 100644 backend/app/routes/format_routes.py delete mode 100644 backend/app/routes/regex_routes.py create mode 100644 backend/app/settings.py create mode 100644 backend/app/utils.py delete mode 100644 backend/app/utils/__init__.py delete mode 100644 backend/app/utils/file_utils.py delete mode 100644 backend/app/utils/regex_operations.py delete mode 100644 backend/custom_formats/2_release_group_tier_1.yml delete mode 100644 backend/custom_formats/3_release_group_tier_2.yml delete mode 100644 backend/regex_patterns/1_ebp.yml delete mode 100644 backend/regex_patterns/2_don.yml delete mode 100644 backend/regex_patterns/3_d-z0n3.yml delete mode 100644 backend/regex_patterns/4_geek.yml delete mode 100644 backend/regex_patterns/5_tayto.yml delete mode 100644 backend/regex_patterns/6_zq.yml create mode 100644 backend/run.py create mode 100644 frontend/src/components/settings/CommitSection.jsx create mode 100644 frontend/src/components/settings/DiffViewer.jsx create mode 100644 frontend/src/components/settings/SettingsBranchModal.jsx create mode 100644 frontend/src/components/settings/SettingsManager.jsx create mode 100644 frontend/src/components/settings/SettingsModal.jsx create mode 100644 frontend/src/components/ui/Alert.jsx create mode 100644 frontend/src/components/ui/TextArea.jsx create mode 100644 frontend/src/components/ui/Tooltip.jsx diff --git a/backend/Dockerfile b/backend/Dockerfile index c8bfca7..65f8046 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["python", "app.py"] +CMD ["python", "run.py"] diff --git a/backend/app.py b/backend/app.py deleted file mode 100644 index 117111d..0000000 --- a/backend/app.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import Flask -from flask_cors import CORS -from app.routes import regex_routes, format_routes - -def create_app(): - app = Flask(__name__) - CORS(app, resources={r"/*": {"origins": "*"}}) - app.register_blueprint(regex_routes.bp) - app.register_blueprint(format_routes.bp) - return app - -if __name__ == '__main__': - app = create_app() - app.run(debug=True, host='0.0.0.0') diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e69de29..998dcab 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -0,0 +1,27 @@ +from flask import Flask +from flask_cors import CORS +from .regex import bp as regex_bp +from .format import bp as format_bp +from .settings import bp as settings_bp +import os + +REGEX_DIR = os.path.join('data', 'db', 'regex_patterns') +FORMAT_DIR = os.path.join('data', 'db', 'custom_formats') + +def create_app(): + app = Flask(__name__) + CORS(app, resources={r"/*": {"origins": "*"}}) + + # Initialize directories to avoid issues with non-existing directories + initialize_directories() + + # Register Blueprints + app.register_blueprint(regex_bp) + app.register_blueprint(format_bp) + app.register_blueprint(settings_bp) + + return app + +def initialize_directories(): + os.makedirs(REGEX_DIR, exist_ok=True) + os.makedirs(FORMAT_DIR, exist_ok=True) diff --git a/backend/app/utils/format_operations.py b/backend/app/format.py similarity index 52% rename from backend/app/utils/format_operations.py rename to backend/app/format.py index a9b728b..a76d2a1 100644 --- a/backend/app/utils/format_operations.py +++ b/backend/app/format.py @@ -1,48 +1,67 @@ +from flask import Blueprint, request, jsonify +from collections import OrderedDict import os import yaml import logging -from collections import OrderedDict -from .file_utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input +from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input -FORMAT_DIR = 'custom_formats' +bp = Blueprint('format', __name__, url_prefix='/format') +FORMAT_DIR = os.path.join('data', 'db', 'custom_formats') -# Set up basic logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def represent_ordereddict(dumper, data): - return dumper.represent_mapping('tag:yaml.org,2002:map', data.items()) +@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) -yaml.add_representer(OrderedDict, represent_ordereddict, Dumper=yaml.SafeDumper) +@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': + if delete_format(id): + return jsonify({"message": f"Format with ID {id} deleted."}), 200 + return jsonify({"error": f"Format with ID {id} not found."}), 404 def save_format(data): - # Log the received data logger.info("Received data for saving format: %s", data) - # Sanitize inputs + # Sanitize and extract necessary fields name = sanitize_input(data.get('name', '')) description = sanitize_input(data.get('description', '')) - - # Determine if it's a new format or an edit format_id = data.get('id', None) - if format_id == 0: # If id is 0, treat it as a new format + + # 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_data = load_format(format_id) - if existing_data: - date_created = existing_data.get('date_created') - old_filename = generate_filename(FORMAT_DIR, format_id, existing_data['name']) - # Delete the old file - if os.path.exists(old_filename): - os.remove(old_filename) + 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: - date_created = get_current_timestamp() + raise FileNotFoundError(f"No existing file found for ID: {format_id}") date_modified = get_current_timestamp() - # Prepare conditions + # Process conditions conditions = [] for condition in data.get('conditions', []): logger.info("Processing condition: %s", condition) @@ -61,10 +80,10 @@ def save_format(data): cond_dict['flag'] = sanitize_input(condition['flag']) conditions.append(cond_dict) - # Prepare tags + # Process tags tags = [sanitize_input(tag) for tag in data.get('tags', [])] - # Create ordered data dictionary + # Construct the ordered data ordered_data = OrderedDict([ ('id', format_id), ('name', name), @@ -75,24 +94,24 @@ def save_format(data): ('tags', tags) ]) - # Generate new filename based on the updated name - new_filename = generate_filename(FORMAT_DIR, format_id, name) - - # Write the YAML file with the new name - with open(new_filename, 'w') as file: + # 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): - files = [f for f in os.listdir(FORMAT_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')] - if files: - filename = os.path.join(FORMAT_DIR, files[0]) + 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 load_all_formats(): formats = [] for filename in os.listdir(FORMAT_DIR): @@ -103,8 +122,8 @@ def load_all_formats(): return formats def delete_format(id): - files = [f for f in os.listdir(FORMAT_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')] - if files: - os.remove(os.path.join(FORMAT_DIR, files[0])) + filename = os.path.join(FORMAT_DIR, f"{id}.yml") + if os.path.exists(filename): + os.remove(filename) return True return False diff --git a/backend/app/regex.py b/backend/app/regex.py new file mode 100644 index 0000000..313094e --- /dev/null +++ b/backend/app/regex.py @@ -0,0 +1,178 @@ +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 + +bp = Blueprint('regex', __name__, url_prefix='/regex') +REGEX_DIR = os.path.join('data', 'db', 'regex_patterns') + +logging.basicConfig(level=logging.DEBUG) + +@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': + if delete_regex(id): + return jsonify({"message": f"Regex with ID {id} deleted."}), 200 + return jsonify({"error": f"Regex with ID {id} not found."}), 404 + +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 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): + filename = os.path.join(REGEX_DIR, f"{id}.yml") + if os.path.exists(filename): + os.remove(filename) + return True + return False diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/routes/format_routes.py b/backend/app/routes/format_routes.py deleted file mode 100644 index 932eba3..0000000 --- a/backend/app/routes/format_routes.py +++ /dev/null @@ -1,31 +0,0 @@ -from flask import Blueprint, request, jsonify -from app.utils.format_operations import save_format, load_all_formats, delete_format, load_format - -bp = Blueprint('format', __name__, url_prefix='/format') - -@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': - if delete_format(id): - return jsonify({"message": f"Format with ID {id} deleted."}), 200 - return jsonify({"error": f"Format with ID {id} not found."}), 404 \ No newline at end of file diff --git a/backend/app/routes/regex_routes.py b/backend/app/routes/regex_routes.py deleted file mode 100644 index f3d8982..0000000 --- a/backend/app/routes/regex_routes.py +++ /dev/null @@ -1,103 +0,0 @@ -from flask import Blueprint, request, jsonify -from app.utils.regex_operations import save_regex, load_all_regexes, delete_regex, load_regex -from app.utils.format_operations import load_all_formats -import json -import logging -import subprocess - -bp = Blueprint('regex', __name__, url_prefix='/regex') -logging.basicConfig(level=logging.DEBUG) - -@bp.route('/regex101', methods=['POST']) -def regex101_proxy(): - try: - # Log the incoming request data - logging.debug(f"Received data from frontend: {request.json}") - - # Validate the request data before sending - 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 - - # Set default flags to 'gmi' for global, multiline, and case-insensitive matching - request.json['flags'] = 'gmi' - - # Include a separate test string if not provided - if 'testString' not in request.json: - request.json['testString'] = "Sample test string" - - # Always include unit tests with every request - request.json['unitTests'] = [ - { - "description": "Sample DOES_MATCH test", - "testString": request.json['testString'], # Use the main test string - "criteria": "DOES_MATCH", - "target": "REGEX" - }, - { - "description": "Sample DOES_NOT_MATCH test", - "testString": "Non-matching string", # This should not match the regex - "criteria": "DOES_NOT_MATCH", - "target": "REGEX" - } - ] - - # Log the complete payload before sending - logging.debug(f"Final payload being sent to Regex101 API: {json.dumps(request.json, indent=2)}") - - # Construct the data payload for curl - data = json.dumps(request.json) - curl_command = ["curl", "-s", "-X", "POST", "-H", "Content-Type: application/json", "-d", data, "https://regex101.com/api/regex"] - - # Execute the curl command - response = subprocess.check_output(curl_command) - - # Log the response from regex101 - logging.debug(f"Response from regex101: {response.decode('utf-8')}") - - # Return the JSON response back to the frontend - 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': - # Check if the regex is used in any custom formats - formats_using_regex = [format for format in load_all_formats() if any(condition.get('regex_id') == id for condition in format.get('conditions', []))] - if formats_using_regex: - return jsonify({"error": "Regex in use"}), 409 # 409 Conflict if in use - - if delete_regex(id): - return jsonify({"message": f"Regex with ID {id} deleted."}), 200 - return jsonify({"error": f"Regex with ID {id} not found."}), 404 \ No newline at end of file diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 0000000..5c658df --- /dev/null +++ b/backend/app/settings.py @@ -0,0 +1,712 @@ +import os +import yaml +import git +from flask import Blueprint, request, jsonify +from git.exc import GitCommandError, InvalidGitRepositoryError +import shutil +import subprocess +import logging +from datetime import datetime +import json +import requests + +logging.basicConfig(level=logging.DEBUG) +logging.getLogger('git').setLevel(logging.WARNING) +logger = logging.getLogger(__name__) + +bp = Blueprint('settings', __name__, url_prefix='/settings') + +SETTINGS_FILE = 'data/config/settings.yml' + +def load_settings(): + try: + if not os.path.exists(SETTINGS_FILE): + return None # Indicate that the settings file does not exist + + with open(SETTINGS_FILE, 'r') as file: + settings = yaml.safe_load(file) + return settings if settings else None + except Exception as e: + return None + +def save_settings(settings): + try: + os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True) + with open(SETTINGS_FILE, 'w') as file: + yaml.dump(settings, file) + except Exception as e: + pass + +def validate_git_token(repo_url, git_token): + try: + parts = repo_url.strip('/').split('/') + if len(parts) < 2: + return False + + repo_owner, repo_name = parts[-2], parts[-1].replace('.git', '') + api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}" + + curl_command = [ + 'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', + '-H', f'Authorization: Bearer {git_token}', + '-H', 'Accept: application/vnd.github+json', + api_url + ] + + result = subprocess.run(curl_command, capture_output=True, text=True) + http_status_code = int(result.stdout.strip()) + + if http_status_code == 200: + return True + elif http_status_code == 401: + return False + else: + return False + except Exception as e: + return False + +def parse_diff(diff_text): + diff_lines = diff_text.splitlines() + parsed_diff = [] + + local_version = [] + incoming_version = [] + in_local = False + in_incoming = False + + for line in diff_lines: + if line.startswith('--- a/'): + in_local = True + in_incoming = False + elif line.startswith('+++ b/'): + in_incoming = True + in_local = False + elif line.startswith('@@'): + # Context lines that indicate a change + parsed_diff.append({'context': line, 'type': 'context'}) + elif line.startswith('-'): + local_version.append(line[1:]) + parsed_diff.append({'text': line[1:], 'type': 'local'}) + elif line.startswith('+'): + incoming_version.append(line[1:]) + parsed_diff.append({'text': line[1:], 'type': 'incoming'}) + else: + parsed_diff.append({'text': line, 'type': 'unchanged'}) + + return parsed_diff, local_version, incoming_version + +def get_changes(repo): + status = repo.git.status('--porcelain', '-z').split('\0') + logger.debug(f"Raw porcelain status: {status}") + + changes = [] + for item in status: + if not item: + continue + + logger.debug(f"Processing status item: {item}") + + if len(item) < 4: + logger.warning(f"Unexpected status item format: {item}") + continue + + x, y, file_path = item[0], item[1], item[3:] + logger.debug(f"Parsed status: x={x}, y={y}, file_path={file_path}") + + full_path = os.path.join(repo.working_dir, file_path) + + if os.path.isdir(full_path): + logger.debug(f"Found directory: {file_path}, going through folder.") + for root, dirs, files in os.walk(full_path): + for file in files: + if file.endswith('.yml') or file.endswith('.yaml'): + file_full_path = os.path.join(root, file) + logger.debug(f"Found file: {file_full_path}, going through file.") + file_data = extract_data_from_yaml(file_full_path) + if file_data: + logger.debug(f"File contents: {file_data}") + logger.debug(f"Found ID: {file_data.get('id')}") + logger.debug(f"Found Name: {file_data.get('name')}") + changes.append({ + 'name': file_data.get('name', ''), + 'id': file_data.get('id', ''), + 'type': determine_type(file_path), + 'status': interpret_git_status(x, y), + 'file_path': os.path.relpath(file_full_path, repo.working_dir), + 'staged': x != '?' and x != ' ', + 'modified': y == 'M', + 'deleted': x == 'D' or y == 'D' + }) + else: + logger.debug(f"No data extracted from file: {file_full_path}") + else: + logger.debug(f"Found file: {full_path}, going through file.") + file_data = extract_data_from_yaml(full_path) + if file_data or x == 'D' or y == 'D': # Ensure that deleted files are handled + if not file_data: + logger.debug(f"No data found, using default file name as name") + file_data = {'name': os.path.basename(file_path).replace('.yml', ''), 'id': None} + logger.debug(f"File contents: {file_data}") + logger.debug(f"Found ID: {file_data.get('id')}") + logger.debug(f"Found Name: {file_data.get('name')}") + changes.append({ + 'name': file_data.get('name', ''), + 'id': file_data.get('id', ''), + 'type': determine_type(file_path), + 'status': interpret_git_status(x, y), + 'file_path': file_path, + 'staged': x != '?' and x != ' ', + 'modified': y == 'M', + 'deleted': x == 'D' or y == 'D' + }) + + logger.debug(f"Final changes: {json.dumps(changes, indent=2)}") + return changes + +def extract_data_from_yaml(file_path): + logger.debug(f"Extracting data from file: {file_path}") + try: + with open(file_path, 'r') as f: + content = yaml.safe_load(f) + logger.debug(f"File content: {content}") # Log the full file content + if content is None: + logger.error(f"Failed to parse YAML file or file is empty: {file_path}") + return None + + # Check if expected keys are in the content + if 'name' not in content or 'id' not in content: + logger.warning(f"'name' or 'id' not found in file: {file_path}") + + return { + 'name': content.get('name'), + 'id': content.get('id') + } + except Exception as e: + logger.warning(f"Error reading file {file_path}: {str(e)}") + return None + + +def determine_type(file_path): + if 'regex_patterns' in file_path: + return 'Regex Pattern' + elif 'custom_formats' in file_path: + return 'Custom Format' + return 'Unknown' + +def interpret_git_status(x, y): + if x == 'D' or y == 'D': + return 'Deleted' + elif x == 'A': + return 'Added' + elif x == 'M' or y == 'M': + return 'Modified' + elif x == 'R': + return 'Renamed' + elif x == 'C': + return 'Copied' + elif x == 'U': + return 'Updated but unmerged' + elif x == '?' and y == '?': + return 'Untracked' + else: + return 'Unknown' + +def get_staged_files(repo): + return [item.a_path for item in repo.index.diff('HEAD')] + +def get_commits_ahead(repo, branch): + return list(repo.iter_commits(f'origin/{branch}..{branch}')) + +def get_commits_behind(repo, branch): + return list(repo.iter_commits(f'{branch}..origin/{branch}')) + +class SettingsManager: + def __init__(self): + self.settings = load_settings() + self.repo_url = self.settings.get('gitRepo') if self.settings else None + self.repo_path = self.settings.get('localRepoPath') if self.settings else None + + def get_default_branch(self): + try: + logger.info(f"Fetching default branch for repo: {self.repo_url}") + parts = self.repo_url.strip('/').split('/') + if len(parts) < 2: + logger.error("Invalid repository URL") + return None + + repo_owner, repo_name = parts[-2], parts[-1].replace('.git', '') + api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}" + + headers = { + 'Authorization': f'Bearer {self.settings["gitToken"]}', + 'Accept': 'application/vnd.github+json' + } + response = requests.get(api_url, headers=headers) + + if response.status_code == 200: + repo_info = response.json() + default_branch = repo_info.get('default_branch', 'main') + logger.info(f"Default branch: {default_branch}") + return default_branch + else: + logger.error(f"Failed to fetch default branch, status code: {response.status_code}") + return None + except Exception as e: + logger.error(f"Error fetching default branch: {str(e)}", exc_info=True) + return None + + def clone_repository(self): + try: + if not validate_git_token(self.repo_url, self.settings["gitToken"]): + return False, "Invalid Git token. Please check your credentials and try again." + + default_branch = self.get_default_branch() + if not default_branch: + return False, "Unable to determine the default branch." + + temp_dir = f"{self.repo_path}_temp" + auth_repo_url = self.repo_url.replace('https://', f'https://{self.settings["gitToken"]}:x-oauth-basic@') + git.Repo.clone_from(auth_repo_url, temp_dir, branch=default_branch, single_branch=True) + + backup_dir = f"{self.repo_path}_backup" + if os.path.exists(self.repo_path): + shutil.move(self.repo_path, backup_dir) + + shutil.move(temp_dir, self.repo_path) + + for folder_name in ['regex_patterns', 'custom_formats']: + folder_path = os.path.join(self.repo_path, folder_name) + backup_folder_path = os.path.join(backup_dir, folder_name) + + if not os.path.exists(folder_path): + os.makedirs(folder_path) + logger.debug(f"Created missing folder: {folder_name} in the cloned repository.") + + cloned_files = [f for f in os.listdir(folder_path) if f.endswith('.yml')] + max_id = max([int(f.split('_')[0]) for f in cloned_files], default=0) + + if os.path.exists(backup_folder_path): + local_files = [f for f in os.listdir(backup_folder_path) if f.endswith('.yml')] + for file_name in local_files: + old_file_path = os.path.join(backup_folder_path, file_name) + with open(old_file_path, 'r') as file: + data = yaml.safe_load(file) + max_id += 1 + data['id'] = max_id + new_file_name = f"{max_id}_{data['name'].replace(' ', '_').lower()}.yml" + new_file_path = os.path.join(folder_path, new_file_name) + with open(new_file_path, 'w') as file: + yaml.dump(data, file) + + if os.path.exists(backup_dir): + shutil.rmtree(backup_dir) + + return True, "Repository cloned successfully and local files updated" + except GitCommandError as e: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + if os.path.exists(backup_dir): + shutil.move(backup_dir, self.repo_path) + return False, f"Git error: {str(e)}" + except Exception as e: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + if os.path.exists(backup_dir): + shutil.move(backup_dir, self.repo_path) + return False, f"Unexpected error: {str(e)}" + + + def get_git_status(self): + try: + logger.debug(f"Attempting to get status for repo at {self.repo_path}") + repo = git.Repo(self.repo_path) + logger.debug(f"Successfully created Repo object") + + # Now, actually call get_changes to process the status lines + changes = get_changes(repo) + logger.debug(f"Changes detected by get_changes: {changes}") + + # Fetch only if necessary + branch = repo.active_branch.name + remote_branch_exists = f"origin/{branch}" in [ref.name for ref in repo.remotes.origin.refs] + + if remote_branch_exists: + repo.remotes.origin.fetch() + commits_behind = get_commits_behind(repo, branch) + commits_ahead = get_commits_ahead(repo, branch) + logger.debug(f"Commits behind: {len(commits_behind)}, Commits ahead: {len(commits_ahead)}") + else: + commits_behind = [] + commits_ahead = [] + logger.debug("Remote branch does not exist, skipping commits ahead/behind calculation.") + + status = { + "branch": branch, + "remote_branch_exists": remote_branch_exists, + "changes": changes, + "commits_behind": len(commits_behind), + "commits_ahead": len(commits_ahead), + } + logger.debug(f"Final status object: {json.dumps(status, indent=2)}") + return True, status + except GitCommandError as e: + logger.error(f"GitCommandError: {str(e)}") + return False, f"Git error: {str(e)}" + except InvalidGitRepositoryError: + logger.error(f"InvalidGitRepositoryError for path: {self.repo_path}") + return False, "Invalid Git repository" + except Exception as e: + logger.error(f"Unexpected error in get_git_status: {str(e)}", exc_info=True) + return False, f"Unexpected error: {str(e)}" + + + + def get_branches(self): + try: + logger.debug("Attempting to get branches") + repo = git.Repo(self.repo_path) + + # Get local branches + local_branches = [{'name': branch.name} for branch in repo.heads] + + # Get remote branches + remote_branches = [{'name': ref.remote_head} for ref in repo.remote().refs] + + # Combine and remove duplicates, and exclude 'HEAD' + all_branches = {branch['name']: branch for branch in local_branches + remote_branches if branch['name'] != 'HEAD'}.values() + + logger.debug(f"Successfully retrieved branches: {[branch['name'] for branch in all_branches]}") + + # Log the branches before sending + logger.info(f"Branches being sent: {[branch['name'] for branch in all_branches]}") + + return True, {"branches": list(all_branches)} + except Exception as e: + logger.error(f"Error getting branches: {str(e)}", exc_info=True) + return False, {"error": f"Error getting branches: {str(e)}"} + + + + + + def create_branch(self, branch_name, base_branch='main'): + try: + logger.debug(f"Attempting to create branch {branch_name} from {base_branch}") + repo = git.Repo(self.repo_path) + + # Check if the branch already exists + if branch_name in repo.heads: + return False, f"Branch '{branch_name}' already exists." + + # Create and checkout the new branch + new_branch = repo.create_head(branch_name, commit=base_branch) + new_branch.checkout() + + # Push the new branch to remote and set the upstream branch + origin = repo.remote(name='origin') + origin.push(refspec=f"{branch_name}:{branch_name}", set_upstream=True) + + logger.debug(f"Successfully created and pushed branch: {branch_name}") + return True, {"message": f"Created and set upstream for branch: {branch_name}", "current_branch": branch_name} + except Exception as e: + logger.error(f"Error creating branch: {str(e)}", exc_info=True) + return False, {"error": f"Error creating branch: {str(e)}"} + + + def checkout_branch(self, branch_name): + try: + logger.debug(f"Attempting to checkout branch: {branch_name}") + repo = git.Repo(self.repo_path) + + # Check if the branch exists + if branch_name not in repo.heads: + return False, f"Branch '{branch_name}' does not exist." + + # Checkout the branch + repo.git.checkout(branch_name) + + logger.debug(f"Successfully checked out branch: {branch_name}") + return True, {"message": f"Checked out branch: {branch_name}", "current_branch": branch_name} + except Exception as e: + logger.error(f"Error checking out branch: {str(e)}", exc_info=True) + return False, {"error": f"Error checking out branch: {str(e)}"} + + def delete_branch(self, branch_name): + try: + logger.debug(f"Attempting to delete branch: {branch_name}") + repo = git.Repo(self.repo_path) + + # Check if the branch exists + if branch_name not in repo.heads: + return False, f"Branch '{branch_name}' does not exist." + + # Check if it's the current branch + if repo.active_branch.name == branch_name: + return False, f"Cannot delete the current branch: {branch_name}" + + # Delete the branch locally + repo.delete_head(branch_name, force=True) + + # Delete the branch remotely + try: + repo.git.push('origin', '--delete', branch_name) + except GitCommandError: + logger.warning(f"Failed to delete remote branch: {branch_name}. It may not exist on remote.") + + logger.debug(f"Successfully deleted branch: {branch_name}") + return True, {"message": f"Deleted branch: {branch_name}", "current_branch": repo.active_branch.name} + except Exception as e: + logger.error(f"Error deleting branch: {str(e)}", exc_info=True) + return False, {"error": f"Error deleting branch: {str(e)}"} + + def get_current_branch(self): + try: + repo = git.Repo(self.repo_path) + return repo.active_branch.name + except Exception as e: + logger.error(f"Error getting current branch: {str(e)}", exc_info=True) + return None + + def stage_files(self, files): + try: + repo = git.Repo(self.repo_path) + for file_path in files: + repo.index.add([file_path]) + return True, "Successfully staged files." + except Exception as e: + logger.error(f"Error staging files: {str(e)}", exc_info=True) + return False, f"Error staging files: {str(e)}" + + def push_files(self, files, commit_message): + try: + repo = git.Repo(self.repo_path) + # Stage the files + self.stage_files(files) + + # Commit the staged files + repo.index.commit(commit_message) + + # Push the commit to the remote repository + origin = repo.remote(name='origin') + origin.push() + + return True, "Successfully committed and pushed files." + except Exception as e: + logger.error(f"Error pushing files: {str(e)}", exc_info=True) + return False, f"Error pushing files: {str(e)}" + + +settings_manager = SettingsManager() + +@bp.route('', methods=['GET']) +def handle_settings(): + settings = load_settings() + if not settings: + return jsonify({}), 204 + return jsonify(settings), 200 + +@bp.route('', methods=['POST']) +def update_settings(): + try: + new_settings = request.json + save_settings(new_settings) + settings_manager.__init__() + success, message = settings_manager.clone_repository() + if success: + return jsonify(new_settings), 200 + else: + if "Invalid Git token" in message: + return jsonify({"error": message}), 401 + return jsonify({"error": message}), 400 + except Exception as e: + return jsonify({"error": "Failed to update settings"}), 500 + +@bp.route('/status', methods=['GET']) +def get_status(): + logger.debug("Received request for git status") + success, message = settings_manager.get_git_status() + if success: + logger.debug("Successfully retrieved git status") + return jsonify({'success': True, 'data': message}), 200 + else: + logger.error(f"Failed to retrieve git status: {message}") + return jsonify({'success': False, 'error': message}), 400 + +# Update the route handlers +@bp.route('/branch', methods=['POST']) +def create_branch(): + branch_name = request.json.get('name') + base_branch = request.json.get('base', 'main') + logger.debug(f"Received request to create branch {branch_name} from {base_branch}") + success, result = settings_manager.create_branch(branch_name, base_branch) + if success: + logger.debug(f"Successfully created branch: {branch_name}") + return jsonify({'success': True, **result}), 200 + else: + logger.error(f"Failed to create branch: {result}") + return jsonify({'success': False, 'error': result}), 400 + +@bp.route('/branches', methods=['GET']) +def get_branches(): + logger.debug("Received request for branches") + success, result = settings_manager.get_branches() + if success: + logger.debug("Successfully retrieved branches") + return jsonify({'success': True, 'data': result}), 200 + else: + logger.error(f"Failed to retrieve branches: {result}") + return jsonify({'success': False, 'error': result}), 400 + +@bp.route('/checkout', methods=['POST']) +def checkout_branch(): + branch_name = request.json.get('branch') + logger.debug(f"Received request to checkout branch: {branch_name}") + success, result = settings_manager.checkout_branch(branch_name) + if success: + logger.debug(f"Successfully checked out branch: {branch_name}") + return jsonify({'success': True, **result}), 200 + else: + logger.error(f"Failed to checkout branch: {result}") + return jsonify({'success': False, 'error': result}), 400 + +@bp.route('/branch/', methods=['DELETE']) +def delete_branch(branch_name): + logger.debug(f"Received request to delete branch: {branch_name}") + success, result = settings_manager.delete_branch(branch_name) + if success: + logger.debug(f"Successfully deleted branch: {branch_name}") + return jsonify({'success': True, **result}), 200 + else: + logger.error(f"Failed to delete branch: {result}") + return jsonify({'success': False, 'error': result}), 400 + +@bp.route('/current-branch', methods=['GET']) +def get_current_branch(): + current_branch = settings_manager.get_current_branch() + if current_branch: + return jsonify({'success': True, 'current_branch': current_branch}), 200 + else: + return jsonify({'success': False, 'error': 'Failed to get current branch'}), 400 + +@bp.route('/stage', methods=['POST']) +def stage_files(): + files = request.json.get('files', []) + + try: + repo = git.Repo(settings_manager.repo_path) + + if not files: # If no files are specified, stage all changes + repo.git.add(A=True) # This adds all changes to staging, including deletions + message = "All changes have been staged." + else: + for file_path in files: + # Staging a deleted file requires just adding the file path. + repo.git.add(file_path) + message = "Specified files have been staged." + + return jsonify({'success': True, 'message': message}), 200 + + except Exception as e: + logger.error(f"Error staging files: {str(e)}", exc_info=True) + return jsonify({'success': False, 'error': f"Error staging files: {str(e)}"}), 400 + + +def generate_commit_message(user_message, files): + file_changes = [] + for file in files: + if 'regex_patterns' in file: + file_changes.append(f"Update regex pattern: {file.split('/')[-1]}") + elif 'custom_formats' in file: + file_changes.append(f"Update custom format: {file.split('/')[-1]}") + else: + file_changes.append(f"Update: {file}") + + commit_message = f"{user_message}\n\nChanges:\n" + "\n".join(file_changes) + return commit_message + +@bp.route('/push', methods=['POST']) +def push_files(): + files = request.json.get('files', []) + user_commit_message = request.json.get('commit_message', "Commit and push staged files") + logger.debug(f"Received request to push files: {files}") + + try: + repo = git.Repo(settings_manager.repo_path) + + # Instead of restaging the files, we directly commit the staged changes + staged_files = repo.index.diff("HEAD") # Get the list of staged files + if not staged_files: + return jsonify({'success': False, 'error': "No staged changes to commit."}), 400 + + # Generate the structured commit message + commit_message = generate_commit_message(user_commit_message, files) + + # Commit the staged changes + repo.index.commit(commit_message) + + # Push the commit to the remote repository + origin = repo.remote(name='origin') + origin.push() + + logger.debug("Successfully committed and pushed files") + return jsonify({'success': True, 'message': "Successfully committed and pushed files."}), 200 + + except Exception as e: + logger.error(f"Error pushing files: {str(e)}", exc_info=True) + return jsonify({'success': False, 'error': f"Error pushing files: {str(e)}"}), 400 + +@bp.route('/revert', methods=['POST']) +def revert_file(): + file_path = request.json.get('file_path') + + if not file_path: + return jsonify({'success': False, 'error': "File path is required."}), 400 + + try: + repo = git.Repo(settings_manager.repo_path) + repo.git.restore(file_path) + repo.git.restore('--staged', file_path) # Ensure staged changes are also reverted + message = f"File {file_path} has been reverted." + return jsonify({'success': True, 'message': message}), 200 + + except Exception as e: + logger.error(f"Error reverting file: {str(e)}", exc_info=True) + return jsonify({'success': False, 'error': f"Error reverting file: {str(e)}"}), 400 + + +@bp.route('/revert-all', methods=['POST']) +def revert_all(): + try: + repo = git.Repo(settings_manager.repo_path) + + # Revert all files to the state of the last commit + repo.git.restore('--staged', '.') + repo.git.restore('.') + + return jsonify({'success': True, 'message': "All changes have been reverted to the last commit."}), 200 + + except Exception as e: + logger.error(f"Error reverting all changes: {str(e)}", exc_info=True) + return jsonify({'success': False, 'error': f"Error reverting all changes: {str(e)}"}), 400 + + +@bp.route('/file', methods=['DELETE']) +def delete_file(): + file_path = request.json.get('file_path') + + if not file_path: + return jsonify({'success': False, 'error': "File path is required."}), 400 + + try: + full_file_path = os.path.join(settings_manager.repo_path, file_path) + + if os.path.exists(full_file_path): + os.remove(full_file_path) + message = f"File {file_path} has been deleted." + return jsonify({'success': True, 'message': message}), 200 + else: + return jsonify({'success': False, 'error': "File does not exist."}), 404 + + except Exception as e: + logger.error(f"Error deleting file: {str(e)}", exc_info=True) + return jsonify({'success': False, 'error': f"Error deleting file: {str(e)}"}), 400 diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..5358019 --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,59 @@ +import os +import git +import datetime +import re +import yaml +from yaml import safe_load +from collections import OrderedDict + +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', 0) + if isinstance(file_id, int) and file_id > max_id: + max_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/backend/app/utils/__init__.py b/backend/app/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/utils/file_utils.py b/backend/app/utils/file_utils.py deleted file mode 100644 index 732417a..0000000 --- a/backend/app/utils/file_utils.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -import datetime - -def get_next_id(directory): - files = [f for f in os.listdir(directory) if f.endswith('.yml')] - if not files: - return 1 - return max(int(f.split('_')[0]) for f in files) + 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() - -import re - -def sanitize_input(input_str): - sanitized_str = input_str.strip() - sanitized_str = re.sub(r'[:#\-\*>\|&]', '', sanitized_str) - sanitized_str = sanitized_str.replace('\t', ' ') - sanitized_str = re.sub(r'\s+', ' ', sanitized_str) - return sanitized_str \ No newline at end of file diff --git a/backend/app/utils/regex_operations.py b/backend/app/utils/regex_operations.py deleted file mode 100644 index 52bbe18..0000000 --- a/backend/app/utils/regex_operations.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -import yaml -from collections import OrderedDict -from .file_utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input - -REGEX_DIR = 'regex_patterns' - -def save_regex(data): - ordered_data = OrderedDict() - if 'id' in data and data['id'] != 0: - ordered_data['id'] = data['id'] - else: - ordered_data['id'] = get_next_id(REGEX_DIR) - - ordered_data['name'] = sanitize_input(data.get('name', '')) - ordered_data['description'] = sanitize_input(data.get('description', '')) - ordered_data['pattern'] = sanitize_input(data.get('pattern', '')) - ordered_data['regex101Link'] = sanitize_input(data.get('regex101Link', '')) - ordered_data['regex101DeleteCode'] = sanitize_input(data.get('regex101DeleteCode', '')) # Add this line to save the delete code - - if ordered_data['id'] != 0: # Existing regex - existing_data = load_regex(ordered_data['id']) - if existing_data: - ordered_data['date_created'] = existing_data.get('date_created', get_current_timestamp()) - else: - ordered_data['date_created'] = get_current_timestamp() - else: # New regex - ordered_data['date_created'] = get_current_timestamp() - - ordered_data['date_modified'] = get_current_timestamp() - ordered_data['tags'] = [sanitize_input(tag) for tag in data.get('tags', [])] - - filename = generate_filename(REGEX_DIR, ordered_data['id'], ordered_data['name']) - with open(filename, 'w') as file: - for key, value in ordered_data.items(): - if key in ['description', 'date_created', 'date_modified', 'regex101Link', 'regex101DeleteCode']: # Add 'regex101DeleteCode' to this list - file.write(f"{key}: '{value}'\n") - elif key == 'tags': - file.write('tags:\n') - for tag in value: - file.write(f'- {tag}\n') - else: - file.write(f'{key}: {value}\n') - - return ordered_data - - -def load_regex(id): - files = [f for f in os.listdir(REGEX_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')] - if files: - filename = os.path.join(REGEX_DIR, files[0]) - 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): - files = [f for f in os.listdir(REGEX_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')] - if files: - os.remove(os.path.join(REGEX_DIR, files[0])) - return True - return False \ No newline at end of file diff --git a/backend/custom_formats/2_release_group_tier_1.yml b/backend/custom_formats/2_release_group_tier_1.yml deleted file mode 100644 index f0d0101..0000000 --- a/backend/custom_formats/2_release_group_tier_1.yml +++ /dev/null @@ -1,32 +0,0 @@ -id: 2 -name: Release Group Tier 1 -description: Test 1 -date_created: '2024-08-16T06:46:47.169722' -date_modified: '2024-08-16T07:35:37.622397' -conditions: -- type: regex - name: EbP - negate: false - required: false - regex_id: 1 -- type: regex - name: DON - negate: false - required: false - regex_id: 2 -- type: regex - name: Geek - negate: false - required: false - regex_id: 4 -- type: flag - name: Tracker Internal - negate: false - required: false - flag: internal -- type: regex - name: DZ0N3 - negate: false - required: false - regex_id: 3 -tags: [] diff --git a/backend/custom_formats/3_release_group_tier_2.yml b/backend/custom_formats/3_release_group_tier_2.yml deleted file mode 100644 index af43ee1..0000000 --- a/backend/custom_formats/3_release_group_tier_2.yml +++ /dev/null @@ -1,32 +0,0 @@ -id: 3 -name: Release Group Tier 2 -description: Test 2 -date_created: '2024-08-16T07:16:57.505528' -date_modified: '2024-08-16T07:16:57.505543' -conditions: -- type: regex - name: Geek - negate: false - required: false - regex_id: 4 -- type: flag - name: Tracker Internal - negate: false - required: false - flag: internal -- type: regex - name: Tayto - negate: false - required: false - regex_id: 5 -- type: regex - name: ZQ - negate: false - required: false - regex_id: 6 -- type: regex - name: Chotab - negate: false - required: false - regex_id: 8 -tags: [] diff --git a/backend/regex_patterns/1_ebp.yml b/backend/regex_patterns/1_ebp.yml deleted file mode 100644 index 5a8471a..0000000 --- a/backend/regex_patterns/1_ebp.yml +++ /dev/null @@ -1,10 +0,0 @@ -id: 1 -name: EbP -description: 'Release group: EbP' -tags: -- Release Group -- HDB Internal -pattern: (?<=^|[\s.-])EbP\b -regex101Link: https://regex101.com/r/5fR6ms -date_created: '2024-08-15T10:46:19.929335' -date_modified: '2024-08-15T15:43:51.614741' diff --git a/backend/regex_patterns/2_don.yml b/backend/regex_patterns/2_don.yml deleted file mode 100644 index 02b704e..0000000 --- a/backend/regex_patterns/2_don.yml +++ /dev/null @@ -1,10 +0,0 @@ -id: 2 -name: DON -description: 'Release Group: DON' -tags: -- Release Group -- HDB Internal -pattern: (?<=^|[\s.-])DON\b -regex101Link: https://regex101.com/r/rH36F9 -date_created: '2024-08-15T10:46:36.718655' -date_modified: '2024-08-15T15:38:30.549125' diff --git a/backend/regex_patterns/3_d-z0n3.yml b/backend/regex_patterns/3_d-z0n3.yml deleted file mode 100644 index c57ddef..0000000 --- a/backend/regex_patterns/3_d-z0n3.yml +++ /dev/null @@ -1,10 +0,0 @@ -id: 3 -name: D-Z0N3 -description: 'Release Group: D-Z0N3' -tags: -- Release Group -- HDB Internal -pattern: (?<=^|[\s.-])D-Z0N3\b -regex101Link: https://regex101.com/r/OWv3R9 -date_created: '2024-08-15T15:37:02.733456' -date_modified: '2024-08-15T16:24:11.809191' diff --git a/backend/regex_patterns/4_geek.yml b/backend/regex_patterns/4_geek.yml deleted file mode 100644 index 9083a2a..0000000 --- a/backend/regex_patterns/4_geek.yml +++ /dev/null @@ -1,10 +0,0 @@ -id: 4 -name: Geek -description: 'Release group: Geek' -pattern: (?<=^|[\s.-])Geek\b -regex101Link: 'https://regex101.com/r/fjF1dV' -date_created: '2024-08-15T23:23:39.653442' -date_modified: '2024-08-16T04:23:58.217800' -tags: -- HDB Internal -- Release Group diff --git a/backend/regex_patterns/5_tayto.yml b/backend/regex_patterns/5_tayto.yml deleted file mode 100644 index 0115dfc..0000000 --- a/backend/regex_patterns/5_tayto.yml +++ /dev/null @@ -1,10 +0,0 @@ -id: 5 -name: TayTo -description: 'Release Group: TayTo' -pattern: (?<=^|[\s.-])TayTo\b -regex101Link: 'https://regex101.com/r/cRbFpF' -date_created: '2024-08-15T23:25:57.501112' -date_modified: '2024-08-16T04:24:07.026677' -tags: -- HDB Internal -- Release Group diff --git a/backend/regex_patterns/6_zq.yml b/backend/regex_patterns/6_zq.yml deleted file mode 100644 index 9a5ec4f..0000000 --- a/backend/regex_patterns/6_zq.yml +++ /dev/null @@ -1,10 +0,0 @@ -id: 6 -name: ZQ -description: 'Release Group: ZQ' -pattern: (?<=^|[\s.-])ZQ\b -regex101Link: '' -date_created: '2024-08-15T23:27:19.233352' -date_modified: '2024-08-16T04:24:16.511572' -tags: -- AHD Internal -- Release Group diff --git a/backend/requirements.txt b/backend/requirements.txt index 09feb47..1555ae3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,4 +2,5 @@ Flask==2.0.1 Flask-CORS==3.0.10 PyYAML==5.4.1 requests==2.26.0 -Werkzeug==2.0.1 \ No newline at end of file +Werkzeug==2.0.1 +GitPython==3.1.24 \ No newline at end of file diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..d1374de --- /dev/null +++ b/backend/run.py @@ -0,0 +1,5 @@ +from app import create_app + +if __name__ == '__main__': + app = create_app() + app.run(debug=True, host='0.0.0.0') \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..d2b15c6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +/backend/data \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e029e3c..fc86715 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,16 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@radix-ui/react-slot": "^1.1.0", "axios": "^0.21.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.428.0", "prop-types": "^15.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-toastify": "^10.0.5", + "tailwind-merge": "^2.5.2" }, "devDependencies": { "@types/react": "^18.0.28", @@ -828,17 +834,48 @@ "node": ">=14" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1104,6 +1141,33 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1164,7 +1228,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/debug": { "version": "4.3.6", @@ -1634,6 +1698,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.428.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.428.0.tgz", + "integrity": "sha512-rGrzslfEcgqwh+TLBC5qJ8wvVIXhLvAIXVFKNHndYyb1utSxxn9rXOC+1CNJLi6yNOooyPqIs6+3YCp6uSiEvg==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -2071,6 +2143,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-toastify": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz", + "integrity": "sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -2336,6 +2420,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", + "integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.10", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8bb8565..809124d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,16 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-slot": "^1.1.0", "axios": "^0.21.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.428.0", "prop-types": "^15.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-toastify": "^10.0.5", + "tailwind-merge": "^2.5.2" }, "devDependencies": { "@types/react": "^18.0.28", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6635bc8..e25987e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,13 @@ import { useState, useEffect } from 'react'; import RegexManager from './components/regex/RegexManager'; import CustomFormatManager from './components/format/FormatManager'; +import Settings from './components/settings/SettingsManager'; import Navbar from './components/ui/Navbar'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; function App() { - const [activeTab, setActiveTab] = useState('format'); + const [activeTab, setActiveTab] = useState('settings'); const [darkMode, setDarkMode] = useState(true); useEffect(() => { @@ -16,19 +19,24 @@ function App() { }, [darkMode]); return ( -
- -
- {activeTab === 'regex' ? : } -
-
- + <> +
+ +
+ {activeTab === 'regex' && } + {activeTab === 'format' && } + {activeTab === 'settings' && } +
+
+ + + ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 3485cb3..aae4403 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -46,7 +46,6 @@ export const deleteRegex = async (id) => { } }; - export const getFormats = async () => { try { const response = await axios.get(`${API_BASE_URL}/format`); @@ -96,3 +95,142 @@ export const createRegex101Link = async (regexData) => { throw error; } }; + +export const getSettings = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/settings`); + return response.data; + } catch (error) { + console.error('Error fetching settings:', error); + throw error; + } +}; + +export const saveSettings = async (settings) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings`, settings); + return response.data; + } catch (error) { + console.error('Error saving settings:', error); + throw error; + } +}; + +export const getGitStatus = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/settings/status`); + return response.data; + } catch (error) { + console.error('Error fetching Git status:', error); + throw error; + } +}; + +export const getBranches = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/settings/branches`); + return response.data; + } catch (error) { + console.error('Error fetching branches:', error); + throw error; + } +}; + +export const checkoutBranch = async (branchName) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/checkout`, { branch: branchName }); + return response.data; + } catch (error) { + console.error('Error checking out branch:', error); + throw error; + } +}; + +export const createBranch = async (branchName, baseBranch) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/branch`, { name: branchName, base: baseBranch }); + return response.data; + } catch (error) { + console.error('Error creating branch:', error); + throw error; + } +}; + +export const deleteBranch = async (branchName) => { + try { + const response = await axios.delete(`${API_BASE_URL}/settings/branch/${branchName}`); + return response.data; + } catch (error) { + console.error('Error deleting branch:', error); + throw error; + } +}; + +export const pullBranch = async (branchName) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/pull`, { branch: branchName }); + return response.data; + } catch (error) { + console.error('Error pulling branch:', error); + throw error; + } +}; + +export const addFiles = async (files) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/stage`, { files }); + return response.data; + } catch (error) { + console.error('Error staging files:', error); + throw error; + } +}; + +export const pushFiles = async (files, commitMessage) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/push`, { + files, + commit_message: commitMessage + }); + return response.data; + } catch (error) { + console.error('Error pushing files:', error); + throw error; + } +}; + +export const revertFile = async (filePath) => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/revert`, { + file_path: filePath + }); + + return response.data; + } catch (error) { + console.error('Error reverting file:', error); + throw error; + } +}; + +export const revertAll = async () => { + try { + const response = await axios.post(`${API_BASE_URL}/settings/revert-all`); + + return response.data; + } catch (error) { + console.error('Error reverting all changes:', error); + throw error; + } +}; + +export const deleteFile = async (filePath) => { + try { + const response = await axios.delete(`${API_BASE_URL}/settings/file`, { + data: { file_path: filePath }, + }); + return response.data; + } catch (error) { + console.error('Error deleting file:', error); + return { success: false, error: 'Error deleting file' }; + } +}; diff --git a/frontend/src/components/regex/RegexCard.jsx b/frontend/src/components/regex/RegexCard.jsx index 0406aff..0c54a2e 100644 --- a/frontend/src/components/regex/RegexCard.jsx +++ b/frontend/src/components/regex/RegexCard.jsx @@ -1,5 +1,9 @@ import PropTypes from 'prop-types'; +function unsanitize(text) { + return text.replace(/\\:/g, ':').replace(/\\n/g, '\n'); +} + function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) { return (

- {regex.name} + {unsanitize(regex.name)}

- {regex.description} + {unsanitize(regex.description)}

{showDate && (

diff --git a/frontend/src/components/regex/RegexModal.jsx b/frontend/src/components/regex/RegexModal.jsx index 747cd18..97abd99 100644 --- a/frontend/src/components/regex/RegexModal.jsx +++ b/frontend/src/components/regex/RegexModal.jsx @@ -3,6 +3,10 @@ import PropTypes from 'prop-types'; import { saveRegex, deleteRegex, createRegex101Link } from '../../api/api'; import Modal from '../ui/Modal'; +function unsanitize(text) { + return text.replace(/\\:/g, ':').replace(/\\n/g, '\n'); +} + function RegexModal({ regex = null, isOpen, onClose, onSave }) { const [name, setName] = useState(''); const [pattern, setPattern] = useState(''); @@ -18,17 +22,17 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { if (isOpen) { if (regex && regex.id !== 0) { initialRegexRef.current = regex; - setName(regex.name); + setName(unsanitize(regex.name)); setPattern(regex.pattern); - setDescription(regex.description); - setTags(regex.tags || []); + setDescription(unsanitize(regex.description)); + setTags(regex.tags ? regex.tags.map(unsanitize) : []); setRegex101Link(regex.regex101Link || ''); } else { initialRegexRef.current = null; - setName(regex ? regex.name : ''); + setName(regex ? unsanitize(regex.name) : ''); setPattern(regex ? regex.pattern : ''); - setDescription(regex ? regex.description : ''); - setTags(regex ? regex.tags : []); + setDescription(regex ? unsanitize(regex.description) : ''); + setTags(regex ? regex.tags.map(unsanitize) : []); setRegex101Link(''); } setError(''); @@ -36,7 +40,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { setIsLoading(false); } }, [regex, isOpen]); - const handleCreateRegex101Link = async () => { if (!pattern.trim()) { @@ -44,7 +47,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { return; } - // Define your unit tests here const unitTests = [ { description: "Test if 'D-Z0N3' is detected correctly", @@ -58,7 +60,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { criteria: "DOES_NOT_MATCH", target: "REGEX" } - // Add more unit tests as needed ]; setIsLoading(true); @@ -85,7 +86,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { }); window.open(regex101Link, '_blank'); - onSave(); // Refresh the list after saving + onSave(); setError(''); } catch (error) { console.error('Error creating regex101 link:', error); @@ -100,7 +101,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { if (!confirmRemoval) return; setIsLoading(true); - setRegex101Link(''); // Clear the regex101Link in state + setRegex101Link(''); try { await saveRegex({ @@ -109,10 +110,10 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { pattern, description, tags, - regex101Link: '', // Save the regex with an empty link + regex101Link: '', }); - onSave(); // Refresh the list after saving + onSave(); setError(''); } catch (error) { console.error('Error removing regex101 link:', error); @@ -149,7 +150,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { if (!confirmDeletion) return; try { - // Attempt to delete the regex const response = await deleteRegex(regex.id); if (response.error) { if (response.error === 'Regex in use') { @@ -165,8 +165,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { console.error('Error deleting regex:', error); setError('Failed to delete regex. Please try again.'); } -}; - + }; const handleAddTag = () => { if (newTag.trim() && !tags.includes(newTag.trim())) { @@ -296,6 +295,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) { ); } + RegexModal.propTypes = { regex: PropTypes.shape({ id: PropTypes.number, diff --git a/frontend/src/components/settings/CommitSection.jsx b/frontend/src/components/settings/CommitSection.jsx new file mode 100644 index 0000000..3ef3710 --- /dev/null +++ b/frontend/src/components/settings/CommitSection.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { CheckSquare, GitCommit, RotateCcw, Loader } from 'lucide-react'; +import Textarea from '../ui/TextArea'; +import Tooltip from '../ui/Tooltip'; + +const CommitSection = ({ status, commitMessage, setCommitMessage, handleStageAll, handleCommitAll, handleRevertAll, loadingAction }) => { + const hasUnstagedChanges = status.changes.some(change => !change.staged || (change.staged && change.modified)); + const hasStagedChanges = status.changes.some(change => change.staged); + const hasAnyChanges = status.changes.length > 0; + + const funMessages = [ + "No changes detected. Your regex is so precise, it could find a needle in a haystack... made of needles. ๐Ÿงต๐Ÿ”", + "All quiet on the commit front. Your custom formats are so perfect, even perfectionists are jealous. ๐Ÿ†", + "No updates needed. Your media automation is running so smoothly, it's making butter jealous. ๐Ÿงˆ", + "Zero modifications. Your torrent setup is seeding so efficiently, farmers are asking for advice. ๐ŸŒพ", + "No edits required. Your regex fu is so strong, it's bench-pressing parentheses for fun. ๐Ÿ’ช()", + "Unchanged status. Your Plex library is so well-organized, librarians are taking notes. ๐Ÿ“š๐Ÿค“", + "No alterations found. Your file naming scheme is so consistent, it's bringing tears to OCD eyes. ๐Ÿ˜ข๐Ÿ‘€", + "All systems nominal. Your download queue is so orderly, it's making Marie Kondo question her career. ๐Ÿงนโœจ", + "No revisions necessary. Your automation scripts are so smart, they're solving captchas for fun. ๐Ÿค–๐Ÿงฉ", + "Steady as she goes. Your media collection is so complete, Netflix is asking you for recommendations. ๐ŸŽฌ๐Ÿ‘‘" + ]; + + const randomMessage = funMessages[Math.floor(Math.random() * funMessages.length)]; + + const CommitButton = () => ( + + ); + + return ( +

+

Changes:

+ {hasAnyChanges ? ( + <> + {hasStagedChanges && ( +