diff --git a/backend/Dockerfile b/backend/Dockerfile index 65f8046..fc51b20 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["python", "run.py"] +CMD ["python", "run.py"] \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 998dcab..361676a 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,25 +3,29 @@ 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 +from .profile import bp as profile_bp import os REGEX_DIR = os.path.join('data', 'db', 'regex_patterns') FORMAT_DIR = os.path.join('data', 'db', 'custom_formats') +PROFILE_DIR = os.path.join('data', 'db', 'profiles') 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) - + app.register_blueprint(profile_bp) + return app def initialize_directories(): os.makedirs(REGEX_DIR, exist_ok=True) os.makedirs(FORMAT_DIR, exist_ok=True) + os.makedirs(PROFILE_DIR, exist_ok=True) \ No newline at end of file diff --git a/backend/app/profile.py b/backend/app/profile.py new file mode 100644 index 0000000..e1a48f2 --- /dev/null +++ b/backend/app/profile.py @@ -0,0 +1,112 @@ +from flask import Blueprint, request, jsonify +from collections import OrderedDict +import os +import yaml +import logging +from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input + +bp = Blueprint('profile', __name__, url_prefix='/profile') +DATA_DIR = '/app/data' +PROFILE_DIR = os.path.join(DATA_DIR, 'db', 'profiles') + +# Ensure the directory exists +os.makedirs(PROFILE_DIR, exist_ok=True) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@bp.route('', methods=['GET', 'POST']) +def handle_profiles(): + if request.method == 'POST': + data = request.json + saved_data = save_profile(data) + return jsonify(saved_data), 201 + else: + profiles = load_all_profiles() + return jsonify(profiles) + +@bp.route('/', methods=['GET', 'PUT', 'DELETE']) +def handle_profile(id): + if request.method == 'GET': + profile = load_profile(id) + if profile: + return jsonify(profile) + return jsonify({"error": "Profile not found"}), 404 + elif request.method == 'PUT': + data = request.json + data['id'] = id + saved_data = save_profile(data) + return jsonify(saved_data) + elif request.method == 'DELETE': + if delete_profile(id): + return jsonify({"message": f"Profile with ID {id} deleted."}), 200 + return jsonify({"error": f"Profile with ID {id} not found."}), 404 + +def save_profile(data): + logger.info("Received data for saving profile: %s", data) + + # Sanitize and extract necessary fields + name = sanitize_input(data.get('name', '')) + description = sanitize_input(data.get('description', '')) + profile_id = data.get('id', None) + + # Determine if this is a new profile or an existing one + if profile_id == 0 or not profile_id: + profile_id = get_next_id(PROFILE_DIR) + logger.info("Assigned new profile ID: %d", profile_id) + date_created = get_current_timestamp() + else: + existing_filename = os.path.join(PROFILE_DIR, f"{profile_id}.yml") + if os.path.exists(existing_filename): + existing_data = load_profile(profile_id) + date_created = existing_data.get('date_created', get_current_timestamp()) + else: + raise FileNotFoundError(f"No existing file found for ID: {profile_id}") + + date_modified = get_current_timestamp() + + # Process tags + tags = [sanitize_input(tag) for tag in data.get('tags', [])] + + # Construct the ordered data + ordered_data = OrderedDict([ + ('id', profile_id), + ('name', name), + ('description', description), + ('date_created', str(date_created)), + ('date_modified', str(date_modified)), + ('tags', tags) + ]) + + # Generate the filename using only the ID + filename = os.path.join(PROFILE_DIR, f"{profile_id}.yml") + + # Write to the file + with open(filename, 'w') as file: + yaml.dump(ordered_data, file, default_flow_style=False, Dumper=yaml.SafeDumper) + + return ordered_data + +def load_profile(id): + filename = os.path.join(PROFILE_DIR, f"{id}.yml") + if os.path.exists(filename): + with open(filename, 'r') as file: + data = yaml.safe_load(file) + return data + return None + +def load_all_profiles(): + profiles = [] + for filename in os.listdir(PROFILE_DIR): + if filename.endswith('.yml'): + with open(os.path.join(PROFILE_DIR, filename), 'r') as file: + data = yaml.safe_load(file) + profiles.append(data) + return profiles + +def delete_profile(id): + filename = os.path.join(PROFILE_DIR, f"{id}.yml") + if os.path.exists(filename): + os.remove(filename) + return True + return False \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e1cdc89..838648a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { useState, useEffect } from "react"; import RegexPage from "./components/regex/RegexPage"; import FormatPage from "./components/format/FormatPage"; +import ProfilePage from "./components/profile/ProfilePage"; import SettingsPage from "./components/settings/SettingsPage"; import Navbar from "./components/ui/Navbar"; import { ToastContainer } from "react-toastify"; @@ -26,6 +27,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 1193b12..54debed 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -244,3 +244,43 @@ export const getDiff = async (filePath) => { throw error; } }; + +export const getProfiles = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/profile`); + return response.data; + } catch (error) { + console.error('Error fetching profiles:', error); + throw error; + } +}; + +export const saveProfile = async (profile) => { + try { + const response = await axios.post(`${API_BASE_URL}/profile`, profile); + return response.data; + } catch (error) { + console.error('Error saving profile:', error); + throw error; + } +}; + +export const updateProfile = async (id, profile) => { + try { + const response = await axios.put(`${API_BASE_URL}/profile/${id}`, profile); + return response.data; + } catch (error) { + console.error('Error updating profile:', error); + throw error; + } +}; + +export const deleteProfile = async (id) => { + try { + const response = await axios.delete(`${API_BASE_URL}/profile/${id}`); + return response.data; + } catch (error) { + console.error('Error deleting profile:', error); + throw error; + } +}; diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx new file mode 100644 index 0000000..9b7a32b --- /dev/null +++ b/frontend/src/components/profile/ProfileCard.jsx @@ -0,0 +1,69 @@ +import PropTypes from "prop-types"; + +function unsanitize(text) { + return text.replace(/\\:/g, ":").replace(/\\n/g, "\n"); +} + +function ProfileCard({ profile, onEdit, onClone, showDate, formatDate }) { + return ( +
onEdit(profile)} + > +
+

+ {unsanitize(profile.name)} +

+ +
+

+ {unsanitize(profile.description)} +

+ {showDate && ( +

+ Modified: {formatDate(profile.date_modified)} +

+ )} +
+ {profile.tags && + profile.tags.map((tag) => ( + + {tag} + + ))} +
+
+ ); +} + +ProfileCard.propTypes = { + profile: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string, + date_modified: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + onEdit: PropTypes.func.isRequired, + onClone: PropTypes.func.isRequired, + showDate: PropTypes.bool.isRequired, + formatDate: PropTypes.func.isRequired, +}; + +export default ProfileCard; diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx new file mode 100644 index 0000000..efa4647 --- /dev/null +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -0,0 +1,180 @@ +import { useState, useEffect, useRef } from "react"; +import PropTypes from "prop-types"; +import { saveProfile, deleteProfile } from "../../api/api"; +import Modal from "../ui/Modal"; + +function unsanitize(text) { + return text.replace(/\\:/g, ":").replace(/\\n/g, "\n"); +} + +function ProfileModal({ profile = null, isOpen, onClose, onSave }) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [tags, setTags] = useState([]); + const [newTag, setNewTag] = useState(""); + const [error, setError] = useState(""); + const initialProfileRef = useRef(profile); + + useEffect(() => { + if (isOpen) { + if (profile && profile.id !== 0) { + initialProfileRef.current = profile; + setName(unsanitize(profile.name)); + setDescription(unsanitize(profile.description)); + setTags(profile.tags ? profile.tags.map(unsanitize) : []); + } else { + initialProfileRef.current = null; + setName(profile ? unsanitize(profile.name) : ""); + setDescription(profile ? unsanitize(profile.description) : ""); + setTags(profile ? profile.tags.map(unsanitize) : []); + } + setError(""); + setNewTag(""); + } + }, [profile, isOpen]); + + const handleSave = async () => { + if (!name.trim()) { + setError("Name is required."); + return; + } + try { + await saveProfile({ + id: profile ? profile.id : 0, + name, + description, + tags, + }); + onSave(); + onClose(); + } catch (error) { + console.error("Error saving profile:", error); + setError("Failed to save profile. Please try again."); + } + }; + + const handleDelete = async () => { + const confirmDeletion = window.confirm( + "Are you sure you want to delete this profile?" + ); + if (!confirmDeletion) return; + + try { + await deleteProfile(profile.id); + onSave(); + onClose(); + } catch (error) { + console.error("Error deleting profile:", error); + setError("Failed to delete profile. Please try again."); + } + }; + + const handleAddTag = () => { + if (newTag.trim() && !tags.includes(newTag.trim())) { + setTags([...tags, newTag.trim()]); + setNewTag(""); + } + }; + + const handleRemoveTag = (tagToRemove) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + return ( + + {error &&
{error}
} +
+ + setName(e.target.value)} + placeholder="Enter profile name" + className="w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" + /> +
+
+ +