From 5412ec13236a9b079329b6c09810b724ffca6482 Mon Sep 17 00:00:00 2001 From: rnsrk Date: Wed, 30 Apr 2025 08:39:11 +0200 Subject: [PATCH] first commit --- .gitignore | 3 + README.md | 99 +++++ config_selector.py | 917 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1019 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 config_selector.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ab7b3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config/** +original_config/** +config_prefixes.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9ab6fa --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Drupal Configuration Selector + +A Python utility to selectively copy configuration files from a source directory to a target directory based on file prefixes. + +## Overview + +This script helps you manage and transfer specific Drupal configuration files by: +- Reading a list of configuration file prefixes from a JSON file +- Identifying matching files in a source directory +- Copying selected files to a target directory +- Providing a TUI (Text User Interface) to manage the selection process + +## Requirements + +- Python 3.6+ +- curses library (built into most Python installations on Linux/Mac) + +## Installation and Setup + +The script operates in the current working directory where you run it. It will create the following subdirectories if they don't exist: +- `original_config/` - Place your source configuration files here +- `config/` - Matched files will be copied here + +## Usage + +1. Copy or move the script to your working directory (or use the full path to run it) +2. Place your source configuration files in the `original_config` directory +3. Run the script: + +```bash +python3 config_selector.py +``` + +4. The script will create a default `config_prefixes.json` in the current directory if it doesn't exist +5. Use the TUI to manage your file selections and copy files + +## Hierarchical Navigation + +The script organizes configuration files in a tree-like structure based on dot-separated prefixes: + +- Files like `field.storage.node.comment.yml` are split into segments: `field` > `field.storage` > `field.storage.node` > etc. +- Use **RIGHT ARROW** to navigate deeper into a selected prefix +- Use **LEFT ARROW** to go back up one level in the hierarchy +- At each level, you see only the unique segments available at that level +- Each segment shows the total count of files under it +- Segments with children have a "▶" indicator +- You can perform actions (add, delete, select files) at any level in the hierarchy + +## TUI Navigation + +The Text User Interface provides the following controls: + +- **UP/DOWN**: Navigate through the list of prefixes +- **LEFT/RIGHT**: Navigate through the hierarchy tree (drill down/go back) +- **SPACE**: Toggle between matched and unmatched prefixes view +- **ENTER**: Select individual files from the selected prefix +- **A**: Add a new prefix (press ESC or Enter with empty input to cancel) +- **O**: Choose a prefix from the list of unmatched prefixes +- **D**: Delete the selected prefix (in matched view) or add the selected prefix (in unmatched view) +- **C**: Copy all matched files to the config directory +- **S**: Save the current list of prefixes to the JSON file +- **Q**: Quit the application + +## Individual File Selection + +When you press **ENTER** on a prefix, a dialog opens that allows you to: + +- In matched view: Select individual files to remove from the matching set +- In unmatched view: Select individual files to add to a matching prefix + +Within the file selection dialog: +- Use **UP/DOWN** to navigate between files +- Press **SPACE** to toggle selection of a file +- Press **ENTER** to confirm your selection +- Press **ESC** to cancel + +## Configuration File + +The script uses a JSON file to store prefixes. The default file is created at first run: + +```json +{ + "prefixes": [ + "put.your.prefixes.here" + ] +} +``` + +You can modify this file directly or use the TUI to manage the prefixes. + +## Directory Structure + +``` +/your/working/directory/ +├── config_selector.py # Main script +├── config_prefixes.json # Configuration prefixes list +├── original_config/ # Source directory containing all configuration files +└── config/ # Target directory where matched files will be copied +``` diff --git a/config_selector.py b/config_selector.py new file mode 100755 index 0000000..3de2831 --- /dev/null +++ b/config_selector.py @@ -0,0 +1,917 @@ +#!/usr/bin/env python3 + +import os +import json +import shutil +import curses +import re +from typing import List, Dict, Set, Tuple, Optional + +# Use current directory instead of hardcoded path +BASE_DIR = os.getcwd() + +class ConfigSelector: + def __init__(self, json_path: str, original_config_path: str, config_path: str): + self.json_path = json_path + self.original_config_path = original_config_path + self.config_path = config_path + self.prefixes: List[str] = [] + self.matched_files: Dict[str, List[str]] = {} + self.unmatched_files: Dict[str, List[str]] = {} + self.load_prefixes() + self.scan_configs() + + def load_prefixes(self) -> None: + """Load file prefixes from JSON file.""" + try: + with open(self.json_path, 'r') as f: + data = json.load(f) + if isinstance(data, list): + self.prefixes = data + elif isinstance(data, dict) and 'prefixes' in data: + self.prefixes = data['prefixes'] + else: + print("Error: JSON file should contain a list of prefixes or a dict with 'prefixes' key") + exit(1) + except (json.JSONDecodeError, FileNotFoundError) as e: + print(f"Error loading JSON file: {e}") + exit(1) + + def save_prefixes(self) -> None: + """Save current prefixes back to JSON file.""" + try: + with open(self.json_path, 'w') as f: + json.dump({'prefixes': self.prefixes}, f, indent=2) + print(f"Prefixes saved to {self.json_path}") + except Exception as e: + print(f"Error saving JSON file: {e}") + + def scan_configs(self) -> None: + """Scan config directories and categorize files based on prefixes.""" + self.matched_files = {prefix: [] for prefix in self.prefixes} + self.unmatched_files = {} + + try: + # Process all files in the directory + for filename in os.listdir(self.original_config_path): + if not filename.endswith('.yml'): + continue + + # Standard prefix matching + matched = False + for prefix in self.prefixes: + if filename.startswith(prefix): + self.matched_files[prefix].append(filename) + matched = True + break + + # Add to unmatched if not matched by any prefix + if not matched: + # Get the first segment for initial grouping + parts = filename.split('.') + potential_prefix = parts[0] + + if potential_prefix not in self.unmatched_files: + self.unmatched_files[potential_prefix] = [] + self.unmatched_files[potential_prefix].append(filename) + + except FileNotFoundError: + print(f"Error: Directory {self.original_config_path} not found") + exit(1) + + def copy_matched_files(self) -> None: + """Copy all matched files from original_config to config.""" + if not os.path.exists(self.config_path): + os.makedirs(self.config_path) + + copied_count = 0 + for prefix in self.prefixes: + for filename in self.matched_files[prefix]: + src = os.path.join(self.original_config_path, filename) + dst = os.path.join(self.config_path, filename) + shutil.copy2(src, dst) + copied_count += 1 + + print(f"Copied {copied_count} files to {self.config_path}") + + def add_prefix(self, prefix: str) -> None: + """Add a new prefix to the list if it doesn't exist.""" + if prefix and prefix not in self.prefixes: + self.prefixes.append(prefix) + self.scan_configs() + + def remove_prefix(self, prefix: str) -> None: + """Remove a prefix from the list if it exists.""" + if prefix in self.prefixes: + self.prefixes.remove(prefix) + self.scan_configs() + + def get_summary(self) -> Tuple[int, int]: + """Return the count of matched and unmatched files.""" + matched_count = sum(len(files) for files in self.matched_files.values()) + unmatched_count = sum(len(files) for files in self.unmatched_files.values()) + return matched_count, unmatched_count + +def get_prefix_segments_at_level(all_prefixes: List[str], current_prefix: str, level: int, selector=None, view_mode=None) -> List[str]: + """ + Get unique prefix segments at the specified level. + + Args: + all_prefixes: List of all prefixes + current_prefix: Current prefix we're viewing (e.g., "field.storage") + level: The level we want to see (0 = root level) + selector: The ConfigSelector instance (needed for unmatched files) + view_mode: Current view mode ("matched" or "unmatched") + + Returns: + List of unique prefix segments at this level + """ + # Current prefix parts (e.g., ["field", "storage"]) + current_parts = current_prefix.split('.') if current_prefix else [] + unique_prefixes = set() + + # Special case for unmatched view with level > 0 + if view_mode == "unmatched" and selector: + # Need to scan actual filenames for deeper levels + for prefix, filenames in selector.unmatched_files.items(): + for filename in filenames: + # Skip .yml extension for determining parts + if filename.endswith('.yml'): + filename = filename[:-4] + + parts = filename.split('.') + + # Skip if not enough parts for this level + if len(parts) <= level: + continue + + # Skip if prefix doesn't match current path + if current_prefix: + filename_prefix = '.'.join(parts[:len(current_parts)]) + if filename_prefix != current_prefix: + continue + + # Add the segment at this level + prefix_at_level = '.'.join(parts[:level+1]) + unique_prefixes.add(prefix_at_level) + + return sorted(list(unique_prefixes)) + + # Standard processing for matched view + elif view_mode == "matched": + for prefix in all_prefixes: + # Split each prefix into parts + parts = prefix.split('.') + + # Skip if not enough parts for the level + if len(parts) <= level: + continue + + # If we're looking at a nested level, only include prefixes that match the current path + if current_prefix: + # Skip if this prefix doesn't start with the current prefix + if not prefix.startswith(current_prefix + '.') and prefix != current_prefix: + continue + + # If current level is within current_prefix, it should match exactly + if level < len(current_parts) and parts[level] != current_parts[level]: + continue + + # Build the prefix up to this level + prefix_at_level = '.'.join(parts[:level+1]) + unique_prefixes.add(prefix_at_level) + + # Basic processing for all other cases + else: + for prefix in all_prefixes: + # Split each prefix into parts + parts = prefix.split('.') + + # Skip if not enough parts for the level + if len(parts) <= level: + continue + + # If we're looking at a nested level, only include prefixes that match the current path + if current_prefix: + # Skip if this prefix doesn't start with the current prefix + if not prefix.startswith(current_prefix + '.') and prefix != current_prefix: + continue + + # If current level is within current_prefix, it should match exactly + if level < len(current_parts) and parts[level] != current_parts[level]: + continue + + # Build the prefix up to this level + prefix_at_level = '.'.join(parts[:level+1]) + unique_prefixes.add(prefix_at_level) + + return sorted(list(unique_prefixes)) + +def get_files_for_segment(selector: ConfigSelector, segment: str, view_mode: str) -> List[str]: + """Get files that match the given segment in the current view.""" + files = [] + + if view_mode == "matched": + # For matched files, we need to check if this segment is an exact prefix or a partial one + if segment in selector.prefixes: + # Exact match: include all files for this prefix + files.extend(selector.matched_files[segment]) + else: + # Partial match: include files from all prefixes that start with this segment + for prefix in selector.prefixes: + if prefix == segment or prefix.startswith(segment + '.'): + files.extend(selector.matched_files[prefix]) + else: + # For unmatched files, collect files that match this segment + segment_parts = segment.split('.') + segment_len = len(segment_parts) + + for prefix, file_list in selector.unmatched_files.items(): + # For root level prefixes (without dots) + if '.' not in prefix and segment_len == 1 and prefix == segment: + files.extend(file_list) + continue + + # For multi-level prefixes and filenames + for filename in file_list: + # Get filename without .yml extension for comparison + filename_no_ext = filename + if filename.endswith('.yml'): + filename_no_ext = filename[:-4] + + filename_parts = filename_no_ext.split('.') + + # Check if this filename matches the segment up to the segment's length + if len(filename_parts) >= segment_len: + if '.'.join(filename_parts[:segment_len]) == segment: + files.append(filename) + + return sorted(list(set(files))) + +def draw_menu(stdscr, selector: ConfigSelector, selected_idx: int, view_mode: str, current_prefix: str = "", level: int = 0): + """Draw the TUI menu.""" + curses.curs_set(0) + stdscr.clear() + h, w = stdscr.getmaxyx() + + # Colors + curses.start_color() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # Selected item + curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) # Matched + curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) # Unmatched + curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Titles + + # Title + title = "Drupal Configuration Selector" + stdscr.addstr(0, (w - len(title)) // 2, title, curses.color_pair(4) | curses.A_BOLD) + + # Summary + matched_count, unmatched_count = selector.get_summary() + summary = f"Matched: {matched_count} files | Unmatched: {unmatched_count} files" + stdscr.addstr(1, (w - len(summary)) // 2, summary) + + # Current path display + if current_prefix: + path_display = f"Current path: {current_prefix}" + stdscr.addstr(2, 2, path_display) + + # Instructions + stdscr.addstr(h-4, 2, "LEFT/RIGHT: Navigate tree levels | SPACE: Toggle view | ENTER: Select files") + stdscr.addstr(h-3, 2, "A: Add prefix | O: Choose prefix | D: Add current path as prefix | C: Copy | S: Save | Q: Quit") + + # Content area + start_y = 3 + max_visible = h - 8 # Account for two instruction lines + + # Get segments at the current level + if view_mode == "matched": + all_prefixes = selector.prefixes + else: + all_prefixes = list(selector.unmatched_files.keys()) + + items = get_prefix_segments_at_level(all_prefixes, current_prefix, level, selector, view_mode) + + # Title based on current path + if current_prefix: + title = f"{view_mode.capitalize()} Prefixes under '{current_prefix}'" + else: + title = f"{view_mode.capitalize()} Prefixes (root level)" + + stdscr.addstr(start_y, 2, title, curses.color_pair(4) | curses.A_BOLD) + start_y += 1 + + # Pagination + if len(items) > max_visible: + page_size = max_visible + current_page = selected_idx // page_size + start_idx = current_page * page_size + end_idx = min(start_idx + page_size, len(items)) + display_items = items[start_idx:end_idx] + + # Adjust selected_idx relative to the page + relative_idx = selected_idx - start_idx + else: + display_items = items + relative_idx = selected_idx if selected_idx < len(items) else 0 + + # Display items + for i, item in enumerate(display_items): + y = start_y + i + + # Get base name (last segment) + item_base = item.split('.')[-1] + + # Check if there are child segments + has_children = has_child_segments(item, view_mode, selector) + + # Get files for this item + files = get_files_for_segment(selector, item, view_mode) + file_count = len(files) + + # Format display text + if has_children: + item_text = f"{item_base} ({file_count} files) ▶" + else: + item_text = f"{item_base} ({file_count} files)" + + if i == relative_idx: + stdscr.addstr(y, 2, item_text, curses.color_pair(1) | curses.A_BOLD) + else: + if view_mode == "matched": + stdscr.addstr(y, 2, item_text, curses.color_pair(2)) + else: + stdscr.addstr(y, 2, item_text, curses.color_pair(3)) + + # Selected item details + if items and len(items) > 0 and selected_idx < len(items): + try: + selected_item = items[selected_idx] + detail_y = start_y + min(len(display_items), max_visible) + 1 + + if detail_y < h - 4: # Make sure we have room to display details + stdscr.addstr(detail_y, 2, f"Files for '{selected_item}':", curses.A_BOLD) + detail_y += 1 + + # Get files for the selected item + files = get_files_for_segment(selector, selected_item, view_mode) + + max_files_to_show = h - detail_y - 4 + if max_files_to_show > 0: + sorted_files = sorted(files)[:max_files_to_show] + for i, file in enumerate(sorted_files): + if detail_y + i < h - 4: # Prevent writing outside window + # Truncate filename if too long + max_width = w - 6 # Allow for padding + if len(file) > max_width: + file_display = file[:max_width-3] + "..." + else: + file_display = file + stdscr.addstr(detail_y + i, 4, file_display) + + if len(files) > max_files_to_show and detail_y + max_files_to_show < h - 4: + stdscr.addstr(detail_y + max_files_to_show, 4, f"...and {len(files) - max_files_to_show} more") + except Exception as e: + # In case of error, just don't display details + error_msg = f"Error displaying file details: {str(e)}" + if h > 5: + stdscr.addstr(h-4, 2, error_msg[:w-4]) + + stdscr.refresh() + return len(items) + +def add_prefix_dialog(stdscr): + """Show dialog to add a new prefix.""" + h, w = stdscr.getmaxyx() + dialog_h, dialog_w = 5, 50 + dialog_y = (h - dialog_h) // 2 + dialog_x = (w - dialog_w) // 2 + + # Draw dialog box + dialog_win = curses.newwin(dialog_h, dialog_w, dialog_y, dialog_x) + dialog_win.box() + dialog_win.addstr(1, 2, "Enter new prefix (ESC or empty input to cancel):") + dialog_win.refresh() + + # Input field + input_win = curses.newwin(1, dialog_w - 6, dialog_y + 2, dialog_x + 3) + input_win.clear() + + # Setup input mode + curses.echo() + curses.curs_set(1) + input_win.keypad(True) # Enable special keys + + # Collect input character by character to handle escape key + prefix = "" + input_win.refresh() + + while True: + try: + key = input_win.getch() + + # Handle escape key + if key == 27: # ASCII code for Escape + prefix = "" + break + # Handle enter key + elif key in (10, 13): # ASCII codes for Enter/Return + break + # Handle backspace/delete + elif key in (8, 127, curses.KEY_BACKSPACE): + if prefix: + prefix = prefix[:-1] + # Clear line and rewrite + input_win.clear() + input_win.addstr(0, 0, prefix) + input_win.refresh() + # Regular character + elif 32 <= key <= 126: # Printable ASCII + prefix += chr(key) + except Exception: + # In case of any error, just return empty string + prefix = "" + break + + # Restore normal cursor visibility and echo settings + curses.noecho() + curses.curs_set(0) + + return prefix.strip() + +def confirm_dialog(stdscr, message): + """Show a confirmation dialog.""" + h, w = stdscr.getmaxyx() + dialog_h, dialog_w = 5, 50 + dialog_y = (h - dialog_h) // 2 + dialog_x = (w - dialog_w) // 2 + + # Draw dialog box + dialog_win = curses.newwin(dialog_h, dialog_w, dialog_y, dialog_x) + dialog_win.box() + dialog_win.addstr(1, 2, message) + dialog_win.addstr(3, 2, "Press Y to confirm, any other key to cancel") + dialog_win.refresh() + + # Get input + key = stdscr.getch() + return key == ord('y') or key == ord('Y') + +def choose_prefix_dialog(stdscr, selector: ConfigSelector): + """Show dialog to choose a prefix from unmatched prefixes.""" + h, w = stdscr.getmaxyx() + + # Get sorted list of unmatched prefixes + unmatched_prefixes = sorted(selector.unmatched_files.keys()) + + if not unmatched_prefixes: + # Show message if no unmatched prefixes + message_win = curses.newwin(3, 40, (h - 3) // 2, (w - 40) // 2) + message_win.box() + message_win.addstr(1, 2, "No unmatched prefixes available.") + message_win.refresh() + message_win.getch() + return None + + # Calculate dialog dimensions + dialog_h = min(15, len(unmatched_prefixes) + 4) # +4 for title, instructions, and border + dialog_w = 60 + dialog_y = (h - dialog_h) // 2 + dialog_x = (w - dialog_w) // 2 + + # Draw dialog box + dialog_win = curses.newwin(dialog_h, dialog_w, dialog_y, dialog_x) + dialog_win.keypad(True) # Enable arrow keys + + # Initialize selection variables + selected_idx = 0 + current_page = 0 + items_per_page = dialog_h - 4 # -4 for title, instructions, and border + + # Draw function + def draw_prefix_list(): + dialog_win.clear() + dialog_win.box() + dialog_win.addstr(1, 2, "Choose a prefix to add (ESC to cancel):") + + # Calculate page items + start_idx = current_page * items_per_page + end_idx = min(start_idx + items_per_page, len(unmatched_prefixes)) + + # Display prefixes with selection highlight + for i, prefix in enumerate(unmatched_prefixes[start_idx:end_idx]): + file_count = len(selector.unmatched_files[prefix]) + item_text = f"{prefix} ({file_count} files)" + + if i + start_idx == selected_idx: + dialog_win.addstr(i + 2, 4, item_text, curses.A_REVERSE) + else: + dialog_win.addstr(i + 2, 4, item_text) + + # Show pagination info if needed + if len(unmatched_prefixes) > items_per_page: + page_info = f"Page {current_page + 1}/{(len(unmatched_prefixes) - 1) // items_per_page + 1}" + dialog_win.addstr(dialog_h - 1, dialog_w - len(page_info) - 2, page_info) + + dialog_win.refresh() + + # Main interaction loop + while True: + draw_prefix_list() + + key = dialog_win.getch() + + if key == 27: # ESC + return None + elif key == curses.KEY_UP and selected_idx > 0: + selected_idx -= 1 + if selected_idx < current_page * items_per_page: + current_page -= 1 + elif key == curses.KEY_DOWN and selected_idx < len(unmatched_prefixes) - 1: + selected_idx += 1 + if selected_idx >= (current_page + 1) * items_per_page: + current_page += 1 + elif key in (10, 13): # Enter + return unmatched_prefixes[selected_idx] + elif key == curses.KEY_NPAGE and current_page < (len(unmatched_prefixes) - 1) // items_per_page: + # Page Down + current_page += 1 + selected_idx = min(selected_idx + items_per_page, len(unmatched_prefixes) - 1) + elif key == curses.KEY_PPAGE and current_page > 0: + # Page Up + current_page -= 1 + selected_idx = max(selected_idx - items_per_page, 0) + +def select_individual_files_dialog(stdscr, selector: ConfigSelector, prefix: str, view_mode: str): + """Show dialog to select individual files from a prefix.""" + h, w = stdscr.getmaxyx() + + # Get the list of files for this prefix + if view_mode == "matched": + files = selector.matched_files.get(prefix, []) + title = f"Select files to remove from '{prefix}'" + action_text = "SPACE: Toggle selection | ENTER: Confirm | ESC: Cancel" + else: + files = selector.unmatched_files.get(prefix, []) + title = f"Select files to add from '{prefix}'" + action_text = "SPACE: Toggle selection | ENTER: Add selected | ESC: Cancel" + + if not files: + # Show message if no files + message_win = curses.newwin(3, 40, (h - 3) // 2, (w - 40) // 2) + message_win.box() + message_win.addstr(1, 2, "No files available.") + message_win.refresh() + message_win.getch() + return [] + + # Sort files for consistent display + files = sorted(files) + + # Track selected files + selected_files = set() + + # Calculate dialog dimensions + dialog_h = min(20, len(files) + 5) # +5 for title, instructions, pagination, and border + dialog_w = min(w - 4, 80) + dialog_y = (h - dialog_h) // 2 + dialog_x = (w - dialog_w) // 2 + + # Draw dialog box + dialog_win = curses.newwin(dialog_h, dialog_w, dialog_y, dialog_x) + dialog_win.keypad(True) # Enable arrow keys + + # Initialize selection variables + selected_idx = 0 + current_page = 0 + items_per_page = dialog_h - 5 # -5 for title, instructions, actions, and border + + # Draw function + def draw_file_list(): + dialog_win.clear() + dialog_win.box() + dialog_win.addstr(1, 2, title, curses.A_BOLD) + dialog_win.addstr(dialog_h - 2, 2, action_text) + + # Calculate page items + start_idx = current_page * items_per_page + end_idx = min(start_idx + items_per_page, len(files)) + + # Display files with selection highlight and checkbox + for i, file in enumerate(files[start_idx:end_idx]): + y = i + 3 # +3 for title, blank line, and border + + # Create checkbox display + checkbox = "[X]" if file in selected_files else "[ ]" + + # Truncate filename if too long + max_width = dialog_w - 8 # Allow for padding and checkbox + if len(file) > max_width: + file_display = file[:max_width-3] + "..." + else: + file_display = file + + item_text = f"{checkbox} {file_display}" + + if i + start_idx == selected_idx: + dialog_win.addstr(y, 2, item_text, curses.A_REVERSE) + else: + dialog_win.addstr(y, 2, item_text) + + # Show pagination info if needed + if len(files) > items_per_page: + page_info = f"Page {current_page + 1}/{(len(files) - 1) // items_per_page + 1}" + dialog_win.addstr(dialog_h - 3, dialog_w - len(page_info) - 2, page_info) + + dialog_win.refresh() + + # Main interaction loop + while True: + draw_file_list() + + key = dialog_win.getch() + + if key == 27: # ESC + return [] + elif key == curses.KEY_UP and selected_idx > 0: + selected_idx -= 1 + if selected_idx < current_page * items_per_page: + current_page -= 1 + elif key == curses.KEY_DOWN and selected_idx < len(files) - 1: + selected_idx += 1 + if selected_idx >= (current_page + 1) * items_per_page: + current_page += 1 + elif key == ord(' '): # SPACE to toggle selection + current_file = files[selected_idx] + if current_file in selected_files: + selected_files.remove(current_file) + else: + selected_files.add(current_file) + elif key in (10, 13): # Enter to confirm + return list(selected_files) + elif key == curses.KEY_NPAGE and current_page < (len(files) - 1) // items_per_page: + # Page Down + current_page += 1 + selected_idx = min(selected_idx + items_per_page, len(files) - 1) + elif key == curses.KEY_PPAGE and current_page > 0: + # Page Up + current_page -= 1 + selected_idx = max(selected_idx - items_per_page, 0) + +def has_child_segments(item, mode, selector): + """ + Check if the given item has child segments. + + Args: + item: Current prefix path (e.g., "field.storage") + mode: View mode ("matched" or "unmatched") + selector: The ConfigSelector instance + + Returns: + True if child segments exist, False otherwise + """ + if mode == "matched": + # For matched prefixes + for prefix in selector.prefixes: + if prefix.startswith(item + '.'): + return True + return False + else: + # For unmatched files, check if any filenames have one more segment + selected_segments = item.split('.') + segment_count = len(selected_segments) + + # Look through all files in all unmatched prefixes + for prefix, file_list in selector.unmatched_files.items(): + for filename in file_list: + # Remove .yml extension for better segment counting + if filename.endswith('.yml'): + filename = filename[:-4] + + filename_parts = filename.split('.') + # Check if this file has this prefix plus at least one more segment + if (len(filename_parts) > segment_count and + '.'.join(filename_parts[:segment_count]) == item): + return True + return False + +def main(stdscr): + # Set paths relative to current directory + json_path = os.path.join(BASE_DIR, "config_prefixes.json") + original_config_path = os.path.join(BASE_DIR, "original_config") + config_path = os.path.join(BASE_DIR, "config") + + # Create directories if they don't exist + if not os.path.exists(original_config_path): + os.makedirs(original_config_path) + print(f"Created directory: {original_config_path}") + print("Please place your source configuration files in this directory.") + + if not os.path.exists(config_path): + os.makedirs(config_path) + print(f"Created directory: {config_path}") + + # Check if we need to create default JSON file + if not os.path.exists(json_path): + with open(json_path, 'w') as f: + json.dump({ + "prefixes": [ + "put.your.prefixes.here", + ] + }, f, indent=2) + print(f"Created default {json_path}") + + # Initialize selector + selector = ConfigSelector( + json_path=json_path, + original_config_path=original_config_path, + config_path=config_path + ) + + # Menu state + selected_idx = 0 + view_mode = "matched" # "matched" or "unmatched" + current_prefix = "" # Current prefix path, e.g. "field.storage" + level = 0 # Current hierarchical level + + # Navigation history for going back + path_history = [] + + # Main loop + while True: + # Get segments at the current level for the current view + all_prefixes = selector.prefixes if view_mode == "matched" else list(selector.unmatched_files.keys()) + items = get_prefix_segments_at_level(all_prefixes, current_prefix, level, selector, view_mode) + + # Draw the menu + item_count = draw_menu(stdscr, selector, selected_idx, view_mode, current_prefix, level) + + if item_count == 0: + key = stdscr.getch() + if key == ord('q') or key == ord('Q'): + break + elif key == ord(' '): + view_mode = "unmatched" if view_mode == "matched" else "matched" + selected_idx = 0 + # Reset navigation when toggling view + current_prefix = "" + level = 0 + path_history = [] + elif key == ord('a') or key == ord('A'): + new_prefix = add_prefix_dialog(stdscr) + if new_prefix: + selector.add_prefix(new_prefix) + elif key == ord('o') or key == ord('O'): + new_prefix = choose_prefix_dialog(stdscr, selector) + if new_prefix: + selector.add_prefix(new_prefix) + elif key == curses.KEY_LEFT and (current_prefix or level > 0): + # Go back one level + if level > 0: + level -= 1 + if current_prefix: + # Remove last segment + parts = current_prefix.split('.') + current_prefix = '.'.join(parts[:-1]) + selected_idx = 0 + elif path_history: + # Pop from history + prev_prefix, prev_level, prev_idx = path_history.pop() + current_prefix = prev_prefix + level = prev_level + selected_idx = prev_idx + continue + + key = stdscr.getch() + + if key == ord('q') or key == ord('Q'): + break + elif key == curses.KEY_UP and selected_idx > 0: + selected_idx -= 1 + elif key == curses.KEY_DOWN and selected_idx < item_count - 1: + selected_idx += 1 + elif key == ord(' '): + view_mode = "unmatched" if view_mode == "matched" else "matched" + selected_idx = 0 + # Reset navigation when toggling view + current_prefix = "" + level = 0 + path_history = [] + elif key == curses.KEY_RIGHT: + # Drill down into the selected item + if selected_idx < len(items): + selected_item = items[selected_idx] + + # Check if there are child segments + if has_child_segments(selected_item, view_mode, selector): + # Save current state to history + path_history.append((current_prefix, level, selected_idx)) + + # Update current prefix and level + current_prefix = selected_item + level += 1 + selected_idx = 0 + elif key == curses.KEY_LEFT and (current_prefix or level > 0): + # Go back one level + if level > 0: + level -= 1 + if current_prefix: + # Remove last segment + parts = current_prefix.split('.') + current_prefix = '.'.join(parts[:-1]) + selected_idx = 0 + elif path_history: + # Pop from history + prev_prefix, prev_level, prev_idx = path_history.pop() + current_prefix = prev_prefix + level = prev_level + selected_idx = prev_idx + elif key == ord('a') or key == ord('A'): + new_prefix = add_prefix_dialog(stdscr) + if new_prefix: + selector.add_prefix(new_prefix) + elif key == ord('o') or key == ord('O'): + new_prefix = choose_prefix_dialog(stdscr, selector) + if new_prefix: + selector.add_prefix(new_prefix) + elif key == ord('d') or key == ord('D'): + if view_mode == "matched" and len(items) > 0 and selected_idx < len(items): + selected_item = items[selected_idx] + # Only allow deleting complete prefixes + if selected_item in selector.prefixes: + if confirm_dialog(stdscr, f"Remove prefix '{selected_item}'?"): + selector.remove_prefix(selected_item) + if selected_idx >= len(items) - 1: + selected_idx = max(0, len(items) - 1) + else: + # Allow adding the current selection (partial prefix) directly + if confirm_dialog(stdscr, f"Add current path '{selected_item}' as a matched prefix?"): + selector.add_prefix(selected_item) + view_mode = "matched" + current_prefix = "" + level = 0 + path_history = [] + selected_idx = 0 + elif view_mode == "unmatched" and len(items) > 0 and selected_idx < len(items): + # Add the item to matched prefixes + selected_item = items[selected_idx] + if confirm_dialog(stdscr, f"Add '{selected_item}' to matched prefixes?"): + selector.add_prefix(selected_item) + view_mode = "matched" + current_prefix = "" + level = 0 + path_history = [] + selected_idx = 0 + elif key == ord('c') or key == ord('C'): + if confirm_dialog(stdscr, "Copy all matched files to config directory?"): + selector.copy_matched_files() + elif key == ord('s') or key == ord('S'): + selector.save_prefixes() + elif key in (10, 13): # ENTER key to select individual files + if len(items) > 0 and selected_idx < len(items): + selected_item = items[selected_idx] + files = get_files_for_segment(selector, selected_item, view_mode) + + if files: + if view_mode == "matched": + # Check if this is a complete prefix in matched + if selected_item in selector.prefixes: + selected_files = select_individual_files_dialog(stdscr, selector, selected_item, view_mode) + if selected_files and confirm_dialog(stdscr, f"Remove {len(selected_files)} files from matched?"): + # Remove individual files from matched_files + for file in selected_files: + if file in selector.matched_files[selected_item]: + selector.matched_files[selected_item].remove(file) + else: + # Show message that you can only select files from complete prefixes + h, w = stdscr.getmaxyx() + message_win = curses.newwin(3, 60, (h - 3) // 2, (w - 60) // 2) + message_win.box() + message_win.addstr(1, 2, "Can only select files from complete prefixes.") + message_win.refresh() + message_win.getch() + else: # Unmatched view + # Find a matching prefix to add files to + target_prefix = None + for prefix in selector.prefixes: + if selected_item.startswith(prefix) or prefix.startswith(selected_item): + target_prefix = prefix + break + + if not target_prefix: + if confirm_dialog(stdscr, f"Add prefix '{selected_item}' to matched prefixes?"): + selector.add_prefix(selected_item) + target_prefix = selected_item + + if target_prefix: + file_selection = select_individual_files_dialog(stdscr, selector, selected_item, view_mode) + if file_selection: + # Add individual files to matched_files + for file in file_selection: + if file not in selector.matched_files[target_prefix]: + selector.matched_files[target_prefix].append(file) + +if __name__ == "__main__": + try: + curses.wrapper(main) + except KeyboardInterrupt: + print("Program terminated by user")