mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-28 05:20:57 +01:00
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.
This commit is contained in:
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
CMD ["python", "run.py"]
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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('/<int:id>', 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
|
||||
178
backend/app/regex.py
Normal file
178
backend/app/regex.py
Normal file
@@ -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('/<int:id>', 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
|
||||
@@ -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('/<int:id>', 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
|
||||
@@ -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('/<int:id>', 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
|
||||
712
backend/app/settings.py
Normal file
712
backend/app/settings.py
Normal file
@@ -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/<branch_name>', 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
|
||||
59
backend/app/utils.py
Normal file
59
backend/app/utils.py
Normal file
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Werkzeug==2.0.1
|
||||
GitPython==3.1.24
|
||||
5
backend/run.py
Normal file
5
backend/run.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app import create_app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(debug=True, host='0.0.0.0')
|
||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/backend/data
|
||||
101
frontend/package-lock.json
generated
101
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
<Navbar
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
darkMode={darkMode}
|
||||
setDarkMode={setDarkMode}
|
||||
/>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6">
|
||||
{activeTab === 'regex' ? <RegexManager /> : <CustomFormatManager />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
<Navbar
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
darkMode={darkMode}
|
||||
setDarkMode={setDarkMode}
|
||||
/>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6">
|
||||
{activeTab === 'regex' && <RegexManager />}
|
||||
{activeTab === 'format' && <CustomFormatManager />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
</div>
|
||||
</div>
|
||||
<ToastContainer position="top-right" autoClose={5000} hideProgressBar={false} newestOnTop={false} closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover />
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -8,7 +12,7 @@ function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-200">
|
||||
{regex.name}
|
||||
{unsanitize(regex.name)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -31,7 +35,7 @@ function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
|
||||
{regex.description}
|
||||
{unsanitize(regex.description)}
|
||||
</p>
|
||||
{showDate && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
|
||||
|
||||
@@ -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 }) {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
RegexModal.propTypes = {
|
||||
regex: PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
|
||||
87
frontend/src/components/settings/CommitSection.jsx
Normal file
87
frontend/src/components/settings/CommitSection.jsx
Normal file
@@ -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 = () => (
|
||||
<button
|
||||
onClick={handleCommitAll}
|
||||
className="flex items-center justify-center px-3 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors text-sm"
|
||||
disabled={loadingAction === 'commit_all' || !commitMessage.trim()}
|
||||
>
|
||||
{loadingAction === 'commit_all' ? <Loader size={16} className="animate-spin mr-2" /> : <GitCommit className="mr-2" size={16} />}
|
||||
Commit All
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-100 mb-4">Changes:</h3>
|
||||
{hasAnyChanges ? (
|
||||
<>
|
||||
{hasStagedChanges && (
|
||||
<Textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Enter your commit message here..."
|
||||
className="w-full p-2 text-sm text-gray-200 bg-gray-600 border border-gray-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y h-[75px] mb-2"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end space-x-2">
|
||||
{hasUnstagedChanges && (
|
||||
<button
|
||||
onClick={handleStageAll}
|
||||
className="flex items-center justify-center px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm"
|
||||
disabled={loadingAction === 'stage_all'}
|
||||
>
|
||||
{loadingAction === 'stage_all' ? <Loader size={16} className="animate-spin mr-2" /> : <CheckSquare className="mr-2" size={16} />}
|
||||
Stage All
|
||||
</button>
|
||||
)}
|
||||
{hasStagedChanges && (
|
||||
!commitMessage.trim() ? (
|
||||
<Tooltip content="Commit message is required">
|
||||
<CommitButton />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<CommitButton />
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={handleRevertAll}
|
||||
className="flex items-center justify-center px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-sm"
|
||||
disabled={loadingAction === 'revert_all'}
|
||||
>
|
||||
{loadingAction === 'revert_all' ? <Loader size={16} className="animate-spin mr-2" /> : <RotateCcw className="mr-2" size={16} />}
|
||||
Revert All
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-300 text-sm italic">{randomMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommitSection;
|
||||
59
frontend/src/components/settings/DiffViewer.jsx
Normal file
59
frontend/src/components/settings/DiffViewer.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
|
||||
const DiffViewer = ({ isOpen, onClose, diffContent = [] }) => {
|
||||
// Ensure diffContent is an array before proceeding
|
||||
if (!Array.isArray(diffContent)) {
|
||||
diffContent = []; // Default to an empty array if diffContent is not an array
|
||||
}
|
||||
|
||||
// Separate content for local and incoming
|
||||
const localContent = diffContent.filter(line => line.type === 'local' || line.type === 'context' || line.type === 'unchanged');
|
||||
const incomingContent = diffContent.filter(line => line.type === 'incoming' || line.type === 'context' || line.type === 'unchanged');
|
||||
|
||||
const renderDiffColumn = (content) => {
|
||||
return content.map((line, index) => {
|
||||
let lineClass = 'text-gray-200'; // Default line class for unchanged lines
|
||||
|
||||
if (line.type === 'local') {
|
||||
lineClass = 'bg-red-400 text-black'; // Highlight local changes
|
||||
} else if (line.type === 'incoming') {
|
||||
lineClass = 'bg-yellow-400 text-black'; // Highlight incoming changes
|
||||
} else if (line.type === 'context') {
|
||||
lineClass = 'text-gray-400'; // Context lines
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={`p-1 ${lineClass}`}>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Diff Viewer"
|
||||
size="xl" // Making the modal larger
|
||||
>
|
||||
<div className="bg-gray-900 p-4 rounded-md overflow-auto text-xs flex space-x-4">
|
||||
<div className="w-1/2">
|
||||
<h3 className="text-gray-300 mb-2">Remote</h3>
|
||||
<div className="bg-gray-800 p-2 rounded-md">
|
||||
{renderDiffColumn(localContent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<h3 className="text-gray-300 mb-2">Local</h3>
|
||||
<div className="bg-gray-800 p-2 rounded-md">
|
||||
{renderDiffColumn(incomingContent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewer;
|
||||
262
frontend/src/components/settings/SettingsBranchModal.jsx
Normal file
262
frontend/src/components/settings/SettingsBranchModal.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import { getBranches, checkoutBranch, createBranch, deleteBranch } from '../../api/api';
|
||||
import { ExternalLink, Trash2, GitBranchPlus, ArrowRightCircle, Loader } from 'lucide-react';
|
||||
import Tooltip from '../ui/Tooltip';
|
||||
import Alert from '../ui/Alert';
|
||||
|
||||
const SettingsBranchModal = ({ isOpen, onClose, repoUrl, currentBranch, onBranchChange }) => {
|
||||
const [branches, setBranches] = useState([]);
|
||||
const [branchOffMode, setBranchOffMode] = useState(null);
|
||||
const [newBranchName, setNewBranchName] = useState('');
|
||||
const [validBranchName, setValidBranchName] = useState(true);
|
||||
const [branchToDelete, setBranchToDelete] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchBranches();
|
||||
resetForm();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchBranches = async () => {
|
||||
try {
|
||||
const response = await getBranches();
|
||||
if (response.success && response.data.branches) {
|
||||
setBranches(response.data.branches);
|
||||
} else {
|
||||
console.error('Error fetching branches:', response.data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching branches:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setBranchOffMode(null);
|
||||
setNewBranchName('');
|
||||
setValidBranchName(true);
|
||||
setBranchToDelete(null);
|
||||
setLoadingAction('');
|
||||
};
|
||||
|
||||
const handleCheckout = async (branchName, isNewBranch = false) => {
|
||||
setLoadingAction(`checkout-${branchName}`);
|
||||
try {
|
||||
const response = await checkoutBranch(branchName);
|
||||
if (response.success) {
|
||||
// Refresh branches after successful checkout
|
||||
await fetchBranches();
|
||||
// Notify parent component to update the status
|
||||
onBranchChange(); // <-- Call the callback to update status in the parent component
|
||||
if (!isNewBranch) {
|
||||
Alert.success('Branch checked out successfully');
|
||||
}
|
||||
onClose(); // Close the modal
|
||||
} else {
|
||||
Alert.error(response.error); // Use the Alert component for error
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 400 && error.response.data.error) {
|
||||
Alert.error(error.response.data.error); // Show alert with specific backend error message
|
||||
} else {
|
||||
Alert.error('An unexpected error occurred while checking out the branch.');
|
||||
console.error('Error checking out branch:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBranchOff = async () => {
|
||||
setLoadingAction('branchOff');
|
||||
if (newBranchName && validBranchName) {
|
||||
try {
|
||||
const response = await createBranch(newBranchName, branchOffMode);
|
||||
if (response.success) {
|
||||
// Checkout the new branch without showing the checkout alert
|
||||
await handleCheckout(newBranchName, true);
|
||||
Alert.success('Branch created and checked out successfully');
|
||||
} else {
|
||||
Alert.error(response.error); // Handle known errors from the backend
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 400 && error.response.data.error) {
|
||||
Alert.error(error.response.data.error); // Specific error from the backend
|
||||
} else {
|
||||
console.error('Error branching off:', error); // Log unexpected errors
|
||||
Alert.error('An unexpected error occurred while creating the branch. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
} else {
|
||||
Alert.error('Please enter a valid branch name.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBranchOffClick = (branchName) => {
|
||||
setBranchOffMode(branchName);
|
||||
setNewBranchName('');
|
||||
setValidBranchName(true);
|
||||
};
|
||||
|
||||
const validateBranchName = (name) => {
|
||||
const isValid = /^[a-zA-Z0-9._-]+$/.test(name);
|
||||
setValidBranchName(isValid);
|
||||
setNewBranchName(name);
|
||||
};
|
||||
|
||||
const handleOpenInGitHub = (branchName) => {
|
||||
const branchUrl = `${repoUrl}/tree/${encodeURIComponent(branchName)}`;
|
||||
window.open(branchUrl, '_blank');
|
||||
};
|
||||
|
||||
const confirmDeleteBranch = (branchName) => {
|
||||
setBranchToDelete(branchName);
|
||||
};
|
||||
|
||||
const handleDeleteBranch = async () => {
|
||||
if (branchToDelete && branchToDelete.toLowerCase() === 'main') {
|
||||
Alert.warning("The 'main' branch cannot be deleted.");
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete-${branchToDelete}`);
|
||||
try {
|
||||
const response = await deleteBranch(branchToDelete);
|
||||
if (response.success) {
|
||||
onBranchChange(); // <-- Call the callback to update status in the parent component
|
||||
await fetchBranches(); // Refresh the list after deletion
|
||||
Alert.success(`Branch '${branchToDelete}' deleted successfully`);
|
||||
setBranchToDelete(null);
|
||||
} else {
|
||||
Alert.error(response.error); // Use the Alert component for error
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while deleting the branch.');
|
||||
console.error('Error deleting branch:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Manage Git Branches">
|
||||
<div className="space-y-4 text-xs">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2 text-gray-100">Branches:</h3>
|
||||
<ul className="space-y-2">
|
||||
{branches.map((branch, index) => (
|
||||
<li
|
||||
key={index} // Ensure you use a unique key
|
||||
className={`flex items-center justify-between p-2 rounded ${
|
||||
branch.name === currentBranch ? 'border border-blue-400 bg-gray-800' : 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={branch.name === currentBranch ? 'text-white' : 'text-gray-300'}>
|
||||
{branch.name ? branch.name : 'Unknown Branch'} {/* Fallback if branch.name is undefined */}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{branch.name !== currentBranch && (
|
||||
<Tooltip content="Checkout">
|
||||
<button
|
||||
onClick={() => handleCheckout(branch.name)}
|
||||
className="p-1 bg-blue-500 text-white rounded hover:bg-blue-600 hover:scale-105 transition-transform duration-200"
|
||||
disabled={loadingAction === `checkout-${branch.name}`}
|
||||
>
|
||||
{loadingAction === `checkout-${branch.name}` ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowRightCircle size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="Branch Off">
|
||||
<button
|
||||
onClick={() => handleBranchOffClick(branch.name)}
|
||||
className="p-1 bg-green-500 text-white rounded hover:bg-green-600 hover:scale-105 transition-transform duration-200"
|
||||
disabled={loadingAction === 'branchOff'}
|
||||
>
|
||||
{loadingAction === 'branchOff' ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<GitBranchPlus size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{branch.name !== currentBranch && branch.name.toLowerCase() !== 'main' && (
|
||||
<Tooltip content="Delete">
|
||||
<button
|
||||
onClick={() => confirmDeleteBranch(branch.name)}
|
||||
className="p-1 bg-red-500 text-white rounded hover:bg-red-600 hover:scale-105 transition-transform duration-200"
|
||||
disabled={loadingAction === `delete-${branch.name}`}
|
||||
>
|
||||
{loadingAction === `delete-${branch.name}` ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="View on GitHub">
|
||||
<button
|
||||
onClick={() => handleOpenInGitHub(branch.name)}
|
||||
className="p-1 bg-gray-600 text-white rounded hover:bg-gray-700 hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{branchOffMode && (
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => validateBranchName(e.target.value)}
|
||||
placeholder={`New branch from ${branchOffMode}`}
|
||||
className={`flex-grow p-2 border rounded bg-gray-800 text-gray-300 ${!validBranchName ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleBranchOff}
|
||||
disabled={!newBranchName || !validBranchName || loadingAction === 'branchOff'}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-xs"
|
||||
>
|
||||
{loadingAction === 'branchOff' ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{branchToDelete && (
|
||||
<div className="mt-4 text-sm text-gray-200">
|
||||
<p>Are you sure you want to delete the branch <strong>{branchToDelete}</strong>? This action cannot be undone.</p>
|
||||
<div className="flex space-x-4 mt-2">
|
||||
<button
|
||||
onClick={handleDeleteBranch}
|
||||
disabled={loadingAction === `delete-${branchToDelete}`}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs"
|
||||
>
|
||||
{loadingAction === `delete-${branchToDelete}` ? 'Deleting...' : 'Confirm Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBranchToDelete(null)}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsBranchModal;
|
||||
438
frontend/src/components/settings/SettingsManager.jsx
Normal file
438
frontend/src/components/settings/SettingsManager.jsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getSettings, saveSettings, getGitStatus, pullBranch, addFiles, pushFiles, revertFile, revertAll, deleteFile } from '../../api/api';
|
||||
import SettingsModal from './SettingsModal';
|
||||
import SettingsBranchModal from './SettingsBranchModal';
|
||||
import DiffViewer from './DiffViewer';
|
||||
import {
|
||||
FileText,
|
||||
Code,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
MinusCircle,
|
||||
Edit,
|
||||
GitBranch,
|
||||
Loader,
|
||||
Eye,
|
||||
RotateCcw
|
||||
} from 'lucide-react';
|
||||
import Alert from '../ui/Alert';
|
||||
import CommitSection from './CommitSection';
|
||||
|
||||
const SettingsManager = () => {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showBranchModal, setShowBranchModal] = useState(false);
|
||||
const [showDiffModal, setShowDiffModal] = useState(false);
|
||||
const [diffContent, setDiffContent] = useState('');
|
||||
const [loadingAction, setLoadingAction] = useState('');
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const fetchedSettings = await getSettings();
|
||||
if (fetchedSettings) {
|
||||
setSettings(fetchedSettings);
|
||||
await fetchGitStatus();
|
||||
} else {
|
||||
setShowModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (newSettings) => {
|
||||
try {
|
||||
setLoadingAction('save_settings'); // Set a loading state if needed
|
||||
const response = await saveSettings(newSettings);
|
||||
|
||||
if (response) {
|
||||
setSettings(response); // Update the settings in the state
|
||||
Alert.success('Settings saved successfully!');
|
||||
await fetchGitStatus(); // Optionally refresh the Git status after saving
|
||||
} else {
|
||||
Alert.error('Failed to save settings. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while saving the settings.');
|
||||
console.error('Error saving settings:', error);
|
||||
} finally {
|
||||
setLoadingAction(''); // Reset the loading state
|
||||
setShowModal(false); // Close the modal after saving
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitStatus = async () => {
|
||||
setLoadingStatus(true);
|
||||
try {
|
||||
const result = await getGitStatus();
|
||||
console.log('================ Git Status Response ================');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.log('======================================================');
|
||||
|
||||
if (result.success) {
|
||||
setStatus({
|
||||
...result.data,
|
||||
changes: Array.isArray(result.data.changes) ? result.data.changes : [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching Git status:', error);
|
||||
Alert.error('Failed to fetch Git status');
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFile = async (filePath) => {
|
||||
setLoadingAction(`add-${filePath}`);
|
||||
try {
|
||||
const response = await addFiles([filePath]);
|
||||
if (response.success) {
|
||||
await fetchGitStatus(); // Refresh status
|
||||
Alert.success(response.message);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while adding the file.');
|
||||
console.error('Error adding untracked file:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageAll = async () => {
|
||||
const unstagedChanges = status.changes.filter(change =>
|
||||
!change.staged || (change.staged && change.modified)
|
||||
);
|
||||
|
||||
if (unstagedChanges.length === 0) {
|
||||
Alert.warning('There are no changes to stage.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingAction('stage_all');
|
||||
try {
|
||||
const response = await addFiles([]);
|
||||
if (response.success) {
|
||||
await fetchGitStatus();
|
||||
Alert.success(response.message);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while staging files.');
|
||||
console.error('Error staging files:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommitAll = async () => {
|
||||
if (!status.changes || !status.changes.some(change => change.staged)) {
|
||||
Alert.warning('There are no staged changes to commit.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!commitMessage.trim()) {
|
||||
Alert.warning('Please enter a commit message.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingAction('commit_all');
|
||||
try {
|
||||
const stagedFiles = status.changes.filter(change => change.staged).map(change => change.file_path);
|
||||
const response = await pushFiles(stagedFiles, commitMessage);
|
||||
if (response.success) {
|
||||
await fetchGitStatus();
|
||||
setCommitMessage('');
|
||||
Alert.success(response.message);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while committing files.');
|
||||
console.error('Error committing files:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevertFile = async (filePath) => {
|
||||
const fileToRevert = status.changes.find(change => change.file_path === filePath);
|
||||
|
||||
const isDeletedFile = fileToRevert && fileToRevert.status.includes('Deleted');
|
||||
|
||||
if (!fileToRevert || (!fileToRevert.staged && !fileToRevert.modified && !isDeletedFile)) {
|
||||
Alert.warning('There is nothing to revert for this file.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingAction(`revert-${filePath}`);
|
||||
try {
|
||||
const response = await revertFile(filePath);
|
||||
if (response.success) {
|
||||
await fetchGitStatus();
|
||||
Alert.success(response.message);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while reverting the file.');
|
||||
console.error('Error reverting file:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevertAll = async () => {
|
||||
const hasChangesToRevert = status.changes.some(change => change.staged || change.modified);
|
||||
|
||||
if (!hasChangesToRevert) {
|
||||
Alert.warning('There are no changes to revert.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingAction('revert_all');
|
||||
try {
|
||||
const response = await revertAll();
|
||||
if (response.success) {
|
||||
await fetchGitStatus();
|
||||
Alert.success(response.message);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while reverting all changes.');
|
||||
console.error('Error reverting all changes:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (filePath) => {
|
||||
setLoadingAction(`delete-${filePath}`);
|
||||
try {
|
||||
const response = await deleteFile(filePath);
|
||||
if (response.success) {
|
||||
await fetchGitStatus(); // Refresh the status after deletion
|
||||
Alert.success(`File ${filePath} has been deleted.`);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error('An unexpected error occurred while deleting the file.');
|
||||
console.error('Error deleting file:', error);
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const getActionButton = (change) => {
|
||||
if (change.status === 'Untracked') {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleDeleteFile(change.file_path)}
|
||||
className="flex items-center px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-xs"
|
||||
disabled={loadingAction === `delete-${change.file_path}`}
|
||||
>
|
||||
{loadingAction === `delete-${change.file_path}` ? <Loader size={12} className="animate-spin" /> : <MinusCircle className="mr-1" size={12} />}
|
||||
Delete
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleRevertFile(change.file_path)}
|
||||
className="flex items-center px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-xs"
|
||||
disabled={loadingAction === `revert-${change.file_path}`}
|
||||
>
|
||||
{loadingAction === `revert-${change.file_path}` ? <Loader size={12} className="animate-spin" /> : <RotateCcw className="mr-1" size={12} />}
|
||||
Revert
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const loadingMessages = [
|
||||
"Checking for changes... don't blink!",
|
||||
"Syncing with the mothership...",
|
||||
"Peeking under the hood...",
|
||||
"Counting bits and bytes...",
|
||||
"Scanning for modifications...",
|
||||
"Looking for new stuff...",
|
||||
"Comparing local and remote...",
|
||||
"Checking your project's pulse...",
|
||||
"Analyzing your code's mood...",
|
||||
"Reading the project's diary..."
|
||||
];
|
||||
|
||||
const getRandomLoadingMessage = () => {
|
||||
return loadingMessages[Math.floor(Math.random() * loadingMessages.length)];
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'Untracked':
|
||||
return <Plus className="text-blue-400" size={16} />;
|
||||
case 'Staged (New)':
|
||||
return <Plus className="text-green-400" size={16} />;
|
||||
case 'Staged (Modified)':
|
||||
case 'Modified':
|
||||
return <Edit className="text-yellow-400" size={16} />;
|
||||
case 'Deleted':
|
||||
return <MinusCircle className="text-red-400" size={16} />;
|
||||
case 'Deleted (Staged)':
|
||||
return <MinusCircle className="text-red-600" size={16} />;
|
||||
case 'Renamed':
|
||||
return <GitBranch className="text-purple-400" size={16} />;
|
||||
default:
|
||||
return <AlertCircle className="text-gray-400" size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'Regex Pattern':
|
||||
return <Code className="text-blue-400" size={16} />;
|
||||
case 'Custom Format':
|
||||
return <FileText className="text-green-400" size={16} />;
|
||||
default:
|
||||
return <AlertCircle className="text-gray-400" size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto mt-8 p-6 bg-gray-800 rounded-lg shadow-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-100">Git Repository Settings</h2>
|
||||
{settings && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-700 p-4 rounded-md">
|
||||
<h3 className="text-sm font-semibold text-gray-100 mb-2">Connected Repository</h3>
|
||||
<a
|
||||
href={settings.gitRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors text-sm"
|
||||
>
|
||||
{settings.gitRepo}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700 p-4 rounded-md">
|
||||
<h3 className="text-sm font-semibold text-gray-100 mb-2">Git Status</h3>
|
||||
{loadingStatus ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader size={24} className="animate-spin text-gray-300" />
|
||||
<span className="ml-2 text-gray-300 text-sm">{getRandomLoadingMessage()}</span>
|
||||
</div>
|
||||
) : (
|
||||
status && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="mr-2 text-green-400" size={14} />
|
||||
<span className="text-gray-200 text-sm">Current Branch: {status.branch}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBranchModal(true)}
|
||||
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-xs"
|
||||
>
|
||||
<Eye size={14} className="mr-2" />
|
||||
View Branches
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{status.changes.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-200 mb-1">Changes:</h4>
|
||||
<div className="grid grid-cols-5 gap-2 text-xs">
|
||||
<div className="text-gray-400 font-semibold">Status</div>
|
||||
<div className="text-gray-400 font-semibold">Type</div>
|
||||
<div className="text-gray-400 font-semibold">Name</div>
|
||||
<div className="text-gray-400 font-semibold text-right"></div>
|
||||
<div className="text-gray-400 font-semibold text-right"></div>
|
||||
|
||||
{Array.isArray(status.changes) && status.changes.map((change, index) => (
|
||||
<React.Fragment key={`change-${index}`}>
|
||||
<div className="py-1 text-gray-300">
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(change.staged ? `${change.status} (Staged)` : change.status)}
|
||||
<span className="ml-1">{change.staged ? `${change.status} (Staged)` : change.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1 text-gray-300">
|
||||
<div className="flex items-center">
|
||||
{getTypeIcon(change.type)}
|
||||
<span className="ml-1">{change.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1 text-gray-300">{change.name || 'Unnamed'}</div>
|
||||
<div className="py-1 text-right col-span-2">
|
||||
<div className="inline-flex space-x-2">
|
||||
{(!change.staged || change.modified) && (
|
||||
<button
|
||||
onClick={() => handleAddFile(change.file_path)}
|
||||
className="flex items-center px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-xs"
|
||||
>
|
||||
<Plus size={12} className="mr-1" />
|
||||
{change.staged ? 'Re-stage' : 'Stage'}
|
||||
</button>
|
||||
)}
|
||||
{getActionButton(change)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<CommitSection
|
||||
status={status}
|
||||
commitMessage={commitMessage}
|
||||
setCommitMessage={setCommitMessage}
|
||||
handleStageAll={handleStageAll}
|
||||
handleCommitAll={handleCommitAll}
|
||||
handleRevertAll={handleRevertAll}
|
||||
loadingAction={loadingAction}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingsModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={(newSettings) => handleSaveSettings(newSettings)}
|
||||
/>
|
||||
{settings && status && (
|
||||
<SettingsBranchModal
|
||||
isOpen={showBranchModal}
|
||||
onClose={() => setShowBranchModal(false)}
|
||||
repoUrl={settings.gitRepo}
|
||||
currentBranch={status.branch}
|
||||
onBranchChange={fetchGitStatus}
|
||||
/>
|
||||
)}
|
||||
<DiffViewer
|
||||
isOpen={showDiffModal}
|
||||
onClose={() => setShowDiffModal(false)}
|
||||
diffContent={diffContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsManager;
|
||||
64
frontend/src/components/settings/SettingsModal.jsx
Normal file
64
frontend/src/components/settings/SettingsModal.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
const SettingsModal = ({ isOpen, onClose, onSave }) => {
|
||||
const [gitRepo, setGitRepo] = useState('');
|
||||
const [gitToken, setGitToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!gitRepo || !gitToken) {
|
||||
alert("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
onSave({ gitRepo, gitToken, localRepoPath: 'data/db' })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Setup Git Repository">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Git Repository URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gitRepo}
|
||||
onChange={(e) => setGitRepo(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-gray-900 text-gray-100 border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://github.com/your-repo.git"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">GitHub Access Token:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={gitToken}
|
||||
onChange={(e) => setGitToken(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-gray-900 text-gray-100 border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Your GitHub Token"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors flex items-center"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader size={16} className="animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
50
frontend/src/components/ui/Alert.jsx
Normal file
50
frontend/src/components/ui/Alert.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/components/ui/Alert.js
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
const baseStyles = {
|
||||
className: 'rounded-lg shadow-lg p-4',
|
||||
bodyClassName: 'text-sm font-medium',
|
||||
progressClassName: 'h-1 rounded-lg',
|
||||
};
|
||||
|
||||
const Alert = {
|
||||
success: (message, options = {}) => {
|
||||
toast.success(message, {
|
||||
...baseStyles,
|
||||
className: `${baseStyles.className} bg-green-600 text-white`,
|
||||
progressClassName: `${baseStyles.progressClassName} bg-green-300`,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
error: (message, options = {}) => {
|
||||
toast.error(message, {
|
||||
...baseStyles,
|
||||
className: `${baseStyles.className} bg-red-600 text-white`,
|
||||
progressClassName: `${baseStyles.progressClassName} bg-red-300`,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
warning: (message, options = {}) => {
|
||||
toast.warn(message, {
|
||||
...baseStyles,
|
||||
className: `${baseStyles.className} bg-yellow-600 text-white`,
|
||||
progressClassName: `${baseStyles.progressClassName} bg-yellow-300`,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
info: (message, options = {}) => {
|
||||
toast.info(message, {
|
||||
...baseStyles,
|
||||
className: `${baseStyles.className} bg-blue-600 text-white`,
|
||||
progressClassName: `${baseStyles.progressClassName} bg-blue-300`,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
function ToggleSwitch({ checked, onChange }) {
|
||||
return (
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input type="checkbox" className="sr-only" checked={checked} onChange={onChange} />
|
||||
<div className={`block w-14 h-8 rounded-full ${checked ? 'bg-blue-600' : 'bg-gray-600'}`}></div>
|
||||
<div className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition ${checked ? 'transform translate-x-6' : ''}`}></div>
|
||||
<div className={`block w-14 h-8 rounded-full ${checked ? 'bg-blue-600' : 'bg-gray-600'} transition-colors duration-300`}></div>
|
||||
<div className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${checked ? 'transform translate-x-6' : ''}`}></div>
|
||||
</div>
|
||||
<div className="ml-3 text-gray-300 font-medium">
|
||||
{checked ? 'Dark' : 'Light'}
|
||||
@@ -22,31 +22,58 @@ ToggleSwitch.propTypes = {
|
||||
};
|
||||
|
||||
function Navbar({ activeTab, setActiveTab, darkMode, setDarkMode }) {
|
||||
const [tabOffset, setTabOffset] = useState(0);
|
||||
const [tabWidth, setTabWidth] = useState(0);
|
||||
const tabsRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabsRef.current[activeTab]) {
|
||||
const tab = tabsRef.current[activeTab];
|
||||
setTabOffset(tab.offsetLeft);
|
||||
setTabWidth(tab.offsetWidth);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-800 shadow-md">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Regexerr
|
||||
</h1>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex space-x-2">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300"
|
||||
style={{ left: tabOffset, width: tabWidth }}
|
||||
></div>
|
||||
<button
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||
activeTab === 'regex' ? 'bg-gray-900 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
ref={(el) => (tabsRef.current['regex'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'regex' ? 'text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setActiveTab('regex')}
|
||||
>
|
||||
Regex
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||
activeTab === 'format' ? 'bg-gray-900 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
ref={(el) => (tabsRef.current['format'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'format' ? 'text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setActiveTab('format')}
|
||||
>
|
||||
Custom Format
|
||||
</button>
|
||||
<button
|
||||
ref={(el) => (tabsRef.current['settings'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'settings' ? 'text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch checked={darkMode} onChange={() => setDarkMode(!darkMode)} />
|
||||
@@ -63,4 +90,4 @@ Navbar.propTypes = {
|
||||
setDarkMode: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
export default Navbar;
|
||||
|
||||
15
frontend/src/components/ui/TextArea.jsx
Normal file
15
frontend/src/components/ui/TextArea.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={`flex min-h-[80px] w-full rounded-md border border-dark-border bg-dark-card px-3 py-2 text-sm text-dark-text placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${className}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export default Textarea;
|
||||
17
frontend/src/components/ui/Tooltip.jsx
Normal file
17
frontend/src/components/ui/Tooltip.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
const Tooltip = ({ content, children }) => {
|
||||
return (
|
||||
<div className="relative flex items-center group">
|
||||
{children}
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
|
||||
<div className="bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap z-50">
|
||||
{content}
|
||||
</div>
|
||||
<div className="absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
@@ -4,7 +4,5 @@ import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
<App />
|
||||
);
|
||||
|
||||
@@ -13,9 +13,14 @@ module.exports = {
|
||||
'0%': { opacity: 0, transform: 'scale(0.95)' },
|
||||
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'modal-open': 'modal-open 0.3s ease-out forwards',
|
||||
'fade-in': 'fade-in 0.5s ease-in-out forwards',
|
||||
},
|
||||
colors: {
|
||||
'dark-bg': '#1a1c23',
|
||||
@@ -25,6 +30,11 @@ module.exports = {
|
||||
'dark-button': '#3182ce',
|
||||
'dark-button-hover': '#2c5282',
|
||||
},
|
||||
borderRadius: {
|
||||
'lg': '0.5rem',
|
||||
'md': '0.375rem',
|
||||
'sm': '0.25rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user