feat: Enhance export functionality with DataFrame support and UI improvements
This commit is contained in:
+15
-6
@@ -54,10 +54,12 @@ class ExportManager:
|
|||||||
self.pathology_manager = pathology_manager
|
self.pathology_manager = pathology_manager
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
def export_data_to_json(self, export_path: str) -> bool:
|
def export_data_to_json(
|
||||||
|
self, export_path: str, df: pd.DataFrame | None = None
|
||||||
|
) -> bool:
|
||||||
"""Export CSV data to JSON format."""
|
"""Export CSV data to JSON format."""
|
||||||
try:
|
try:
|
||||||
df = self.data_manager.load_data()
|
df = df if df is not None else self.data_manager.load_data()
|
||||||
if df.empty:
|
if df.empty:
|
||||||
self.logger.warning("No data to export")
|
self.logger.warning("No data to export")
|
||||||
return False
|
return False
|
||||||
@@ -87,10 +89,12 @@ class ExportManager:
|
|||||||
self.logger.error(f"Error exporting to JSON: {str(e)}")
|
self.logger.error(f"Error exporting to JSON: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def export_data_to_xml(self, export_path: str) -> bool:
|
def export_data_to_xml(
|
||||||
|
self, export_path: str, df: pd.DataFrame | None = None
|
||||||
|
) -> bool:
|
||||||
"""Export CSV data to XML format."""
|
"""Export CSV data to XML format."""
|
||||||
try:
|
try:
|
||||||
df = self.data_manager.load_data()
|
df = df if df is not None else self.data_manager.load_data()
|
||||||
if df.empty:
|
if df.empty:
|
||||||
self.logger.warning("No data to export")
|
self.logger.warning("No data to export")
|
||||||
return False
|
return False
|
||||||
@@ -203,10 +207,15 @@ class ExportManager:
|
|||||||
self.logger.error(f"Error saving graph image: {str(e)}")
|
self.logger.error(f"Error saving graph image: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def export_to_pdf(self, export_path: str, include_graph: bool = True) -> bool:
|
def export_to_pdf(
|
||||||
|
self,
|
||||||
|
export_path: str,
|
||||||
|
include_graph: bool = True,
|
||||||
|
df: pd.DataFrame | None = None,
|
||||||
|
) -> bool:
|
||||||
"""Export data and optionally graph to PDF format."""
|
"""Export data and optionally graph to PDF format."""
|
||||||
try:
|
try:
|
||||||
df = self.data_manager.load_data()
|
df = df if df is not None else self.data_manager.load_data()
|
||||||
|
|
||||||
# Create PDF document in landscape format for better table/graph display
|
# Create PDF document in landscape format for better table/graph display
|
||||||
doc = SimpleDocTemplate(
|
doc = SimpleDocTemplate(
|
||||||
|
|||||||
+36
-4
@@ -5,6 +5,7 @@ Provides a GUI interface for exporting data and graphs to various formats.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
@@ -14,9 +15,15 @@ from export_manager import ExportManager
|
|||||||
class ExportWindow:
|
class ExportWindow:
|
||||||
"""Export window for data and graph export functionality."""
|
"""Export window for data and graph export functionality."""
|
||||||
|
|
||||||
def __init__(self, parent: tk.Tk, export_manager: ExportManager) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: tk.Tk,
|
||||||
|
export_manager: ExportManager,
|
||||||
|
get_current_filtered_df: Callable[[], object] | None = None,
|
||||||
|
) -> None:
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.export_manager = export_manager
|
self.export_manager = export_manager
|
||||||
|
self._get_current_filtered_df = get_current_filtered_df
|
||||||
|
|
||||||
# Create the export window
|
# Create the export window
|
||||||
self.window = tk.Toplevel(parent)
|
self.window = tk.Toplevel(parent)
|
||||||
@@ -113,6 +120,21 @@ Medicines: {", ".join(export_info["medicines"])}"""
|
|||||||
)
|
)
|
||||||
graph_check.pack(anchor=tk.W, pady=(0, 10))
|
graph_check.pack(anchor=tk.W, pady=(0, 10))
|
||||||
|
|
||||||
|
# Export scope option
|
||||||
|
self.scope_var = tk.StringVar(value="all")
|
||||||
|
scope_frame = ttk.Frame(options_frame)
|
||||||
|
scope_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT)
|
||||||
|
ttk.Radiobutton(
|
||||||
|
scope_frame, text="All data", variable=self.scope_var, value="all"
|
||||||
|
).pack(side=tk.LEFT, padx=10)
|
||||||
|
ttk.Radiobutton(
|
||||||
|
scope_frame,
|
||||||
|
text="Current (filtered) view",
|
||||||
|
variable=self.scope_var,
|
||||||
|
value="filtered",
|
||||||
|
).pack(side=tk.LEFT)
|
||||||
|
|
||||||
# Format selection
|
# Format selection
|
||||||
format_label = ttk.Label(options_frame, text="Export Format:")
|
format_label = ttk.Label(options_frame, text="Export Format:")
|
||||||
format_label.pack(anchor=tk.W)
|
format_label.pack(anchor=tk.W)
|
||||||
@@ -182,17 +204,27 @@ Medicines: {", ".join(export_info["medicines"])}"""
|
|||||||
if not filename:
|
if not filename:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Determine scope DataFrame (if requested and available)
|
||||||
|
scoped_df = None
|
||||||
|
if self.scope_var.get() == "filtered" and self._get_current_filtered_df:
|
||||||
|
try:
|
||||||
|
scoped_df = self._get_current_filtered_df()
|
||||||
|
except Exception:
|
||||||
|
scoped_df = None
|
||||||
|
|
||||||
# Perform export based on selected format
|
# Perform export based on selected format
|
||||||
success = False
|
success = False
|
||||||
try:
|
try:
|
||||||
if selected_format == "JSON":
|
if selected_format == "JSON":
|
||||||
success = self.export_manager.export_data_to_json(filename)
|
success = self.export_manager.export_data_to_json(
|
||||||
|
filename, df=scoped_df
|
||||||
|
)
|
||||||
elif selected_format == "XML":
|
elif selected_format == "XML":
|
||||||
success = self.export_manager.export_data_to_xml(filename)
|
success = self.export_manager.export_data_to_xml(filename, df=scoped_df)
|
||||||
elif selected_format == "PDF":
|
elif selected_format == "PDF":
|
||||||
include_graph = self.include_graph_var.get()
|
include_graph = self.include_graph_var.get()
|
||||||
success = self.export_manager.export_to_pdf(
|
success = self.export_manager.export_to_pdf(
|
||||||
filename, include_graph=include_graph
|
filename, include_graph=include_graph, df=scoped_df
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
+14
-1
@@ -681,7 +681,20 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
|||||||
def _open_export_window(self) -> None:
|
def _open_export_window(self) -> None:
|
||||||
"""Open the export window."""
|
"""Open the export window."""
|
||||||
self.ui_manager.update_status("Opening export window", "info")
|
self.ui_manager.update_status("Opening export window", "info")
|
||||||
ExportWindow(self.root, self.export_manager)
|
|
||||||
|
def _get_current_filtered_df():
|
||||||
|
try:
|
||||||
|
if self.current_filtered_data is not None:
|
||||||
|
return self.current_filtered_data
|
||||||
|
# If no live filtered DF, but filters are active, compute one-off
|
||||||
|
if self.data_filter.get_filter_summary().get("has_filters"):
|
||||||
|
df = self.data_manager.load_data()
|
||||||
|
return self.data_filter.apply_filters(df)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
ExportWindow(self.root, self.export_manager, _get_current_filtered_df)
|
||||||
if hasattr(self.ui_manager, "show_toast"):
|
if hasattr(self.ui_manager, "show_toast"):
|
||||||
self.ui_manager.show_toast("Export window opened", 1200)
|
self.ui_manager.show_toast("Export window opened", 1200)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ _DEFAULTS: dict[str, Any] = {
|
|||||||
"last_window_geometry": "",
|
"last_window_geometry": "",
|
||||||
# Keep window always on top
|
# Keep window always on top
|
||||||
"always_on_top": False,
|
"always_on_top": False,
|
||||||
|
# Search/filter UI state
|
||||||
|
"search_panel_visible": False,
|
||||||
|
"last_filter_state": None,
|
||||||
|
# Table column UX
|
||||||
|
"column_widths": {},
|
||||||
|
"last_sort": {"column": None, "ascending": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
_PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
|
_PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
|
||||||
|
|||||||
+120
-6
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from tkinter import ttk
|
from tkinter import messagebox, simpledialog, ttk
|
||||||
|
|
||||||
from init import logger
|
from init import logger
|
||||||
|
from preferences import get_pref, save_preferences, set_pref
|
||||||
from search_filter import DataFilter, QuickFilters, SearchHistory
|
from search_filter import DataFilter, QuickFilters, SearchHistory
|
||||||
|
|
||||||
|
|
||||||
@@ -41,10 +42,10 @@ class SearchFilterWidget:
|
|||||||
# Visibility and UI init state
|
# Visibility and UI init state
|
||||||
self.is_visible = False
|
self.is_visible = False
|
||||||
self._ui_initialized = False
|
self._ui_initialized = False
|
||||||
self.frame: ttk.LabelFrame | None = None
|
self.frame = None
|
||||||
|
|
||||||
# Debouncing mechanism to reduce filter update frequency
|
# Debouncing mechanism to reduce filter update frequency
|
||||||
self._update_timer: str | None = None
|
self._update_timer = None
|
||||||
self._debounce_delay = 450 # milliseconds
|
self._debounce_delay = 450 # milliseconds
|
||||||
|
|
||||||
# History and UI state variables
|
# History and UI state variables
|
||||||
@@ -53,10 +54,13 @@ class SearchFilterWidget:
|
|||||||
self.start_date_var = tk.StringVar()
|
self.start_date_var = tk.StringVar()
|
||||||
self.end_date_var = tk.StringVar()
|
self.end_date_var = tk.StringVar()
|
||||||
|
|
||||||
|
# Presets state
|
||||||
|
self.preset_var = tk.StringVar()
|
||||||
|
|
||||||
# Medicine and pathology filter variables
|
# Medicine and pathology filter variables
|
||||||
self.medicine_vars: dict[str, tk.StringVar] = {}
|
self.medicine_vars = {}
|
||||||
self.pathology_min_vars: dict[str, tk.StringVar] = {}
|
self.pathology_min_vars = {}
|
||||||
self.pathology_max_vars: dict[str, tk.StringVar] = {}
|
self.pathology_max_vars = {}
|
||||||
|
|
||||||
def _setup_ui(self) -> None:
|
def _setup_ui(self) -> None:
|
||||||
"""Set up the search and filter UI."""
|
"""Set up the search and filter UI."""
|
||||||
@@ -68,9 +72,29 @@ class SearchFilterWidget:
|
|||||||
content_frame.pack(fill="both", expand=True)
|
content_frame.pack(fill="both", expand=True)
|
||||||
|
|
||||||
# Top row: Search and Quick filters
|
# Top row: Search and Quick filters
|
||||||
|
# Top row: Presets, Search and Quick filters
|
||||||
top_row = ttk.Frame(content_frame)
|
top_row = ttk.Frame(content_frame)
|
||||||
top_row.pack(fill="x", pady=(0, 5))
|
top_row.pack(fill="x", pady=(0, 5))
|
||||||
|
|
||||||
|
# Presets section (leftmost)
|
||||||
|
presets_frame = ttk.Frame(top_row)
|
||||||
|
presets_frame.pack(side="left", padx=(0, 10))
|
||||||
|
ttk.Label(presets_frame, text="Preset:").pack(side="left")
|
||||||
|
self.preset_combo = ttk.Combobox(
|
||||||
|
presets_frame, textvariable=self.preset_var, state="readonly", width=18
|
||||||
|
)
|
||||||
|
self._refresh_presets_combo()
|
||||||
|
self.preset_combo.pack(side="left", padx=(5, 5))
|
||||||
|
ttk.Button(presets_frame, text="Load", command=self._load_preset).pack(
|
||||||
|
side="left", padx=(0, 2)
|
||||||
|
)
|
||||||
|
ttk.Button(presets_frame, text="Save", command=self._save_preset).pack(
|
||||||
|
side="left", padx=(0, 2)
|
||||||
|
)
|
||||||
|
ttk.Button(presets_frame, text="Delete", command=self._delete_preset).pack(
|
||||||
|
side="left"
|
||||||
|
)
|
||||||
|
|
||||||
# Search section (left side of top row)
|
# Search section (left side of top row)
|
||||||
search_frame = ttk.Frame(top_row)
|
search_frame = ttk.Frame(top_row)
|
||||||
search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
@@ -438,6 +462,96 @@ class SearchFilterWidget:
|
|||||||
|
|
||||||
self.status_label.config(text=status_text)
|
self.status_label.config(text=status_text)
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Presets management
|
||||||
|
# ---------------------
|
||||||
|
def _refresh_presets_combo(self) -> None:
|
||||||
|
presets = get_pref("filter_presets", {}) or {}
|
||||||
|
names = sorted(presets.keys())
|
||||||
|
if hasattr(self, "preset_combo") and self.preset_combo:
|
||||||
|
self.preset_combo["values"] = names
|
||||||
|
if names and not self.preset_var.get():
|
||||||
|
self.preset_var.set(names[0])
|
||||||
|
|
||||||
|
def _apply_filter_summary(self, summary: dict) -> None:
|
||||||
|
"""Apply a saved summary dict into the DataFilter and UI, then update."""
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
return
|
||||||
|
# Clear then set pieces
|
||||||
|
self.data_filter.clear_all_filters()
|
||||||
|
self.data_filter.set_search_term(summary.get("search_term", ""))
|
||||||
|
filt = summary.get("filters", {}) or {}
|
||||||
|
# Date
|
||||||
|
date_rng = filt.get("date_range") or {}
|
||||||
|
self.data_filter.set_date_range_filter(
|
||||||
|
date_rng.get("start") or None, date_rng.get("end") or None
|
||||||
|
)
|
||||||
|
# Medicines
|
||||||
|
meds = filt.get("medicines") or {}
|
||||||
|
for key in meds.get("taken", []) or []:
|
||||||
|
self.data_filter.set_medicine_filter(key, True)
|
||||||
|
for key in meds.get("not_taken", []) or []:
|
||||||
|
self.data_filter.set_medicine_filter(key, False)
|
||||||
|
# Pathologies
|
||||||
|
paths = filt.get("pathologies") or {}
|
||||||
|
for key, range_text in paths.items():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
s = str(range_text)
|
||||||
|
parts = s.split("-")
|
||||||
|
mn = parts[0].strip() if parts else ""
|
||||||
|
mx = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
mn_i = int(mn) if mn and mn.lower() != "any" else None
|
||||||
|
mx_i = int(mx) if mx and mx.lower() != "any" else None
|
||||||
|
self.data_filter.set_pathology_range_filter(key, mn_i, mx_i)
|
||||||
|
# Sync UI and notify
|
||||||
|
self.sync_ui_from_filter()
|
||||||
|
self.update_callback()
|
||||||
|
|
||||||
|
def _load_preset(self) -> None:
|
||||||
|
name = self.preset_var.get().strip()
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
presets = get_pref("filter_presets", {}) or {}
|
||||||
|
summary = presets.get(name)
|
||||||
|
if not summary:
|
||||||
|
messagebox.showwarning("Preset", f"Preset '{name}' not found.")
|
||||||
|
return
|
||||||
|
self._apply_filter_summary(summary)
|
||||||
|
|
||||||
|
def _save_preset(self) -> None:
|
||||||
|
# Ask for a name
|
||||||
|
name = simpledialog.askstring("Save Preset", "Preset name:", parent=self.parent)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
presets = get_pref("filter_presets", {}) or {}
|
||||||
|
presets[name] = self.data_filter.get_filter_summary()
|
||||||
|
set_pref("filter_presets", presets)
|
||||||
|
save_preferences()
|
||||||
|
self._refresh_presets_combo()
|
||||||
|
self.preset_var.set(name)
|
||||||
|
self._update_status()
|
||||||
|
|
||||||
|
def _delete_preset(self) -> None:
|
||||||
|
name = self.preset_var.get().strip()
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Delete Preset", f"Delete preset '{name}'?", parent=self.parent
|
||||||
|
):
|
||||||
|
return
|
||||||
|
presets = get_pref("filter_presets", {}) or {}
|
||||||
|
if name in presets:
|
||||||
|
del presets[name]
|
||||||
|
set_pref("filter_presets", presets)
|
||||||
|
save_preferences()
|
||||||
|
self.preset_var.set("")
|
||||||
|
self._refresh_presets_combo()
|
||||||
|
|
||||||
def get_widget(self) -> ttk.LabelFrame | None:
|
def get_widget(self) -> ttk.LabelFrame | None:
|
||||||
"""Get the main widget for embedding in UI (may be None until shown)."""
|
"""Get the main widget for embedding in UI (may be None until shown)."""
|
||||||
return self.frame
|
return self.frame
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from PIL import Image, ImageTk
|
|||||||
|
|
||||||
from medicine_manager import MedicineManager
|
from medicine_manager import MedicineManager
|
||||||
from pathology_manager import PathologyManager
|
from pathology_manager import PathologyManager
|
||||||
|
from preferences import get_pref, save_preferences, set_pref
|
||||||
from tooltip_system import TooltipManager
|
from tooltip_system import TooltipManager
|
||||||
|
|
||||||
|
|
||||||
@@ -410,6 +411,28 @@ class UIManager:
|
|||||||
for col, width, anchor in col_settings:
|
for col, width, anchor in col_settings:
|
||||||
tree.column(col, width=width, anchor=anchor)
|
tree.column(col, width=width, anchor=anchor)
|
||||||
|
|
||||||
|
# Apply saved column widths if available
|
||||||
|
try:
|
||||||
|
saved_widths = get_pref("column_widths", {}) or {}
|
||||||
|
if isinstance(saved_widths, dict):
|
||||||
|
for col in tree["columns"]:
|
||||||
|
w = saved_widths.get(col)
|
||||||
|
if isinstance(w, int) and w > 0:
|
||||||
|
tree.column(col, width=w)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Initialize last sort from preferences
|
||||||
|
try:
|
||||||
|
last_sort = get_pref("last_sort", {}) or {}
|
||||||
|
col = last_sort.get("column")
|
||||||
|
asc = last_sort.get("ascending", True)
|
||||||
|
if col in tree["columns"]:
|
||||||
|
self._last_sorted_column = col
|
||||||
|
self._last_sorted_ascending = bool(asc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
tree.pack(side="left", fill="both", expand=True)
|
tree.pack(side="left", fill="both", expand=True)
|
||||||
|
|
||||||
# Add scrollbars with optimized scroll handling
|
# Add scrollbars with optimized scroll handling
|
||||||
@@ -422,6 +445,9 @@ class UIManager:
|
|||||||
# Optimize tree scrolling performance
|
# Optimize tree scrolling performance
|
||||||
self._optimize_tree_scrolling(tree)
|
self._optimize_tree_scrolling(tree)
|
||||||
|
|
||||||
|
# Install debounced save of column widths
|
||||||
|
self._install_column_width_persistence(tree)
|
||||||
|
|
||||||
return {"frame": table_frame, "tree": tree}
|
return {"frame": table_frame, "tree": tree}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -462,6 +488,13 @@ class UIManager:
|
|||||||
# Re-apply alternating row tags after sort
|
# Re-apply alternating row tags after sort
|
||||||
self.normalize_tree_stripes(tree)
|
self.normalize_tree_stripes(tree)
|
||||||
|
|
||||||
|
# Persist last sort
|
||||||
|
try:
|
||||||
|
set_pref("last_sort", {"column": column, "ascending": ascending})
|
||||||
|
save_preferences()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _sort_tree_column_direction(
|
def _sort_tree_column_direction(
|
||||||
self, tree: ttk.Treeview, column: str, ascending: bool
|
self, tree: ttk.Treeview, column: str, ascending: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -572,6 +605,35 @@ class UIManager:
|
|||||||
# Ensure alternating stripes are normalized after updates
|
# Ensure alternating stripes are normalized after updates
|
||||||
self.normalize_tree_stripes(tree)
|
self.normalize_tree_stripes(tree)
|
||||||
|
|
||||||
|
# --- Column width persistence helpers ---
|
||||||
|
def _install_column_width_persistence(self, tree: ttk.Treeview) -> None:
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
self._col_width_save_after_id = None
|
||||||
|
|
||||||
|
def _debounced_save(*_args):
|
||||||
|
if getattr(self, "_col_width_save_after_id", None):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.root.after_cancel(self._col_width_save_after_id)
|
||||||
|
self._col_width_save_after_id = self.root.after(600, _save_now)
|
||||||
|
|
||||||
|
def _save_now():
|
||||||
|
widths = {}
|
||||||
|
for col in tree["columns"]:
|
||||||
|
try:
|
||||||
|
widths[col] = int(tree.column(col, option="width"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
set_pref("column_widths", widths)
|
||||||
|
save_preferences()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._col_width_save_after_id = None
|
||||||
|
|
||||||
|
tree.bind("<ButtonRelease-1>", _debounced_save, add="+")
|
||||||
|
tree.bind("<Configure>", _debounced_save, add="+")
|
||||||
|
|
||||||
def normalize_tree_stripes(self, tree: ttk.Treeview) -> None:
|
def normalize_tree_stripes(self, tree: ttk.Treeview) -> None:
|
||||||
"""Normalize alternating row tags based on current visual order.
|
"""Normalize alternating row tags based on current visual order.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user