diff --git a/web_server.py b/web_server.py index bbd614d5..e29638ed 100644 --- a/web_server.py +++ b/web_server.py @@ -9829,6 +9829,371 @@ def get_write_tags_batch_status(): return jsonify(state) +# ── Reorganize Album Files endpoint ── + +_reorganize_state = { + 'status': 'idle', + 'total': 0, + 'processed': 0, + 'moved': 0, + 'skipped': 0, + 'failed': 0, + 'current_track': '', + 'errors': [], +} +_reorganize_lock = threading.Lock() + + +@app.route('/api/library/album//reorganize/preview', methods=['POST']) +def reorganize_album_preview(album_id): + """Preview file reorganization for an album — returns current vs proposed paths without moving anything.""" + try: + database = get_database() + data = request.get_json() or {} + template = data.get('template', '').strip() + if not template: + return jsonify({"success": False, "error": "Template is required"}), 400 + + conn = database._get_connection() + cursor = conn.cursor() + + # Get album + artist info + cursor.execute(""" + SELECT al.*, a.name as artist_name + FROM albums al + JOIN artists a ON al.artist_id = a.id + WHERE al.id = ? + """, (str(album_id),)) + album_row = cursor.fetchone() + if not album_row: + return jsonify({"success": False, "error": "Album not found"}), 404 + album_data = dict(album_row) + + # Get all tracks for this album + cursor.execute(""" + SELECT t.*, a.name as artist_name + FROM tracks t + JOIN artists a ON t.artist_id = a.id + WHERE t.album_id = ? + ORDER BY t.track_number + """, (str(album_id),)) + tracks = [dict(r) for r in cursor.fetchall()] + + if not tracks: + return jsonify({"success": False, "error": "No tracks found for this album"}), 404 + + transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) + + preview_items = [] + for track in tracks: + file_path = track.get('file_path') + resolved = _resolve_library_file_path(file_path) if file_path else None + + # Read disc_number from file tags if available + disc_number = 1 + if resolved: + try: + from core.tag_writer import read_file_tags + file_tags = read_file_tags(resolved) + disc_number = file_tags.get('disc_number') or 1 + except Exception: + pass + + # Get file extension from current path + file_ext = os.path.splitext(resolved or file_path or '.mp3')[1] + + # Detect quality using the same format as the download pipeline + quality = _get_audio_quality_string(resolved) if resolved else '' + + # Build context for template + year_val = album_data.get('year') or '' + context = { + 'artist': track.get('artist_name') or 'Unknown Artist', + 'albumartist': album_data.get('artist_name') or track.get('artist_name') or 'Unknown Artist', + 'album': album_data.get('title') or 'Unknown Album', + 'title': track.get('title') or 'Unknown Track', + 'track_number': track.get('track_number') or 1, + 'disc_number': disc_number, + 'year': year_val, + 'quality': quality, + } + + # Build new path using the template + folder_path, filename = _get_file_path_from_template_raw(template, context) + new_relative = os.path.join(folder_path, f"{filename}{file_ext}") if folder_path else f"{filename}{file_ext}" + new_full = os.path.join(transfer_dir, new_relative) + + # Current path relative to transfer dir for display + current_display = file_path or 'No file' + if resolved and transfer_dir and resolved.startswith(transfer_dir): + current_display = resolved[len(transfer_dir):].lstrip(os.sep).lstrip('/') + + same = resolved and os.path.normpath(resolved) == os.path.normpath(new_full) + + preview_items.append({ + 'track_id': track['id'], + 'title': track.get('title', ''), + 'track_number': track.get('track_number', 0), + 'current_path': current_display, + 'new_path': new_relative, + 'new_full_normalized': os.path.normpath(new_full) if resolved else None, + 'file_exists': resolved is not None, + 'unchanged': same, + 'collision': False, + }) + + # Detect collisions: multiple tracks mapping to the same destination + seen_paths = {} + for item in preview_items: + norm = item.get('new_full_normalized') + if not norm or not item['file_exists'] or item['unchanged']: + continue + if norm in seen_paths: + item['collision'] = True + # Also mark the first one that claimed this path + seen_paths[norm]['collision'] = True + else: + seen_paths[norm] = item + + # Remove internal field from response + for item in preview_items: + item.pop('new_full_normalized', None) + + return jsonify({ + "success": True, + "album": album_data.get('title', ''), + "artist": album_data.get('artist_name', ''), + "tracks": preview_items, + "transfer_dir": transfer_dir, + }) + + except Exception as e: + logger.error(f"Reorganize preview error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/album//reorganize', methods=['POST']) +def reorganize_album_files(album_id): + """Move album files to new paths based on the provided template.""" + try: + data = request.get_json() or {} + template = data.get('template', '').strip() + if not template: + return jsonify({"success": False, "error": "Template is required"}), 400 + + # Atomic check-and-set to prevent concurrent reorganizations + with _reorganize_lock: + if _reorganize_state['status'] == 'running': + return jsonify({"success": False, "error": "A reorganization is already in progress"}), 409 + _reorganize_state['status'] = 'running' + + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + + # Get album + artist info + cursor.execute(""" + SELECT al.*, a.name as artist_name + FROM albums al + JOIN artists a ON al.artist_id = a.id + WHERE al.id = ? + """, (str(album_id),)) + album_row = cursor.fetchone() + if not album_row: + with _reorganize_lock: + _reorganize_state['status'] = 'idle' + return jsonify({"success": False, "error": "Album not found"}), 404 + album_data = dict(album_row) + + # Get all tracks + cursor.execute(""" + SELECT t.*, a.name as artist_name + FROM tracks t + JOIN artists a ON t.artist_id = a.id + WHERE t.album_id = ? + ORDER BY t.track_number + """, (str(album_id),)) + tracks = [dict(r) for r in cursor.fetchall()] + + if not tracks: + with _reorganize_lock: + _reorganize_state['status'] = 'idle' + return jsonify({"success": False, "error": "No tracks found"}), 404 + + transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) + + # Initialize state (already set to 'running' above) + with _reorganize_lock: + _reorganize_state.update({ + 'total': len(tracks), + 'processed': 0, + 'moved': 0, + 'skipped': 0, + 'failed': 0, + 'current_track': '', + 'errors': [], + }) + + def _run_reorganize(): + bg_conn = None + try: + # Single DB connection for the background thread + bg_db = get_database() + bg_conn = bg_db._get_connection() + + # Pre-compute all destination paths to detect collisions + dest_paths = {} # normalized_new_path -> track_id + for track in tracks: + file_path = track.get('file_path') + resolved = _resolve_library_file_path(file_path) if file_path else None + if not resolved: + continue + + disc_number = 1 + try: + from core.tag_writer import read_file_tags + file_tags = read_file_tags(resolved) + disc_number = file_tags.get('disc_number') or 1 + except Exception: + pass + + file_ext = os.path.splitext(resolved)[1] + quality = _get_audio_quality_string(resolved) + + year_val = album_data.get('year') or '' + context = { + 'artist': track.get('artist_name') or 'Unknown Artist', + 'albumartist': album_data.get('artist_name') or track.get('artist_name') or 'Unknown Artist', + 'album': album_data.get('title') or 'Unknown Album', + 'title': track.get('title') or 'Unknown Track', + 'track_number': track.get('track_number') or 1, + 'disc_number': disc_number, + 'year': year_val, + 'quality': quality, + } + + folder_path, filename = _get_file_path_from_template_raw(template, context) + new_relative = os.path.join(folder_path, f"{filename}{file_ext}") if folder_path else f"{filename}{file_ext}" + new_full = os.path.join(transfer_dir, new_relative) + norm_new = os.path.normpath(new_full) + + # Check for collision: two tracks mapping to same destination + if norm_new in dest_paths and dest_paths[norm_new] != str(track['id']): + # Mark as collision so the move pass skips it + track['_collision'] = True + with _reorganize_lock: + _reorganize_state['failed'] += 1 + _reorganize_state['processed'] += 1 + _reorganize_state['errors'].append({ + 'track_id': track['id'], + 'title': track.get('title', 'Unknown'), + 'error': f"Path collision with another track — add $track or $disc to template" + }) + continue + + dest_paths[norm_new] = str(track['id']) + + # Store computed info on the track dict for the move pass + track['_resolved'] = resolved + track['_new_full'] = new_full + track['_disc_number'] = disc_number + + # Now do the actual moves + for track in tracks: + resolved = track.get('_resolved') + new_full = track.get('_new_full') + track_title = track.get('title', 'Unknown') + + with _reorganize_lock: + _reorganize_state['current_track'] = track_title + + # Skip tracks already handled (collision or file not found) + if track.get('_collision'): + continue + + if not resolved or not new_full: + # File not found — only count if not already handled in pre-computation + if '_resolved' not in track: + with _reorganize_lock: + _reorganize_state['skipped'] += 1 + _reorganize_state['processed'] += 1 + _reorganize_state['errors'].append({ + 'track_id': track['id'], + 'title': track_title, + 'error': 'File not found on disk' + }) + continue + + # Skip if already at target + if os.path.normpath(resolved) == os.path.normpath(new_full): + with _reorganize_lock: + _reorganize_state['skipped'] += 1 + _reorganize_state['processed'] += 1 + continue + + try: + # Move file + _safe_move_file(resolved, new_full) + + # Update DB file_path + bg_cursor = bg_conn.cursor() + bg_cursor.execute( + "UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (new_full, str(track['id'])) + ) + bg_conn.commit() + + # Clean up empty directories left behind + _cleanup_empty_directories(transfer_dir, resolved) + + with _reorganize_lock: + _reorganize_state['moved'] += 1 + _reorganize_state['processed'] += 1 + + except Exception as move_err: + logger.error(f"Reorganize move error for {track_title}: {move_err}") + with _reorganize_lock: + _reorganize_state['failed'] += 1 + _reorganize_state['processed'] += 1 + _reorganize_state['errors'].append({ + 'track_id': track['id'], + 'title': track_title, + 'error': str(move_err) + }) + + except Exception as e: + logger.error(f"Reorganize background error: {e}") + finally: + if bg_conn: + try: + bg_conn.close() + except Exception: + pass + with _reorganize_lock: + _reorganize_state['status'] = 'done' + _reorganize_state['current_track'] = '' + + thread = threading.Thread(target=_run_reorganize, daemon=True, name="ReorganizeAlbum") + thread.start() + + return jsonify({"success": True, "message": "Reorganization started", "total": len(tracks)}) + + except Exception as e: + logger.error(f"Reorganize error: {e}") + with _reorganize_lock: + _reorganize_state['status'] = 'idle' + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/album/reorganize/status', methods=['GET']) +def get_reorganize_status(): + """Poll the status of a running reorganization.""" + with _reorganize_lock: + state = dict(_reorganize_state) + state['errors'] = list(_reorganize_state['errors']) + return jsonify(state) + + def _sync_tracks_to_server(track_rows, server_type): """Sync metadata for tracks to the active media server after writing file tags. @@ -12664,6 +13029,67 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): new_filename = f"{final_track_name_sanitized}{file_ext}" return os.path.join(single_dir, new_filename), True +def _get_file_path_from_template_raw(template: str, context: dict) -> tuple: + """ + Build file path using a user-provided template string directly. + Unlike _get_file_path_from_template, this bypasses config lookup and the + file_organization.enabled check — used by the reorganize feature where the + user supplies the template explicitly. + + Args: + template: Template string like "$artist/$album/$track - $title" + context: Dict with all track/album metadata + + Returns: + (folder_path, filename_base) tuple — no file extension included + """ + import re + full_path = _apply_path_template(template, context) + + quality_value = context.get('quality', '') + disc_value = f"{context.get('disc_number', 1):02d}" + + path_parts = full_path.split('/') + + if len(path_parts) > 1: + folder_parts = path_parts[:-1] + filename_base = path_parts[-1] + + cleaned_folders = [] + for part in folder_parts: + part = part.replace('$quality', '') + part = part.replace('$disc', '') + part = re.sub(r'\s*\[\s*\]', '', part) + part = re.sub(r'\s*\(\s*\)', '', part) + part = re.sub(r'\s*\{\s*\}', '', part) + part = re.sub(r'\s*-\s*$', '', part) + part = re.sub(r'^\s*-\s*', '', part) + part = re.sub(r'\s+', ' ', part).strip() + if part: + cleaned_folders.append(part) + + filename_base = filename_base.replace('$quality', quality_value) + filename_base = filename_base.replace('$disc', disc_value) + filename_base = re.sub(r'\s*\[\s*\]', '', filename_base) + filename_base = re.sub(r'\s*\(\s*\)', '', filename_base) + filename_base = re.sub(r'\s*\{\s*\}', '', filename_base) + filename_base = re.sub(r'\s*-\s*$', '', filename_base) + filename_base = re.sub(r'\s+', ' ', filename_base).strip() + + sanitized_folders = [_sanitize_filename(part) for part in cleaned_folders] + folder_path = os.path.join(*sanitized_folders) if sanitized_folders else '' + return folder_path, _sanitize_filename(filename_base) + else: + full_path = full_path.replace('$quality', quality_value) + full_path = full_path.replace('$disc', disc_value) + full_path = re.sub(r'\s*\[\s*\]', '', full_path) + full_path = re.sub(r'\s*\(\s*\)', '', full_path) + full_path = re.sub(r'\s*\{\s*\}', '', full_path) + full_path = re.sub(r'\s*-\s*$', '', full_path) + full_path = re.sub(r'\s+', ' ', full_path).strip() + return '', _sanitize_filename(full_path) + + def _get_audio_quality_string(file_path): """ Read audio file and return a quality descriptor string. diff --git a/webui/index.html b/webui/index.html index bb6a7a4f..da7d6448 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2641,6 +2641,23 @@ + + +
diff --git a/webui/static/script.js b/webui/static/script.js index 78ef96ac..e66f2a43 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -35966,6 +35966,14 @@ function renderExpandedAlbumHeader(album) { writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); }; enrichRow.appendChild(writeTagsBtn); + // Reorganize button + const reorganizeBtn = document.createElement('button'); + reorganizeBtn.className = 'enhanced-reorganize-album-btn'; + reorganizeBtn.innerHTML = '📁 Reorganize'; + reorganizeBtn.title = 'Reorganize album files using a custom path template'; + reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); }; + enrichRow.appendChild(reorganizeBtn); + // Delete album button const deleteAlbumBtn = document.createElement('button'); deleteAlbumBtn.className = 'enhanced-delete-album-btn'; @@ -37499,6 +37507,255 @@ function _pollBatchWriteTagsStatus() { _batchWriteTagsPollTimer = setTimeout(poll, 800); } +// ── Reorganize Album Files ── + +let _reorganizeAlbumId = null; +let _reorganizePollTimer = null; + +async function showReorganizeModal(albumId) { + _reorganizeAlbumId = albumId; + const overlay = document.getElementById('reorganize-overlay'); + const body = document.getElementById('reorganize-modal-body'); + const title = document.getElementById('reorganize-modal-title'); + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (!overlay || !body) return; + + // Find album data from enhanced view state + let albumData = null; + let artistName = ''; + if (artistDetailPageState.enhancedData) { + artistName = artistDetailPageState.enhancedData.artist.name || ''; + const allAlbums = artistDetailPageState.enhancedData.albums || []; + albumData = allAlbums.find(a => String(a.id) === String(albumId)); + } + + title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`; + if (applyBtn) applyBtn.disabled = true; + + // Build modal content + const variables = [ + { var: '$artist', desc: 'Track artist', example: artistName || 'Artist' }, + { var: '$albumartist', desc: 'Album artist', example: artistName || 'Album Artist' }, + { var: '$artistletter', desc: 'First letter of artist', example: (artistName || 'A')[0].toUpperCase() }, + { var: '$album', desc: 'Album title', example: albumData ? albumData.title : 'Album' }, + { var: '$title', desc: 'Track title', example: 'Track Name' }, + { var: '$track', desc: 'Track number (zero-padded)', example: '01' }, + { var: '$disc', desc: 'Disc number (filename only)', example: '01' }, + { var: '$year', desc: 'Release year', example: albumData && albumData.year ? String(albumData.year) : '2024' }, + { var: '$quality', desc: 'Audio quality (filename only)', example: 'FLAC 16bit/44kHz' }, + ]; + + let html = '
'; + + // Template input + html += '
'; + html += ''; + html += '
Use / to separate folders. The last segment becomes the filename.
'; + html += ' { + html += `
`; + html += `${v.var}${v.desc}`; + html += '
'; + }); + html += '
'; + + // Preview area + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += '
Click "Generate Preview" to see how files will be reorganized.
'; + html += '
'; + + html += '
'; + body.innerHTML = html; + overlay.classList.remove('hidden'); + + // Wire up live preview on enter key + setTimeout(() => { + const input = document.getElementById('reorganize-template-input'); + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + loadReorganizePreview(); + } + }); + input.focus(); + } + }, 50); +} + +function insertReorganizeVar(varName) { + const input = document.getElementById('reorganize-template-input'); + if (!input) return; + const start = input.selectionStart; + const end = input.selectionEnd; + const val = input.value; + input.value = val.substring(0, start) + varName + val.substring(end); + input.focus(); + const newPos = start + varName.length; + input.setSelectionRange(newPos, newPos); +} + +function closeReorganizeModal() { + const overlay = document.getElementById('reorganize-overlay'); + if (overlay) overlay.classList.add('hidden'); + _reorganizeAlbumId = null; +} + +async function loadReorganizePreview() { + const template = document.getElementById('reorganize-template-input')?.value?.trim(); + const previewBody = document.getElementById('reorganize-preview-body'); + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (!template || !previewBody || !_reorganizeAlbumId) return; + + if (applyBtn) applyBtn.disabled = true; + previewBody.innerHTML = '
Loading preview...
'; + + try { + const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template }) + }); + const result = await response.json(); + if (!result.success) { + previewBody.innerHTML = `
${escapeHtml(result.error || 'Preview failed')}
`; + return; + } + + const tracks = result.tracks || []; + if (tracks.length === 0) { + previewBody.innerHTML = '
No tracks found.
'; + return; + } + + let hasChanges = false; + let hasCollisions = false; + let html = ''; + html += ''; + html += ''; + + tracks.forEach(t => { + const unchanged = t.unchanged; + const noFile = !t.file_exists; + const collision = t.collision; + if (!unchanged && t.file_exists) hasChanges = true; + if (collision) hasCollisions = true; + + const rowClass = collision ? 'reorganize-row-collision' : noFile ? 'reorganize-row-missing' : unchanged ? 'reorganize-row-unchanged' : 'reorganize-row-changed'; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + + html += '
#TitleCurrent PathNew Path
${t.track_number || ''}${escapeHtml(t.title)}${noFile ? 'File not found' : escapeHtml(t.current_path)}${collision ? '!!' : unchanged ? '=' : noFile ? '' : '→'}${noFile ? '' : escapeHtml(t.new_path)}${collision ? ' (collision)' : ''}
'; + + const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision).length; + const skippedCount = tracks.filter(t => t.unchanged).length; + const missingCount = tracks.filter(t => !t.file_exists).length; + const collisionCount = tracks.filter(t => t.collision).length; + + let summary = `
`; + if (changedCount > 0) summary += `${changedCount} will move`; + if (skippedCount > 0) summary += `${skippedCount} unchanged`; + if (missingCount > 0) summary += `${missingCount} missing`; + if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — add $track or $disc to fix`; + summary += '
'; + + previewBody.innerHTML = summary + html; + + // Block apply if collisions exist + if (applyBtn) applyBtn.disabled = !hasChanges || hasCollisions; + + } catch (error) { + previewBody.innerHTML = `
Error: ${escapeHtml(error.message)}
`; + } +} + +async function executeReorganize() { + const template = document.getElementById('reorganize-template-input')?.value?.trim(); + if (!template || !_reorganizeAlbumId) return; + + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = 'Reorganizing...'; + } + + try { + const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + closeReorganizeModal(); + showToast(`Reorganizing ${result.total} tracks...`, 'info'); + _pollReorganizeStatus(); + + } catch (error) { + showToast(`Reorganize failed: ${error.message}`, 'error'); + if (applyBtn) { + applyBtn.disabled = false; + applyBtn.textContent = 'Apply'; + } + } +} + +function _pollReorganizeStatus() { + if (_reorganizePollTimer) clearTimeout(_reorganizePollTimer); + + async function poll() { + try { + const response = await fetch('/api/library/album/reorganize/status'); + const state = await response.json(); + + if (state.status === 'running') { + const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; + showToast(`Reorganizing: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); + _reorganizePollTimer = setTimeout(poll, 800); + } else if (state.status === 'done') { + let msg = `Reorganized: ${state.moved} moved`; + if (state.skipped > 0) msg += `, ${state.skipped} skipped`; + if (state.failed > 0) msg += `, ${state.failed} failed`; + if (state.failed > 0 && state.errors && state.errors.length > 0) { + msg += ` (${state.errors[0].error})`; + } + showToast(msg, state.failed > 0 ? 'warning' : 'success'); + _reorganizePollTimer = null; + + // Refresh the enhanced view to show updated paths + if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { + loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + } + } catch (error) { + console.error('Poll reorganize status failed:', error); + _reorganizePollTimer = null; + } + } + + _reorganizePollTimer = setTimeout(poll, 600); +} + async function playLibraryTrack(track, albumTitle, artistName) { if (!track.file_path) { showToast('No file available for this track', 'error'); diff --git a/webui/static/style.css b/webui/static/style.css index bdadba67..2e44f189 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34514,6 +34514,235 @@ textarea.enhanced-meta-field-input { color: rgba(29, 185, 84, 0.9); border-color: rgba(29, 185, 84, 0.35); } +.enhanced-reorganize-album-btn { + padding: 6px 14px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + background: rgba(100, 149, 237, 0.06); + border: 1px solid rgba(100, 149, 237, 0.15); + color: rgba(100, 149, 237, 0.6); + transition: all 0.15s ease; +} +.enhanced-reorganize-album-btn:hover { + background: rgba(100, 149, 237, 0.12); + color: rgba(100, 149, 237, 0.9); + border-color: rgba(100, 149, 237, 0.35); +} + +/* ── Reorganize Modal ── */ +.reorganize-modal { + max-width: 900px; + width: 95vw; +} +.reorganize-content { + display: flex; + flex-direction: column; + gap: 16px; +} +.reorganize-label { + display: block; + font-size: 13px; + font-weight: 600; + color: rgba(255,255,255,0.8); + margin-bottom: 6px; +} +.reorganize-template-section { + padding-bottom: 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.reorganize-template-hint { + font-size: 11px; + color: rgba(255,255,255,0.35); + margin-bottom: 8px; +} +.reorganize-template-hint code { + background: rgba(255,255,255,0.06); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; +} +.reorganize-template-input { + width: 100%; + padding: 10px 14px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + color: #e0e0e0; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; +} +.reorganize-template-input:focus { + border-color: rgba(100, 149, 237, 0.5); +} +.reorganize-variables { + padding-bottom: 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.reorganize-var-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.reorganize-var-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + font-size: 12px; +} +.reorganize-var-chip:hover { + background: rgba(100, 149, 237, 0.1); + border-color: rgba(100, 149, 237, 0.3); +} +.reorganize-var-chip code { + color: rgba(100, 149, 237, 0.9); + font-size: 12px; + font-weight: 600; +} +.reorganize-var-desc { + color: rgba(255,255,255,0.4); + font-size: 11px; +} +.reorganize-preview-section { + flex: 1; +} +.reorganize-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.reorganize-preview-header .reorganize-label { + margin-bottom: 0; +} +.reorganize-preview-btn { + padding: 5px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + background: rgba(100, 149, 237, 0.1); + border: 1px solid rgba(100, 149, 237, 0.25); + color: rgba(100, 149, 237, 0.9); + transition: all 0.15s; +} +.reorganize-preview-btn:hover { + background: rgba(100, 149, 237, 0.2); + border-color: rgba(100, 149, 237, 0.4); +} +.reorganize-preview-body { + max-height: 350px; + overflow-y: auto; + background: rgba(0,0,0,0.15); + border-radius: 8px; + padding: 10px; +} +.reorganize-preview-hint, +.reorganize-preview-loading { + text-align: center; + color: rgba(255,255,255,0.3); + font-size: 12px; + padding: 24px; +} +.reorganize-preview-error { + text-align: center; + color: rgba(255, 80, 80, 0.8); + font-size: 12px; + padding: 24px; +} +.reorganize-preview-summary { + display: flex; + gap: 12px; + margin-bottom: 10px; + font-size: 12px; +} +.reorganize-stat { + padding: 3px 10px; + border-radius: 12px; + font-weight: 500; +} +.reorganize-stat.changed { + background: rgba(100, 149, 237, 0.12); + color: rgba(100, 149, 237, 0.9); +} +.reorganize-stat.unchanged { + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.4); +} +.reorganize-stat.missing { + background: rgba(255, 170, 50, 0.1); + color: rgba(255, 170, 50, 0.8); +} +.reorganize-preview-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.reorganize-preview-table th { + text-align: left; + padding: 6px 8px; + color: rgba(255,255,255,0.4); + font-weight: 500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.reorganize-preview-table td { + padding: 6px 8px; + border-bottom: 1px solid rgba(255,255,255,0.04); + color: rgba(255,255,255,0.7); + vertical-align: top; +} +.reorganize-path { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 11px; + word-break: break-all; + max-width: 300px; +} +.reorganize-arrow { + text-align: center; + color: rgba(100, 149, 237, 0.7); + font-weight: bold; + white-space: nowrap; +} +.reorganize-row-changed .reorganize-path:last-child { + color: rgba(100, 149, 237, 0.9); +} +.reorganize-row-unchanged td { + color: rgba(255,255,255,0.25); +} +.reorganize-row-missing td { + color: rgba(255, 170, 50, 0.5); +} +.reorganize-row-missing em { + font-style: italic; +} +.reorganize-row-collision td { + color: rgba(255, 60, 60, 0.8); +} +.reorganize-row-collision .reorganize-arrow { + color: rgba(255, 60, 60, 0.9); + font-weight: bold; +} +.reorganize-row-collision em { + font-style: italic; + color: rgba(255, 60, 60, 0.6); +} +.reorganize-stat.collision { + background: rgba(255, 60, 60, 0.1); + color: rgba(255, 60, 60, 0.9); +} .enhanced-bulk-btn.tag-write { background: rgba(29, 185, 84, 0.08);