From 1272e4009853e47df9fddeae08490dc17464d1da Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Thu, 20 Feb 2025 23:37:15 +1030 Subject: [PATCH 01/11] chore: add build workflows for beta/release (#144) - feat: add GitHub workflows for building Docker images for beta and release - fix: update notify workflow to trigger on version tags --- .../{docker-build.yml => beta-build.yml} | 8 +-- .github/workflows/notify.yml | 5 +- .github/workflows/release-build.yml | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) rename .github/workflows/{docker-build.yml => beta-build.yml} (91%) create mode 100644 .github/workflows/release-build.yml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/beta-build.yml similarity index 91% rename from .github/workflows/docker-build.yml rename to .github/workflows/beta-build.yml index c951dfb..71d2595 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/beta-build.yml @@ -1,13 +1,12 @@ -# .github/workflows/docker-build.yml -name: Build and Publish Docker Image +name: Build Beta Docker Image on: push: branches: - - v2-beta + - dev pull_request: branches: - - v2-beta + - dev jobs: build: @@ -38,6 +37,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml index 038a396..c4c5fc4 100644 --- a/.github/workflows/notify.yml +++ b/.github/workflows/notify.yml @@ -1,8 +1,9 @@ name: Notify on: push: - branches: - - 'v2-beta' + tags: + - 'v*' + jobs: notify: uses: Dictionarry-Hub/parrot/.github/workflows/notify.yml@main diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..b5ee300 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,53 @@ +name: Build Release Docker Image + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get tag + id: tag + run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Build frontend + working-directory: ./frontend + run: | + npm ci + npm run build + + - name: Prepare dist directory + run: | + mkdir -p dist/backend dist/static + cp -r frontend/dist/* dist/static/ + cp -r backend/* dist/backend/ + cp backend/requirements.txt dist/ + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + santiagosayshey/profilarr:latest + santiagosayshey/profilarr:${{ steps.tag.outputs.tag }} From b9289510eb15b2ff3379ff69842884b411de01fe Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Fri, 21 Feb 2025 00:30:01 +1030 Subject: [PATCH 02/11] refactor: remove tweaks functionality from profile handling (#145) - removed because I couldn't integrate it nicely with the rest of the application, will keep in the todo pile for later. --- backend/app/compile/profile_compiler.py | 6 - backend/app/data/utils.py | 3 +- backend/app/importarr/profile.py | 123 ------------ .../src/components/profile/ProfileModal.jsx | 35 +--- .../components/profile/ProfileTweaksTab.jsx | 179 ------------------ 5 files changed, 4 insertions(+), 342 deletions(-) delete mode 100644 frontend/src/components/profile/ProfileTweaksTab.jsx diff --git a/backend/app/compile/profile_compiler.py b/backend/app/compile/profile_compiler.py index a56a61c..71d8330 100644 --- a/backend/app/compile/profile_compiler.py +++ b/backend/app/compile/profile_compiler.py @@ -201,15 +201,10 @@ class ProfileConverter: language=selected_language) used_qualities = set() - tweaks = profile.get('tweaks', {}) - allow_prereleases = tweaks.get('allowPrereleases', False) for quality_entry in profile.get("qualities", []): if quality_entry.get("id", 0) < 0: converted_group = self.convert_quality_group(quality_entry) - if (quality_entry.get("name") == "Prereleases" - and not allow_prereleases): - converted_group["allowed"] = False if converted_group["items"]: converted_profile.items.append(converted_group) for q in quality_entry.get("qualities", []): @@ -246,7 +241,6 @@ class ProfileConverter: if cutoff_id < 0: converted_profile.cutoff = self._convert_group_id(cutoff_id) else: - # And use mapped_cutoff_name here instead of cutoff_name converted_profile.cutoff = self.quality_mappings[ mapped_cutoff_name]["id"] diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py index b468536..819a69f 100644 --- a/backend/app/data/utils.py +++ b/backend/app/data/utils.py @@ -33,8 +33,7 @@ PROFILE_FIELDS = [ "custom_formats", # Array of {name, score} objects "qualities", # Array of strings "upgrade_until", - "language", - "tweaks" + "language" ] # Category mappings diff --git a/backend/app/importarr/profile.py b/backend/app/importarr/profile.py index 1360fba..128410f 100644 --- a/backend/app/importarr/profile.py +++ b/backend/app/importarr/profile.py @@ -77,12 +77,6 @@ def import_profiles_to_arr(profile_names: List[str], original_names: List[str], f"Profile '{profile_name}' has language override: {profile_language}" ) - logger.info( - f"Processing tweaks and importing formats for profile '{profile_name}'" - ) - profile_data = process_tweaks(profile_data, base_url, api_key, - arr_type, import_as_unique) - logger.info("Compiling quality profile...") compiled_profiles = compile_quality_profile( profile_data=profile_data, @@ -221,123 +215,6 @@ def sync_format_ids(profile_data: Dict, format_id_map: Dict[str, int]) -> Dict: return profile_data -def process_tweaks(profile_data: Dict, - base_url: str, - api_key: str, - arr_type: str, - import_as_unique: bool = False) -> Dict: - logger.debug(f"Processing tweaks for profile: {profile_data.get('name')}") - tweaks = profile_data.get('tweaks', {}) - - if tweaks.get('preferFreeleech', False): - freeleech_formats = ["Free25", "Free50", "Free75", "Free100"] - freeleech_scores = [{ - "name": n, - "score": s - } for n, s in zip(freeleech_formats, range(1, 5))] - _import_and_score_formats(formats=freeleech_formats, - scores=freeleech_scores, - profile_data=profile_data, - base_url=base_url, - api_key=api_key, - arr_type=arr_type, - feature_name="freeleech", - import_as_unique=import_as_unique) - - lossless_formats = [ - "FLAC", "DTS-X", "DTS-HD MA", "TrueHD", "TrueHD (Missing)" - ] - default_score = 0 if tweaks.get('allowLosslessAudio', False) else -9999 - lossless_scores = [{ - "name": f, - "score": default_score - } for f in lossless_formats] - _import_and_score_formats(formats=lossless_formats, - scores=lossless_scores, - profile_data=profile_data, - base_url=base_url, - api_key=api_key, - arr_type=arr_type, - feature_name="lossless audio", - import_as_unique=import_as_unique) - - dv_formats = ["Dolby Vision (Without Fallback)"] - dv_score = 0 if tweaks.get('allowDVNoFallback', False) else -9999 - dv_scores = [{"name": n, "score": dv_score} for n in dv_formats] - _import_and_score_formats(formats=dv_formats, - scores=dv_scores, - profile_data=profile_data, - base_url=base_url, - api_key=api_key, - arr_type=arr_type, - feature_name="Dolby Vision no fallback", - import_as_unique=import_as_unique) - - codec_formats = ["AV1", "VVC"] - codec_score = 0 if tweaks.get('allowBleedingEdgeCodecs', False) else -9999 - codec_scores = [{"name": f, "score": codec_score} for f in codec_formats] - _import_and_score_formats(formats=codec_formats, - scores=codec_scores, - profile_data=profile_data, - base_url=base_url, - api_key=api_key, - arr_type=arr_type, - feature_name="bleeding edge codecs", - import_as_unique=import_as_unique) - - return profile_data - - -def _import_and_score_formats(formats: List[str], - scores: List[Dict[str, Any]], - profile_data: Dict, - base_url: str, - api_key: str, - arr_type: str, - feature_name: str, - import_as_unique: bool = False) -> None: - logger.info( - f"Processing {feature_name} formats for profile '{profile_data.get('name')}'" - ) - try: - # Create modified format names if import_as_unique is true - format_names = [ - f"{name} [Dictionarry]" if import_as_unique else name - for name in formats - ] - - result = import_formats_to_arr( - format_names=format_names, # Use modified names for import - original_names=formats, # Original names for file lookup - base_url=base_url, - api_key=api_key, - arr_type=arr_type) - - if not result.get('success', False): - logger.warning( - f"Failed to import {feature_name} formats for '{profile_data.get('name')}'" - ) - return - - if 'custom_formats' not in profile_data: - profile_data['custom_formats'] = [] - - # Use the modified format names in the profile's format list - modified_scores = [] - for i, score in enumerate(scores): - score_copy = score.copy() - # Use the same modified name that was used for import - score_copy['name'] = format_names[i] - modified_scores.append(score_copy) - - # Only append once with the modified scores - profile_data['custom_formats'].extend(modified_scores) - - except Exception as e: - logger.error(f"Error importing {feature_name} formats: {str(e)}") - return - - def process_profile(profile_data: Dict, existing_names: Dict[str, int], base_url: str, api_key: str) -> Dict: profile_name = profile_data['name'] diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx index 1fb620d..584a42d 100644 --- a/frontend/src/components/profile/ProfileModal.jsx +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -8,17 +8,8 @@ import ProfileGeneralTab from './ProfileGeneralTab'; import ProfileScoringTab from './scoring/ProfileScoringTab'; import ProfileQualitiesTab from './ProfileQualitiesTab'; import ProfileLangaugesTab from './ProfileLangaugesTab'; -import ProfileTweaksTab from './ProfileTweaksTab'; import QUALITIES from '../../constants/qualities'; -const DEFAULT_TWEAKS = { - preferFreeleech: true, - allowLosslessAudio: true, - allowDVNoFallback: false, - allowBleedingEdgeCodecs: false, - allowPrereleases: false -}; - function unsanitize(text) { if (!text) return ''; return text.replace(/\\:/g, ':').replace(/\\n/g, '\n'); @@ -70,15 +61,11 @@ function ProfileModal({ // Language state const [language, setLanguage] = useState('must_english'); - // Tweaks state - const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS); - const tabs = [ {id: 'general', label: 'General'}, {id: 'scoring', label: 'Scoring'}, {id: 'qualities', label: 'Qualities'}, - {id: 'languages', label: 'Languages'}, - {id: 'tweaks', label: 'Tweaks'} + {id: 'languages', label: 'Languages'} ]; const resetState = () => { @@ -124,7 +111,6 @@ function ProfileModal({ // Reset other states setLanguage('must_english'); - setTweaks(DEFAULT_TWEAKS); }; useEffect(() => { @@ -185,12 +171,6 @@ function ProfileModal({ }); setTagScores(initialTagScores); - // Tweaks - setTweaks({ - ...DEFAULT_TWEAKS, - ...(content.tweaks || {}) - }); - // Qualities setup - include all qualities, set enabled status const allQualitiesMap = {}; // Map of all qualities by id QUALITIES.forEach(quality => { @@ -335,7 +315,6 @@ function ProfileModal({ // Initialize with defaults setLanguage('must_english'); - setTweaks(DEFAULT_TWEAKS); } setLoading(false); @@ -401,8 +380,7 @@ function ProfileModal({ }) } : null, - language, - tweaks + language }; if (isCloning || !initialProfile) { @@ -589,12 +567,6 @@ function ProfileModal({ onLanguageChange={setLanguage} /> )} - {activeTab === 'tweaks' && ( - - )} )} @@ -638,8 +610,7 @@ ProfileModal.propTypes = { id: PropTypes.number.isRequired, name: PropTypes.string.isRequired }), - language: PropTypes.string, - tweaks: PropTypes.object + language: PropTypes.string }) }), isOpen: PropTypes.bool.isRequired, diff --git a/frontend/src/components/profile/ProfileTweaksTab.jsx b/frontend/src/components/profile/ProfileTweaksTab.jsx deleted file mode 100644 index 71af22e..0000000 --- a/frontend/src/components/profile/ProfileTweaksTab.jsx +++ /dev/null @@ -1,179 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {InfoIcon, AlertTriangle} from 'lucide-react'; - -const ProfileTweaksTab = ({tweaks, onTweaksChange}) => { - const handleTweakChange = key => { - onTweaksChange({ - ...tweaks, - [key]: !tweaks[key] - }); - }; - - return ( -
-
-
- -

- Tweaks are custom changes that can be toggled according - to your preference. These settings are profile-specific - and won't create merge conflicts when synchronizing with - remote repositories. Use tweaks to fine-tune your - profile's behavior without affecting the core - configuration. -

-
- -
- {/* Allow Dolby Vision without Fallback */} -
handleTweakChange('allowDVNoFallback')} - className={` - p-4 rounded-lg cursor-pointer select-none - border transition-colors duration-200 - ${ - tweaks.allowDVNoFallback - ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' - } - hover:border-blue-500 dark:hover:border-blue-400 - `}> -
-

- Allow Dolby Vision without Fallback -

-

- Allow Dolby Vision releases that don't include - HDR10 fallback. These may display incorrectly on - non-Dolby Vision displays. -

-
- -

- Only enable if your display supports Dolby - Vision -

-
-
-
- - {/* Allow Bleeding Edge Codecs */} -
- handleTweakChange('allowBleedingEdgeCodecs') - } - className={` - p-4 rounded-lg cursor-pointer select-none - border transition-colors duration-200 - ${ - tweaks.allowBleedingEdgeCodecs - ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' - } - hover:border-blue-500 dark:hover:border-blue-400 - `}> -
-

- Allow Bleeding Edge Codecs -

-

- Allow releases using newer codecs like AV1 and - H.266/VVC. These may offer better compression - but have limited hardware support. -

-
-
- - {/* Allow Lossless Audio */} -
handleTweakChange('allowLosslessAudio')} - className={` - p-4 rounded-lg cursor-pointer select-none - border transition-colors duration-200 - ${ - tweaks.allowLosslessAudio ?? true - ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' - } - hover:border-blue-500 dark:hover:border-blue-400 - `}> -
-

- Allow Lossless Audio -

-

- Allow high-quality lossless audio formats - including TrueHD + Atmos, DTS-HD MA, DTS-X, - FLAC, and PCM. -

-
- -

- May skip better quality releases if disabled -

-
-
-
- - {/* Allow Prereleases */} -
handleTweakChange('allowPrereleases')} - className={` - p-4 rounded-lg cursor-pointer select-none - border transition-colors duration-200 - ${ - tweaks.allowPrereleases - ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' - } - hover:border-blue-500 dark:hover:border-blue-400 - `}> -
-

- Allow Prereleases -

-

- Allow early releases like CAMs, Telecines, - Telesyncs, and Screeners. These are typically - available before official releases but at lower - quality. -

-
-
- - {/* Prefer Freeleech */} -
handleTweakChange('preferFreeleech')} - className={` - p-4 rounded-lg cursor-pointer select-none - border transition-colors duration-200 - ${ - tweaks.preferFreeleech - ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' - } - hover:border-blue-500 dark:hover:border-blue-400 - `}> -
-

- Prefer Freeleech -

-

- Prioritize releases tagged as freeleech when - choosing between different indexers' releases. -

-
-
-
-
-
- ); -}; - -ProfileTweaksTab.propTypes = { - tweaks: PropTypes.object.isRequired, - onTweaksChange: PropTypes.func.isRequired -}; - -export default ProfileTweaksTab; From f91fea113f00ed674f064957ee18c6cc7e0d2d10 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Sun, 23 Feb 2025 04:12:10 +1030 Subject: [PATCH 03/11] fix/improve: arr config modal (#146) - Fix: remove / update dangling references on data delete / rename in arr config. - Fix: Show dangling references in data selector so they can be removed after git operation (cascade operations not possible). - Fix: improve onclick behaviour so that button clicks in arr modal don't incorrectly trigger save / update. - Refactor: Turn data selector into a non modal component - now exists on the same pane as the arr modal. - Feat: Add search / sort functionality to data selector. Sorted A-Z by default now. --- backend/app/data/utils.py | 25 +- backend/app/db/__init__.py | 11 +- backend/app/db/queries/arr.py | 100 ++++++- .../src/components/settings/arrs/ArrModal.jsx | 198 ++++++-------- .../components/settings/arrs/DataSelector.jsx | 250 ++++++++++++++++++ .../settings/arrs/DataSelectorModal.jsx | 139 ---------- .../src/components/ui/DataBar/SearchBar.jsx | 104 ++++---- frontend/src/components/ui/SortDropdown.jsx | 42 +-- 8 files changed, 525 insertions(+), 344 deletions(-) create mode 100644 frontend/src/components/settings/arrs/DataSelector.jsx delete mode 100644 frontend/src/components/settings/arrs/DataSelectorModal.jsx diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py index 819a69f..884a664 100644 --- a/backend/app/data/utils.py +++ b/backend/app/data/utils.py @@ -7,6 +7,7 @@ from typing import Dict, List, Any, Tuple, Union import git import regex import logging +from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -165,8 +166,20 @@ def update_yaml_file(file_path: str, data: Dict[str, Any], # Update references before performing the rename try: + # Update regular references updated_files = update_references(category, old_name, new_name) logger.info(f"Updated references in: {updated_files}") + + # Update arr configs if this is a format or profile + if category in ['custom_format', 'profile']: + arr_category = 'customFormats' if category == 'custom_format' else 'profiles' + updated_configs = update_arr_config_on_rename( + arr_category, old_name, new_name) + if updated_configs: + logger.info( + f"Updated arr configs for {category} rename: {updated_configs}" + ) + except Exception as e: logger.error(f"Failed to update references: {e}") raise Exception(f"Failed to update references: {str(e)}") @@ -262,9 +275,9 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]: format_data = load_yaml_file(format_path) # Check each condition in the format for condition in format_data.get('conditions', []): - if (condition['type'] in [ + if condition['type'] in [ 'release_title', 'release_group', 'edition' - ] and condition.get('pattern') == check_name): + ] and condition.get('pattern') == check_name: references.append( f"custom format: {format_data['name']}") except Exception as e: @@ -299,6 +312,14 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]: f"Error checking profile file {profile_file}: {e}") continue + # Update arr configs for formats and profiles + if category in ['custom_format', 'profile']: + arr_category = 'customFormats' if category == 'custom_format' else 'profiles' + updated_configs = update_arr_config_on_delete(arr_category, name) + if updated_configs: + logger.info( + f"Removed {name} from arr configs: {updated_configs}") + if references: error_msg = f"Cannot delete - item is referenced in:\n" + "\n".join( f"- {ref}" for ref in references) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py index a8b4f80..3bc49f6 100644 --- a/backend/app/db/__init__.py +++ b/backend/app/db/__init__.py @@ -1,12 +1,15 @@ -# backend/app/db/__init__.py from .connection import get_db from .queries.settings import get_settings, get_secret_key, save_settings -from .queries.arr import get_unique_arrs -from .queries.format_renames import add_format_to_renames, remove_format_from_renames, is_format_in_renames +from .queries.arr import (get_unique_arrs, update_arr_config_on_rename, + update_arr_config_on_delete) +from .queries.format_renames import (add_format_to_renames, + remove_format_from_renames, + is_format_in_renames) from .migrations.runner import run_migrations __all__ = [ 'get_db', 'get_settings', 'get_secret_key', 'save_settings', - 'get_unique_arrs', 'run_migrations', 'add_format_to_renames', + 'get_unique_arrs', 'update_arr_config_on_rename', + 'update_arr_config_on_delete', 'run_migrations', 'add_format_to_renames', 'remove_format_from_renames', 'is_format_in_renames' ] diff --git a/backend/app/db/queries/arr.py b/backend/app/db/queries/arr.py index 5b62c1e..592bc02 100644 --- a/backend/app/db/queries/arr.py +++ b/backend/app/db/queries/arr.py @@ -1,14 +1,15 @@ -# backend/app/db/queries/arr.py from ..connection import get_db +import json +import logging + +logger = logging.getLogger(__name__) def get_unique_arrs(arr_ids): """ Get import_as_unique settings for a list of arr IDs. - Args: arr_ids (list): List of arr configuration IDs - Returns: dict: Dictionary mapping arr IDs to their import_as_unique settings and names """ @@ -18,12 +19,12 @@ def get_unique_arrs(arr_ids): with get_db() as conn: placeholders = ','.join('?' * len(arr_ids)) query = f''' - SELECT id, name, import_as_unique - FROM arr_config - WHERE id IN ({placeholders}) + SELECT id, name, import_as_unique + FROM arr_config + WHERE id IN ({placeholders}) ''' - results = conn.execute(query, arr_ids).fetchall() + return { row['id']: { 'import_as_unique': bool(row['import_as_unique']), @@ -31,3 +32,88 @@ def get_unique_arrs(arr_ids): } for row in results } + + +def update_arr_config_on_rename(category, old_name, new_name): + """ + Update arr_config data_to_sync when a format or profile is renamed. + Args: + category (str): Either 'customFormats' or 'profiles' + old_name (str): Original name being changed + new_name (str): New name to change to + Returns: + list: IDs of arr_config rows that were updated + """ + updated_ids = [] + + with get_db() as conn: + # Get all configs that might reference this name + rows = conn.execute( + 'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL' + ).fetchall() + + for row in rows: + try: + data = json.loads(row['data_to_sync']) + # Check if this config has the relevant category data + if category in data: + # Update any matching names + if old_name in data[category]: + # Replace old name with new name + data[category] = [ + new_name if x == old_name else x + for x in data[category] + ] + # Save changes back to database + conn.execute( + 'UPDATE arr_config SET data_to_sync = ? WHERE id = ?', + (json.dumps(data), row['id'])) + updated_ids.append(row['id']) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in arr_config id={row['id']}") + continue + + if updated_ids: + conn.commit() + + return updated_ids + + +def update_arr_config_on_delete(category, name): + """ + Update arr_config data_to_sync when a format or profile is deleted. + Args: + category (str): Either 'customFormats' or 'profiles' + name (str): Name being deleted + Returns: + list: IDs of arr_config rows that were updated + """ + updated_ids = [] + + with get_db() as conn: + # Get all configs that might reference this name + rows = conn.execute( + 'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL' + ).fetchall() + + for row in rows: + try: + data = json.loads(row['data_to_sync']) + # Check if this config has the relevant category data + if category in data: + # Remove any matching names + if name in data[category]: + data[category].remove(name) + # Save changes back to database + conn.execute( + 'UPDATE arr_config SET data_to_sync = ? WHERE id = ?', + (json.dumps(data), row['id'])) + updated_ids.append(row['id']) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in arr_config id={row['id']}") + continue + + if updated_ids: + conn.commit() + + return updated_ids diff --git a/frontend/src/components/settings/arrs/ArrModal.jsx b/frontend/src/components/settings/arrs/ArrModal.jsx index 7c79ed2..88b558c 100644 --- a/frontend/src/components/settings/arrs/ArrModal.jsx +++ b/frontend/src/components/settings/arrs/ArrModal.jsx @@ -1,8 +1,10 @@ +// ArrModal.jsx + import React from 'react'; import {Plus, TestTube, Loader, Save, X, Trash, Check} from 'lucide-react'; import Modal from '@ui/Modal'; import {useArrModal} from '@hooks/useArrModal'; -import DataSelectorModal from './DataSelectorModal'; +import DataSelector from './DataSelector'; import SyncModal from './SyncModal'; const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { @@ -44,43 +46,34 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { {value: 'schedule', label: 'Scheduled'} ]; - // Ensure data_to_sync always has the required structure const safeSelectedData = { profiles: formData.data_to_sync?.profiles || [], customFormats: formData.data_to_sync?.customFormats || [] }; - // Handle sync method change - const handleSyncMethodChange = e => { - const newMethod = e.target.value; - handleInputChange({ - target: { - id: 'sync_method', - value: newMethod - } - }); - - // Reset data_to_sync when switching to manual - if (newMethod === 'manual') { - handleInputChange({ - target: { - id: 'data_to_sync', - value: {profiles: [], customFormats: []} - } - }); - } + const handleFormSubmit = e => { + e.preventDefault(); + e.stopPropagation(); + handleSubmit(e); }; - const inputClasses = errorKey => - `w-full px-3 py-2 text-sm rounded-lg border ${ - errors[errorKey] - ? 'border-red-500' - : 'border-gray-300 dark:border-gray-600' - } bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${ - errors[errorKey] - ? 'focus:ring-red-500 focus:border-red-500' - : 'focus:ring-blue-500 focus:border-blue-500' - } placeholder-gray-400 dark:placeholder-gray-500 transition-all`; + const inputClasses = errorKey => ` + w-full px-3 py-2 text-sm rounded-lg border ${ + errors[errorKey] + ? 'border-red-500' + : 'border-gray-300 dark:border-gray-600' + } bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 ${ + errors[errorKey] + ? 'focus:ring-red-500 focus:border-red-500' + : 'focus:ring-blue-500 focus:border-blue-500' + } placeholder-gray-400 dark:placeholder-gray-500 transition-all + `; + + const handleSyncMethodChange = e => { + e.preventDefault(); + e.stopPropagation(); + handleInputChange(e); + }; return ( { !formData.apiKey } className='flex items-center px-3 py-2 text-sm rounded-lg bg-emerald-600 hover:bg-emerald-700 - disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'> + disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'> {isTestingConnection ? ( <> @@ -142,7 +135,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { type='submit' form='arrForm' className='flex items-center px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 - text-white font-medium transition-colors'> + text-white font-medium transition-colors'> {saveConfirm ? ( <> @@ -162,8 +155,16 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { }> -
- {/* Name Field */} + { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + } + }} + className='space-y-4'>
- {/* Type Field */}
- {/* Tags Field */}
- {/* Server URL Field */}
- {/* API Key Field */}
- {/* Sync Method Field */}
- {/* Import as Unique - Now always visible */}
Creates a unique hash from the data and target - instance name, allowing the same profile/format to - be imported multiple times + instance name
- {/* Conditional Fields for Sync Method */} {formData.sync_method === 'schedule' && (
)} - {/* Sync Options */} {formData.sync_method !== 'manual' && ( <> - - +
+

+ Select Data to Sync +

+ +
{errors.data_to_sync && (

{errors.data_to_sync} @@ -436,34 +397,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { )} )} - - setIsDataDrawerOpen(false)} - isLoading={isLoading} - availableData={availableData} - selectedData={safeSelectedData} - onDataToggle={handleDataToggle} - error={errors.data_to_sync} - /> - {showSyncConfirm && ( - { - setShowSyncConfirm(false); - onSubmit(); - }} - onSkip={() => { - setShowSyncConfirm(false); - onSubmit(); - }} - onSync={handleManualSync} - isSyncing={isInitialSyncing} - /> - )} + + {showSyncConfirm && ( + { + setShowSyncConfirm(false); + onSubmit(); + }} + onSkip={() => { + setShowSyncConfirm(false); + onSubmit(); + }} + onSync={handleManualSync} + isSyncing={isInitialSyncing} + /> + )} ); }; diff --git a/frontend/src/components/settings/arrs/DataSelector.jsx b/frontend/src/components/settings/arrs/DataSelector.jsx new file mode 100644 index 0000000..1c970ad --- /dev/null +++ b/frontend/src/components/settings/arrs/DataSelector.jsx @@ -0,0 +1,250 @@ +import React from 'react'; +import {Loader, AlertTriangle} from 'lucide-react'; +import useSearch from '@hooks/useSearch'; +import {useSorting} from '@hooks/useSorting'; +import SearchBar from '@ui/DataBar/SearchBar'; +import SortDropdown from '@ui/SortDropdown'; + +const DataSelector = ({ + isLoading, + availableData = {profiles: [], customFormats: []}, + selectedData = {profiles: [], customFormats: []}, + onDataToggle, + error +}) => { + const profiles = selectedData?.profiles || []; + const customFormats = selectedData?.customFormats || []; + + const availableProfileNames = new Set( + availableData.profiles.map(p => p.content.name) + ); + const availableFormatNames = new Set( + availableData.customFormats.map(f => f.content.name) + ); + + const missingProfiles = profiles.filter( + name => !availableProfileNames.has(name) + ); + const missingFormats = customFormats.filter( + name => !availableFormatNames.has(name) + ); + + const combinedProfiles = [ + ...missingProfiles.map(name => ({name, isMissing: true})), + ...availableData.profiles.map(profile => ({ + name: profile.content.name, + isMissing: false + })) + ]; + + const combinedFormats = [ + ...missingFormats.map(name => ({name, isMissing: true})), + ...availableData.customFormats.map(format => ({ + name: format.content.name, + isMissing: false + })) + ]; + + const { + items: filteredProfiles, + searchTerms: searchTermsProfiles, + currentInput: currentInputProfiles, + setCurrentInput: setCurrentInputProfiles, + addSearchTerm: addSearchTermProfiles, + removeSearchTerm: removeSearchTermProfiles, + clearSearchTerms: clearSearchTermsProfiles + } = useSearch(combinedProfiles, { + searchableFields: ['name'] + }); + + const { + items: filteredFormats, + searchTerms: searchTermsFormats, + currentInput: currentInputFormats, + setCurrentInput: setCurrentInputFormats, + addSearchTerm: addSearchTermFormats, + removeSearchTerm: removeSearchTermFormats, + clearSearchTerms: clearSearchTermsFormats + } = useSearch(combinedFormats, { + searchableFields: ['name'] + }); + + const { + sortConfig: profilesSortConfig, + updateSort: updateProfilesSort, + sortData: sortProfiles + } = useSorting({field: 'name', direction: 'asc'}); + + const { + sortConfig: formatsSortConfig, + updateSort: updateFormatsSort, + sortData: sortFormats + } = useSorting({field: 'name', direction: 'asc'}); + + const sortedProfiles = sortProfiles(filteredProfiles); + const sortedFormats = sortFormats(filteredFormats); + + const sortOptions = [{label: 'Name', value: 'name'}]; + + const renderItem = (item, type) => ( +

+ onDataToggle(type, item.name)} + className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0' + /> + + {item.name} + + {item.isMissing && ( +
+ + File not found +
+ )} +
+ + ); + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+
+

+ Quality Profiles +

+
+ {missingProfiles.length > 0 && ( + + {missingProfiles.length} missing + + )} + + {profiles.length} selected + +
+
+
+ { + clearSearchTermsProfiles(); + setCurrentInputProfiles(''); + }} + className='flex-1' + /> +
{ + e.stopPropagation(); + }}> + +
+
+
+ {sortedProfiles.map(item => + renderItem(item, 'profiles') + )} +
+
+ +
+
+
+

+ Custom Formats +

+
+ {missingFormats.length > 0 && ( + + {missingFormats.length} missing + + )} + + {customFormats.length} selected + +
+
+

+ Note: Custom formats used in selected quality + profiles are automatically imported and don't + need to be selected here. +

+
+
+ { + clearSearchTermsFormats(); + setCurrentInputFormats(''); + }} + className='flex-1' + /> +
{ + e.stopPropagation(); + }}> + +
+
+
+ {sortedFormats.map(item => + renderItem(item, 'customFormats') + )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + )} +
+ ); +}; + +export default DataSelector; diff --git a/frontend/src/components/settings/arrs/DataSelectorModal.jsx b/frontend/src/components/settings/arrs/DataSelectorModal.jsx deleted file mode 100644 index 6b3cb8e..0000000 --- a/frontend/src/components/settings/arrs/DataSelectorModal.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import {Loader} from 'lucide-react'; -import Modal from '@ui/Modal'; - -const DataSelectorModal = ({ - isOpen, - onClose, - isLoading, - availableData = {profiles: [], customFormats: []}, - selectedData = {profiles: [], customFormats: []}, - onDataToggle, - error -}) => { - // Ensure we have safe defaults for selectedData - const profiles = selectedData?.profiles || []; - const customFormats = selectedData?.customFormats || []; - - return ( - -
- {isLoading ? ( -
- -
- ) : ( - <> - {/* Quality Profiles Section */} -
-
-

- Quality Profiles -

- - {profiles.length} selected - -
-
- {(availableData?.profiles || []).map( - profile => ( - - ) - )} -
-
- - {/* Custom Formats Section */} -
-
-
-

- Custom Formats -

- - {customFormats.length} selected - -
-

- Note: Custom formats used in selected - quality profiles are automatically imported - and don't need to be selected here. -

-
-
- {(availableData?.customFormats || []).map( - format => ( - - ) - )} -
-
- - {error && ( -
-

{error}

-
- )} - - )} -
-
- ); -}; - -export default DataSelectorModal; diff --git a/frontend/src/components/ui/DataBar/SearchBar.jsx b/frontend/src/components/ui/DataBar/SearchBar.jsx index cad58ba..2d4a76d 100644 --- a/frontend/src/components/ui/DataBar/SearchBar.jsx +++ b/frontend/src/components/ui/DataBar/SearchBar.jsx @@ -1,3 +1,5 @@ +// SearchBar.jsx + import React, {useState, useEffect} from 'react'; import {Search, X} from 'lucide-react'; @@ -15,7 +17,7 @@ const SearchBar = ({ const [isFocused, setIsFocused] = useState(false); useEffect(() => { - const handleKeyDown = e => { + const handleKeyDownGlobal = e => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); document.querySelector('input[type="text"]')?.focus(); @@ -24,93 +26,90 @@ const SearchBar = ({ onClearTerms(); } }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener('keydown', handleKeyDownGlobal); + return () => + document.removeEventListener('keydown', handleKeyDownGlobal); }, [onClearTerms]); const handleKeyDown = e => { - // Handle backspace when input is empty and there are search terms + if (requireEnter && e.key === 'Enter' && currentInput.trim()) { + e.preventDefault(); + onAddTerm(currentInput); + return; + } if (e.key === 'Backspace' && !currentInput && searchTerms.length > 0) { e.preventDefault(); onRemoveTerm(searchTerms[searchTerms.length - 1]); } }; - const handleKeyPress = e => { - if (requireEnter && e.key === 'Enter' && currentInput.trim()) { - onAddTerm(currentInput); - } - }; - return (
-
+ w-full min-h-10 pl-9 pr-8 rounded-md + transition-all duration-200 ease-in-out + border shadow-sm flex items-center flex-wrap gap-2 p-2 + ${ + isFocused + ? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5' + : 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600' + } + bg-white dark:bg-gray-800 + `}> {searchTerms.map((term, index) => (
+ bg-blue-500/10 dark:bg-blue-500/20 + border border-blue-500/20 dark:border-blue-400/20 + text-blue-600 dark:text-blue-400 + rounded-md shadow-sm + hover:bg-blue-500/15 dark:hover:bg-blue-500/25 + hover:border-blue-500/30 dark:hover:border-blue-400/30 + group/badge + transition-all duration-200 + '> {term}
))} + onInputChange(e.target.value)} - onKeyPress={handleKeyPress} - onKeyDown={handleKeyDown} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} + onChange={e => onInputChange(e.target.value)} + onKeyDown={handleKeyDown} placeholder={ searchTerms.length ? 'Add another filter...' : placeholder } className='flex-1 min-w-[200px] bg-transparent - text-gray-900 dark:text-gray-100 - placeholder:text-gray-500 dark:placeholder:text-gray-400 - focus:outline-none' + text-gray-900 dark:text-gray-100 + placeholder:text-gray-500 dark:placeholder:text-gray-400 + focus:outline-none' />
@@ -118,12 +117,11 @@ const SearchBar = ({ )} diff --git a/frontend/src/components/ui/SortDropdown.jsx b/frontend/src/components/ui/SortDropdown.jsx index 8e52fb1..8169142 100644 --- a/frontend/src/components/ui/SortDropdown.jsx +++ b/frontend/src/components/ui/SortDropdown.jsx @@ -1,5 +1,7 @@ +// SortDropdown.jsx + import React, {useState} from 'react'; -import {ChevronDown, ChevronUp, ArrowDown, ArrowUp} from 'lucide-react'; +import {ArrowDown, ArrowUp} from 'lucide-react'; const SortDropdown = ({ sortOptions, @@ -24,14 +26,19 @@ const SortDropdown = ({ return (
@@ -70,8 +70,9 @@ const ProfileScoringTab = ({ Format Settings

- Configure when upgrades should be downloaded and what - scores are required + Assign scores to different formats to control download + preferences. View formats in the traditional arr style, + or in filtered A/V grids.

diff --git a/frontend/src/components/settings/git/status/PushRow.jsx b/frontend/src/components/settings/git/status/PushRow.jsx index d342dde..3f3365e 100644 --- a/frontend/src/components/settings/git/status/PushRow.jsx +++ b/frontend/src/components/settings/git/status/PushRow.jsx @@ -2,8 +2,6 @@ import React from 'react'; import {GitCommit, Code, FileText, Settings, File} from 'lucide-react'; const PushRow = ({change}) => { - console.log('Push Row Change:', change); - const getTypeIcon = type => { switch (type) { case 'Regex Pattern': From b4029768482e15724c8cdcde29cd3534fb456f8b Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Sun, 23 Feb 2025 09:51:31 +1030 Subject: [PATCH 05/11] fix: update PAT status on startup - now allows transition from non dev -> dev environments or vice versa --- backend/app/db/__init__.py | 4 ++-- backend/app/db/queries/settings.py | 33 ++++++++++++++++++++++++++++++ backend/app/init.py | 7 +++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py index 3bc49f6..a67f20d 100644 --- a/backend/app/db/__init__.py +++ b/backend/app/db/__init__.py @@ -1,5 +1,5 @@ from .connection import get_db -from .queries.settings import get_settings, get_secret_key, save_settings +from .queries.settings import get_settings, get_secret_key, save_settings, update_pat_status from .queries.arr import (get_unique_arrs, update_arr_config_on_rename, update_arr_config_on_delete) from .queries.format_renames import (add_format_to_renames, @@ -11,5 +11,5 @@ __all__ = [ 'get_db', 'get_settings', 'get_secret_key', 'save_settings', 'get_unique_arrs', 'update_arr_config_on_rename', 'update_arr_config_on_delete', 'run_migrations', 'add_format_to_renames', - 'remove_format_from_renames', 'is_format_in_renames' + 'remove_format_from_renames', 'is_format_in_renames', 'update_pat_status' ] diff --git a/backend/app/db/queries/settings.py b/backend/app/db/queries/settings.py index f2fcf1e..9d7377a 100644 --- a/backend/app/db/queries/settings.py +++ b/backend/app/db/queries/settings.py @@ -1,5 +1,9 @@ # backend/app/db/queries/settings.py from ..connection import get_db +import logging +import os + +logger = logging.getLogger(__name__) def get_settings(): @@ -30,3 +34,32 @@ def save_settings(settings_dict): updated_at = CURRENT_TIMESTAMP ''', (key, value)) conn.commit() + + +def update_pat_status(): + """Update the has_profilarr_pat setting based on current environment.""" + with get_db() as conn: + profilarr_pat = os.environ.get('PROFILARR_PAT') + pat_exists = str(bool(profilarr_pat)).lower() + + # Get current value + current = conn.execute('SELECT value FROM settings WHERE key = ?', + ('has_profilarr_pat', )).fetchone() + + conn.execute( + ''' + INSERT INTO settings (key, value, updated_at) + VALUES ('has_profilarr_pat', ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = ?, + updated_at = CURRENT_TIMESTAMP + ''', (pat_exists, pat_exists)) + conn.commit() + + if current is None: + logger.info(f"PAT status created: {pat_exists}") + elif current[0] != pat_exists: + logger.info( + f"PAT status updated from {current[0]} to {pat_exists}") + else: + logger.debug("PAT status unchanged") diff --git a/backend/app/init.py b/backend/app/init.py index 62422b8..bbdec4a 100644 --- a/backend/app/init.py +++ b/backend/app/init.py @@ -4,7 +4,7 @@ import subprocess import logging import logging.config from .config import config -from .db import get_secret_key +from .db import get_secret_key, update_pat_status def setup_logging(): @@ -103,7 +103,7 @@ def setup_logging(): def init_git_user(): - """Initialize Git user configuration globally.""" + """Initialize Git user configuration globally and update PAT status.""" logger = logging.getLogger(__name__) logger.info("Starting Git user configuration") @@ -124,6 +124,9 @@ def init_git_user(): subprocess.run(['git', 'config', '--global', 'user.email', git_email], check=True) + # Update PAT status in database + update_pat_status() + # Verify configuration configured_name = subprocess.run( ['git', 'config', '--global', 'user.name'], From ca1c2bf7778792f8a341e747ba9bb7d058dfad89 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Mon, 24 Feb 2025 03:11:51 +1030 Subject: [PATCH 06/11] feat: format view improvements (#148) - feat: seperate group tier lists into seperate category, hide groups with no formats - style: adjust NumberInput width and text alignment for better usability --- .../profile/scoring/AdvancedView.jsx | 64 +++++++++---------- frontend/src/components/ui/NumberInput.jsx | 4 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/profile/scoring/AdvancedView.jsx b/frontend/src/components/profile/scoring/AdvancedView.jsx index 00c76fb..51edad8 100644 --- a/frontend/src/components/profile/scoring/AdvancedView.jsx +++ b/frontend/src/components/profile/scoring/AdvancedView.jsx @@ -15,7 +15,8 @@ import { Video, Flag, Zap, - Package + Package, + List } from 'lucide-react'; const AdvancedView = ({formats, onScoreChange}) => { @@ -35,7 +36,8 @@ const AdvancedView = ({formats, onScoreChange}) => { tag.includes('HDR') || tag.includes('Flag') || tag.includes('Language') || - tag.includes('Release Group') || + (tag.includes('Release Group') && !tag.includes('Tier')) || + tag.includes('Release Group Tier') || tag.includes('Resolution') || tag.includes('Source') || tag.includes('Storage') || @@ -75,7 +77,13 @@ const AdvancedView = ({formats, onScoreChange}) => { .filter(([tag]) => tag.includes('Language')) .flatMap(([_, formats]) => formats), 'Release Groups': Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Release Group')) + .filter( + ([tag]) => + tag.includes('Release Group') && !tag.includes('Tier') + ) + .flatMap(([_, formats]) => formats), + 'Group Tier Lists': Object.entries(groupedFormats) + .filter(([tag]) => tag.includes('Release Group Tier')) .flatMap(([_, formats]) => formats), Resolution: Object.entries(groupedFormats) .filter(([tag]) => tag.includes('Resolution')) @@ -97,6 +105,7 @@ const AdvancedView = ({formats, onScoreChange}) => { Audio: , HDR: , 'Release Groups': , + 'Group Tier Lists': , 'Streaming Services': , Codecs: , Storage: , @@ -111,8 +120,9 @@ const AdvancedView = ({formats, onScoreChange}) => { }; // Create sort instances for each group - const groupSorts = Object.entries(formatGroups).reduce( - (acc, [groupName, formats]) => { + const groupSorts = Object.entries(formatGroups) + .filter(([_, formats]) => formats.length > 0) // Only create sorts for non-empty groups + .reduce((acc, [groupName, formats]) => { const defaultSort = {field: 'name', direction: 'asc'}; const {sortConfig, updateSort, sortData} = useSorting(defaultSort); @@ -122,13 +132,12 @@ const AdvancedView = ({formats, onScoreChange}) => { updateSort }; return acc; - }, - {} - ); + }, {}); return (
{Object.entries(formatGroups) + .filter(([_, formats]) => formats.length > 0) // Only render non-empty groups .sort(([a], [b]) => a.localeCompare(b)) .map(([groupName, formats]) => { const {sortedData, sortConfig, updateSort} = @@ -151,32 +160,23 @@ const AdvancedView = ({formats, onScoreChange}) => {
- {sortedData.length > 0 ? ( - sortedData.map(format => ( -
-
-

- {format.name} -

-
- - onScoreChange( - format.id, - value - ) - } - /> + {sortedData.map(format => ( +
+
+

+ {format.name} +

- )) - ) : ( -
- No formats found + + onScoreChange(format.id, value) + } + />
- )} + ))}
); diff --git a/frontend/src/components/ui/NumberInput.jsx b/frontend/src/components/ui/NumberInput.jsx index 4b77ba5..d636999 100644 --- a/frontend/src/components/ui/NumberInput.jsx +++ b/frontend/src/components/ui/NumberInput.jsx @@ -71,8 +71,8 @@ const NumberInput = ({ }; const inputClasses = [ - 'w-16 h-8 px-2 py-1 text-sm border border-gray-700', - 'rounded-l focus:outline-none', + 'w-20 h-8 px-2 py-1 text-sm border border-gray-700', + 'rounded-l focus:outline-none text-left', 'bg-gray-800', isFocused ? 'text-blue-400' : 'text-gray-300', '[appearance:textfield]', From 49e36d67a6211357d548e27e0d6e64ed8d626ac8 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Wed, 26 Feb 2025 11:17:06 +1030 Subject: [PATCH 07/11] feat: search dropdown (#150) - new search dropdown component that adds search / sort functionality to dropdowns - replaces browser select component in select condition type dropdowns - add font size prop to sort dropdown / search bar to scale size for dropdowns - fix bad background color on tests page - add scrollable class description / condition area for format / regex cards --- CLAUDE.md | 25 ++ frontend/src/components/format/FormatCard.jsx | 4 +- .../components/format/FormatTestingTab.jsx | 2 +- .../format/conditions/ConditionCard.jsx | 12 +- .../format/conditions/EditionCondition.jsx | 26 +- .../conditions/ReleaseGroupCondition.jsx | 38 +-- .../conditions/ReleaseTitleCondition.jsx | 38 +-- frontend/src/components/regex/RegexCard.jsx | 2 +- .../src/components/regex/RegexTestingTab.jsx | 2 +- .../src/components/ui/DataBar/SearchBar.jsx | 54 +++-- frontend/src/components/ui/SearchDropdown.jsx | 223 ++++++++++++++++++ frontend/src/components/ui/SortDropdown.jsx | 21 +- 12 files changed, 370 insertions(+), 77 deletions(-) create mode 100644 CLAUDE.md create mode 100644 frontend/src/components/ui/SearchDropdown.jsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..22d3bf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# Profilarr Development Guide + +## Commands +- **Frontend**: `cd frontend && npm run dev` - Start React dev server +- **Backend**: `cd backend && gunicorn -b 0.0.0.0:5000 app.main:app` - Run Flask server +- **Docker**: `docker compose up` - Start both frontend/backend in dev mode +- **Lint**: `cd frontend && npx eslint 'src/**/*.{js,jsx}'` - Check frontend code style +- **Build**: `cd frontend && npm run build` - Build for production + +## Code Style +### Frontend (React) +- **Imports**: React first, third-party libs next, components, then utils +- **Components**: Functional components with hooks, PascalCase naming +- **Props**: PropTypes for validation, destructure props in component signature +- **State**: Group related state, useCallback for memoized handlers +- **JSX**: 4-space indentation, attributes on new lines for readability +- **Error Handling**: try/catch for async operations, toast notifications + +### Backend (Python) +- **Imports**: Standard lib first, third-party next, local modules last +- **Naming**: snake_case for functions/vars/files, PascalCase for classes +- **Functions**: Single responsibility, descriptive docstrings +- **Error Handling**: Specific exception catches, return (success, message) tuples +- **Indentation**: 4 spaces consistently +- **Modularity**: Related functionality grouped in directories \ No newline at end of file diff --git a/frontend/src/components/format/FormatCard.jsx b/frontend/src/components/format/FormatCard.jsx index 0f1b9ba..506cb43 100644 --- a/frontend/src/components/format/FormatCard.jsx +++ b/frontend/src/components/format/FormatCard.jsx @@ -168,7 +168,7 @@ function FormatCard({ : 'translate-x-0' }`}> {/* Conditions */} -
+
{content.conditions?.map((condition, index) => ( {/* Description */} -
+
{content.description ? (
diff --git a/frontend/src/components/format/FormatTestingTab.jsx b/frontend/src/components/format/FormatTestingTab.jsx index 7cf2f3f..3a5a346 100644 --- a/frontend/src/components/format/FormatTestingTab.jsx +++ b/frontend/src/components/format/FormatTestingTab.jsx @@ -120,7 +120,7 @@ const FormatTestingTab = ({ ))}
) : ( -
+

No tests added yet

diff --git a/frontend/src/components/format/conditions/ConditionCard.jsx b/frontend/src/components/format/conditions/ConditionCard.jsx index f720f47..78ef1c3 100644 --- a/frontend/src/components/format/conditions/ConditionCard.jsx +++ b/frontend/src/components/format/conditions/ConditionCard.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import {CONDITION_TYPES, createCondition} from './conditionTypes'; import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react'; -import BrowserSelect from '@ui/BrowserSelect'; +import SearchDropdown from '@ui/SearchDropdown'; const ConditionCard = ({ condition, @@ -21,7 +21,8 @@ const ConditionCard = ({ const typeOptions = Object.values(CONDITION_TYPES).map(type => ({ value: type.id, - label: type.name + label: type.name, + description: type.description || '' })); const handleTypeChange = e => { @@ -57,14 +58,13 @@ const ConditionCard = ({
{/* Type Selection */} - {/* Render the specific condition component */} diff --git a/frontend/src/components/format/conditions/EditionCondition.jsx b/frontend/src/components/format/conditions/EditionCondition.jsx index 6cc2448..8bc0243 100644 --- a/frontend/src/components/format/conditions/EditionCondition.jsx +++ b/frontend/src/components/format/conditions/EditionCondition.jsx @@ -1,25 +1,31 @@ import React from 'react'; import PropTypes from 'prop-types'; -import BrowserSelect from '@ui/BrowserSelect'; +import SearchDropdown from '@ui/SearchDropdown'; const EditionCondition = ({condition, onChange, patterns}) => { - // Convert patterns to options format + // Format patterns for the dropdown with descriptions if available const patternOptions = patterns.map(pattern => ({ value: pattern.name, - label: pattern.name + label: pattern.name, + description: pattern.description || 'No description available', + priority: pattern.priority })); + const handlePatternChange = e => { + onChange({...condition, pattern: e.target.value}); + }; + return ( -
- + - onChange({...condition, pattern: e.target.value}) - } + onChange={handlePatternChange} options={patternOptions} placeholder='Select edition pattern...' - className='w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 - rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' + searchableFields={['label', 'description']} + className='min-w-[200px]' + width='w-auto' + dropdownWidth='100%' />
); diff --git a/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx b/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx index aefccec..7a60953 100644 --- a/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx +++ b/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx @@ -1,23 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; +import SearchDropdown from '@ui/SearchDropdown'; const ReleaseGroupCondition = ({condition, onChange, patterns}) => { - const sortedPatterns = [...patterns].sort((a, b) => - a.name.localeCompare(b.name) - ); + // Format patterns for the dropdown with descriptions if available + const patternOptions = patterns.map(pattern => ({ + value: pattern.name, + label: pattern.name, + description: pattern.description || 'No description available', + priority: pattern.priority + })); + + const handlePatternChange = e => { + onChange({...condition, pattern: e.target.value}); + }; return ( - +
+ +
); }; diff --git a/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx b/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx index dad2ae7..7231791 100644 --- a/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx +++ b/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx @@ -1,23 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; +import SearchDropdown from '@ui/SearchDropdown'; const ReleaseTitleCondition = ({condition, onChange, patterns}) => { - const sortedPatterns = [...patterns].sort((a, b) => - a.name.localeCompare(b.name) - ); + // Format patterns for the dropdown with enhanced descriptions + const patternOptions = patterns.map(pattern => ({ + value: pattern.name, + label: pattern.name, + description: pattern.description || 'No description available', + priority: pattern.priority + })); + + const handlePatternChange = e => { + onChange({...condition, pattern: e.target.value}); + }; return ( - +
+ +
); }; diff --git a/frontend/src/components/regex/RegexCard.jsx b/frontend/src/components/regex/RegexCard.jsx index b36efeb..1fde0ac 100644 --- a/frontend/src/components/regex/RegexCard.jsx +++ b/frontend/src/components/regex/RegexCard.jsx @@ -142,7 +142,7 @@ const RegexCard = ({ [&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4 [&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4 [&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5 - [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50'> + [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50 scrollable'> {pattern.description}
)} diff --git a/frontend/src/components/regex/RegexTestingTab.jsx b/frontend/src/components/regex/RegexTestingTab.jsx index cad5c62..8b64dc4 100644 --- a/frontend/src/components/regex/RegexTestingTab.jsx +++ b/frontend/src/components/regex/RegexTestingTab.jsx @@ -148,7 +148,7 @@ const RegexTestingTab = ({ ))}
) : ( -
+

No tests added yet

diff --git a/frontend/src/components/ui/DataBar/SearchBar.jsx b/frontend/src/components/ui/DataBar/SearchBar.jsx index 2d4a76d..78abd30 100644 --- a/frontend/src/components/ui/DataBar/SearchBar.jsx +++ b/frontend/src/components/ui/DataBar/SearchBar.jsx @@ -12,7 +12,11 @@ const SearchBar = ({ onInputChange, onAddTerm, onRemoveTerm, - onClearTerms + onClearTerms, + textSize = 'text-sm', // Default text size + badgeTextSize = 'text-sm', // Default badge text size + iconSize = 'h-4 w-4', // Default icon size + minHeight = 'min-h-10' // Default min height }) => { const [isFocused, setIsFocused] = useState(false); @@ -47,7 +51,7 @@ const SearchBar = ({
(
- + className={` + flex items-center gap-1.5 px-2 + ${ + minHeight && minHeight.startsWith('h-') + ? 'py-0.5' + : 'py-1' + } + bg-blue-500/10 dark:bg-blue-500/20 + border border-blue-500/20 dark:border-blue-400/20 + text-blue-600 dark:text-blue-400 + rounded-md shadow-sm + hover:bg-blue-500/15 dark:hover:bg-blue-500/25 + hover:border-blue-500/30 dark:hover:border-blue-400/30 + group/badge flex-shrink-0 + transition-all duration-200 + `}> + {term}
@@ -122,7 +138,7 @@ const SearchBar = ({ hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 group/clear'> - + )}
diff --git a/frontend/src/components/ui/SearchDropdown.jsx b/frontend/src/components/ui/SearchDropdown.jsx new file mode 100644 index 0000000..336091c --- /dev/null +++ b/frontend/src/components/ui/SearchDropdown.jsx @@ -0,0 +1,223 @@ +import React, {useEffect, useRef, useState, useCallback} from 'react'; +import PropTypes from 'prop-types'; +import {ChevronDown, Check, Search} from 'lucide-react'; +import SearchBar from './DataBar/SearchBar'; +import SortDropdown from './SortDropdown'; +import useSearch from '@hooks/useSearch'; +import {useSorting} from '@hooks/useSorting'; + +const SearchDropdown = ({ + value, + onChange, + options, + placeholder, + className, + width = 'w-full', + dropdownWidth, + labelKey = 'label', + valueKey = 'value', + searchableFields = ['label', 'description'] +}) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedOption, setSelectedOption] = useState( + options.find(opt => opt[valueKey] === value) || null + ); + + const dropdownRef = useRef(null); + const menuRef = useRef(null); + + const { + searchTerms, + currentInput, + setCurrentInput, + addSearchTerm, + removeSearchTerm, + clearSearchTerms, + items: filteredOptions + } = useSearch(options, { + searchableFields, + initialSortBy: 'label' + }); + + const {sortConfig, updateSort, sortData} = useSorting({ + field: 'label', + direction: 'asc' + }); + + // Sort options configuration for the dropdown (name only) + const sortOptions = [{value: 'label', label: 'Name (A-Z)'}]; + + // Update selected option when value changes externally + useEffect(() => { + setSelectedOption(options.find(opt => opt[valueKey] === value) || null); + }, [value, options, valueKey]); + + // Handle dropdown visibility + useEffect(() => { + // Handle clicks outside dropdown (close the dropdown) + const handleClickOutside = event => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Apply final sorting to the filtered results + const sortedOptions = useCallback(() => { + return sortData(filteredOptions); + }, [sortData, filteredOptions]); + + // Handle selection + const handleSelect = useCallback( + option => { + setSelectedOption(option); + onChange({target: {value: option[valueKey]}}); + setIsOpen(false); + }, + [onChange, valueKey] + ); + + return ( +
+ {/* Selected Value Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+
+
+
+
+ +
+ +
+
+ + {/* Options List */} +
+ {sortedOptions().length > 0 ? ( +
+ {sortedOptions().map(option => ( +
handleSelect(option)} + className={`px-2.5 py-1.5 text-xs cursor-pointer rounded + ${ + selectedOption?.[valueKey] === + option[valueKey] + ? 'bg-blue-600 text-white' + : 'text-gray-100 hover:bg-gray-700' + }`}> +
+
+ {option[labelKey]} +
+ {selectedOption?.[valueKey] === + option[valueKey] && ( + + )} +
+
+ ))} +
+ ) : ( +
+
+ +

+ No options match your search +

+ +
+
+ )} +
+
+ )} +
+ ); +}; + +SearchDropdown.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + description: PropTypes.string + }) + ).isRequired, + placeholder: PropTypes.string, + className: PropTypes.string, + width: PropTypes.string, + dropdownWidth: PropTypes.string, + labelKey: PropTypes.string, + valueKey: PropTypes.string, + searchableFields: PropTypes.arrayOf(PropTypes.string) +}; + +export default SearchDropdown; diff --git a/frontend/src/components/ui/SortDropdown.jsx b/frontend/src/components/ui/SortDropdown.jsx index 8169142..c10eac8 100644 --- a/frontend/src/components/ui/SortDropdown.jsx +++ b/frontend/src/components/ui/SortDropdown.jsx @@ -7,7 +7,10 @@ const SortDropdown = ({ sortOptions, currentSort, onSortChange, - className = '' + className = '', + textSize = 'text-sm', // Default text size + menuTextSize = 'text-xs', // Default menu text size + iconSize = 16 // Default icon size }) => { const [isOpen, setIsOpen] = useState(false); @@ -30,7 +33,7 @@ const SortDropdown = ({ onClick={toggleDropdown} className={` inline-flex items-center justify-between - min-h-[40px] px-4 py-2 text-sm + px-4 py-2 ${textSize} bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-gray-100 @@ -42,9 +45,9 @@ const SortDropdown = ({ {getCurrentSortLabel()} {currentSort.direction === 'asc' ? ( - + ) : ( - + )} @@ -63,17 +66,17 @@ const SortDropdown = ({ key={option.value} type='button' onClick={() => handleSortClick(option.value)} - className=' + className={` flex items-center justify-between w-full px-4 py-2 - text-xs text-gray-700 dark:text-gray-200 + ${menuTextSize} text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 - '> + `}> {option.label} {currentSort.field === option.value && (currentSort.direction === 'asc' ? ( - + ) : ( - + ))} ))} From df16d7c52f1c3673ade9836e3bcd89918504dca1 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Wed, 26 Feb 2025 18:11:35 +1030 Subject: [PATCH 08/11] fix: merge/delete conflicts (#151) feat: enhance merge conflict handling with improved status messages and conditional rendering feat: improved modify / delete conflict handling - fix ours/theirs messages for deleted files - fix handling of choice moniker - fix parsing of local/incoming name when deleted --- backend/app/git/operations/resolve.py | 33 +++++++++++-- backend/app/git/status/conflict_comparison.py | 46 ++++++++++++++----- backend/app/git/status/merge_conflicts.py | 46 +++++++++++++++---- .../settings/git/status/ConflictRow.jsx | 3 +- .../settings/git/status/MergeConflicts.jsx | 37 +++++++++++---- 5 files changed, 131 insertions(+), 34 deletions(-) diff --git a/backend/app/git/operations/resolve.py b/backend/app/git/operations/resolve.py index b993e34..363d718 100644 --- a/backend/app/git/operations/resolve.py +++ b/backend/app/git/operations/resolve.py @@ -30,10 +30,12 @@ def get_version_data(repo, ref, file_path): def resolve_conflicts( repo, resolutions: Dict[str, Dict[str, str]]) -> Dict[str, Any]: - logger.debug(f"Received resolutions for files: {list(resolutions.keys())}") """ Resolve merge conflicts based on provided resolutions. """ + logger.debug(f"Received resolutions for files: {list(resolutions.keys())}") + logger.debug(f"Full resolutions data: {resolutions}") + try: status = repo.git.status('--porcelain', '-z').split('\0') conflicts = [] @@ -82,6 +84,7 @@ def resolve_conflicts( if modify_delete_conflicts[file_path]: logger.debug( f"Handling modify/delete conflict for {file_path}") + logger.debug(f"Field resolutions for modify/delete: {field_resolutions}") # Get the existing version (either from HEAD or MERGE_HEAD) head_data = get_version_data(repo, 'HEAD', file_path) @@ -92,9 +95,17 @@ def resolve_conflicts( is_deleted_in_head = head_data is None existing_data = merge_head_data if is_deleted_in_head else head_data logger.debug(f"Existing version data: {existing_data}") + logger.debug(f"is_deleted_in_head: {is_deleted_in_head}") + logger.debug(f"head_data: {head_data}") + logger.debug(f"merge_head_data: {merge_head_data}") - choice = field_resolutions.get('file') + # Try both lowercase and capitalized versions of 'file' + choice = field_resolutions.get('file') or field_resolutions.get('File') + logger.debug(f"Resolution choice for file: {choice}") + if not choice: + logger.error("No 'file' or 'File' resolution found in field_resolutions!") + logger.error(f"Available keys: {list(field_resolutions.keys())}") raise Exception( "No resolution provided for modify/delete conflict") @@ -153,9 +164,23 @@ def resolve_conflicts( ours_data = get_version_data(repo, 'HEAD', file_path) theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path) + # For files that were previously involved in modify/delete conflicts + # we may not be able to get all versions if not base_data or not ours_data or not theirs_data: - raise Exception( - f"Couldn't get all versions of {file_path}") + logger.warning(f"Couldn't get all versions of {file_path} - may have been previously resolved as a modify/delete conflict") + logger.warning(f"base_data: {base_data}, ours_data: {ours_data}, theirs_data: {theirs_data}") + + # If it was previously resolved as "incoming" but ours_data is missing, use theirs_data + if not ours_data and theirs_data: + logger.info(f"Using incoming version for {file_path} as base for resolution") + ours_data = theirs_data + # If it was previously resolved as "local" but theirs_data is missing, use ours_data + elif ours_data and not theirs_data: + logger.info(f"Using local version for {file_path} as base for resolution") + theirs_data = ours_data + # If we can't recover either version, we can't proceed + else: + raise Exception(f"Couldn't get required versions of {file_path}") # Start with a deep copy of ours_data to preserve all fields resolved_data = deepcopy(ours_data) diff --git a/backend/app/git/status/conflict_comparison.py b/backend/app/git/status/conflict_comparison.py index 6aece54..fc51de3 100644 --- a/backend/app/git/status/conflict_comparison.py +++ b/backend/app/git/status/conflict_comparison.py @@ -29,16 +29,20 @@ def compare_conflict_yaml(ours_data: Any, if ours_data is None and theirs_data is None: return conflicts if ours_data is None: + # Local version deleted + param_name = path or 'File' return [{ - 'parameter': path or 'File', - 'local_value': 'deleted', - 'incoming_value': theirs_data + 'parameter': param_name, + 'local_value': '🗑️ File deleted in local version', + 'incoming_value': '📄 File exists in incoming version' }] if theirs_data is None: + # Incoming version deleted + param_name = path or 'File' return [{ - 'parameter': path or 'File', - 'local_value': ours_data, - 'incoming_value': 'deleted' + 'parameter': param_name, + 'local_value': '📄 File exists in local version', + 'incoming_value': '🗑️ File deleted in incoming version' }] # Handle different types as conflicts @@ -197,6 +201,7 @@ def create_conflict_summary(file_path: str, - file_path: Path to the conflicted file - type: Type of item - name: Name from our version or filename + - incoming_name: Name from their version (if available) - status: Current conflict status - conflict_details: List of specific conflicts """ @@ -209,17 +214,36 @@ def create_conflict_summary(file_path: str, compare_conflict_yaml(ours_data, theirs_data) } - # Get name from our version or fallback to filename - name = ours_data.get('name') if ours_data else os.path.basename( - file_path) + # Get local name + local_name = None + if ours_data and isinstance(ours_data, dict) and 'name' in ours_data: + local_name = ours_data.get('name') + + if not local_name: + # Strip the extension to get a cleaner name + basename = os.path.basename(file_path) + local_name = os.path.splitext(basename)[0] + + # Get incoming name + incoming_name = None + if theirs_data and isinstance(theirs_data, dict) and 'name' in theirs_data: + incoming_name = theirs_data.get('name') + + if not incoming_name: + # Strip the extension to get a cleaner name + basename = os.path.basename(file_path) + incoming_name = os.path.splitext(basename)[0] - return { + result = { 'file_path': file_path, 'type': determine_type(file_path), - 'name': name, + 'name': local_name, + 'incoming_name': incoming_name, 'status': status, 'conflict_details': conflict_details } + + return result except Exception as e: logger.error( diff --git a/backend/app/git/status/merge_conflicts.py b/backend/app/git/status/merge_conflicts.py index 950fe83..31825b9 100644 --- a/backend/app/git/status/merge_conflicts.py +++ b/backend/app/git/status/merge_conflicts.py @@ -31,20 +31,46 @@ def process_modify_delete_conflict(repo, file_path, deleted_in_head): else: status = MODIFY_DELETE - # Get the surviving version + # For delete conflicts, we need to extract the name for display purposes + # This will be the name of the actual file before it was deleted + basename = os.path.basename(file_path) + filename = os.path.splitext(basename)[0] # Strip extension + + # Get metadata from existing version to extract name if possible if file_exists: - with open(os.path.join(repo.working_dir, file_path), 'r') as f: - existing_data = yaml.safe_load(f.read()) + # File exists locally, read it + try: + with open(os.path.join(repo.working_dir, file_path), 'r') as f: + existing_data = yaml.safe_load(f.read()) + except Exception as read_error: + logger.warning(f"Could not read existing file {file_path}: {str(read_error)}") + existing_data = {'name': filename} else: - existing_data = get_version_data(repo, 'MERGE_HEAD', file_path) + # File was deleted locally, try to get from merge head + try: + existing_data = get_version_data(repo, 'MERGE_HEAD', file_path) + except Exception as merge_error: + logger.warning(f"Could not get merge head for {file_path}: {str(merge_error)}") + existing_data = {'name': filename} - if not existing_data: - return None + # Simplified placeholder data for deleted version + if deleted_in_head: + # File was deleted in HEAD (local) but exists in MERGE_HEAD (incoming) + local_data = None # This indicates deleted + try: + # Try to get name from incoming + incoming_data = existing_data if existing_data else {'name': filename} + except Exception: + incoming_data = {'name': filename} + else: + # File exists in HEAD (local) but deleted in MERGE_HEAD (incoming) + try: + local_data = existing_data if existing_data else {'name': filename} + except Exception: + local_data = {'name': filename} + incoming_data = None # This indicates deleted - # Create summary with the appropriate sides marked as deleted - return create_conflict_summary( - file_path, None if deleted_in_head else existing_data, - existing_data if deleted_in_head else None, status) + return create_conflict_summary(file_path, local_data, incoming_data, status) except Exception as e: logger.error( diff --git a/frontend/src/components/settings/git/status/ConflictRow.jsx b/frontend/src/components/settings/git/status/ConflictRow.jsx index 675e27c..c6d36ba 100644 --- a/frontend/src/components/settings/git/status/ConflictRow.jsx +++ b/frontend/src/components/settings/git/status/ConflictRow.jsx @@ -15,9 +15,10 @@ const ConflictRow = ({change, fetchGitStatus}) => { param => param.parameter === 'name' ); + // Check for name in conflicting parameters or directly in change object const displayLocalName = nameConflict?.local_value || change.name || 'Unnamed'; - const displayIncomingName = nameConflict?.incoming_value || 'Unnamed'; + const displayIncomingName = nameConflict?.incoming_value || change?.incoming_name || 'Unnamed'; const isResolved = change.status === 'RESOLVED'; diff --git a/frontend/src/components/settings/git/status/MergeConflicts.jsx b/frontend/src/components/settings/git/status/MergeConflicts.jsx index 85f4bd4..15643ef 100644 --- a/frontend/src/components/settings/git/status/MergeConflicts.jsx +++ b/frontend/src/components/settings/git/status/MergeConflicts.jsx @@ -10,14 +10,22 @@ const MergeConflicts = ({ areAllConflictsResolved, fetchGitStatus }) => { - if (!conflicts || conflicts.length === 0) return null; - + const hasConflicts = conflicts && conflicts.length > 0; + return (

- - Merge Conflicts + {areAllConflictsResolved() ? ( + + ) : ( + + )} + + {areAllConflictsResolved() + ? 'All Conflicts Resolved' + : 'Merge Conflicts'} +

- + + {/* Only show the conflict table if there are conflicts */} + {hasConflicts && ( + + )} + + {/* Show a success message when all conflicts are resolved */} + {!hasConflicts && areAllConflictsResolved() && ( +
+

+ All conflicts have been successfully resolved. You can now commit the merge. +

+
+ )}
); }; From 923ab1ebd8ce0abb3754501af71457b8f047d37b Mon Sep 17 00:00:00 2001 From: Samuel Chau Date: Fri, 7 Mar 2025 18:47:47 +1030 Subject: [PATCH 09/11] feat: format selection and scoring customization options (#153) - new selection resolver allowing users to add formats with any score (including 0) - basic / advanced view for format selector - seperate formatGroups into shared constants file --- .../src/components/profile/ProfileModal.jsx | 80 +++- .../profile/scoring/AdvancedView.jsx | 184 +++------ .../components/profile/scoring/BasicView.jsx | 35 +- .../profile/scoring/FormatSelector.jsx | 75 ++++ .../profile/scoring/FormatSelectorModal.jsx | 235 ++++++++++++ .../profile/scoring/FormatSettings.jsx | 354 +++++++++++++----- .../profile/scoring/ProfileScoringTab.jsx | 7 +- .../profile/scoring/SelectiveView.jsx | 219 +++++++++++ frontend/src/constants/formatGroups.js | 167 +++++++++ 9 files changed, 1108 insertions(+), 248 deletions(-) create mode 100644 frontend/src/components/profile/scoring/FormatSelector.jsx create mode 100644 frontend/src/components/profile/scoring/FormatSelectorModal.jsx create mode 100644 frontend/src/components/profile/scoring/SelectiveView.jsx create mode 100644 frontend/src/constants/formatGroups.js diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx index 584a42d..9ca4099 100644 --- a/frontend/src/components/profile/ProfileModal.jsx +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -337,20 +337,74 @@ function ProfileModal({ minCustomFormatScore, upgradeUntilScore, minScoreIncrement, - custom_formats: customFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - // First sort by score (descending) - if (b.score !== a.score) { - return b.score - a.score; + custom_formats: (() => { + // Check if selective mode is enabled + const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode'); + const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode); + + if (useSelectiveMode) { + // In selective mode, save both: + // 1. Formats with non-zero scores as usual + // 2. Formats with zero score that have been explicitly selected in selectedFormatIds + + try { + // Get the list of explicitly selected format IDs + const selectedFormatIdsStr = localStorage.getItem('selectedFormatIds'); + const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : []; + + // Get formats with non-zero scores + const nonZeroFormats = customFormats.filter(format => format.score !== 0); + + // Get formats with zero scores that are explicitly selected + const explicitlySelectedZeroFormats = customFormats.filter(format => + format.score === 0 && selectedFormatIds.includes(format.id) + ); + + // Combine both lists + return [...nonZeroFormats, ...explicitlySelectedZeroFormats] + .sort((a, b) => { + // First sort by score (descending) + if (b.score !== a.score) { + return b.score - a.score; + } + // Then alphabetically for equal scores + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); + } catch (e) { + // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores + return customFormats + .filter(format => format.score !== 0) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); } - // Then alphabetically for equal scores - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })), + } else { + // Standard behavior - only include formats with non-zero scores + return customFormats + .filter(format => format.score !== 0) + .sort((a, b) => { + // First sort by score (descending) + if (b.score !== a.score) { + return b.score - a.score; + } + // Then alphabetically for equal scores + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); + } + })(), qualities: sortedQualities .filter(q => q.enabled) .map(q => { diff --git a/frontend/src/components/profile/scoring/AdvancedView.jsx b/frontend/src/components/profile/scoring/AdvancedView.jsx index 51edad8..2cfd114 100644 --- a/frontend/src/components/profile/scoring/AdvancedView.jsx +++ b/frontend/src/components/profile/scoring/AdvancedView.jsx @@ -1,138 +1,34 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import NumberInput from '@ui/NumberInput'; import {useSorting} from '@hooks/useSorting'; import SortDropdown from '@ui/SortDropdown'; -import { - Music, - Tv, - Users, - Cloud, - Film, - HardDrive, - Maximize, - Globe, - Video, - Flag, - Zap, - Package, - List -} from 'lucide-react'; +import { X } from 'lucide-react'; +import { groupFormatsByTags, getGroupIcon } from '@constants/formatGroups'; -const AdvancedView = ({formats, onScoreChange}) => { +const AdvancedView = ({formats, onScoreChange, onFormatRemove, showRemoveButton}) => { const sortOptions = [ {label: 'Name', value: 'name'}, {label: 'Score', value: 'score'} ]; - // Group formats by their tags - const groupedFormats = formats.reduce((acc, format) => { - // Check if format has any tags that match our known categories - const hasKnownTag = format.tags?.some( - tag => - tag.includes('Audio') || - tag.includes('Codec') || - tag.includes('Enhancement') || - tag.includes('HDR') || - tag.includes('Flag') || - tag.includes('Language') || - (tag.includes('Release Group') && !tag.includes('Tier')) || - tag.includes('Release Group Tier') || - tag.includes('Resolution') || - tag.includes('Source') || - tag.includes('Storage') || - tag.includes('Streaming Service') - ); + // Use the shared helper function to group formats + const formatGroups = groupFormatsByTags(formats); - if (!hasKnownTag) { - if (!acc['Uncategorized']) acc['Uncategorized'] = []; - acc['Uncategorized'].push(format); - return acc; - } - - format.tags.forEach(tag => { - if (!acc[tag]) acc[tag] = []; - acc[tag].push(format); - }); - return acc; - }, {}); - - const formatGroups = { - Audio: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Audio')) - .flatMap(([_, formats]) => formats), - Codecs: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Codec')) - .flatMap(([_, formats]) => formats), - Enhancements: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Enhancement')) - .flatMap(([_, formats]) => formats), - HDR: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('HDR')) - .flatMap(([_, formats]) => formats), - 'Indexer Flags': Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Flag')) - .flatMap(([_, formats]) => formats), - Language: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Language')) - .flatMap(([_, formats]) => formats), - 'Release Groups': Object.entries(groupedFormats) - .filter( - ([tag]) => - tag.includes('Release Group') && !tag.includes('Tier') - ) - .flatMap(([_, formats]) => formats), - 'Group Tier Lists': Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Release Group Tier')) - .flatMap(([_, formats]) => formats), - Resolution: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Resolution')) - .flatMap(([_, formats]) => formats), - Source: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Source')) - .flatMap(([_, formats]) => formats), - Storage: Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Storage')) - .flatMap(([_, formats]) => formats), - 'Streaming Services': Object.entries(groupedFormats) - .filter(([tag]) => tag.includes('Streaming Service')) - .flatMap(([_, formats]) => formats), - Uncategorized: groupedFormats['Uncategorized'] || [] - }; - - const getGroupIcon = groupName => { - const icons = { - Audio: , - HDR: , - 'Release Groups': , - 'Group Tier Lists': , - 'Streaming Services': , - Codecs: , - Storage: , - Resolution: , - Language: , - Source:
))}
@@ -194,7 +101,14 @@ AdvancedView.propTypes = { tags: PropTypes.arrayOf(PropTypes.string) }) ).isRequired, - onScoreChange: PropTypes.func.isRequired + onScoreChange: PropTypes.func.isRequired, + onFormatRemove: PropTypes.func, + showRemoveButton: PropTypes.bool +}; + +AdvancedView.defaultProps = { + onFormatRemove: () => {}, + showRemoveButton: false }; export default AdvancedView; diff --git a/frontend/src/components/profile/scoring/BasicView.jsx b/frontend/src/components/profile/scoring/BasicView.jsx index 7bbce91..a72d305 100644 --- a/frontend/src/components/profile/scoring/BasicView.jsx +++ b/frontend/src/components/profile/scoring/BasicView.jsx @@ -3,8 +3,9 @@ import PropTypes from 'prop-types'; import NumberInput from '@ui/NumberInput'; import {useSorting} from '@hooks/useSorting'; import SortDropdown from '@ui/SortDropdown'; +import { X } from 'lucide-react'; -const BasicView = ({formats, onScoreChange}) => { +const BasicView = ({formats, onScoreChange, onFormatRemove, showRemoveButton}) => { const sortOptions = [ {label: 'Score', value: 'score'}, {label: 'Name', value: 'name'} @@ -48,12 +49,23 @@ const BasicView = ({formats, onScoreChange}) => { )}
- - onScoreChange(format.id, value) - } - /> +
+ + onScoreChange(format.id, value) + } + /> + {showRemoveButton && ( + + )} +
)) ) : ( @@ -75,7 +87,14 @@ BasicView.propTypes = { tags: PropTypes.arrayOf(PropTypes.string) }) ).isRequired, - onScoreChange: PropTypes.func.isRequired + onScoreChange: PropTypes.func.isRequired, + onFormatRemove: PropTypes.func, + showRemoveButton: PropTypes.bool +}; + +BasicView.defaultProps = { + onFormatRemove: () => {}, + showRemoveButton: false }; export default BasicView; diff --git a/frontend/src/components/profile/scoring/FormatSelector.jsx b/frontend/src/components/profile/scoring/FormatSelector.jsx new file mode 100644 index 0000000..cf29019 --- /dev/null +++ b/frontend/src/components/profile/scoring/FormatSelector.jsx @@ -0,0 +1,75 @@ +import React, {useState, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import SearchDropdown from '@ui/SearchDropdown'; + +const FormatSelector = ({availableFormats, onFormatAdd}) => { + const [selectedFormats, setSelectedFormats] = useState([]); + const [dropdownOptions, setDropdownOptions] = useState([]); + + // Transform availableFormats into the format expected by SearchDropdown + useEffect(() => { + if (availableFormats && availableFormats.length > 0) { + const options = availableFormats.map(format => ({ + value: format.id, + label: format.name, + description: format.tags ? format.tags.join(', ') : '', + tags: format.tags + })); + setDropdownOptions(options); + } else { + setDropdownOptions([]); + } + }, [availableFormats]); + + const handleSelectFormat = e => { + const formatId = e.target.value; + if (formatId && !selectedFormats.includes(formatId)) { + setSelectedFormats(prev => [...prev, formatId]); + onFormatAdd(formatId); + } + }; + + return ( +
+
+

+ Available Formats +

+

+ Select formats to include in your profile. Zero-scored + formats are still saved when selected. +

+ + +
+ + {dropdownOptions.length === 0 && ( +
+ No available formats to add +
+ )} +
+ ); +}; + +FormatSelector.propTypes = { + availableFormats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, + onFormatAdd: PropTypes.func.isRequired +}; + +export default FormatSelector; diff --git a/frontend/src/components/profile/scoring/FormatSelectorModal.jsx b/frontend/src/components/profile/scoring/FormatSelectorModal.jsx new file mode 100644 index 0000000..2111e40 --- /dev/null +++ b/frontend/src/components/profile/scoring/FormatSelectorModal.jsx @@ -0,0 +1,235 @@ +import React, { useState, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import Modal from '@ui/Modal'; +import SearchBar from '@ui/DataBar/SearchBar'; +import useSearch from '@hooks/useSearch'; +import { Plus, Check, Settings, Grid3X3 } from 'lucide-react'; +import { groupFormatsByTags, getGroupIcon, FORMAT_GROUP_NAMES } from '@constants/formatGroups'; + +const FormatSelectorModal = ({ + isOpen, + onClose, + availableFormats, + selectedFormatIds, + allFormats, + onFormatToggle +}) => { + // State to track view mode (basic/advanced) + const [viewMode, setViewMode] = useState(() => { + const stored = localStorage.getItem('formatSelectorViewMode'); + return stored === null ? 'basic' : JSON.parse(stored); + }); + + // Save view mode preference + const toggleViewMode = () => { + const newMode = viewMode === 'basic' ? 'advanced' : 'basic'; + setViewMode(newMode); + localStorage.setItem('formatSelectorViewMode', JSON.stringify(newMode)); + }; + + // Group formats for advanced view + const groupedFormats = useMemo(() => { + return groupFormatsByTags(allFormats); + }, [allFormats]); + + // Search functionality + const { + searchTerms, + currentInput, + setCurrentInput, + addSearchTerm, + removeSearchTerm, + clearSearchTerms, + items: filteredFormats + } = useSearch(allFormats, { + searchableFields: ['name', 'tags'] + }); + + // Handle format selection/deselection + const handleFormatClick = (formatId) => { + onFormatToggle(formatId); + }; + + // Handle format card rendering for basic view + const renderFormatCard = (format) => { + const isSelected = selectedFormatIds.includes(format.id) || format.score !== 0; + + return ( +
handleFormatClick(format.id)} + > +
+
+

{format.name}

+ {format.tags && format.tags.length > 0 && ( +
+ {format.tags.slice(0, 2).map(tag => ( + + {tag} + + ))} + {format.tags.length > 2 && ( + + +{format.tags.length - 2} + + )} +
+ )} +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ ); + }; + + // Render advanced (grouped) view + const renderAdvancedView = () => { + return ( +
+ {Object.entries(groupedFormats) + .filter(([_, formats]) => formats.length > 0) // Only render non-empty groups + .sort(([a], [b]) => a.localeCompare(b)) + .map(([groupName, formats]) => { + // Filter formats to match search + const filteredGroupFormats = formats.filter(format => + filteredFormats.some(f => f.id === format.id) + ); + + // Skip empty groups after filtering + if (filteredGroupFormats.length === 0) { + return null; + } + + return ( +
+

+ {getGroupIcon(groupName)} + {groupName} + ({filteredGroupFormats.length}) +

+
+ {filteredGroupFormats.map(renderFormatCard)} +
+
+ ); + }) + } + + {filteredFormats.length === 0 && ( +
+ No formats found matching your search +
+ )} +
+ ); + }; + + // Render basic view (simple grid) + const renderBasicView = () => { + return ( + <> + {filteredFormats.length > 0 ? ( +
+ {filteredFormats.map(renderFormatCard)} +
+ ) : ( +
+ No formats found matching your search +
+ )} + + ); + }; + + return ( + +
+
+
+ Select formats to include in your profile. Click a format to toggle its selection. +
+ +
+ + + +
+
+ +
+ {selectedFormatIds.length + allFormats.filter(f => f.score !== 0).length} of {allFormats.length} formats selected +
+ +
+ {viewMode === 'basic' ? renderBasicView() : renderAdvancedView()} +
+
+
+ ); +}; + +FormatSelectorModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + availableFormats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ), + selectedFormatIds: PropTypes.arrayOf(PropTypes.string).isRequired, + allFormats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, + onFormatToggle: PropTypes.func.isRequired +}; + +export default FormatSelectorModal; \ No newline at end of file diff --git a/frontend/src/components/profile/scoring/FormatSettings.jsx b/frontend/src/components/profile/scoring/FormatSettings.jsx index 8e632f1..93ca15f 100644 --- a/frontend/src/components/profile/scoring/FormatSettings.jsx +++ b/frontend/src/components/profile/scoring/FormatSettings.jsx @@ -1,10 +1,12 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState, useEffect, useMemo} from 'react'; import PropTypes from 'prop-types'; import SearchBar from '@ui/DataBar/SearchBar'; import useSearch from '@hooks/useSearch'; import AdvancedView from './AdvancedView'; import BasicView from './BasicView'; -import {ChevronDown, Settings, List} from 'lucide-react'; +import FormatSelectorModal from './FormatSelectorModal'; +import {ChevronDown, Settings, List, CheckSquare, Plus} from 'lucide-react'; +import Tooltip from '@ui/Tooltip'; const FormatSettings = ({formats, onScoreChange}) => { // Initialize state from localStorage, falling back to true if no value is stored @@ -12,16 +14,74 @@ const FormatSettings = ({formats, onScoreChange}) => { const stored = localStorage.getItem('formatSettingsView'); return stored === null ? true : JSON.parse(stored); }); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - // Save to localStorage whenever isAdvancedView changes + // Initialize selectiveMode from localStorage + const [showSelectiveMode, setShowSelectiveMode] = useState(() => { + const stored = localStorage.getItem('formatSettingsSelectiveMode'); + return stored === null ? false : JSON.parse(stored); + }); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [availableFormats, setAvailableFormats] = useState([]); + const [selectedFormatIds, setSelectedFormatIds] = useState(() => { + try { + const stored = localStorage.getItem('selectedFormatIds'); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } + }); + + // Format selector modal state + const [isSelectorModalOpen, setIsSelectorModalOpen] = useState(false); + + // Calculate which formats to display + const displayFormats = useMemo(() => { + if (showSelectiveMode) { + // In selective mode: + // 1. Display all formats with non-zero scores + // 2. Also display formats with zero scores that are explicitly selected + const nonZeroFormats = formats.filter(f => f.score !== 0); + const selectedZeroFormats = formats.filter(f => + f.score === 0 && selectedFormatIds.includes(f.id) + ); + + return [...nonZeroFormats, ...selectedZeroFormats]; + } else { + // In regular mode, display all formats as usual + return formats; + } + }, [formats, showSelectiveMode, selectedFormatIds]); + + // Save to localStorage whenever view preferences change useEffect(() => { - localStorage.setItem( - 'formatSettingsView', - JSON.stringify(isAdvancedView) - ); + localStorage.setItem('formatSettingsView', JSON.stringify(isAdvancedView)); }, [isAdvancedView]); + useEffect(() => { + localStorage.setItem('formatSettingsSelectiveMode', JSON.stringify(showSelectiveMode)); + }, [showSelectiveMode]); + + // Save selected format IDs to localStorage + useEffect(() => { + localStorage.setItem('selectedFormatIds', JSON.stringify(selectedFormatIds)); + }, [selectedFormatIds]); + + // Calculate available formats for selection (not already in use) + useEffect(() => { + // To be "available", a format must have zero score and not be in selectedFormatIds + const usedFormatIds = formats.filter(f => f.score !== 0).map(f => f.id); + const allUnavailableIds = [...usedFormatIds, ...selectedFormatIds]; + + // Available formats are those not already used or selected + const available = formats.filter(format => + !allUnavailableIds.includes(format.id) + ); + + setAvailableFormats(available); + }, [formats, selectedFormatIds]); + + // Search hook for filtering formats const { searchTerms, currentInput, @@ -30,12 +90,66 @@ const FormatSettings = ({formats, onScoreChange}) => { removeSearchTerm, clearSearchTerms, items: filteredFormats - } = useSearch(formats, { + } = useSearch(displayFormats, { searchableFields: ['name'] }); + // Handle format toggle (add/remove) + const handleFormatToggle = (formatId) => { + const format = formats.find(f => f.id === formatId); + + if (!format) return; + + // Check if this format is already selected (either has a non-zero score or is in selectedFormatIds) + const isSelected = format.score !== 0 || selectedFormatIds.includes(formatId); + + if (isSelected) { + // Remove format + if (format.score !== 0) { + // If format has a non-zero score, set it to 0 (don't remove it completely) + onScoreChange(formatId, 0); + } + // If format was explicitly selected, remove from the selection list + setSelectedFormatIds(prev => prev.filter(id => id !== formatId)); + } else { + // Add format + // Set the format score to 0 initially, just to mark it as "selected" + onScoreChange(formatId, 0); + + // Add to our list of explicitly selected format IDs + setSelectedFormatIds(prev => [...prev, formatId]); + } + }; + + // When a format score changes, we need to update our tracking + const handleScoreChange = (formatId, score) => { + // Pass the score change to parent + onScoreChange(formatId, score); + + // If the score is changing from 0 to non-zero, we no longer need to track it + // as an explicitly selected format (it's tracked by virtue of its non-zero score) + if (score !== 0) { + const format = formats.find(f => f.id === formatId); + if (format && format.score === 0 && selectedFormatIds.includes(formatId)) { + // Format was previously explicitly selected with zero score, but now has a non-zero score + // We can remove it from our explicit selection tracking + setSelectedFormatIds(prev => prev.filter(id => id !== formatId)); + } + } + }; + + // Toggle selective mode on/off + const toggleSelectiveMode = () => { + setShowSelectiveMode(prev => !prev); + }; + + // Open the format selector modal + const openFormatSelector = () => { + setIsSelectorModalOpen(true); + }; + return ( -
+
{ onClearTerms={clearSearchTerms} /> -
- - {isDropdownOpen && ( - <> -
setIsDropdownOpen(false)} +
+ {/* View Mode Dropdown */} +
+ - + + {isDropdownOpen && ( + <> +
setIsDropdownOpen(false)} + /> +
+
+ + +
-
- - )} + + )} +
+ + {/* Selective Mode with Format Selector */} +
+ + + {showSelectiveMode && ( + + + + )} + + {!showSelectiveMode && ( + +
+ + Add +
+
+ )} +
+ {/* Format Selector Modal */} + setIsSelectorModalOpen(false)} + availableFormats={availableFormats} + selectedFormatIds={selectedFormatIds} + allFormats={formats} + onFormatToggle={handleFormatToggle} + /> + + {/* Format Display */} {isAdvancedView ? ( handleFormatToggle(formatId)} + showRemoveButton={showSelectiveMode} /> ) : ( handleFormatToggle(formatId)} + showRemoveButton={showSelectiveMode} /> )}
@@ -156,4 +332,4 @@ FormatSettings.propTypes = { onScoreChange: PropTypes.func.isRequired }; -export default FormatSettings; +export default FormatSettings; \ No newline at end of file diff --git a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx index 1b34fb3..0f73429 100644 --- a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx +++ b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx @@ -70,9 +70,10 @@ const ProfileScoringTab = ({ Format Settings

- Assign scores to different formats to control download - preferences. View formats in the traditional arr style, - or in filtered A/V grids. + Customize format scoring to prioritize your preferred downloads. + Use Basic mode for a simple list view with sliders, Advanced mode for + detailed A/V category grids, and Selective mode to display and manage + only formats you care about instead of all available formats.

diff --git a/frontend/src/components/profile/scoring/SelectiveView.jsx b/frontend/src/components/profile/scoring/SelectiveView.jsx new file mode 100644 index 0000000..d19ef89 --- /dev/null +++ b/frontend/src/components/profile/scoring/SelectiveView.jsx @@ -0,0 +1,219 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import NumberInput from '@ui/NumberInput'; +import { useSorting } from '@hooks/useSorting'; +import SortDropdown from '@ui/SortDropdown'; +import { Plus, X } from 'lucide-react'; + +const SelectiveView = ({ formats, onScoreChange, allFormats }) => { + const [selectedFormats, setSelectedFormats] = useState([]); + const [availableFormats, setAvailableFormats] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [searchInput, setSearchInput] = useState(''); + + const sortOptions = [ + { label: 'Score', value: 'score' }, + { label: 'Name', value: 'name' } + ]; + + const { sortConfig, updateSort, sortData } = useSorting({ + field: 'score', + direction: 'desc' + }); + + // Initialize selected formats from the formats prop + useEffect(() => { + setSelectedFormats(formats); + + // Set available formats (those not already selected) + updateAvailableFormats(formats); + + // Save selected format IDs to localStorage + const selectedIds = formats.map(f => f.id); + localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds)); + }, [formats, allFormats]); + + // Update available formats list (excluding already selected ones) + const updateAvailableFormats = (selectedFormats) => { + const selectedIds = selectedFormats.map(f => f.id); + setAvailableFormats(allFormats.filter(f => !selectedIds.includes(f.id))); + }; + + // Add a format to the selected list + const addFormat = (format) => { + // Always start with score 0 for newly added formats + const formatWithScore = {...format, score: 0}; + const newSelectedFormats = [...selectedFormats, formatWithScore]; + setSelectedFormats(newSelectedFormats); + updateAvailableFormats(newSelectedFormats); + setDropdownOpen(false); + setSearchInput(''); + + // Update the localStorage list of selected formats + const selectedIds = newSelectedFormats.map(f => f.id); + localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds)); + + // Notify parent component about the new format + onScoreChange(format.id, 0); + }; + + // Remove a format from the selected list + const removeFormat = (formatId) => { + const newSelectedFormats = selectedFormats.filter(f => f.id !== formatId); + setSelectedFormats(newSelectedFormats); + updateAvailableFormats(newSelectedFormats); + + // Update the localStorage list of selected formats + const selectedIds = newSelectedFormats.map(f => f.id); + localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds)); + + // Also notify parent component that this format is no longer used + onScoreChange(formatId, 0); + }; + + // Filter available formats based on search input + const filteredAvailableFormats = availableFormats.filter(format => + format.name.toLowerCase().includes(searchInput.toLowerCase()) + ); + + const sortedFormats = sortData(selectedFormats); + + return ( +
+
+

+ Selected Formats +

+
+ +
+
+ +
+ {/* Add new format button */} +
+ + + {/* Dropdown for selecting a format to add */} + {dropdownOpen && ( + <> +
setDropdownOpen(false)} + /> +
+
+ setSearchInput(e.target.value)} + className='w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-700 dark:text-gray-300' + onClick={(e) => e.stopPropagation()} + /> +
+
+ {filteredAvailableFormats.length > 0 ? ( + filteredAvailableFormats.map(format => ( + + )) + ) : ( +
+ No formats available +
+ )} +
+
+ + )} +
+ + {/* List of selected formats */} + {sortedFormats.length > 0 ? ( + sortedFormats.map(format => ( +
+
+
+

+ {format.name} +

+ {format.tags && format.tags.length > 0 && ( + + {format.tags.join(', ')} + + )} +
+
+
+ onScoreChange(format.id, value)} + /> + +
+
+ )) + ) : ( +
+ No formats selected +
+ )} +
+
+ ); +}; + +SelectiveView.propTypes = { + formats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, + onScoreChange: PropTypes.func.isRequired, + allFormats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired +}; + +export default SelectiveView; \ No newline at end of file diff --git a/frontend/src/constants/formatGroups.js b/frontend/src/constants/formatGroups.js new file mode 100644 index 0000000..89679f7 --- /dev/null +++ b/frontend/src/constants/formatGroups.js @@ -0,0 +1,167 @@ +import React from 'react'; +import { + Music, + Tv, + Users, + Cloud, + Film, + HardDrive, + Maximize, + Globe, + Video, + Flag, + Zap, + Package, + List, + BookOpen, + X +} from 'lucide-react'; + +// Format tag categories for grouping +export const FORMAT_TAG_CATEGORIES = { + AUDIO: 'Audio', + CODEC: 'Codec', + EDITION: 'Edition', + ENHANCEMENT: 'Enhancement', + HDR: 'HDR', + FLAG: 'Flag', + LANGUAGE: 'Language', + RELEASE_GROUP: 'Release Group', + RELEASE_GROUP_TIER: 'Release Group Tier', + RESOLUTION: 'Resolution', + SOURCE: 'Source', + STORAGE: 'Storage', + STREAMING_SERVICE: 'Streaming Service' +}; + +// Format grouping mappings (tag to display group) +export const FORMAT_GROUP_NAMES = { + Audio: 'Audio', + Codecs: 'Codecs', + Edition: 'Edition', + Enhancements: 'Enhancements', + HDR: 'HDR', + 'Indexer Flags': 'Indexer Flags', + Language: 'Language', + 'Release Groups': 'Release Groups', + 'Group Tier Lists': 'Group Tier Lists', + Resolution: 'Resolution', + Source: 'Source', + Storage: 'Storage', + 'Streaming Services': 'Streaming Services', + Uncategorized: 'Uncategorized' +}; + +// Icon components creation function +const createIcon = (IconComponent, size = 16) => { + return React.createElement(IconComponent, { size }); +}; + +// Icons for each format group +export const FORMAT_GROUP_ICONS = { + Audio: createIcon(Music), + HDR: createIcon(Tv), + 'Release Groups': createIcon(Users), + 'Group Tier Lists': createIcon(List), + 'Streaming Services': createIcon(Cloud), + Codecs: createIcon(Film), + Edition: createIcon(BookOpen), + Storage: createIcon(HardDrive), + Resolution: createIcon(Maximize), + Language: createIcon(Globe), + Source: createIcon(Video), + 'Indexer Flags': createIcon(Flag), + Enhancements: createIcon(Zap), + Uncategorized: createIcon(Package), + Remove: createIcon(X) +}; + +// Helper function to group formats by their tags +export const groupFormatsByTags = (formats) => { + // First group by tags + const groupedByTags = formats.reduce((acc, format) => { + // Check if format has any tags that match known categories + const hasKnownTag = format.tags?.some( + tag => + tag.includes(FORMAT_TAG_CATEGORIES.AUDIO) || + tag.includes(FORMAT_TAG_CATEGORIES.CODEC) || + tag.includes(FORMAT_TAG_CATEGORIES.EDITION) || + tag.includes(FORMAT_TAG_CATEGORIES.ENHANCEMENT) || + tag.includes(FORMAT_TAG_CATEGORIES.HDR) || + tag.includes(FORMAT_TAG_CATEGORIES.FLAG) || + tag.includes(FORMAT_TAG_CATEGORIES.LANGUAGE) || + (tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP) && !tag.includes('Tier')) || + tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP_TIER) || + tag.includes(FORMAT_TAG_CATEGORIES.RESOLUTION) || + tag.includes(FORMAT_TAG_CATEGORIES.SOURCE) || + tag.includes(FORMAT_TAG_CATEGORIES.STORAGE) || + tag.includes(FORMAT_TAG_CATEGORIES.STREAMING_SERVICE) + ); + + // Place in uncategorized if no known tags + if (!hasKnownTag) { + if (!acc['Uncategorized']) acc['Uncategorized'] = []; + acc['Uncategorized'].push(format); + return acc; + } + + // Otherwise, place in each relevant tag category + format.tags.forEach(tag => { + if (!acc[tag]) acc[tag] = []; + acc[tag].push(format); + }); + return acc; + }, {}); + + // Then map to proper format groups + return { + Audio: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.AUDIO)) + .flatMap(([_, formats]) => formats), + Codecs: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.CODEC)) + .flatMap(([_, formats]) => formats), + Edition: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.EDITION)) + .flatMap(([_, formats]) => formats), + Enhancements: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.ENHANCEMENT)) + .flatMap(([_, formats]) => formats), + HDR: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.HDR)) + .flatMap(([_, formats]) => formats), + 'Indexer Flags': Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.FLAG)) + .flatMap(([_, formats]) => formats), + Language: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.LANGUAGE)) + .flatMap(([_, formats]) => formats), + 'Release Groups': Object.entries(groupedByTags) + .filter( + ([tag]) => + tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP) && !tag.includes('Tier') + ) + .flatMap(([_, formats]) => formats), + 'Group Tier Lists': Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP_TIER)) + .flatMap(([_, formats]) => formats), + Resolution: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.RESOLUTION)) + .flatMap(([_, formats]) => formats), + Source: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.SOURCE)) + .flatMap(([_, formats]) => formats), + Storage: Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.STORAGE)) + .flatMap(([_, formats]) => formats), + 'Streaming Services': Object.entries(groupedByTags) + .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.STREAMING_SERVICE)) + .flatMap(([_, formats]) => formats), + Uncategorized: groupedByTags['Uncategorized'] || [] + }; +}; + +// Get the appropriate icon for a group name +export const getGroupIcon = (groupName) => { + return FORMAT_GROUP_ICONS[groupName] || FORMAT_GROUP_ICONS.Uncategorized; +}; \ No newline at end of file From f9989ee0cda215c74b48522904aff0cb6ed0232c Mon Sep 17 00:00:00 2001 From: Samuel Chau Date: Sat, 8 Mar 2025 01:51:31 +1030 Subject: [PATCH 10/11] feat: various styling improvements + simple language processing (#154) style: improve quality item aesthetic - perma edit / delete buttons for groups - replace radarr/sonarr badges with tooltip - replace selected styling with tick icon refactor: upgrade until logic - remove dropdown selection - let individual quality items be selectable as the upgrade until target - fix: let modal handle scrolling on quality tab - style: improve quality header / create group button - feat: add special choice functionality to search dropdown - style: add bottom margin to qualities container feat: language improvements - simple mode added to change for profile langauge back compatability in radarr - improved styling for language tab style: profile footer improvements - save confirmation - improved styling for save / delete buttons - feat: enhance modal close animations and add closing state management - fix: append [copy] to cloned profiles - fix: change keyboard shortcut from 'a' to 'm' for selection mode --- backend/app/compile/profile_compiler.py | 31 +- backend/app/importarr/profile.py | 16 +- frontend/src/components/format/FormatPage.jsx | 2 +- .../src/components/profile/ProfileCard.jsx | 34 +- .../profile/ProfileLangaugesTab.jsx | 125 ----- .../src/components/profile/ProfileModal.jsx | 298 ++++++------ .../src/components/profile/ProfilePage.jsx | 9 +- .../src/components/profile/QualityItem.jsx | 124 ----- .../profile/language/AdvancedView.jsx | 133 ++++++ .../profile/language/ProfileLangaugesTab.jsx | 447 ++++++++++++++++++ .../profile/language/SimpleView.jsx | 51 ++ .../{ => quality}/CreateGroupModal.jsx | 2 +- .../{ => quality}/ProfileQualitiesTab.jsx | 356 +++++++++----- .../profile/quality/QualityItem.jsx | 32 ++ .../profile/quality/QualityItemGroup.jsx | 174 +++++++ .../profile/quality/QualityItemSingle.jsx | 147 ++++++ frontend/src/components/regex/RegexPage.jsx | 2 +- frontend/src/components/ui/Modal.jsx | 41 +- frontend/src/components/ui/SearchDropdown.jsx | 57 ++- frontend/tailwind.config.js | 45 +- 20 files changed, 1572 insertions(+), 554 deletions(-) delete mode 100644 frontend/src/components/profile/ProfileLangaugesTab.jsx delete mode 100644 frontend/src/components/profile/QualityItem.jsx create mode 100644 frontend/src/components/profile/language/AdvancedView.jsx create mode 100644 frontend/src/components/profile/language/ProfileLangaugesTab.jsx create mode 100644 frontend/src/components/profile/language/SimpleView.jsx rename frontend/src/components/profile/{ => quality}/CreateGroupModal.jsx (99%) rename frontend/src/components/profile/{ => quality}/ProfileQualitiesTab.jsx (57%) create mode 100644 frontend/src/components/profile/quality/QualityItem.jsx create mode 100644 frontend/src/components/profile/quality/QualityItemGroup.jsx create mode 100644 frontend/src/components/profile/quality/QualityItemSingle.jsx diff --git a/backend/app/compile/profile_compiler.py b/backend/app/compile/profile_compiler.py index 71d8330..dc285d0 100644 --- a/backend/app/compile/profile_compiler.py +++ b/backend/app/compile/profile_compiler.py @@ -172,22 +172,39 @@ class ProfileConverter: return converted_group def convert_profile(self, profile: Dict) -> ConvertedProfile: - language = profile.get('language') - if language != 'any': + language = profile.get('language', 'any') + + # Handle language processing for advanced mode (with behavior_language format) + if language != 'any' and '_' in language: language_parts = language.split('_', 1) - behaviour, language = language_parts + behaviour, language_code = language_parts try: language_formats = self._process_language_formats( - behaviour, language) + behaviour, language_code) if 'custom_formats' not in profile: profile['custom_formats'] = [] profile['custom_formats'].extend(language_formats) except Exception as e: logger.error(f"Failed to process language formats: {e}") - selected_language = ValueResolver.get_language('any', - self.target_app, - for_profile=True) + # Simple mode: just use the language directly without custom formats + # This lets the Arr application's built-in language filter handle it + + # Get the appropriate language data for the profile + if language != 'any' and '_' not in language: + # Simple mode - use the language directly + selected_language = ValueResolver.get_language(language, + self.target_app, + for_profile=True) + logger.info(f"Using simple language mode: {language}") + logger.info(f"Selected language data: {selected_language}") + else: + # Advanced mode or 'any' - set language to 'any' as filtering is done via formats + selected_language = ValueResolver.get_language('any', + self.target_app, + for_profile=True) + logger.info( + f"Using advanced mode or 'any', setting language to 'any'") converted_profile = ConvertedProfile( name=profile["name"], diff --git a/backend/app/importarr/profile.py b/backend/app/importarr/profile.py index 128410f..cedc1e8 100644 --- a/backend/app/importarr/profile.py +++ b/backend/app/importarr/profile.py @@ -73,9 +73,19 @@ def import_profiles_to_arr(profile_names: List[str], original_names: List[str], profile_language = profile_data.get('language', 'any') if profile_language != 'any': - logger.info( - f"Profile '{profile_name}' has language override: {profile_language}" - ) + # Detect if we're using simple or advanced mode + is_simple_mode = '_' not in profile_language + if is_simple_mode: + logger.info( + f"Profile '{profile_name}' has simple mode language: {profile_language}" + ) + logger.info( + f"Simple mode will set language filter to: {profile_language}" + ) + else: + logger.info( + f"Profile '{profile_name}' has advanced mode language: {profile_language}" + ) logger.info("Compiling quality profile...") compiled_profiles = compile_quality_profile( diff --git a/frontend/src/components/format/FormatPage.jsx b/frontend/src/components/format/FormatPage.jsx index 55966ec..1c8d2b7 100644 --- a/frontend/src/components/format/FormatPage.jsx +++ b/frontend/src/components/format/FormatPage.jsx @@ -106,7 +106,7 @@ function FormatPage() { lastSelectedIndex } = useMassSelection(); - useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true}); + useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true}); const { name, diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx index 56c59e4..bb23d9b 100644 --- a/frontend/src/components/profile/ProfileCard.jsx +++ b/frontend/src/components/profile/ProfileCard.jsx @@ -10,6 +10,7 @@ import { } from 'lucide-react'; import Tooltip from '@ui/Tooltip'; import ReactMarkdown from 'react-markdown'; +import { LANGUAGES } from '@constants/languages'; function unsanitize(text) { if (!text) return ''; @@ -17,19 +18,38 @@ function unsanitize(text) { } function parseLanguage(languageStr) { - if (!languageStr || languageStr === 'any') return 'Any'; - + // Handle empty or "any" case + if (!languageStr || languageStr === 'any') return 'Any Language'; + + // Handle "original" language case + if (languageStr === 'original') return 'Original'; + + // Check if this is a simple language choice (not in format of type_language) + const matchedLanguage = LANGUAGES.find(lang => lang.id === languageStr); + if (matchedLanguage) { + return matchedLanguage.name; + } + + // If we get here, it's an advanced language setting with type_language format const [type, language] = languageStr.split('_'); - const capitalizedLanguage = - language.charAt(0).toUpperCase() + language.slice(1); + + // If language part is missing, just return the type + if (!language) return type; + + // Find language name from constants + const langObj = LANGUAGES.find(lang => lang.id === language); + const languageName = langObj ? langObj.name : language.charAt(0).toUpperCase() + language.slice(1); + // Format based on type switch (type) { case 'only': - return `Must Only Be: ${capitalizedLanguage}`; + return `Must Only Be: ${languageName}`; case 'must': - return `Must Include: ${capitalizedLanguage}`; + return `Must Include: ${languageName}`; + case 'mustnot': + return `Must Not Include: ${languageName}`; default: - return capitalizedLanguage; + return languageName; } } diff --git a/frontend/src/components/profile/ProfileLangaugesTab.jsx b/frontend/src/components/profile/ProfileLangaugesTab.jsx deleted file mode 100644 index a1ef56b..0000000 --- a/frontend/src/components/profile/ProfileLangaugesTab.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {InfoIcon, AlertTriangle} from 'lucide-react'; -import {LANGUAGES} from '@constants/languages'; - -const ProfileLanguagesTab = ({language, onLanguageChange}) => { - const handleLanguageChange = (type, value) => { - // If selecting 'any' behavior, just return 'any' - if (type === 'behavior' && value === 'any') { - onLanguageChange('any'); - return; - } - - // For other cases, split current language setting - const [behavior, lang] = (language || 'must_english').split('_'); - - const newValue = - type === 'behavior' - ? `${value}_${lang || 'english'}` - : `${behavior || 'must'}_${value}`; - - onLanguageChange(newValue); - }; - - // Split current language setting only if it's not 'any' - const [currentBehavior, currentLanguage] = - language === 'any' - ? ['any', ''] - : (language || 'must_english').split('_'); - - return ( -
-
-
-

- Language Requirements -

-

- Configure language requirements for media content. -

-
-
- -
-
- -

- Configure how languages should be handled for your media - content. Select "Any" to accept all languages, or - configure specific language requirements. -

-
- -
-
-
-

- Language Settings -

-

- Configure language requirements for releases -

-
- -
- - - {currentBehavior !== 'any' && ( - - )} -
- - {currentBehavior === 'only' && ( -
- -

- "Must Only Be" will reject releases with - multiple languages -

-
- )} -
-
-
-
- ); -}; - -ProfileLanguagesTab.propTypes = { - language: PropTypes.string.isRequired, - onLanguageChange: PropTypes.func.isRequired -}; - -export default ProfileLanguagesTab; diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx index 9ca4099..0d6c680 100644 --- a/frontend/src/components/profile/ProfileModal.jsx +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -3,11 +3,11 @@ import PropTypes from 'prop-types'; import {Profiles} from '@api/data'; import Modal from '../ui/Modal'; import Alert from '@ui/Alert'; -import {Loader} from 'lucide-react'; +import {Loader, Save, Trash2, Check} from 'lucide-react'; import ProfileGeneralTab from './ProfileGeneralTab'; import ProfileScoringTab from './scoring/ProfileScoringTab'; -import ProfileQualitiesTab from './ProfileQualitiesTab'; -import ProfileLangaugesTab from './ProfileLangaugesTab'; +import ProfileQualitiesTab from './quality/ProfileQualitiesTab'; +import ProfileLangaugesTab from './language/ProfileLangaugesTab'; import QUALITIES from '../../constants/qualities'; function unsanitize(text) { @@ -28,6 +28,7 @@ function ProfileModal({ const [description, setDescription] = useState(''); const [error, setError] = useState(''); const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [loading, setLoading] = useState(true); const [modalTitle, setModalTitle] = useState(''); @@ -116,6 +117,8 @@ function ProfileModal({ useEffect(() => { if (isOpen) { setLoading(true); + setIsDeleting(false); + setIsSaving(false); setModalTitle( isCloning @@ -322,46 +325,93 @@ function ProfileModal({ }, [initialProfile, isOpen, formats, isCloning]); const handleSave = async () => { - if (!name.trim()) { - setError('Name is required.'); - Alert.error('Please enter a profile name'); - return; - } + if (isSaving) { + // This is the confirmation click + if (!name.trim()) { + setError('Name is required.'); + Alert.error('Please enter a profile name'); + setIsSaving(false); + return; + } - try { - const profileData = { - name, - description, - tags, - upgradesAllowed, - minCustomFormatScore, - upgradeUntilScore, - minScoreIncrement, - custom_formats: (() => { - // Check if selective mode is enabled - const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode'); - const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode); - - if (useSelectiveMode) { - // In selective mode, save both: - // 1. Formats with non-zero scores as usual - // 2. Formats with zero score that have been explicitly selected in selectedFormatIds - - try { - // Get the list of explicitly selected format IDs - const selectedFormatIdsStr = localStorage.getItem('selectedFormatIds'); - const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : []; - - // Get formats with non-zero scores - const nonZeroFormats = customFormats.filter(format => format.score !== 0); - - // Get formats with zero scores that are explicitly selected - const explicitlySelectedZeroFormats = customFormats.filter(format => - format.score === 0 && selectedFormatIds.includes(format.id) - ); - - // Combine both lists - return [...nonZeroFormats, ...explicitlySelectedZeroFormats] + try { + const profileData = { + name, + description, + tags, + upgradesAllowed, + minCustomFormatScore, + upgradeUntilScore, + minScoreIncrement, + custom_formats: (() => { + // Check if selective mode is enabled + const selectiveMode = localStorage.getItem( + 'formatSettingsSelectiveMode' + ); + const useSelectiveMode = + selectiveMode !== null && JSON.parse(selectiveMode); + + if (useSelectiveMode) { + // In selective mode, save both: + // 1. Formats with non-zero scores as usual + // 2. Formats with zero score that have been explicitly selected in selectedFormatIds + + try { + // Get the list of explicitly selected format IDs + const selectedFormatIdsStr = + localStorage.getItem('selectedFormatIds'); + const selectedFormatIds = selectedFormatIdsStr + ? JSON.parse(selectedFormatIdsStr) + : []; + + // Get formats with non-zero scores + const nonZeroFormats = customFormats.filter( + format => format.score !== 0 + ); + + // Get formats with zero scores that are explicitly selected + const explicitlySelectedZeroFormats = + customFormats.filter( + format => + format.score === 0 && + selectedFormatIds.includes(format.id) + ); + + // Combine both lists + return [ + ...nonZeroFormats, + ...explicitlySelectedZeroFormats + ] + .sort((a, b) => { + // First sort by score (descending) + if (b.score !== a.score) { + return b.score - a.score; + } + // Then alphabetically for equal scores + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); + } catch (e) { + // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores + return customFormats + .filter(format => format.score !== 0) + .sort((a, b) => { + if (b.score !== a.score) + return b.score - a.score; + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); + } + } else { + // Standard behavior - only include formats with non-zero scores + return customFormats + .filter(format => format.score !== 0) .sort((a, b) => { // First sort by score (descending) if (b.score !== a.score) { @@ -374,93 +424,69 @@ function ProfileModal({ name: format.name, score: format.score })); - } catch (e) { - // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores - return customFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); } - } else { - // Standard behavior - only include formats with non-zero scores - return customFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - // First sort by score (descending) - if (b.score !== a.score) { - return b.score - a.score; - } - // Then alphabetically for equal scores - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } - })(), - qualities: sortedQualities - .filter(q => q.enabled) - .map(q => { - if ('qualities' in q) { - return { - id: q.id, - name: q.name, - description: q.description || '', - qualities: q.qualities.map(subQ => ({ - id: subQ.id, - name: subQ.name - })) - }; - } else { - return { - id: q.id, - name: q.name - }; - } - }), - upgrade_until: selectedUpgradeQuality - ? { - id: selectedUpgradeQuality.id, - name: selectedUpgradeQuality.name, - ...(selectedUpgradeQuality.description && { - description: selectedUpgradeQuality.description - }) - } - : null, - language - }; + })(), + qualities: sortedQualities + .filter(q => q.enabled) + .map(q => { + if ('qualities' in q) { + return { + id: q.id, + name: q.name, + description: q.description || '', + qualities: q.qualities.map(subQ => ({ + id: subQ.id, + name: subQ.name + })) + }; + } else { + return { + id: q.id, + name: q.name + }; + } + }), + upgrade_until: selectedUpgradeQuality + ? { + id: selectedUpgradeQuality.id, + name: selectedUpgradeQuality.name, + ...(selectedUpgradeQuality.description && { + description: selectedUpgradeQuality.description + }) + } + : null, + language + }; - if (isCloning || !initialProfile) { - // Creating new profile - await Profiles.create(profileData); - Alert.success('Profile created successfully'); - } else { - // Updating existing profile - const originalName = initialProfile.content.name; - const isNameChanged = originalName !== name; - await Profiles.update( - initialProfile.file_name.replace('.yml', ''), - profileData, - isNameChanged ? name : undefined - ); - Alert.success('Profile updated successfully'); + if (isCloning || !initialProfile) { + // Creating new profile + await Profiles.create(profileData); + Alert.success('Profile created successfully'); + } else { + // Updating existing profile + const originalName = initialProfile.content.name; + const isNameChanged = originalName !== name; + await Profiles.update( + initialProfile.file_name.replace('.yml', ''), + profileData, + isNameChanged ? name : undefined + ); + Alert.success('Profile updated successfully'); + } + + onSave(); + onClose(); + } catch (error) { + console.error('Error saving profile:', error); + const errorMessage = + error.message || 'An unexpected error occurred'; + Alert.error(errorMessage); + setError(errorMessage); + setIsSaving(false); } - - onSave(); - onClose(); - } catch (error) { - console.error('Error saving profile:', error); - const errorMessage = - error.message || 'An unexpected error occurred'; - Alert.error(errorMessage); - setError(errorMessage); + } else { + // First click - show confirmation + setIsSaving(true); } }; @@ -509,16 +535,24 @@ function ProfileModal({ {initialProfile && ( )}
}> diff --git a/frontend/src/components/profile/ProfilePage.jsx b/frontend/src/components/profile/ProfilePage.jsx index 1376ef0..8ff6b29 100644 --- a/frontend/src/components/profile/ProfilePage.jsx +++ b/frontend/src/components/profile/ProfilePage.jsx @@ -111,7 +111,7 @@ function ProfilePage() { lastSelectedIndex } = useMassSelection(); - useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true}); + useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true}); useEffect(() => { fetchGitStatus(); @@ -242,9 +242,10 @@ function ProfilePage() { if (isSelectionMode) return; const clonedProfile = { ...profile, - id: 0, - name: `${profile.name} [COPY]`, - custom_formats: profile.custom_formats || [] + content: { + ...profile.content, + name: `${profile.content.name} [COPY]` + } }; setSelectedProfile(clonedProfile); setIsModalOpen(true); diff --git a/frontend/src/components/profile/QualityItem.jsx b/frontend/src/components/profile/QualityItem.jsx deleted file mode 100644 index 078099f..0000000 --- a/frontend/src/components/profile/QualityItem.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; -import RadarrLogo from '@logo/Radarr.svg'; -import SonarrLogo from '@logo/Sonarr.svg'; -import {Pencil, Trash2} from 'lucide-react'; - -const QualityItem = ({ - quality, - isDragging, - listeners, - attributes, - style, - onDelete, - onEdit -}) => { - const isGroup = 'qualities' in quality; - - return ( -
- {/* Header Section */} -
- {/* Title and Description */} -
-

- {quality.name} -

- {isGroup && quality.description && ( -

- {quality.description} -

- )} -
- - {/* Actions and Icons */} -
- {/* App Icons */} -
- {quality.radarr && ( - Radarr - )} - {quality.sonarr && ( - Sonarr - )} -
- - {/* Edit/Delete Actions */} - {isGroup && ( -
- {onEdit && ( - - )} - {onDelete && ( - - )} -
- )} -
-
- - {/* Quality Tags Section */} - {isGroup && ( -
- {quality.qualities.map(q => ( - - {q.name} - - ))} -
- )} - - {/* Non-group Description */} - {!isGroup && quality.description && ( -

- {quality.description} -

- )} -
- ); -}; - -export default QualityItem; diff --git a/frontend/src/components/profile/language/AdvancedView.jsx b/frontend/src/components/profile/language/AdvancedView.jsx new file mode 100644 index 0000000..89835fa --- /dev/null +++ b/frontend/src/components/profile/language/AdvancedView.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {InfoIcon, AlertTriangle} from 'lucide-react'; +import {LANGUAGES} from '@constants/languages'; + +const AdvancedView = ({language, onLanguageChange}) => { + const handleLanguageChange = (type, value) => { + // If selecting 'any' behavior, just return 'any' + if (type === 'behavior' && value === 'any') { + onLanguageChange('any'); + return; + } + + // For other cases, split current language setting + const [behavior, lang] = (language || 'must_english').split('_'); + + const newValue = + type === 'behavior' + ? `${value}_${lang || 'english'}` + : `${behavior || 'must'}_${value}`; + + onLanguageChange(newValue); + }; + + // Split current language setting only if it's not 'any' + const [currentBehavior, currentLanguage] = + language === 'any' + ? ['any', ''] + : (language || 'must_english').split('_'); + + return ( + <> + {/* Type Dropdown */} + + + {/* Language Dropdown */} + {currentBehavior !== 'any' && ( + + )} + + {/* Help text below the controls is in the parent component */} + +
+ {currentBehavior === 'any' && ( +
+ +

Accept content in any language.

+
+ )} + + {currentBehavior === 'must' && ( +
+ +

+ Content must include{' '} + {currentLanguage + ? LANGUAGES.find( + l => l.id === currentLanguage + )?.name || currentLanguage + : 'English'} + , but can include other languages as well. +

+
+ )} + + {currentBehavior === 'only' && ( +
+ +

+ Content must ONLY be in{' '} + {currentLanguage + ? LANGUAGES.find( + l => l.id === currentLanguage + )?.name || currentLanguage + : 'English'} + . This will reject releases containing + multiple languages. +

+
+ )} + + {currentBehavior === 'mustnot' && ( +
+ +

+ Content must NOT include{' '} + {currentLanguage + ? LANGUAGES.find( + l => l.id === currentLanguage + )?.name || currentLanguage + : 'English'} + . Any other language is acceptable. +

+
+ )} +
+
+
+ ); +}; + +AdvancedView.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired +}; + +export default AdvancedView; diff --git a/frontend/src/components/profile/language/ProfileLangaugesTab.jsx b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx new file mode 100644 index 0000000..f0008b3 --- /dev/null +++ b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx @@ -0,0 +1,447 @@ +import React, {useState, useEffect, useMemo} from 'react'; +import PropTypes from 'prop-types'; +import { + Settings, + List, + ChevronDown, + InfoIcon, + AlertTriangle +} from 'lucide-react'; +import {LANGUAGES} from '@constants/languages'; +import SearchDropdown from '@ui/SearchDropdown'; + +const ProfileLanguagesTab = ({language, onLanguageChange}) => { + // Determine advanced view based on language format + const [isAdvancedView, setIsAdvancedView] = useState(() => { + // If language includes an underscore (e.g., must_english) or doesn't exist, it's advanced mode + // If it's a simple language ID without underscore (e.g., english, original, any), it's simple mode + return !language || language.includes('_'); + }); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // Update mode whenever language changes externally + useEffect(() => { + // If no language provided, set a default value for new profiles + if (!language) { + // Default for new profiles: Advanced mode with "must include original" + onLanguageChange('must_original'); + } else { + // Otherwise determine mode from language format + setIsAdvancedView(!language || language.includes('_')); + } + }, [language, onLanguageChange]); + + // For simple view - language options + const languageOptions = useMemo(() => { + return [ + // Any language at the very top - special item + { + value: 'any', + label: 'Any Language', + description: 'Accepting content in any language', + isSpecial: true + }, + // Original language next - special item + { + value: 'original', + label: 'Original', + description: + 'Content must include Original, but can include other languages as well', + isSpecial: true + }, + // All other languages - sorted alphabetically + ...LANGUAGES.filter(lang => lang.id !== 'original') // Skip original since we added it manually above + .sort((a, b) => a.name.localeCompare(b.name)) + .map(lang => ({ + value: lang.id, + label: lang.name, + description: `Content must include ${lang.name}, but can include other languages as well` + })) + ]; + }, []); + + // For advanced view - handle language changes + const handleAdvancedLanguageChange = (type, value) => { + // If selecting 'any' behavior, just return 'any' + if (type === 'behavior' && value === 'any') { + onLanguageChange('any'); + return; + } + + // For other cases, split current language setting + const [behavior, lang] = (language || 'must_english').split('_'); + + const newValue = + type === 'behavior' + ? `${value}_${lang || 'english'}` + : `${behavior || 'must'}_${value}`; + + onLanguageChange(newValue); + }; + + // Split current language setting only if it's not 'any' + const [currentBehavior, currentLanguage] = + language === 'any' + ? ['any', ''] + : (language || 'must_english').split('_'); + + return ( +
+
+ {/* Simple header with title and description */} +
+

+ Language Settings +

+

+ Configure how language preferences are applied to your + profiles. + {isAdvancedView + ? ' Advanced mode creates custom formats for precise language control in both Radarr and Sonarr. This is required for Sonarr as it lacks built-in language settings.' + : ' Simple mode sets language preferences directly in Radarr without custom formats. For Sonarr, consider using Advanced mode since it has no built-in language filtering.'} +

+
+ + {/* Controls row - display mode dropdown with other controls */} +
+ {/* Mode Selector (always visible) */} +
+ + {isDropdownOpen && ( + <> +
setIsDropdownOpen(false)} + /> +
+
+ + +
+
+ + )} +
+ + {/* SIMPLE MODE: just one language dropdown */} + {!isAdvancedView && ( +
+ onLanguageChange(e.target.value)} + options={languageOptions} + placeholder='Select language...' + dropdownWidth='100%' + className='bg-gray-800 dark:border-gray-600 text-gray-100' + /> +
+ )} + + {/* ADVANCED MODE: two dropdowns (type and language) */} + {isAdvancedView && ( + <> + {/* Type Dropdown - Custom styled */} +
+ +
+ +
+
+ + {/* Language Dropdown */} + {currentBehavior !== 'any' && ( +
+ + handleAdvancedLanguageChange( + 'language', + e.target.value + ) + } + options={[ + // Special items at the top + { + value: 'original', + label: 'Original', + description: + 'Content must include Original, but can include other languages as well', + isSpecial: true + }, + // All other languages - sorted alphabetically + ...LANGUAGES.filter( + lang => lang.id !== 'original' + ) // Skip original since we added it manually above + .sort((a, b) => + a.name.localeCompare(b.name) + ) + .map(lang => ({ + value: lang.id, + label: lang.name, + description: `Content must include ${lang.name}, but can include other languages as well` + })) + ]} + placeholder='Select language...' + dropdownWidth='100%' + className='bg-gray-800 dark:border-gray-600 text-gray-100' + /> +
+ )} + + )} +
+ + {/* Help text section - display the appropriate help text based on view mode and selection */} +
+ {/* Simple mode help */} + {!isAdvancedView && ( +
+ +

+ {language === 'any' ? ( + <> + Attempts to set{' '} + + Any Language + {' '} + in Radarr profiles. For Sonarr, language + will default to "Original" since it + lacks native language settings. + + ) : language === 'original' ? ( + <> + Attempts to set{' '} + + Original + {' '} + language in Radarr profiles. For Sonarr, + language will default to "Original" + since it lacks native language settings. + + ) : ( + <> + Attempts to set{' '} + + {LANGUAGES.find( + l => l.id === language + )?.name || language} + {' '} + language in Radarr profiles. For Sonarr, + language will default to "Original" + since it lacks native language settings. + + )} +

+
+ )} + + {/* Advanced mode help based on selections */} + {isAdvancedView && ( + <> + {language === 'any' && ( +
+ +

Accept content in any language.

+
+ )} + + {language && language.startsWith('must_') && ( +
+ +

+ Content must include{' '} + + {language.split('_')[1] + ? LANGUAGES.find( + l => + l.id === + language.split('_')[1] + )?.name || + language.split('_')[1] + : 'English'} + + , but can include other languages as + well. +

+
+ )} + + {language && language.startsWith('only_') && ( +
+ +

+ Content must ONLY be in{' '} + + {language.split('_')[1] + ? LANGUAGES.find( + l => + l.id === + language.split('_')[1] + )?.name || + language.split('_')[1] + : 'English'} + + . This will reject releases containing + multiple languages. +

+
+ )} + + {language && language.startsWith('mustnot_') && ( +
+ +

+ Content must NOT include{' '} + + {language.split('_')[1] + ? LANGUAGES.find( + l => + l.id === + language.split('_')[1] + )?.name || + language.split('_')[1] + : 'English'} + + . Any other language is acceptable. +

+
+ )} + + )} +
+
+
+ ); +}; + +ProfileLanguagesTab.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired +}; + +export default ProfileLanguagesTab; diff --git a/frontend/src/components/profile/language/SimpleView.jsx b/frontend/src/components/profile/language/SimpleView.jsx new file mode 100644 index 0000000..bd222fb --- /dev/null +++ b/frontend/src/components/profile/language/SimpleView.jsx @@ -0,0 +1,51 @@ +import React, {useMemo} from 'react'; +import PropTypes from 'prop-types'; +import {LANGUAGES} from '@constants/languages'; +import SearchDropdown from '@ui/SearchDropdown'; + +const SimpleView = ({language, onLanguageChange}) => { + const languageOptions = useMemo(() => { + return [ + // Any language at the very top - special item + { + value: 'any', + label: 'Any Language', + description: 'Accepting content in any language', + isSpecial: true + }, + // Original language next - special item + { + value: 'original', + label: 'Original', + description: 'Content must include Original, but can include other languages as well', + isSpecial: true + }, + // All other languages - sorted alphabetically + ...LANGUAGES + .filter(lang => lang.id !== 'original') // Skip original since we added it manually above + .sort((a, b) => a.name.localeCompare(b.name)) + .map(lang => ({ + value: lang.id, + label: lang.name, + description: `Content must include ${lang.name}, but can include other languages as well` + })) + ]; + }, []); + + return ( + onLanguageChange(e.target.value)} + options={languageOptions} + placeholder="Select language..." + dropdownWidth="100%" + /> + ); +}; + +SimpleView.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired +}; + +export default SimpleView; diff --git a/frontend/src/components/profile/CreateGroupModal.jsx b/frontend/src/components/profile/quality/CreateGroupModal.jsx similarity index 99% rename from frontend/src/components/profile/CreateGroupModal.jsx rename to frontend/src/components/profile/quality/CreateGroupModal.jsx index b5c5de5..af3862c 100644 --- a/frontend/src/components/profile/CreateGroupModal.jsx +++ b/frontend/src/components/profile/quality/CreateGroupModal.jsx @@ -1,5 +1,5 @@ import React, {useState, useEffect} from 'react'; -import Modal from '../ui/Modal'; +import Modal from '@ui/Modal'; import Tooltip from '@ui/Tooltip'; import {InfoIcon} from 'lucide-react'; diff --git a/frontend/src/components/profile/ProfileQualitiesTab.jsx b/frontend/src/components/profile/quality/ProfileQualitiesTab.jsx similarity index 57% rename from frontend/src/components/profile/ProfileQualitiesTab.jsx rename to frontend/src/components/profile/quality/ProfileQualitiesTab.jsx index bb99876..3acf7d7 100644 --- a/frontend/src/components/profile/ProfileQualitiesTab.jsx +++ b/frontend/src/components/profile/quality/ProfileQualitiesTab.jsx @@ -6,8 +6,7 @@ import { KeyboardSensor, PointerSensor, useSensor, - useSensors, - DragOverlay + useSensors } from '@dnd-kit/core'; import { arrayMove, @@ -20,54 +19,20 @@ import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers'; -import {InfoIcon} from 'lucide-react'; -import Modal from '../ui/Modal'; +import Modal from '@ui/Modal'; import CreateGroupModal from './CreateGroupModal'; import QualityItem from './QualityItem'; -import QUALITIES from '../../constants/qualities'; +import QUALITIES from '@constants/qualities'; import Alert from '@ui/Alert'; -const UpgradeSection = ({ - enabledQualities, - selectedUpgradeQuality, - onUpgradeQualityChange +const SortableItem = ({ + quality, + onToggle, + onDelete, + onEdit, + isUpgradeUntil, + onUpgradeUntilClick }) => { - if (enabledQualities.length === 0) { - return null; - } - - return ( -
-
-

- Upgrade Until -

-

- Downloads will be upgraded until this quality is reached. - Lower qualities will be upgraded, while higher qualities - will be left unchanged. -

- -
-
- ); -}; - -const SortableItem = ({quality, onToggle, onDelete, onEdit}) => { const { attributes, listeners, @@ -99,6 +64,10 @@ const SortableItem = ({quality, onToggle, onDelete, onEdit}) => { style={style} onEdit={'qualities' in quality ? onEdit : undefined} onDelete={'qualities' in quality ? onDelete : undefined} + isUpgradeUntil={isUpgradeUntil} + onUpgradeUntilClick={ + quality.enabled ? onUpgradeUntilClick : undefined + } />
); @@ -217,6 +186,19 @@ const ProfileQualitiesTab = ({ const handleQualityToggle = quality => { if (!activeId) { + // Prevent disabling a quality that's set as the upgrade until quality + if ( + quality.enabled && + upgradesAllowed && + selectedUpgradeQuality && + isUpgradeUntilQuality(quality) + ) { + Alert.error( + "You can't disable a quality that's set as 'upgrade until'. Please set another quality as 'upgrade until' first." + ); + return; + } + const currentEnabledCount = sortedQualities.filter( q => q.enabled ).length; @@ -252,34 +234,69 @@ const ProfileQualitiesTab = ({ onQualitiesChange(allEnabledQualities); - // Only update the upgrade quality if we're disabling the current upgrade quality + // We shouldn't reach this point for the upgrade until quality, + // but keeping as a safety measure if ( + upgradesAllowed && selectedUpgradeQuality && quality.enabled === false && // We're disabling a quality - (quality.id === selectedUpgradeQuality.id || // Direct match - ('qualities' in quality && // Group match + (quality.id === selectedUpgradeQuality.id || // Direct match (group or quality) + ('qualities' in quality && // Quality is in a group that's being disabled + !('qualities' in selectedUpgradeQuality) && quality.qualities.some( q => q.id === selectedUpgradeQuality.id ))) ) { + // Find another enabled quality to set as upgrade until const nearestQuality = findNearestEnabledQuality( newQualities, quality.id ); - onSelectedUpgradeQualityChange?.(nearestQuality); + + if (nearestQuality) { + onSelectedUpgradeQualityChange?.(nearestQuality); + Alert.info( + `Upgrade until quality changed to ${nearestQuality.name}` + ); + } } } }; + const handleUpgradeUntilClick = quality => { + // Make sure we're setting the quality object properly + if (quality) { + // For single qualities, pass as is + // For groups, we pass the group itself to maintain the group ID in the selection + onSelectedUpgradeQualityChange?.(quality); + + // Provide user feedback + Alert.success(`${quality.name} set as upgrade until quality`); + } + }; + const handleCreateOrUpdateGroup = groupData => { - if ( - selectedUpgradeQuality && - !('qualities' in selectedUpgradeQuality) - ) { - const qualityMovingToGroup = groupData.qualities.some( - q => q.id === selectedUpgradeQuality.id - ); - if (qualityMovingToGroup) { + // Check if the currently selected upgrade quality is being moved into this group + if (selectedUpgradeQuality) { + // If the selected upgrade quality is a single quality (not a group itself) + if (!('qualities' in selectedUpgradeQuality)) { + const qualityMovingToGroup = groupData.qualities.some( + q => q.id === selectedUpgradeQuality.id + ); + + // If the current upgrade quality is being moved into this group + // Update the upgrade quality to be the group instead + if (qualityMovingToGroup) { + onSelectedUpgradeQualityChange({ + id: groupData.id, + name: groupData.name, + description: groupData.description + }); + } + } + // If the selected upgrade quality is the group we're editing + else if (selectedUpgradeQuality.id === editingGroup?.id) { + // Update the upgrade quality to reflect the new group data onSelectedUpgradeQualityChange({ id: groupData.id, name: groupData.name, @@ -371,15 +388,39 @@ const ProfileQualitiesTab = ({ const handleDeleteGroup = group => { // Check if we're deleting the currently selected upgrade group if (selectedUpgradeQuality && selectedUpgradeQuality.id === group.id) { - const firstQualityFromGroup = group.qualities[0]; - onSelectedUpgradeQualityChange(firstQualityFromGroup); + // Find the first quality from the group and set it as the upgrade until quality + if (group.qualities && group.qualities.length > 0) { + const firstQualityFromGroup = group.qualities[0]; + onSelectedUpgradeQualityChange(firstQualityFromGroup); + Alert.info( + `Upgrade until quality changed to ${firstQualityFromGroup.name}` + ); + } else { + // If somehow the group has no qualities, find the first enabled quality + const firstEnabledQuality = sortedQualities.find( + q => q.enabled && q.id !== group.id + ); + if (firstEnabledQuality) { + onSelectedUpgradeQualityChange(firstEnabledQuality); + Alert.info( + `Upgrade until quality changed to ${firstEnabledQuality.name}` + ); + } + } } onSortedQualitiesChange(prev => { const index = prev.findIndex(q => q.id === group.id); if (index === -1) return prev; const newQualities = [...prev]; - newQualities.splice(index, 1, ...group.qualities); + + // Make sure all qualities from the group are set as enabled + const enabledGroupQualities = group.qualities.map(q => ({ + ...q, + enabled: true + })); + + newQualities.splice(index, 1, ...enabledGroupQualities); return newQualities; }); }; @@ -410,75 +451,146 @@ const ProfileQualitiesTab = ({ setActiveId(null); }; + const isUpgradeUntilQuality = quality => { + if (!selectedUpgradeQuality) return false; + + // Direct ID match (works for both individual qualities and groups) + if (quality.id === selectedUpgradeQuality.id) { + return true; + } + + // Check if the selected upgrade quality is a member of this group + if ( + 'qualities' in quality && + !('qualities' in selectedUpgradeQuality) && + quality.qualities.some(q => q.id === selectedUpgradeQuality.id) + ) { + return true; + } + + return false; + }; + return (
-
-
-

+
+
+

Quality Rankings

- -

- Qualities higher in the list are more preferred even if - not checked. Qualities within the same group are equal. - Only checked qualities are wanted. -

- - -
-
- - {upgradesAllowed && ( - q.enabled)} - selectedUpgradeQuality={selectedUpgradeQuality} - onUpgradeQualityChange={onSelectedUpgradeQualityChange} - /> - )} - -
- -
-
- q.id)} - strategy={verticalListSortingStrategy}> - {sortedQualities.map(quality => ( - - ))} - -
+
+ + + + + Drag to reorder + + + + + + + Click to toggle + + {upgradesAllowed && ( + <> + + + + + + Set upgrade target + + + )}
- +
+
+ +
+
+ q.id)} + strategy={verticalListSortingStrategy}> + {sortedQualities.map(quality => ( + + ))} + +
+
+
+ { diff --git a/frontend/src/components/profile/quality/QualityItem.jsx b/frontend/src/components/profile/quality/QualityItem.jsx new file mode 100644 index 0000000..3697bd3 --- /dev/null +++ b/frontend/src/components/profile/quality/QualityItem.jsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import QualityItemSingle from './QualityItemSingle'; +import QualityItemGroup from './QualityItemGroup'; + +const QualityItem = (props) => { + const [hoveredItem, setHoveredItem] = useState(null); + const isGroup = 'qualities' in props.quality; + + const handleMouseEnter = (id) => { + setHoveredItem(id); + }; + + const handleMouseLeave = () => { + setHoveredItem(null); + }; + + // Add mouseEnter/Leave handlers and hoveredState to props + const enhancedProps = { + ...props, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + willBeSelected: hoveredItem === props.quality.id + }; + + if (isGroup) { + return ; + } else { + return ; + } +}; + +export default QualityItem; \ No newline at end of file diff --git a/frontend/src/components/profile/quality/QualityItemGroup.jsx b/frontend/src/components/profile/quality/QualityItemGroup.jsx new file mode 100644 index 0000000..bb9119b --- /dev/null +++ b/frontend/src/components/profile/quality/QualityItemGroup.jsx @@ -0,0 +1,174 @@ +import React from 'react'; +import RadarrLogo from '@logo/Radarr.svg'; +import SonarrLogo from '@logo/Sonarr.svg'; +import {Pencil, Trash2, Check, ArrowUp} from 'lucide-react'; +import Tooltip from '@ui/Tooltip'; + +const QualityItemGroup = ({ + quality, + isDragging, + listeners, + attributes, + style, + onDelete, + onEdit, + onMouseEnter, + onMouseLeave, + willBeSelected, + isUpgradeUntil, + onUpgradeUntilClick +}) => { + const handleUpgradeClick = e => { + e.stopPropagation(); + onUpgradeUntilClick?.(quality); + }; + + return ( +
onMouseEnter?.(quality.id)} + onMouseLeave={onMouseLeave}> + {/* Header Row */} +
+ {/* Title and Description */} +
+
+

+ {quality.name} +

+ + {/* Quality tags inline with name */} +
+ {quality.qualities.map(q => ( + + {q.name} + + ))} +
+
+ {quality.description && ( +

+ {quality.description} +

+ )} +
+ + {/* Right Section */} +
+ {/* App Icons */} +
+ {quality.radarr && ( +
+ Radarr + + Radarr + +
+ )} + {quality.sonarr && ( +
+ Sonarr + + Sonarr + +
+ )} +
+ + {/* Edit/Delete Actions */} +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ + {/* Upgrade Until button - only shown when enabled and upgrade is allowed */} + {quality.enabled && onUpgradeUntilClick && ( + + + + )} + + {/* Selected indicator - shows all three states */} +
+ {quality.enabled && ( + + )} + {willBeSelected && !quality.enabled && ( +
+ )} +
+
+
+
+ ); +}; + +export default QualityItemGroup; diff --git a/frontend/src/components/profile/quality/QualityItemSingle.jsx b/frontend/src/components/profile/quality/QualityItemSingle.jsx new file mode 100644 index 0000000..34d2b2f --- /dev/null +++ b/frontend/src/components/profile/quality/QualityItemSingle.jsx @@ -0,0 +1,147 @@ +import React from 'react'; +import {Check, Info, ArrowUp} from 'lucide-react'; +import Tooltip from '@ui/Tooltip'; +import RadarrLogo from '@logo/Radarr.svg'; +import SonarrLogo from '@logo/Sonarr.svg'; + +const QualityItemSingle = ({ + quality, + isDragging, + listeners, + attributes, + style, + onMouseEnter, + onMouseLeave, + willBeSelected, + isUpgradeUntil, + onUpgradeUntilClick +}) => { + // Create tooltip content with just icons and text + const AppTooltipContent = () => ( +
+ {quality.radarr && ( +
+ Radarr + Radarr +
+ )} + {quality.sonarr && ( +
+ Sonarr + Sonarr +
+ )} +
+ ); + + const handleUpgradeClick = e => { + e.stopPropagation(); + onUpgradeUntilClick?.(quality); + }; + + return ( +
onMouseEnter?.(quality.id)} + onMouseLeave={onMouseLeave}> + {/* Content Row */} +
+ {/* Left Section with Title and Info */} +
+ {/* Title Row */} +
+

+ {quality.name} +

+
+ + {/* Description Row */} + {quality.description && ( +

+ {quality.description} +

+ )} +
+ + {/* Right Section - Info Icon and Selection indicators */} +
+ {/* Info Badge with Tooltip */} + {(quality.radarr || quality.sonarr) && ( + }> +
+ +
+
+ )} + + {/* Upgrade Until button - only shown when enabled and upgrade is allowed */} + {quality.enabled && onUpgradeUntilClick && ( + + + + )} + + {/* Selection indicator */} +
+ {quality.enabled && ( + + )} + {willBeSelected && !quality.enabled && ( +
+ )} +
+
+
+
+ ); +}; + +export default QualityItemSingle; diff --git a/frontend/src/components/regex/RegexPage.jsx b/frontend/src/components/regex/RegexPage.jsx index 080cf52..436431d 100644 --- a/frontend/src/components/regex/RegexPage.jsx +++ b/frontend/src/components/regex/RegexPage.jsx @@ -78,7 +78,7 @@ function RegexPage() { } = useMassSelection(); // Keyboard shortcuts - useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true}); + useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true}); // Mouse position tracking for shift-select useEffect(() => { diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx index c8885e4..f541275 100644 --- a/frontend/src/components/ui/Modal.jsx +++ b/frontend/src/components/ui/Modal.jsx @@ -18,12 +18,27 @@ const Modal = ({ }) => { const modalRef = useRef(); const [activeTab, setActiveTab] = useState(tabs?.[0]?.id); + const [isClosing, setIsClosing] = useState(false); + + useEffect(() => { + if (isOpen) { + setIsClosing(false); + } + }, [isOpen]); + + const handleClose = () => { + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 200); // Match animation duration + }; useEffect(() => { if (isOpen && !disableCloseOnEscape) { const handleEscape = event => { if (event.key === 'Escape') { - onClose(); + handleClose(); } }; document.addEventListener('keydown', handleEscape); @@ -31,7 +46,7 @@ const Modal = ({ document.removeEventListener('keydown', handleEscape); }; } - }, [isOpen, onClose, disableCloseOnEscape]); + }, [isOpen, disableCloseOnEscape]); const handleClickOutside = e => { // Get the current selection @@ -42,9 +57,10 @@ const Modal = ({ modalRef.current && !modalRef.current.contains(e.target) && !disableCloseOnOutsideClick && - !hasSelection // Don't close if there's text selected + !hasSelection && // Don't close if there's text selected + !isClosing ) { - onClose(); + handleClose(); } }; @@ -87,13 +103,13 @@ const Modal = ({ return (
e.stopPropagation()}> + {/* Header */}

@@ -128,7 +149,7 @@ const Modal = ({

)}