pomera-ai-commander 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -324,8 +324,9 @@ class MigrationManager:
324
324
  """
325
325
  try:
326
326
  with self.connection_manager.transaction() as conn:
327
- # Clear existing data
328
- self._clear_all_tables(conn)
327
+ # NOTE: Do NOT clear tables - use INSERT OR REPLACE for upsert semantics
328
+ # The _clear_all_tables() call was removed because it caused data loss
329
+ # when save_settings() was called with incomplete/empty data
329
330
 
330
331
  # Migrate core settings
331
332
  self._migrate_core_settings(conn, json_data)
@@ -432,7 +433,7 @@ class MigrationManager:
432
433
  serialized_value = self.converter.serialize_value(value)
433
434
 
434
435
  conn.execute(
435
- "INSERT INTO core_settings (key, value, data_type) VALUES (?, ?, ?)",
436
+ "INSERT OR REPLACE INTO core_settings (key, value, data_type) VALUES (?, ?, ?)",
436
437
  (key, serialized_value, data_type)
437
438
  )
438
439
 
@@ -454,7 +455,7 @@ class MigrationManager:
454
455
  serialized_value = self.converter.serialize_value(value)
455
456
 
456
457
  conn.execute(
457
- "INSERT INTO tool_settings (tool_name, setting_path, setting_value, data_type) VALUES (?, ?, ?, ?)",
458
+ "INSERT OR REPLACE INTO tool_settings (tool_name, setting_path, setting_value, data_type) VALUES (?, ?, ?, ?)",
458
459
  (tool_name, setting_path, serialized_value, data_type)
459
460
  )
460
461
  else:
@@ -463,7 +464,7 @@ class MigrationManager:
463
464
  serialized_value = self.converter.serialize_value(tool_config)
464
465
 
465
466
  conn.execute(
466
- "INSERT INTO tool_settings (tool_name, setting_path, setting_value, data_type) VALUES (?, ?, ?, ?)",
467
+ "INSERT OR REPLACE INTO tool_settings (tool_name, setting_path, setting_value, data_type) VALUES (?, ?, ?, ?)",
467
468
  (tool_name, 'value', serialized_value, data_type)
468
469
  )
469
470
 
@@ -480,7 +481,7 @@ class MigrationManager:
480
481
  input_tabs = json_data['input_tabs']
481
482
  for i, content in enumerate(input_tabs):
482
483
  conn.execute(
483
- "INSERT INTO tab_content (tab_type, tab_index, content) VALUES (?, ?, ?)",
484
+ "INSERT OR REPLACE INTO tab_content (tab_type, tab_index, content) VALUES (?, ?, ?)",
484
485
  ('input', i, content or '')
485
486
  )
486
487
 
@@ -489,7 +490,7 @@ class MigrationManager:
489
490
  output_tabs = json_data['output_tabs']
490
491
  for i, content in enumerate(output_tabs):
491
492
  conn.execute(
492
- "INSERT INTO tab_content (tab_type, tab_index, content) VALUES (?, ?, ?)",
493
+ "INSERT OR REPLACE INTO tab_content (tab_type, tab_index, content) VALUES (?, ?, ?)",
493
494
  ('output', i, content or '')
494
495
  )
495
496
 
@@ -511,7 +512,7 @@ class MigrationManager:
511
512
  serialized_value = self.converter.serialize_value(value)
512
513
 
513
514
  conn.execute(
514
- "INSERT INTO performance_settings (category, setting_key, setting_value, data_type) VALUES (?, ?, ?, ?)",
515
+ "INSERT OR REPLACE INTO performance_settings (category, setting_key, setting_value, data_type) VALUES (?, ?, ?, ?)",
515
516
  (category, setting_key, serialized_value, data_type)
516
517
  )
517
518
  else:
@@ -520,7 +521,7 @@ class MigrationManager:
520
521
  serialized_value = self.converter.serialize_value(settings)
521
522
 
522
523
  conn.execute(
523
- "INSERT INTO performance_settings (category, setting_key, setting_value, data_type) VALUES (?, ?, ?, ?)",
524
+ "INSERT OR REPLACE INTO performance_settings (category, setting_key, setting_value, data_type) VALUES (?, ?, ?, ?)",
524
525
  (category, 'value', serialized_value, data_type)
525
526
  )
526
527
 
@@ -539,7 +540,7 @@ class MigrationManager:
539
540
  serialized_value = self.converter.serialize_value(value)
540
541
 
541
542
  conn.execute(
542
- "INSERT INTO font_settings (font_type, property, value, data_type) VALUES (?, ?, ?, ?)",
543
+ "INSERT OR REPLACE INTO font_settings (font_type, property, value, data_type) VALUES (?, ?, ?, ?)",
543
544
  (font_type, property_name, serialized_value, data_type)
544
545
  )
545
546
 
@@ -560,7 +561,7 @@ class MigrationManager:
560
561
  self.logger.debug(f"Inserting dialog setting: {category}.{property_name} = {value} (type: {data_type})")
561
562
 
562
563
  conn.execute(
563
- "INSERT INTO dialog_settings (category, property, value, data_type) VALUES (?, ?, ?, ?)",
564
+ "INSERT OR REPLACE INTO dialog_settings (category, property, value, data_type) VALUES (?, ?, ?, ?)",
564
565
  (category, property_name, serialized_value, data_type)
565
566
  )
566
567
 
@@ -833,6 +833,14 @@ class SettingsDefaultsRegistry:
833
833
  "description": "Error messages for critical issues (cannot be disabled)",
834
834
  "examples": ["File not found", "Network error", "Invalid configuration"]
835
835
  }
836
+ },
837
+ # UI Layout Settings for tool search and collapsible panels
838
+ "ui_layout": {
839
+ "options_panel_collapsed": False,
840
+ "search_bar_collapsed": False,
841
+ "favorite_tools": [],
842
+ "recent_tools": [],
843
+ "recent_tools_max": 10
836
844
  }
837
845
  }
838
846
 
@@ -0,0 +1,417 @@
1
+ """
2
+ Tool Search Widget - Compact tool selector with popup dropdown.
3
+
4
+ This module provides a compact search bar that shows the current tool name
5
+ and opens a popup dropdown with search results when focused.
6
+
7
+ Author: Pomera AI Commander Team
8
+ """
9
+
10
+ import tkinter as tk
11
+ from tkinter import ttk
12
+ from typing import Optional, Callable, List, Dict, Any
13
+ import logging
14
+
15
+ # Try to import rapidfuzz for fuzzy matching
16
+ try:
17
+ from rapidfuzz import fuzz, process
18
+ RAPIDFUZZ_AVAILABLE = True
19
+ except ImportError:
20
+ RAPIDFUZZ_AVAILABLE = False
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ToolSearchPalette(ttk.Frame):
27
+ """
28
+ Compact tool selector with popup dropdown.
29
+
30
+ Features:
31
+ - Compact bar showing current tool name
32
+ - Popup dropdown appears on click/focus
33
+ - Fuzzy search with keyboard navigation
34
+ - Dropdown hides on selection or click outside
35
+
36
+ Layout:
37
+ [🔍 Current Tool Name (Ctrl+K)]
38
+ ↓ (on focus)
39
+ ┌────────────────────────────────────┐
40
+ │ Search: [_____________] │
41
+ ├────────────────────────────────────┤
42
+ │ ⭐ Case Tool │
43
+ │ Email Extraction - Extract... │
44
+ │ Hash Generator - Generate... │
45
+ └────────────────────────────────────┘
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ parent: tk.Widget,
51
+ tool_loader: Any,
52
+ on_tool_selected: Callable[[str], None],
53
+ settings: Optional[Dict[str, Any]] = None,
54
+ on_settings_change: Optional[Callable[[Dict[str, Any]], None]] = None,
55
+ **kwargs
56
+ ):
57
+ super().__init__(parent, **kwargs)
58
+
59
+ self._tool_loader = tool_loader
60
+ self._on_tool_selected = on_tool_selected
61
+ self._settings = settings or {}
62
+ self._on_settings_change = on_settings_change
63
+
64
+ # Get UI layout settings
65
+ ui_layout = self._settings.get("ui_layout", {})
66
+ self._favorites: List[str] = list(ui_layout.get("favorite_tools", []))
67
+ self._recent: List[str] = list(ui_layout.get("recent_tools", []))
68
+ self._recent_max = ui_layout.get("recent_tools_max", 10)
69
+
70
+ # Current state
71
+ self._current_tool: str = self._settings.get("selected_tool", "Case Tool")
72
+ self._filtered_tools: List[str] = []
73
+ self._selected_index: int = 0
74
+ self._popup: Optional[tk.Toplevel] = None
75
+ self._popup_listbox: Optional[tk.Listbox] = None
76
+
77
+ self._create_widgets()
78
+
79
+ def _create_widgets(self) -> None:
80
+ """Create the compact search bar (centered, half-width like VS Code)."""
81
+ # Centering frame
82
+ self.center_frame = ttk.Frame(self)
83
+ self.center_frame.pack(expand=True) # Centers horizontally
84
+
85
+ # Main bar frame (fixed width, centered)
86
+ self.bar_frame = ttk.Frame(self.center_frame)
87
+ self.bar_frame.pack()
88
+
89
+ # "Search Tools" label instead of icon
90
+ self.icon_label = ttk.Label(self.bar_frame, text="Search Tools", cursor="hand2")
91
+ self.icon_label.pack(side=tk.LEFT, padx=(5, 0))
92
+ self.icon_label.bind("<Button-1>", self._on_bar_click)
93
+
94
+ # StringVar for reliable text change detection
95
+ self._search_var = tk.StringVar(value=self._current_tool)
96
+ self._search_var.trace_add("write", self._on_search_change)
97
+
98
+ # Current tool name (wider entry for dropdown width + Ctrl+K hint space)
99
+ self.tool_entry = ttk.Entry(self.bar_frame, textvariable=self._search_var, width=65)
100
+ self.tool_entry.pack(side=tk.LEFT, padx=5)
101
+
102
+ # Make entry clickable to show dropdown
103
+ self.tool_entry.bind("<FocusIn>", self._on_entry_focus)
104
+ self.tool_entry.bind("<Return>", self._on_enter)
105
+ self.tool_entry.bind("<Escape>", self._hide_popup)
106
+ self.tool_entry.bind("<Down>", self._on_key_down)
107
+ self.tool_entry.bind("<Up>", self._on_key_up)
108
+
109
+ # Shortcut button (clickable, focuses search)
110
+ self.hint_button = ttk.Button(self.bar_frame, text="(Ctrl+K)", width=8,
111
+ command=self.focus_search)
112
+ self.hint_button.pack(side=tk.RIGHT, padx=(0, 5))
113
+
114
+ # Bind to MAIN WINDOW Configure event for resize/move handling
115
+ self.winfo_toplevel().bind("<Configure>", self._on_main_window_configure, add="+")
116
+
117
+ def _on_search_change(self, *args) -> None:
118
+ """Handle text changes in search entry (via StringVar trace)."""
119
+ # Show popup if not visible
120
+ if not (self._popup and self._popup.winfo_exists()):
121
+ self._show_popup()
122
+ else:
123
+ self._update_popup_list()
124
+
125
+ def _on_main_window_configure(self, event=None) -> None:
126
+ """Handle main window resize/move - hide popup to prevent floating."""
127
+ if self._popup and self._popup.winfo_exists():
128
+ self._hide_popup()
129
+
130
+ def _on_bar_click(self, event=None) -> None:
131
+ """Handle click on the bar - show dropdown."""
132
+ self.tool_entry.focus_set()
133
+ self._show_popup()
134
+
135
+ def _on_configure(self, event=None) -> None:
136
+ """Handle window resize/move - hide popup to prevent floating."""
137
+ if self._popup and self._popup.winfo_exists():
138
+ self._hide_popup()
139
+
140
+ def _on_entry_focus(self, event=None) -> None:
141
+ """Handle focus on entry - clear text and show dropdown."""
142
+ # Clear search field to allow fresh search
143
+ self._search_var.set("")
144
+ self._show_popup()
145
+
146
+ def _show_popup(self) -> None:
147
+ """Show the dropdown popup below the search bar."""
148
+ if self._popup and self._popup.winfo_exists():
149
+ self._update_popup_list()
150
+ return
151
+
152
+ # Create popup window
153
+ self._popup = tk.Toplevel(self)
154
+ self._popup.wm_overrideredirect(True) # No window decorations
155
+ self._popup.wm_attributes("-topmost", True)
156
+
157
+ # Position below the entry
158
+ x = self.tool_entry.winfo_rootx()
159
+ y = self.tool_entry.winfo_rooty() + self.tool_entry.winfo_height()
160
+ width = max(400, self.tool_entry.winfo_width() + 50)
161
+ self._popup.geometry(f"{width}x250+{x}+{y}")
162
+
163
+ # Popup content frame
164
+ popup_frame = ttk.Frame(self._popup, relief="solid", borderwidth=1)
165
+ popup_frame.pack(fill=tk.BOTH, expand=True)
166
+
167
+ # Results listbox
168
+ self._popup_listbox = tk.Listbox(
169
+ popup_frame,
170
+ selectmode=tk.SINGLE,
171
+ font=("TkDefaultFont", 10),
172
+ activestyle="dotbox"
173
+ )
174
+ self._popup_listbox.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
175
+
176
+ # Scrollbar
177
+ scrollbar = ttk.Scrollbar(popup_frame, command=self._popup_listbox.yview)
178
+ scrollbar.place(relx=1.0, rely=0, relheight=1.0, anchor="ne")
179
+ self._popup_listbox.configure(yscrollcommand=scrollbar.set)
180
+
181
+ # Bind events
182
+ self._popup_listbox.bind("<Double-Button-1>", self._on_listbox_select)
183
+ self._popup_listbox.bind("<Return>", self._on_listbox_select)
184
+
185
+ # Close popup when clicking outside
186
+ self._popup.bind("<FocusOut>", self._on_popup_focus_out)
187
+ self.tool_entry.bind("<FocusOut>", self._on_entry_focus_out)
188
+
189
+ # Populate list
190
+ self._update_popup_list()
191
+
192
+ # Sub-tools that are nested within parent categories (for indentation)
193
+ SUB_TOOLS = {
194
+ "Slug Generator", "Hash Generator", "ASCII Art Generator",
195
+ "URL Link Extractor", "Regex Extractor", "Email Extraction",
196
+ "Word Frequency Counter", "HTML Tool",
197
+ }
198
+
199
+ def _update_popup_list(self) -> None:
200
+ """Update the popup listbox with filtered tools."""
201
+ if not self._popup_listbox:
202
+ return
203
+
204
+ # Get search query
205
+ query = self._search_var.get().strip()
206
+
207
+ # Get grouped tools (processing tools only, with parent-child hierarchy)
208
+ if self._tool_loader:
209
+ grouped_tools = self._tool_loader.get_grouped_tools()
210
+ all_tools = [t[0] for t in grouped_tools]
211
+ else:
212
+ grouped_tools = []
213
+ all_tools = []
214
+
215
+ # Filter by search query or show all grouped
216
+ if query and query != self._current_tool:
217
+ # Search mode - fuzzy search all tools
218
+ matched = self._fuzzy_search(all_tools, query)
219
+ self._filtered_tools = matched
220
+ # Build display with is_sub_tool info
221
+ tool_info = {t[0]: t[1] for t in grouped_tools}
222
+ display_tools = [(t, tool_info.get(t, False)) for t in matched]
223
+ else:
224
+ # Default mode - show grouped tools in order
225
+ display_tools = grouped_tools
226
+ self._filtered_tools = [t[0] for t in grouped_tools]
227
+
228
+ # Update listbox
229
+ self._popup_listbox.delete(0, tk.END)
230
+ for tool, is_sub_tool in display_tools:
231
+ # Format sub-tools with arrow prefix
232
+ if is_sub_tool:
233
+ indent = " ↳ " # Arrow prefix for sub-tools
234
+ else:
235
+ indent = ""
236
+
237
+ prefix = "⭐ " if tool in self._favorites else ""
238
+
239
+ # Get description
240
+ desc = ""
241
+ if self._tool_loader:
242
+ spec = self._tool_loader.get_tool_spec(tool)
243
+ if spec and spec.description:
244
+ desc = f" - {spec.description[:35]}..."
245
+
246
+ self._popup_listbox.insert(tk.END, f"{indent}{prefix}{tool}{desc}")
247
+
248
+ # Select first item
249
+ if self._filtered_tools:
250
+ self._selected_index = 0
251
+ self._popup_listbox.selection_set(0)
252
+
253
+ def _fuzzy_search(self, tools: List[str], query: str) -> List[str]:
254
+ """Perform fuzzy search on tool names."""
255
+ if not query:
256
+ return tools
257
+
258
+ if RAPIDFUZZ_AVAILABLE:
259
+ search_data = {}
260
+ for tool in tools:
261
+ search_text = tool
262
+ if self._tool_loader:
263
+ spec = self._tool_loader.get_tool_spec(tool)
264
+ if spec and spec.description:
265
+ search_text = f"{tool} {spec.description}"
266
+ # Use lowercase for case-insensitive matching
267
+ search_data[tool] = search_text.lower()
268
+
269
+ # Convert query to lowercase for case-insensitive search
270
+ results = process.extract(query.lower(), search_data, scorer=fuzz.WRatio, limit=15)
271
+ return [match[2] for match in results if match[1] >= 40]
272
+ else:
273
+ # Fallback substring matching
274
+ query_lower = query.lower()
275
+ matches = [(t, t.lower().find(query_lower)) for t in tools if query_lower in t.lower()]
276
+ matches.sort(key=lambda x: x[1])
277
+ return [m[0] for m in matches]
278
+
279
+ def _on_search_key(self, event=None) -> None:
280
+ """Handle key press in search entry."""
281
+ if event and event.keysym in ("Up", "Down", "Return", "Escape"):
282
+ return # Handled separately
283
+ self._update_popup_list()
284
+
285
+ def _on_key_down(self, event=None) -> str:
286
+ """Handle down arrow."""
287
+ if self._popup_listbox and self._filtered_tools:
288
+ if self._selected_index < len(self._filtered_tools) - 1:
289
+ self._selected_index += 1
290
+ self._popup_listbox.selection_clear(0, tk.END)
291
+ self._popup_listbox.selection_set(self._selected_index)
292
+ self._popup_listbox.see(self._selected_index)
293
+ return "break"
294
+
295
+ def _on_key_up(self, event=None) -> str:
296
+ """Handle up arrow."""
297
+ if self._popup_listbox and self._filtered_tools:
298
+ if self._selected_index > 0:
299
+ self._selected_index -= 1
300
+ self._popup_listbox.selection_clear(0, tk.END)
301
+ self._popup_listbox.selection_set(self._selected_index)
302
+ self._popup_listbox.see(self._selected_index)
303
+ return "break"
304
+
305
+ def _on_enter(self, event=None) -> str:
306
+ """Handle Enter key - select current tool."""
307
+ if self._filtered_tools and 0 <= self._selected_index < len(self._filtered_tools):
308
+ tool = self._filtered_tools[self._selected_index]
309
+ self._select_tool(tool)
310
+ return "break"
311
+
312
+ def _on_listbox_select(self, event=None) -> None:
313
+ """Handle listbox selection."""
314
+ if not self._popup_listbox:
315
+ return
316
+ selection = self._popup_listbox.curselection()
317
+ if selection and selection[0] < len(self._filtered_tools):
318
+ tool = self._filtered_tools[selection[0]]
319
+ self._select_tool(tool)
320
+
321
+ def _select_tool(self, tool_name: str) -> None:
322
+ """Select a tool and hide popup."""
323
+ self._current_tool = tool_name
324
+
325
+ # Update entry to show selected tool (use StringVar)
326
+ self._search_var.set(tool_name)
327
+
328
+ # Update recent tools
329
+ if tool_name in self._recent:
330
+ self._recent.remove(tool_name)
331
+ self._recent.insert(0, tool_name)
332
+ self._recent = self._recent[:self._recent_max]
333
+
334
+ # Hide popup
335
+ self._hide_popup()
336
+
337
+ # Remove focus from search entry so clicking again shows dropdown
338
+ self.winfo_toplevel().focus_set()
339
+
340
+ # Save settings
341
+ self._save_settings()
342
+
343
+ # Notify callback
344
+ if self._on_tool_selected:
345
+ try:
346
+ self._on_tool_selected(tool_name)
347
+ except Exception as e:
348
+ logger.error(f"Error in on_tool_selected callback: {e}")
349
+
350
+ def _hide_popup(self, event=None) -> str:
351
+ """Hide the popup dropdown and restore tool name."""
352
+ if self._popup and self._popup.winfo_exists():
353
+ self._popup.destroy()
354
+ self._popup = None
355
+ self._popup_listbox = None
356
+
357
+ # Restore current tool name if search field is empty or contains partial text
358
+ if not self._search_var.get().strip() or self._search_var.get() != self._current_tool:
359
+ self._search_var.set(self._current_tool)
360
+
361
+ return "break"
362
+
363
+ def _on_popup_focus_out(self, event=None) -> None:
364
+ """Handle focus leaving popup."""
365
+ # Delay to check if focus went to entry
366
+ self.after(100, self._check_focus)
367
+
368
+ def _on_entry_focus_out(self, event=None) -> None:
369
+ """Handle focus leaving entry."""
370
+ # Delay to check if focus went to popup
371
+ self.after(100, self._check_focus)
372
+
373
+ def _check_focus(self) -> None:
374
+ """Check if focus is on entry or popup, hide if not."""
375
+ try:
376
+ focused = self.focus_get()
377
+ if focused not in (self.tool_entry, self._popup_listbox):
378
+ if self._popup and self._popup.winfo_exists():
379
+ # Check if focus is inside popup
380
+ try:
381
+ if not str(focused).startswith(str(self._popup)):
382
+ self._hide_popup()
383
+ except:
384
+ pass
385
+ except:
386
+ pass
387
+
388
+ def _save_settings(self) -> None:
389
+ """Save favorites and recent to settings."""
390
+ if self._on_settings_change:
391
+ try:
392
+ self._on_settings_change({
393
+ "ui_layout": {
394
+ "favorite_tools": self._favorites.copy(),
395
+ "recent_tools": self._recent.copy(),
396
+ }
397
+ })
398
+ except Exception as e:
399
+ logger.error(f"Error saving settings: {e}")
400
+
401
+ def focus_search(self) -> None:
402
+ """Focus the search entry (Ctrl+K shortcut)."""
403
+ self.tool_entry.focus_set()
404
+ self.tool_entry.selection_range(0, tk.END)
405
+ self._show_popup()
406
+
407
+ def get_selected_tool(self) -> Optional[str]:
408
+ """Get the currently selected tool name."""
409
+ return self._current_tool
410
+
411
+ def set_tool_loader(self, loader: Any) -> None:
412
+ """Update the tool loader."""
413
+ self._tool_loader = loader
414
+
415
+
416
+ # Module availability flag
417
+ TOOL_SEARCH_WIDGET_AVAILABLE = True
package/mcp.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pomera-ai-commander",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "Text processing toolkit with 22 MCP tools including case transformation, encoding, hashing, text analysis, and notes management for AI assistants.",
5
5
  "homepage": "https://github.com/matbanik/Pomera-AI-Commander",
6
6
  "repository": "https://github.com/matbanik/Pomera-AI-Commander",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pomera-ai-commander",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "Text processing toolkit with 22 MCP tools for AI assistants - case transformation, encoding, hashing, text analysis, and notes management",
5
5
  "main": "pomera_mcp_server.py",
6
6
  "bin": {