From 528175226980ee8bab1c8198b1f386f20a05dc10 Mon Sep 17 00:00:00 2001 From: Samuel Chau Date: Mon, 16 Jun 2025 11:46:12 +0930 Subject: [PATCH] feat: dynamic language score (#207) - add database setting for language score on imports - add settings container on general tab to let user update the value - improved styling of general settings page to be more like media management page --- backend/app/compile/profile_compiler.py | 9 +- .../versions/003_language_import_score.py | 33 ++ backend/app/db/queries/settings.py | 46 ++ backend/app/settings/__init__.py | 32 ++ frontend/src/api/settings.js | 48 ++ .../media-management/CategoryContainer.jsx | 2 +- .../settings/general/ApiSettingsContainer.jsx | 109 +++++ .../settings/general/CategoryContainer.jsx | 28 ++ .../settings/general/GeneralContainer.jsx | 442 +----------------- .../general/ImportSettingsContainer.jsx | 100 ++++ .../general/UserSettingsContainer.jsx | 263 +++++++++++ 11 files changed, 690 insertions(+), 422 deletions(-) create mode 100644 backend/app/db/migrations/versions/003_language_import_score.py create mode 100644 frontend/src/components/settings/general/ApiSettingsContainer.jsx create mode 100644 frontend/src/components/settings/general/CategoryContainer.jsx create mode 100644 frontend/src/components/settings/general/ImportSettingsContainer.jsx create mode 100644 frontend/src/components/settings/general/UserSettingsContainer.jsx diff --git a/backend/app/compile/profile_compiler.py b/backend/app/compile/profile_compiler.py index f4513be..92ebb08 100644 --- a/backend/app/compile/profile_compiler.py +++ b/backend/app/compile/profile_compiler.py @@ -11,6 +11,7 @@ import aiohttp from .mappings import TargetApp, ValueResolver from ..data.utils import load_yaml_file, get_category_directory from ..importarr.format_memory import import_format_from_memory, async_import_format_from_memory +from ..db.queries.settings import get_language_import_score logger = logging.getLogger(__name__) @@ -162,7 +163,7 @@ class ProfileConverter: format_configs.append({ 'name': format_name, - 'score': -9999 + 'score': get_language_import_score() }) return format_configs @@ -182,7 +183,7 @@ class ProfileConverter: format_configs.append({ 'name': format_name, - 'score': -9999 + 'score': get_language_import_score() }) return format_configs @@ -226,7 +227,7 @@ class ProfileConverter: format_configs.append({ 'name': format_name, - 'score': -9999 + 'score': get_language_import_score() }) except Exception as e: @@ -280,7 +281,7 @@ class ProfileConverter: format_configs.append({ 'name': display_name, - 'score': -9999 + 'score': get_language_import_score() }) except Exception as e: logger.error(f"Error importing format {format_name}: {str(e)} (async)") diff --git a/backend/app/db/migrations/versions/003_language_import_score.py b/backend/app/db/migrations/versions/003_language_import_score.py new file mode 100644 index 0000000..7bb9022 --- /dev/null +++ b/backend/app/db/migrations/versions/003_language_import_score.py @@ -0,0 +1,33 @@ +# backend/app/db/migrations/versions/003_language_import_score.py +from ...connection import get_db + +version = 3 +name = "language_import_score" + + +def up(): + """Add language_import_config table.""" + with get_db() as conn: + # Create language_import_config table + conn.execute(''' + CREATE TABLE IF NOT EXISTS language_import_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + score INTEGER NOT NULL DEFAULT -99999, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Insert default record + conn.execute(''' + INSERT INTO language_import_config (score, updated_at) + VALUES (-99999, CURRENT_TIMESTAMP) + ''') + + conn.commit() + + +def down(): + """Remove language_import_config table.""" + with get_db() as conn: + conn.execute('DROP TABLE IF EXISTS language_import_config') + conn.commit() \ No newline at end of file diff --git a/backend/app/db/queries/settings.py b/backend/app/db/queries/settings.py index 9d7377a..648fa2c 100644 --- a/backend/app/db/queries/settings.py +++ b/backend/app/db/queries/settings.py @@ -63,3 +63,49 @@ def update_pat_status(): f"PAT status updated from {current[0]} to {pat_exists}") else: logger.debug("PAT status unchanged") + + +def get_language_import_score(): + """Get the current language import score.""" + with get_db() as conn: + result = conn.execute( + 'SELECT score FROM language_import_config ORDER BY id DESC LIMIT 1' + ).fetchone() + return result['score'] if result else -99999 + + +def update_language_import_score(score): + """Update the language import score.""" + with get_db() as conn: + # Get current score first + current = conn.execute( + 'SELECT score FROM language_import_config ORDER BY id DESC LIMIT 1' + ).fetchone() + current_score = current['score'] if current else None + + # Check if record exists + existing = conn.execute( + 'SELECT id FROM language_import_config ORDER BY id DESC LIMIT 1' + ).fetchone() + + if existing: + # Update existing record + conn.execute( + ''' + UPDATE language_import_config + SET score = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (score, existing['id'])) + else: + # Insert new record + conn.execute( + ''' + INSERT INTO language_import_config (score, updated_at) + VALUES (?, CURRENT_TIMESTAMP) + ''', (score,)) + + conn.commit() + if current_score is not None: + logger.info(f"Language import score updated from {current_score} to {score}") + else: + logger.info(f"Language import score set to: {score}") diff --git a/backend/app/settings/__init__.py b/backend/app/settings/__init__.py index f89fa26..85a2f71 100644 --- a/backend/app/settings/__init__.py +++ b/backend/app/settings/__init__.py @@ -4,6 +4,7 @@ from flask import Blueprint, jsonify, request, session from werkzeug.security import generate_password_hash, check_password_hash import secrets from ..db import get_db +from ..db.queries.settings import get_language_import_score, update_language_import_score import logging logger = logging.getLogger(__name__) @@ -124,3 +125,34 @@ def reset_api_key(): except Exception as e: logger.error(f'Failed to reset API key: {str(e)}') return jsonify({'error': 'Failed to reset API key'}), 500 + + +@bp.route('/language-import-score', methods=['GET']) +def get_language_import_score_route(): + try: + score = get_language_import_score() + return jsonify({'score': score}) + except Exception as e: + logger.error(f'Failed to get language import score: {str(e)}') + return jsonify({'error': 'Failed to get language import score'}), 500 + + +@bp.route('/language-import-score', methods=['PUT']) +def update_language_import_score_route(): + data = request.get_json() + score = data.get('score') + + if score is None: + return jsonify({'error': 'Score is required'}), 400 + + try: + score = int(score) + except (ValueError, TypeError): + return jsonify({'error': 'Score must be an integer'}), 400 + + try: + update_language_import_score(score) + return jsonify({'message': 'Language import score updated successfully'}) + except Exception as e: + logger.error(f'Failed to update language import score: {str(e)}') + return jsonify({'error': 'Failed to update language import score'}), 500 diff --git a/frontend/src/api/settings.js b/frontend/src/api/settings.js index edf92ae..cf0f06e 100644 --- a/frontend/src/api/settings.js +++ b/frontend/src/api/settings.js @@ -135,3 +135,51 @@ export const resetApiKey = async currentPassword => { throw error; } }; + +export const fetchLanguageImportScore = async () => { + try { + const response = await fetch(`${API_PREFIX}/settings/language-import-score`, { + credentials: 'include' + }); + + const data = await response.json(); + if (!response.ok) { + const errorMessage = data.error || 'Failed to fetch language import score'; + Alert.error(errorMessage); + throw createHandledError(errorMessage); + } + return data; + } catch (error) { + if (!error.isHandled) { + Alert.error(error.message || 'Failed to fetch language import score'); + } + throw error; + } +}; + +export const updateLanguageImportScore = async (score) => { + try { + const response = await fetch(`${API_PREFIX}/settings/language-import-score`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({score}) + }); + + const data = await response.json(); + if (!response.ok) { + const errorMessage = data.error || 'Failed to update language import score'; + Alert.error(errorMessage); + throw createHandledError(errorMessage); + } + Alert.success('Language import score updated successfully'); + return data; + } catch (error) { + if (!error.isHandled) { + Alert.error(error.message || 'Failed to update language import score'); + } + throw error; + } +}; diff --git a/frontend/src/components/media-management/CategoryContainer.jsx b/frontend/src/components/media-management/CategoryContainer.jsx index 1c208a1..c740989 100644 --- a/frontend/src/components/media-management/CategoryContainer.jsx +++ b/frontend/src/components/media-management/CategoryContainer.jsx @@ -20,7 +20,7 @@ const CategoryContainer = ({ + + + + + +
+ setApiKeyCurrentPassword(e.target.value)} + placeholder='Enter current password to reset API key' + className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors' + /> + +
+ + + + ); +}; + +export default ApiSettingsContainer; \ No newline at end of file diff --git a/frontend/src/components/settings/general/CategoryContainer.jsx b/frontend/src/components/settings/general/CategoryContainer.jsx new file mode 100644 index 0000000..b6ca4f3 --- /dev/null +++ b/frontend/src/components/settings/general/CategoryContainer.jsx @@ -0,0 +1,28 @@ +import React, {useState} from 'react'; +import {ChevronDown, ChevronRight} from 'lucide-react'; + +const CategoryContainer = ({title, children, defaultExpanded = true}) => { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +}; + +export default CategoryContainer; \ No newline at end of file diff --git a/frontend/src/components/settings/general/GeneralContainer.jsx b/frontend/src/components/settings/general/GeneralContainer.jsx index 39d65f2..e2bab92 100644 --- a/frontend/src/components/settings/general/GeneralContainer.jsx +++ b/frontend/src/components/settings/general/GeneralContainer.jsx @@ -1,34 +1,16 @@ // settings/general/GeneralContainer.jsx import React, {useState, useEffect} from 'react'; -import {Eye, EyeOff, Copy, RefreshCw, Check} from 'lucide-react'; -import { - fetchGeneralSettings, - updateUsername, - updatePassword, - resetApiKey -} from '@api/settings'; -import Alert from '@ui/Alert'; +import {RefreshCw} from 'lucide-react'; +import {fetchGeneralSettings} from '@api/settings'; +import ApiSettingsContainer from './ApiSettingsContainer'; +import UserSettingsContainer from './UserSettingsContainer'; +import ImportSettingsContainer from './ImportSettingsContainer'; const GeneralContainer = () => { const [loading, setLoading] = useState(true); - const [showApiKey, setShowApiKey] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [showCurrentPassword, setShowCurrentPassword] = useState(false); - const [showUsernameCurrentPassword, setShowUsernameCurrentPassword] = - useState(false); - const [showApiKeyCurrentPassword, setShowApiKeyCurrentPassword] = - useState(false); - const [copySuccess, setCopySuccess] = useState(false); - const [formData, setFormData] = useState({ + const [settings, setSettings] = useState({ apiKey: '', - username: '', - usernameCurrentPassword: '', - password: '', - confirmPassword: '', - currentPassword: '', - currentUsername: '', - apiKeyCurrentPassword: '' + username: '' }); useEffect(() => { @@ -39,17 +21,10 @@ const GeneralContainer = () => { setLoading(true); try { const {username, api_key} = await fetchGeneralSettings(); - setFormData(prev => ({ - ...prev, + setSettings({ apiKey: api_key, - username: username, - currentUsername: username, - usernameCurrentPassword: '', - password: '', - confirmPassword: '', - currentPassword: '', - apiKeyCurrentPassword: '' - })); + username: username + }); } catch (error) { console.error('Error fetching settings:', error); } finally { @@ -57,125 +32,20 @@ const GeneralContainer = () => { } }; - const handleCopyApiKey = async () => { - await navigator.clipboard.writeText(formData.apiKey); - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 1000); - }; - - const handleResetApiKey = async () => { - if (!formData.apiKeyCurrentPassword) { - Alert.error( - 'Please enter your current password to reset the API key.' - ); - return; - } - - const confirmed = window.confirm( - 'Are you sure you want to reset your API key? This action cannot be undone and your current key will stop working immediately.' - ); - - if (confirmed) { - try { - const response = await resetApiKey( - formData.apiKeyCurrentPassword - ); - setFormData(prev => ({ - ...prev, - apiKey: response.api_key, - apiKeyCurrentPassword: '' - })); - } catch (error) { - console.error('Error resetting API key:', error); - } - } - }; - - const handleUsernameChange = e => { - setFormData(prev => ({ + const handleApiKeyUpdate = newApiKey => { + setSettings(prev => ({ ...prev, - username: e.target.value + apiKey: newApiKey })); }; - const handleUsernameCurrentPasswordChange = e => { - setFormData(prev => ({ + const handleUsernameUpdate = newUsername => { + setSettings(prev => ({ ...prev, - usernameCurrentPassword: e.target.value + username: newUsername })); }; - const handlePasswordChange = e => { - const newPassword = e.target.value; - setFormData(prev => ({ - ...prev, - password: newPassword - })); - }; - - const handleConfirmPasswordChange = e => { - setFormData(prev => ({ - ...prev, - confirmPassword: e.target.value - })); - }; - - const handleCurrentPasswordChange = e => { - setFormData(prev => ({ - ...prev, - currentPassword: e.target.value - })); - }; - - const handleApiKeyCurrentPasswordChange = e => { - setFormData(prev => ({ - ...prev, - apiKeyCurrentPassword: e.target.value - })); - }; - - const handleSaveUsername = async () => { - try { - await updateUsername( - formData.username, - formData.usernameCurrentPassword - ); - setFormData(prev => ({ - ...prev, - currentUsername: formData.username, - usernameCurrentPassword: '' - })); - } catch (error) { - console.error('Error updating username:', error); - } - }; - - const handleSavePassword = async () => { - if (formData.password !== formData.confirmPassword) { - Alert.error('Passwords do not match'); - return; - } - - const confirmed = window.confirm( - 'Are you sure you want to change your password? You will need to log in again.' - ); - - if (confirmed) { - try { - const response = await updatePassword( - formData.currentPassword, - formData.password - ); - - if (response.requireRelogin) { - window.location.href = '/login'; - } - } catch (error) { - console.error('Error updating password:', error); - } - } - }; - if (loading) { return (
@@ -184,279 +54,17 @@ const GeneralContainer = () => { ); } - const hasUsernameChanges = formData.username !== formData.currentUsername; - return (
-
-

- API Settings -

-
- -
-
- - -
- - -
- -
- - -
-
-
- -
-

- User Settings -

-
-
- -
-
- -
- - {hasUsernameChanges && ( -
-
- - -
- -
- )} -
-
- -
- -
-
- - -
- - {formData.password && ( - <> -
- - -
- -
-
- - -
- -
- - )} -
-
-
-
+ + +
); }; diff --git a/frontend/src/components/settings/general/ImportSettingsContainer.jsx b/frontend/src/components/settings/general/ImportSettingsContainer.jsx new file mode 100644 index 0000000..c78d8ed --- /dev/null +++ b/frontend/src/components/settings/general/ImportSettingsContainer.jsx @@ -0,0 +1,100 @@ +import React, {useState, useEffect} from 'react'; +import {Save, Check} from 'lucide-react'; +import {fetchLanguageImportScore, updateLanguageImportScore} from '@api/settings'; +import Alert from '@ui/Alert'; +import NumberInput from '@ui/NumberInput'; +import CategoryContainer from './CategoryContainer'; + +const ImportSettingsContainer = () => { + const [formData, setFormData] = useState({ + languageImportScore: 0 + }); + const [saveSuccess, setSaveSuccess] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchScore(); + }, []); + + const fetchScore = async () => { + setLoading(true); + try { + const {score} = await fetchLanguageImportScore(); + setFormData({ + languageImportScore: score + }); + } catch (error) { + console.error('Error fetching language import score:', error); + } finally { + setLoading(false); + } + }; + + const handleScoreChange = value => { + setFormData(prev => ({...prev, languageImportScore: value})); + }; + + const handleSave = async () => { + const score = formData.languageImportScore; + + if (score >= 0) { + Alert.error('Score must be negative'); + return; + } + + try { + await updateLanguageImportScore(score); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 1000); + } catch (error) { + console.error('Error updating language import score:', error); + } + }; + + if (loading) { + return ( + +
+
+
+
+ ); + } + + return ( + +
+
+ +

+ Default score assigned to language-specific custom formats when importing profiles. Must be negative. +

+
+ + +
+
+
+
+ ); +}; + +export default ImportSettingsContainer; \ No newline at end of file diff --git a/frontend/src/components/settings/general/UserSettingsContainer.jsx b/frontend/src/components/settings/general/UserSettingsContainer.jsx new file mode 100644 index 0000000..72d03db --- /dev/null +++ b/frontend/src/components/settings/general/UserSettingsContainer.jsx @@ -0,0 +1,263 @@ +import React, {useState} from 'react'; +import {Eye, EyeOff, Save, Check} from 'lucide-react'; +import {updateUsername, updatePassword} from '@api/settings'; +import Alert from '@ui/Alert'; +import CategoryContainer from './CategoryContainer'; + +const UserSettingsContainer = ({username, onUsernameUpdate}) => { + const [formData, setFormData] = useState({ + username: username, + usernameCurrentPassword: '', + password: '', + confirmPassword: '', + currentPassword: '' + }); + const [showPasswords, setShowPasswords] = useState({ + password: false, + confirmPassword: false, + currentPassword: false, + usernameCurrentPassword: false + }); + const [saveSuccess, setSaveSuccess] = useState({ + username: false, + password: false + }); + + const hasUsernameChanges = formData.username !== username; + + const handleUsernameChange = e => { + setFormData(prev => ({...prev, username: e.target.value})); + }; + + const handleSaveUsername = async () => { + try { + await updateUsername(formData.username, formData.usernameCurrentPassword); + onUsernameUpdate(formData.username); + setFormData(prev => ({...prev, usernameCurrentPassword: ''})); + setSaveSuccess(prev => ({...prev, username: true})); + setTimeout(() => setSaveSuccess(prev => ({...prev, username: false})), 1000); + } catch (error) { + console.error('Error updating username:', error); + } + }; + + const handleSavePassword = async () => { + if (formData.password !== formData.confirmPassword) { + Alert.error('Passwords do not match'); + return; + } + + const confirmed = window.confirm( + 'Are you sure you want to change your password? You will need to log in again.' + ); + + if (confirmed) { + try { + const response = await updatePassword( + formData.currentPassword, + formData.password + ); + + if (response.requireRelogin) { + window.location.href = '/login'; + } else { + setSaveSuccess(prev => ({...prev, password: true})); + setTimeout(() => setSaveSuccess(prev => ({...prev, password: false})), 1000); + setFormData(prev => ({ + ...prev, + password: '', + confirmPassword: '', + currentPassword: '' + })); + } + } catch (error) { + console.error('Error updating password:', error); + } + } + }; + + return ( + +
+
+ +
+ + + {hasUsernameChanges && ( +
+
+ setFormData(prev => ({ + ...prev, + usernameCurrentPassword: e.target.value + }))} + placeholder='Enter current password' + className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors' + /> + +
+ +
+ )} +
+
+ +
+ +
+
+ setFormData(prev => ({ + ...prev, + password: e.target.value + }))} + placeholder='Enter new password' + className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors' + /> + +
+ + {formData.password && ( + <> +
+ setFormData(prev => ({ + ...prev, + confirmPassword: e.target.value + }))} + placeholder='Confirm new password' + className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors' + /> + +
+ +
+
+ setFormData(prev => ({ + ...prev, + currentPassword: e.target.value + }))} + placeholder='Enter current password' + className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors' + /> + +
+ +
+ + )} +
+
+
+
+ ); +}; + +export default UserSettingsContainer; \ No newline at end of file