first commit
This commit is contained in:
commit
5412ec1323
3 changed files with 1019 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config/**
|
||||
original_config/**
|
||||
config_prefixes.json
|
||||
99
README.md
Normal file
99
README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
917
config_selector.py
Executable file
917
config_selector.py
Executable file
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue