pomera-ai-commander 0.1.0 → 1.2.1

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.
Files changed (191) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -680
  3. package/bin/pomera-ai-commander.js +62 -62
  4. package/core/__init__.py +65 -65
  5. package/core/app_context.py +482 -482
  6. package/core/async_text_processor.py +421 -421
  7. package/core/backup_manager.py +655 -655
  8. package/core/backup_recovery_manager.py +1033 -1033
  9. package/core/content_hash_cache.py +508 -508
  10. package/core/context_menu.py +313 -313
  11. package/core/data_validator.py +1066 -1066
  12. package/core/database_connection_manager.py +744 -744
  13. package/core/database_curl_settings_manager.py +608 -608
  14. package/core/database_promera_ai_settings_manager.py +446 -446
  15. package/core/database_schema.py +411 -411
  16. package/core/database_schema_manager.py +395 -395
  17. package/core/database_settings_manager.py +1507 -1507
  18. package/core/database_settings_manager_interface.py +456 -456
  19. package/core/dialog_manager.py +734 -734
  20. package/core/efficient_line_numbers.py +510 -510
  21. package/core/error_handler.py +746 -746
  22. package/core/error_service.py +431 -431
  23. package/core/event_consolidator.py +511 -511
  24. package/core/mcp/__init__.py +43 -43
  25. package/core/mcp/protocol.py +288 -288
  26. package/core/mcp/schema.py +251 -251
  27. package/core/mcp/server_stdio.py +299 -299
  28. package/core/mcp/tool_registry.py +2372 -2345
  29. package/core/memory_efficient_text_widget.py +711 -711
  30. package/core/migration_manager.py +914 -914
  31. package/core/migration_test_suite.py +1085 -1085
  32. package/core/migration_validator.py +1143 -1143
  33. package/core/optimized_find_replace.py +714 -714
  34. package/core/optimized_pattern_engine.py +424 -424
  35. package/core/optimized_search_highlighter.py +552 -552
  36. package/core/performance_monitor.py +674 -674
  37. package/core/persistence_manager.py +712 -712
  38. package/core/progressive_stats_calculator.py +632 -632
  39. package/core/regex_pattern_cache.py +529 -529
  40. package/core/regex_pattern_library.py +350 -350
  41. package/core/search_operation_manager.py +434 -434
  42. package/core/settings_defaults_registry.py +1087 -1087
  43. package/core/settings_integrity_validator.py +1111 -1111
  44. package/core/settings_serializer.py +557 -557
  45. package/core/settings_validator.py +1823 -1823
  46. package/core/smart_stats_calculator.py +709 -709
  47. package/core/statistics_update_manager.py +619 -619
  48. package/core/stats_config_manager.py +858 -858
  49. package/core/streaming_text_handler.py +723 -723
  50. package/core/task_scheduler.py +596 -596
  51. package/core/update_pattern_library.py +168 -168
  52. package/core/visibility_monitor.py +596 -596
  53. package/core/widget_cache.py +498 -498
  54. package/mcp.json +51 -61
  55. package/package.json +61 -57
  56. package/pomera.py +7482 -7482
  57. package/pomera_mcp_server.py +183 -144
  58. package/requirements.txt +32 -0
  59. package/tools/__init__.py +4 -4
  60. package/tools/ai_tools.py +2891 -2891
  61. package/tools/ascii_art_generator.py +352 -352
  62. package/tools/base64_tools.py +183 -183
  63. package/tools/base_tool.py +511 -511
  64. package/tools/case_tool.py +308 -308
  65. package/tools/column_tools.py +395 -395
  66. package/tools/cron_tool.py +884 -884
  67. package/tools/curl_history.py +600 -600
  68. package/tools/curl_processor.py +1207 -1207
  69. package/tools/curl_settings.py +502 -502
  70. package/tools/curl_tool.py +5467 -5467
  71. package/tools/diff_viewer.py +1071 -1071
  72. package/tools/email_extraction_tool.py +248 -248
  73. package/tools/email_header_analyzer.py +425 -425
  74. package/tools/extraction_tools.py +250 -250
  75. package/tools/find_replace.py +1750 -1750
  76. package/tools/folder_file_reporter.py +1463 -1463
  77. package/tools/folder_file_reporter_adapter.py +480 -480
  78. package/tools/generator_tools.py +1216 -1216
  79. package/tools/hash_generator.py +255 -255
  80. package/tools/html_tool.py +656 -656
  81. package/tools/jsonxml_tool.py +729 -729
  82. package/tools/line_tools.py +419 -419
  83. package/tools/markdown_tools.py +561 -561
  84. package/tools/mcp_widget.py +1417 -1417
  85. package/tools/notes_widget.py +973 -973
  86. package/tools/number_base_converter.py +372 -372
  87. package/tools/regex_extractor.py +571 -571
  88. package/tools/slug_generator.py +310 -310
  89. package/tools/sorter_tools.py +458 -458
  90. package/tools/string_escape_tool.py +392 -392
  91. package/tools/text_statistics_tool.py +365 -365
  92. package/tools/text_wrapper.py +430 -430
  93. package/tools/timestamp_converter.py +421 -421
  94. package/tools/tool_loader.py +710 -710
  95. package/tools/translator_tools.py +522 -522
  96. package/tools/url_link_extractor.py +261 -261
  97. package/tools/url_parser.py +204 -204
  98. package/tools/whitespace_tools.py +355 -355
  99. package/tools/word_frequency_counter.py +146 -146
  100. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  102. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  103. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  104. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  105. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  106. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  107. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  108. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  109. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  110. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  111. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  112. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  113. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  114. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  115. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  116. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  117. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  118. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  119. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  120. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  121. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  122. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  123. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  124. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  125. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  126. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  127. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  128. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  129. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  130. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  131. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  132. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  133. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  134. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  135. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  136. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  137. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  138. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  139. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  140. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  141. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  142. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  143. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  144. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  145. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  146. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  147. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  148. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  149. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  150. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  151. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  152. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  153. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  154. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  155. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  156. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  157. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  158. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  159. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  160. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  161. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  162. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  163. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  164. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  165. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  166. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  167. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  168. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  169. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  170. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  171. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  172. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  173. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  174. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  175. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  176. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  177. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  178. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  179. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  180. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  181. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  182. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  183. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  184. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  185. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  186. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  187. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  188. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  189. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  190. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  191. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
@@ -1,973 +1,973 @@
1
- """
2
- Notes Widget Module for Pomera
3
-
4
- This module provides a Notes widget that allows saving INPUT/OUTPUT tab content
5
- to a local SQLite database. The UI is modeled after prompt_mini but simplified
6
- to only include Date Created, Date Modified, Title, INPUT, and OUTPUT fields.
7
-
8
- Features:
9
- - Save INPUT/OUTPUT tab content as notes
10
- - Full-text search (FTS5) across Title, Input, Output
11
- - Sortable treeview (ID, Created, Modified, Title)
12
- - Send To feature for sending note content back to input tabs
13
- - In-place editing mode
14
- - Text statistics display
15
-
16
- Author: Pomera AI Commander
17
- """
18
-
19
- import tkinter as tk
20
- from tkinter import ttk, messagebox, scrolledtext
21
- import sqlite3
22
- import logging
23
- import threading
24
- from datetime import datetime
25
- from typing import Optional, List, Dict, Any, Tuple, Generator
26
- from contextlib import contextmanager
27
- from concurrent.futures import ThreadPoolExecutor, Future
28
- import os
29
- import re
30
-
31
-
32
- class NotesWidget:
33
- """Notes widget for saving and managing INPUT/OUTPUT tab content."""
34
-
35
- def __init__(self, parent, logger=None, send_to_input_callback=None, dialog_manager=None):
36
- """
37
- Initialize the Notes widget.
38
-
39
- Args:
40
- parent: Parent tkinter widget
41
- logger: Logger instance for debugging
42
- send_to_input_callback: Callback function to send content to input tabs
43
- dialog_manager: DialogManager instance for configurable dialogs
44
- """
45
- self.parent = parent
46
- self.logger = logger or logging.getLogger(__name__)
47
- self.send_to_input_callback = send_to_input_callback
48
- self.dialog_manager = dialog_manager
49
-
50
- # Database path - use same directory as Pomera
51
- db_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
52
- self.db_path = os.path.join(db_dir, 'notes.db')
53
-
54
- # State management
55
- self.search_debounce_timer: Optional[str] = None
56
- self.selected_items: List[int] = []
57
- self.current_item: Optional[int] = None
58
- self.editing_mode: bool = False
59
- self.has_unsaved_changes: bool = False
60
- self.original_data: Optional[Dict] = None
61
- self.sort_column: Optional[str] = None
62
- self.sort_direction: Optional[str] = None
63
- self.note_cache: Dict[int, Tuple] = {}
64
-
65
- # Thread pool for cancellable searches
66
- self.search_executor = ThreadPoolExecutor(max_workers=1)
67
- self.current_search_future: Optional[Future] = None
68
-
69
- # Initialize database
70
- self.init_database()
71
-
72
- # Create UI
73
- self.create_ui()
74
-
75
- # Perform initial search
76
- self.perform_search(select_first=True)
77
-
78
- @contextmanager
79
- def get_db_connection(self) -> Generator[sqlite3.Connection, None, None]:
80
- """Provide a managed database connection."""
81
- conn = None
82
- try:
83
- conn = sqlite3.connect(self.db_path, timeout=10.0)
84
- conn.row_factory = sqlite3.Row
85
- conn.execute('PRAGMA foreign_keys = ON')
86
- conn.execute('PRAGMA journal_mode=WAL')
87
- yield conn
88
- except Exception as e:
89
- self.logger.error(f"Database connection error: {e}")
90
- if conn:
91
- conn.rollback()
92
- raise
93
- finally:
94
- if conn:
95
- conn.close()
96
-
97
- def init_database(self) -> None:
98
- """Initialize the SQLite database and Full-Text Search (FTS5) table."""
99
- try:
100
- with self.get_db_connection() as conn:
101
- conn.execute('''
102
- CREATE TABLE IF NOT EXISTS notes (
103
- id INTEGER PRIMARY KEY AUTOINCREMENT,
104
- Created DATETIME DEFAULT CURRENT_TIMESTAMP,
105
- Modified DATETIME DEFAULT CURRENT_TIMESTAMP,
106
- Title TEXT(255),
107
- Input TEXT,
108
- Output TEXT
109
- )
110
- ''')
111
-
112
- # Drop legacy triggers and FTS table to ensure schema is correct
113
- for trigger in ['notes_after_insert', 'notes_after_delete', 'notes_after_update']:
114
- conn.execute(f'DROP TRIGGER IF EXISTS {trigger}')
115
- conn.execute('DROP TABLE IF EXISTS notes_fts')
116
-
117
- conn.execute('''
118
- CREATE VIRTUAL TABLE notes_fts USING fts5(
119
- Title, Input, Output,
120
- content='notes',
121
- content_rowid='id'
122
- )
123
- ''')
124
-
125
- conn.executescript('''
126
- CREATE TRIGGER IF NOT EXISTS notes_after_insert AFTER INSERT ON notes BEGIN
127
- INSERT INTO notes_fts(rowid, Title, Input, Output)
128
- VALUES (new.id, new.Title, new.Input, new.Output);
129
- END;
130
- CREATE TRIGGER IF NOT EXISTS notes_after_delete AFTER DELETE ON notes BEGIN
131
- INSERT INTO notes_fts(notes_fts, rowid, Title, Input, Output)
132
- VALUES ('delete', old.id, old.Title, old.Input, old.Output);
133
- END;
134
- CREATE TRIGGER IF NOT EXISTS notes_after_update AFTER UPDATE ON notes BEGIN
135
- INSERT INTO notes_fts(notes_fts, rowid, Title, Input, Output)
136
- VALUES ('delete', old.id, old.Title, old.Input, old.Output);
137
- INSERT INTO notes_fts(rowid, Title, Input, Output)
138
- VALUES (new.id, new.Title, new.Input, new.Output);
139
- END;
140
- ''')
141
-
142
- conn.execute('INSERT INTO notes_fts(notes_fts) VALUES("rebuild")')
143
- conn.commit()
144
- self.logger.info("Notes database initialized successfully")
145
- except Exception as e:
146
- self.logger.error(f"Database initialization error: {e}")
147
- if self.dialog_manager:
148
- self.dialog_manager.show_error("Database Error", f"Failed to initialize database: {e}")
149
- else:
150
- messagebox.showerror("Database Error", f"Failed to initialize database: {e}", parent=self.parent)
151
-
152
- def create_ui(self) -> None:
153
- """Create the main user interface components."""
154
- # Search frame
155
- search_frame = ttk.Frame(self.parent)
156
- search_frame.pack(fill=tk.X, padx=10, pady=5)
157
-
158
- help_btn = ttk.Button(search_frame, text="?", width=3, command=self.show_search_help)
159
- help_btn.pack(side=tk.LEFT, padx=(0, 5))
160
-
161
- self.search_var = tk.StringVar()
162
- self.search_var.trace_add('write', self.on_search_change)
163
- self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var, width=80)
164
- self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
165
-
166
- search_btn = ttk.Button(search_frame, text="Search", command=lambda: self.perform_search())
167
- search_btn.pack(side=tk.RIGHT, padx=(5, 0))
168
-
169
- # Main frame with paned window
170
- main_frame = ttk.Frame(self.parent)
171
- main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
172
-
173
- try:
174
- self.paned_window = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL, sashwidth=8, sashrelief=tk.RAISED)
175
- except tk.TclError:
176
- self.paned_window = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL)
177
- self.paned_window.pack(fill=tk.BOTH, expand=True)
178
-
179
- # Left frame - treeview
180
- left_frame = ttk.Frame(self.paned_window)
181
- self.paned_window.add(left_frame, weight=7)
182
-
183
- columns = ('ID', 'Created', 'Modified', 'Title')
184
- self.tree = ttk.Treeview(left_frame, columns=columns, show='headings', selectmode='extended')
185
-
186
- for col in columns:
187
- self.tree.heading(col, text=col, command=lambda c=col: self.sort_by_column(c))
188
-
189
- self.tree.column('ID', width=40, minwidth=30, stretch=False)
190
- self.tree.column('Created', width=120, minwidth=100, stretch=False)
191
- self.tree.column('Modified', width=120, minwidth=100, stretch=False)
192
- self.tree.column('Title', width=200, minwidth=120)
193
-
194
- tree_scroll = ttk.Scrollbar(left_frame, orient=tk.VERTICAL, command=self.tree.yview)
195
- self.tree.configure(yscrollcommand=tree_scroll.set)
196
-
197
- self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
198
- tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
199
-
200
- self.tree.bind('<<TreeviewSelect>>', self.on_tree_select)
201
- self.tree.bind('<Double-1>', self.on_tree_double_click)
202
-
203
- # Right frame - detail view
204
- right_frame = ttk.Frame(self.paned_window)
205
- self.paned_window.add(right_frame, weight=3)
206
-
207
- # Button frame
208
- self.btn_frame = ttk.Frame(right_frame)
209
- self.btn_frame.pack(fill=tk.X, pady=(0, 10))
210
-
211
- self.new_btn = ttk.Button(self.btn_frame, text="New Note", command=self.new_note)
212
- self.duplicate_btn = ttk.Button(self.btn_frame, text="Duplicate", command=self.duplicate_note)
213
- self.change_btn = ttk.Button(self.btn_frame, text="Change", command=self.change_note)
214
- self.delete_btn = ttk.Button(self.btn_frame, text="Delete", command=self.delete_notes)
215
- self.save_btn = ttk.Button(self.btn_frame, text="Save", command=self.save_edits)
216
- self.cancel_btn = ttk.Button(self.btn_frame, text="Cancel", command=self.cancel_edits)
217
-
218
- self.update_action_buttons()
219
-
220
- # Display frame
221
- display_frame = ttk.Frame(right_frame)
222
- display_frame.pack(fill=tk.BOTH, expand=True)
223
-
224
- self.create_item_display(display_frame)
225
-
226
- # Status bar
227
- self.status_bar = ttk.Label(self.parent, text="Ready", relief=tk.SUNKEN, anchor=tk.W)
228
- self.status_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
229
-
230
- def create_item_display(self, parent: ttk.Frame) -> None:
231
- """Create the widgets for displaying a single note item."""
232
- # Date frame
233
- date_frame = ttk.Frame(parent)
234
- date_frame.pack(fill=tk.X, pady=(0, 5))
235
- self.created_label = ttk.Label(date_frame, text="Created: ", foreground="green")
236
- self.created_label.pack(side=tk.LEFT)
237
- self.modified_label = ttk.Label(date_frame, text="Modified: ", foreground="blue")
238
- self.modified_label.pack(side=tk.RIGHT)
239
-
240
- # Title frame
241
- self.title_frame = ttk.Frame(parent)
242
- self.title_frame.pack(fill=tk.X, pady=(0, 5))
243
- ttk.Label(self.title_frame, text="Title:").pack(side=tk.LEFT)
244
- self.title_display = ttk.Label(self.title_frame, text="", font=('TkDefaultFont', 9, 'bold'))
245
- self.title_display.pack(side=tk.LEFT, padx=(5, 0))
246
-
247
- # INPUT section
248
- input_label_frame = ttk.Frame(parent)
249
- input_label_frame.pack(fill=tk.X, pady=(0, 2))
250
- ttk.Label(input_label_frame, text="INPUT:", font=('TkDefaultFont', 9, 'bold')).pack(side=tk.LEFT)
251
-
252
- # Send To dropdown for INPUT
253
- self.input_send_var = tk.StringVar(value="Send To")
254
- self.input_send_menu_btn = ttk.Menubutton(input_label_frame, textvariable=self.input_send_var, direction="below")
255
- self.input_send_menu_btn.pack(side=tk.RIGHT, padx=(5, 0))
256
- self.input_send_dropdown = tk.Menu(self.input_send_menu_btn, tearoff=0)
257
- self.input_send_menu_btn.config(menu=self.input_send_dropdown)
258
- self._build_send_to_menu(self.input_send_dropdown, "input")
259
-
260
- input_frame = ttk.Frame(parent)
261
- input_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
262
-
263
- line_frame = ttk.Frame(input_frame)
264
- line_frame.pack(side=tk.LEFT, fill=tk.Y)
265
- self.input_line_numbers = tk.Text(line_frame, width=4, padx=3, takefocus=0, border=0, state='disabled', wrap='none')
266
- self.input_line_numbers.pack(fill=tk.Y, expand=True)
267
-
268
- text_frame = ttk.Frame(input_frame)
269
- text_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
270
- self.input_display = tk.Text(text_frame, wrap=tk.WORD, state='disabled', undo=True, maxundo=50)
271
- input_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL)
272
- self.input_display.config(yscrollcommand=lambda *args: self.sync_scroll(input_scrollbar, self.input_line_numbers, *args))
273
- input_scrollbar.config(command=lambda *args: self.sync_scroll_command(self.input_display, self.input_line_numbers, *args))
274
- self.input_display.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
275
- input_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
276
-
277
- input_status_frame = ttk.Frame(parent)
278
- input_status_frame.pack(fill=tk.X, pady=(0, 5))
279
- self.input_status_label = ttk.Label(input_status_frame, text="Char: 0 | Word: 0 | Line: 0")
280
- self.input_status_label.pack(side=tk.LEFT)
281
-
282
- # OUTPUT section
283
- output_label_frame = ttk.Frame(parent)
284
- output_label_frame.pack(fill=tk.X, pady=(0, 2))
285
- ttk.Label(output_label_frame, text="OUTPUT:", font=('TkDefaultFont', 9, 'bold')).pack(side=tk.LEFT)
286
-
287
- # Send To dropdown for OUTPUT
288
- self.output_send_var = tk.StringVar(value="Send To")
289
- self.output_send_menu_btn = ttk.Menubutton(output_label_frame, textvariable=self.output_send_var, direction="below")
290
- self.output_send_menu_btn.pack(side=tk.RIGHT, padx=(5, 0))
291
- self.output_send_dropdown = tk.Menu(self.output_send_menu_btn, tearoff=0)
292
- self.output_send_menu_btn.config(menu=self.output_send_dropdown)
293
- self._build_send_to_menu(self.output_send_dropdown, "output")
294
-
295
- output_frame = ttk.Frame(parent)
296
- output_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
297
-
298
- line_frame = ttk.Frame(output_frame)
299
- line_frame.pack(side=tk.LEFT, fill=tk.Y)
300
- self.output_line_numbers = tk.Text(line_frame, width=4, padx=3, takefocus=0, border=0, state='disabled', wrap='none')
301
- self.output_line_numbers.pack(fill=tk.Y, expand=True)
302
-
303
- text_frame = ttk.Frame(output_frame)
304
- text_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
305
- self.output_display = tk.Text(text_frame, wrap=tk.WORD, state='disabled', undo=True, maxundo=50)
306
- output_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL)
307
- self.output_display.config(yscrollcommand=lambda *args: self.sync_scroll(output_scrollbar, self.output_line_numbers, *args))
308
- output_scrollbar.config(command=lambda *args: self.sync_scroll_command(self.output_display, self.output_line_numbers, *args))
309
- self.output_display.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
310
- output_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
311
-
312
- output_status_frame = ttk.Frame(parent)
313
- output_status_frame.pack(fill=tk.X, pady=(0, 5))
314
- self.output_status_label = ttk.Label(output_status_frame, text="Char: 0 | Word: 0 | Line: 0")
315
- self.output_status_label.pack(side=tk.LEFT)
316
-
317
- def _build_send_to_menu(self, menu: tk.Menu, content_type: str) -> None:
318
- """Build the Send To dropdown menu with Tab 1-7 options."""
319
- menu.delete(0, tk.END)
320
- for i in range(7):
321
- # Use a factory function to properly capture the variables
322
- def make_command(tab_idx, content_t):
323
- return lambda: self._send_to_input_tab(tab_idx, content_t)
324
- menu.add_command(
325
- label=f"Tab {i+1}",
326
- command=make_command(i, content_type)
327
- )
328
-
329
- def _send_to_input_tab(self, tab_index: int, content_type: str) -> None:
330
- """Send note content to a specific input tab."""
331
- if not self.send_to_input_callback:
332
- if self.dialog_manager:
333
- self.dialog_manager.show_warning("Warning", "Send to Input functionality is not available.")
334
- else:
335
- messagebox.showwarning("Warning", "Send to Input functionality is not available.", parent=self.parent)
336
- return
337
-
338
- # Get content based on type - ONLY get the specified type
339
- content = ""
340
- if content_type == "input":
341
- content = self.input_display.get("1.0", tk.END).strip()
342
- self.logger.debug(f"Sending INPUT content to tab {tab_index + 1} (length: {len(content)})")
343
- elif content_type == "output":
344
- content = self.output_display.get("1.0", tk.END).strip()
345
- self.logger.debug(f"Sending OUTPUT content to tab {tab_index + 1} (length: {len(content)})")
346
- else:
347
- self.logger.warning(f"Unknown content_type: {content_type}")
348
- return
349
-
350
- if not content:
351
- if self.dialog_manager:
352
- self.dialog_manager.show_warning("Warning", f"No {content_type.upper()} content available to send.")
353
- else:
354
- messagebox.showwarning("Warning", f"No {content_type.upper()} content available to send.", parent=self.parent)
355
- return
356
-
357
- # Send to input tab using callback - ONLY the selected content
358
- self.send_to_input_callback(tab_index, content)
359
-
360
- # Show success message
361
- content_name = content_type.upper()
362
- if self.dialog_manager:
363
- self.dialog_manager.show_info("Success", f"{content_name} content sent to Input Tab {tab_index + 1}")
364
- else:
365
- messagebox.showinfo("Success", f"{content_name} content sent to Input Tab {tab_index + 1}", parent=self.parent)
366
- self.logger.info(f"{content_name} content sent to input tab {tab_index + 1}")
367
-
368
- def sync_scroll(self, scrollbar: ttk.Scrollbar, line_numbers: tk.Text, *args: str) -> None:
369
- """Synchronize scrolling between a text widget and its line numbers."""
370
- scrollbar.set(*args)
371
- if len(args) >= 2:
372
- top = float(args[0])
373
- line_numbers.yview_moveto(top)
374
-
375
- def sync_scroll_command(self, main_text: tk.Text, line_numbers: tk.Text, *args: str) -> None:
376
- """Handle scrollbar commands to sync two text widgets."""
377
- main_text.yview(*args)
378
- line_numbers.yview(*args)
379
-
380
- def on_search_change(self, *args: Any) -> None:
381
- """Handle search input changes with debouncing."""
382
- if self.search_debounce_timer:
383
- self.parent.after_cancel(self.search_debounce_timer)
384
- self.search_debounce_timer = self.parent.after(300, lambda: self.perform_search())
385
-
386
- def perform_search(self, select_item_id: Optional[int] = None, select_first: bool = False) -> None:
387
- """Perform a cancellable search using a thread pool."""
388
- search_term = self.search_var.get().strip()
389
-
390
- if self.current_search_future and not self.current_search_future.done():
391
- self.current_search_future.cancel()
392
-
393
- if hasattr(self, 'status_bar'):
394
- self.status_bar.config(text="Searching...")
395
- self.parent.config(cursor="wait")
396
- self.parent.update_idletasks()
397
-
398
- def search_worker(term: str) -> List[Tuple]:
399
- try:
400
- with self.get_db_connection() as conn:
401
- if term:
402
- cursor = conn.execute('''
403
- SELECT n.id, n.Created, n.Modified, n.Title, n.Input, n.Output
404
- FROM notes n JOIN notes_fts fts ON n.id = fts.rowid
405
- WHERE notes_fts MATCH ? ORDER BY rank
406
- ''', (term + '*',))
407
- else:
408
- cursor = conn.execute('''
409
- SELECT id, Created, Modified, Title, Input, Output
410
- FROM notes ORDER BY Modified DESC
411
- ''')
412
- return cursor.fetchall()
413
- except Exception as e:
414
- self.logger.error(f"Search worker error: {e}")
415
- return []
416
-
417
- self.current_search_future = self.search_executor.submit(search_worker, search_term)
418
- self.current_search_future.add_done_callback(
419
- lambda future: self._safe_after(lambda: self._handle_search_results(future, select_item_id, select_first))
420
- )
421
-
422
- def _safe_after(self, callback, delay: int = 0) -> None:
423
- """Schedule a callback only if the parent widget still exists."""
424
- try:
425
- if self.parent.winfo_exists():
426
- self.parent.after(delay, callback)
427
- except Exception:
428
- # Widget was destroyed, ignore
429
- pass
430
-
431
- def _handle_search_results(self, future: Future, select_item_id: Optional[int] = None, select_first: bool = False) -> None:
432
- """Process search results in the main UI thread."""
433
- if future.cancelled():
434
- return
435
-
436
- # Check if widget still exists before updating
437
- try:
438
- if not self.parent.winfo_exists():
439
- return
440
- except Exception:
441
- return
442
-
443
- if hasattr(self, 'status_bar'):
444
- try:
445
- self.status_bar.config(text="Ready")
446
- self.parent.config(cursor="")
447
- except Exception:
448
- # Widget was destroyed, ignore
449
- return
450
-
451
- error = future.exception()
452
- if error:
453
- self.logger.error(f"Search failed: {error}")
454
- if self.dialog_manager:
455
- self.dialog_manager.show_error("Search Error", f"Search failed: {error}")
456
- else:
457
- messagebox.showerror("Search Error", f"Search failed: {error}", parent=self.parent)
458
- self.search_results = []
459
- else:
460
- self.search_results = future.result()
461
-
462
- self.refresh_search_view()
463
-
464
- if select_item_id:
465
- self._safe_after_idle(lambda: self._select_item_in_tree(select_item_id))
466
- elif select_first:
467
- self._safe_after_idle(self._select_first_item_in_tree)
468
-
469
- def _safe_after_idle(self, callback) -> None:
470
- """Schedule an idle callback only if the parent widget still exists."""
471
- try:
472
- if self.parent.winfo_exists():
473
- self.parent.after_idle(callback)
474
- except Exception:
475
- # Widget was destroyed, ignore
476
- pass
477
-
478
- def sort_by_column(self, column: str) -> None:
479
- """Sort the treeview by a specified column, cycling through directions."""
480
- if self.sort_column == column:
481
- if self.sort_direction == 'asc':
482
- self.sort_direction = 'desc'
483
- else:
484
- self.sort_column = None
485
- self.sort_direction = None
486
- else:
487
- self.sort_column = column
488
- self.sort_direction = 'asc'
489
-
490
- self.update_column_headers()
491
- self.refresh_search_view()
492
-
493
- def update_column_headers(self) -> None:
494
- """Update treeview column headers with sort direction indicators."""
495
- for col in ['ID', 'Created', 'Modified', 'Title']:
496
- text = col
497
- if col == self.sort_column:
498
- if self.sort_direction == 'asc':
499
- text += " ↑"
500
- elif self.sort_direction == 'desc':
501
- text += " ↓"
502
- self.tree.heading(col, text=text)
503
-
504
- def refresh_search_view(self) -> None:
505
- """Refresh the search results treeview, applying sorting if active."""
506
- self.tree.unbind('<<TreeviewSelect>>')
507
- try:
508
- self.tree.delete(*self.tree.get_children())
509
-
510
- display_results = getattr(self, 'search_results', [])
511
-
512
- if self.sort_column and self.sort_direction and display_results:
513
- col_index = self.tree['columns'].index(self.sort_column)
514
- reverse = (self.sort_direction == 'desc')
515
-
516
- def sort_key(row):
517
- val = row[col_index]
518
- if self.sort_column == 'ID':
519
- return int(val) if val else 0
520
- return (val or '').lower()
521
-
522
- display_results = sorted(display_results, key=sort_key, reverse=reverse)
523
-
524
- if display_results:
525
- for row in display_results:
526
- self.tree.insert('', 'end', values=(
527
- row['id'],
528
- self.format_datetime(row['Created']),
529
- self.format_datetime(row['Modified']),
530
- (row['Title'] or '')[:50] + ("..." if len(row['Title'] or '') > 50 else "")
531
- ))
532
- finally:
533
- self.tree.bind('<<TreeviewSelect>>', self.on_tree_select)
534
-
535
- def format_datetime(self, dt_str: Optional[str]) -> str:
536
- """Format a datetime string for display."""
537
- if not dt_str:
538
- return ""
539
- try:
540
- dt_str = dt_str.replace('Z', '+00:00')
541
- dt = datetime.fromisoformat(dt_str)
542
- return dt.strftime('%Y-%m-%d %I:%M %p')
543
- except (ValueError, TypeError):
544
- return dt_str
545
-
546
- def on_tree_select(self, event: Optional[tk.Event]) -> None:
547
- """Handle selection changes in the results treeview."""
548
- if self.editing_mode:
549
- if self.current_item:
550
- self._select_item_in_tree(self.current_item)
551
- return
552
-
553
- selection = self.tree.selection()
554
- self.selected_items = [self.tree.item(item)['values'][0] for item in selection]
555
-
556
- if len(self.selected_items) == 1:
557
- self.current_item = self.selected_items[0]
558
- self.update_item_display()
559
- else:
560
- self.current_item = None
561
- self.clear_item_display()
562
-
563
- self.update_action_buttons()
564
-
565
- def on_tree_double_click(self, event: tk.Event) -> None:
566
- """Handle double-click on a tree item to enter edit mode."""
567
- if self.current_item:
568
- self.change_note()
569
-
570
- def _select_item_in_tree(self, item_id: int) -> None:
571
- """Select an item in the tree by its ID."""
572
- for item in self.tree.get_children():
573
- if str(self.tree.item(item)['values'][0]) == str(item_id):
574
- self.tree.selection_set(item)
575
- self.tree.focus(item)
576
- self.tree.see(item)
577
- self.on_tree_select(None)
578
- return
579
- self._select_first_item_in_tree()
580
-
581
- def _select_first_item_in_tree(self) -> None:
582
- """Select the first item in the tree."""
583
- children = self.tree.get_children()
584
- if children:
585
- self.tree.selection_set(children[0])
586
- self.tree.focus(children[0])
587
- self.tree.see(children[0])
588
- self.on_tree_select(None)
589
-
590
- def update_item_display(self, force_refresh: bool = False) -> None:
591
- """Update the item display panel, using a cache for performance."""
592
- if not self.current_item:
593
- return
594
-
595
- try:
596
- row = self.note_cache.get(self.current_item) if not force_refresh else None
597
- if row:
598
- self.logger.debug(f"Using cached data for item {self.current_item}")
599
- else:
600
- with self.get_db_connection() as conn:
601
- row = conn.execute('SELECT * FROM notes WHERE id = ?', (self.current_item,)).fetchone()
602
- if not row:
603
- return
604
-
605
- if len(self.note_cache) > 50:
606
- del self.note_cache[next(iter(self.note_cache))]
607
-
608
- self.note_cache[self.current_item] = row
609
- self.logger.debug(f"Fetched and cached data for item {self.current_item}")
610
-
611
- self.created_label.config(text=f"Created: {self.format_datetime(row['Created'])}")
612
- self.modified_label.config(text=f"Modified: {self.format_datetime(row['Modified'])}")
613
- self.title_display.config(text=row['Title'] or "")
614
-
615
- for widget in [self.input_display, self.output_display]:
616
- widget.config(state='normal')
617
- widget.delete(1.0, tk.END)
618
-
619
- if row['Input']:
620
- self.input_display.insert(1.0, row['Input'])
621
- if row['Output']:
622
- self.output_display.insert(1.0, row['Output'])
623
-
624
- for widget in [self.input_display, self.output_display]:
625
- widget.config(state='disabled')
626
-
627
- self.update_line_numbers(row['Input'] or "", self.input_line_numbers)
628
- self.update_line_numbers(row['Output'] or "", self.output_line_numbers)
629
- self.update_status(row['Input'] or "", self.input_status_label)
630
- self.update_status(row['Output'] or "", self.output_status_label)
631
-
632
- except Exception as e:
633
- self.logger.error(f"Error updating item display: {e}")
634
-
635
- def clear_item_display(self) -> None:
636
- """Clear all fields in the item display panel."""
637
- self.created_label.config(text="Created: ")
638
- self.modified_label.config(text="Modified: ")
639
- self.title_display.config(text="")
640
-
641
- for widget in [self.input_display, self.output_display, self.input_line_numbers, self.output_line_numbers]:
642
- widget.config(state='normal')
643
- widget.delete(1.0, tk.END)
644
- widget.config(state='disabled')
645
-
646
- self.input_status_label.config(text="Char: 0 | Word: 0 | Line: 0")
647
- self.output_status_label.config(text="Char: 0 | Word: 0 | Line: 0")
648
-
649
- def update_line_numbers(self, text: str, line_numbers_widget: tk.Text) -> None:
650
- """Update the line numbers displayed next to the text."""
651
- line_numbers_widget.config(state='normal')
652
- line_numbers_widget.delete(1.0, tk.END)
653
- if text:
654
- line_count = text.count('\n') + 1
655
- line_nums = '\n'.join(map(str, range(1, line_count + 1)))
656
- line_numbers_widget.insert(1.0, line_nums)
657
- line_numbers_widget.config(state='disabled')
658
-
659
- def _get_text_statistics(self, text: str) -> Dict[str, int]:
660
- """Calculate statistics for a given block of text."""
661
- if not text:
662
- return {'char_count': 0, 'word_count': 0, 'line_count': 0}
663
-
664
- char_count = len(text)
665
- word_count = len(text.split())
666
- line_count = text.count('\n') + 1
667
-
668
- return {'char_count': char_count, 'word_count': word_count, 'line_count': line_count}
669
-
670
- def update_status(self, text: str, status_label: ttk.Label) -> None:
671
- """Update the status label with text statistics."""
672
- stats = self._get_text_statistics(text)
673
- status_label.config(
674
- text=f"Char: {stats['char_count']} | Word: {stats['word_count']} | Line: {stats['line_count']}"
675
- )
676
-
677
- def show_search_help(self) -> None:
678
- """Show a dialog with FTS5 search syntax help."""
679
- help_text = """Search Tips:
680
- • Use simple keywords to search all fields.
681
- • Use "quotes" for exact phrases: "machine learning".
682
- • Use AND/OR/NOT operators: python AND tutorial.
683
- • Use wildcards: web* (matches web, website, etc.).
684
- • Search specific columns: Title:refactor OR Input:code.
685
- • Leave empty to show all records."""
686
- if self.dialog_manager:
687
- self.dialog_manager.show_info("Search Help", help_text)
688
- else:
689
- messagebox.showinfo("Search Help", help_text, parent=self.parent)
690
-
691
- def new_note(self) -> None:
692
- """Create a new note."""
693
- self.current_item = None
694
- self.clear_item_display()
695
- self.enter_editing_mode()
696
-
697
- def duplicate_note(self) -> None:
698
- """Duplicate the currently selected note."""
699
- if self.current_item:
700
- try:
701
- with self.get_db_connection() as conn:
702
- row = conn.execute('SELECT * FROM notes WHERE id = ?', (self.current_item,)).fetchone()
703
- if row:
704
- now = datetime.now().isoformat()
705
- conn.execute('''
706
- INSERT INTO notes (Created, Modified, Title, Input, Output)
707
- VALUES (?, ?, ?, ?, ?)
708
- ''', (now, now, row['Title'], row['Input'], row['Output']))
709
- conn.commit()
710
- self.perform_search(select_first=True)
711
- self.logger.info(f"Duplicated note {self.current_item}")
712
- except Exception as e:
713
- self.logger.error(f"Error duplicating note: {e}")
714
- if self.dialog_manager:
715
- self.dialog_manager.show_error("Error", f"Failed to duplicate note: {e}")
716
- else:
717
- messagebox.showerror("Error", f"Failed to duplicate note: {e}", parent=self.parent)
718
-
719
- def change_note(self) -> None:
720
- """Enter in-place editing mode for the selected note."""
721
- if self.current_item and not self.editing_mode:
722
- self.enter_editing_mode()
723
-
724
- def delete_notes(self) -> None:
725
- """Delete one or more selected notes."""
726
- if not self.selected_items:
727
- return
728
-
729
- count = len(self.selected_items)
730
- # Ensure dialog is modal to the Notes widget window
731
- if self.dialog_manager:
732
- confirmed = self.dialog_manager.ask_yes_no("Confirm Delete", f"Delete {count} item(s)? This cannot be undone.", "confirmation", parent=self.parent)
733
- else:
734
- confirmed = messagebox.askyesno("Confirm Delete", f"Delete {count} item(s)? This cannot be undone.", parent=self.parent)
735
-
736
- if confirmed:
737
- try:
738
- with self.get_db_connection() as conn:
739
- item_ids = tuple(self.selected_items)
740
- conn.execute(f"DELETE FROM notes WHERE id IN ({','.join('?' for _ in item_ids)})", item_ids)
741
- for item_id in item_ids:
742
- if item_id in self.note_cache:
743
- del self.note_cache[item_id]
744
- conn.commit()
745
-
746
- self.current_item = None
747
- self.selected_items = []
748
- self.clear_item_display()
749
- self.perform_search(select_first=True)
750
- self.logger.info(f"Deleted {count} items")
751
- except Exception as e:
752
- self.logger.error(f"Delete error: {e}")
753
- if self.dialog_manager:
754
- self.dialog_manager.show_error("Delete Error", f"Failed to delete: {e}")
755
- else:
756
- messagebox.showerror("Delete Error", f"Failed to delete: {e}", parent=self.parent)
757
-
758
- def update_action_buttons(self) -> None:
759
- """Centralized state machine for managing action buttons."""
760
- for btn in [self.new_btn, self.duplicate_btn, self.change_btn, self.delete_btn, self.save_btn, self.cancel_btn]:
761
- btn.pack_forget()
762
-
763
- if self.editing_mode:
764
- self.save_btn.pack(side=tk.LEFT, padx=(0, 5))
765
- self.cancel_btn.pack(side=tk.LEFT, padx=5)
766
- else:
767
- self.new_btn.pack(side=tk.LEFT, padx=(0, 5))
768
-
769
- num_selected = len(self.selected_items)
770
-
771
- self.duplicate_btn.config(state='normal' if num_selected == 1 else 'disabled')
772
- self.change_btn.config(state='normal' if num_selected == 1 else 'disabled')
773
- self.duplicate_btn.pack(side=tk.LEFT, padx=5)
774
- self.change_btn.pack(side=tk.LEFT, padx=5)
775
-
776
- self.delete_btn.config(state='normal' if num_selected > 0 else 'disabled')
777
- self.delete_btn.config(text=f"Delete ({num_selected})" if num_selected > 1 else "Delete")
778
- self.delete_btn.pack(side=tk.LEFT, padx=(20, 0))
779
-
780
- def save_edits(self) -> None:
781
- """Save in-place edits to the database."""
782
- if not self.editing_mode:
783
- return
784
-
785
- try:
786
- title = self.title_entry.get() if hasattr(self, 'title_entry') else ""
787
- input_content = self.input_display.get(1.0, tk.END).strip()
788
- output_content = self.output_display.get(1.0, tk.END).strip()
789
-
790
- now = datetime.now().isoformat()
791
-
792
- with self.get_db_connection() as conn:
793
- if self.current_item:
794
- # Update existing note
795
- conn.execute('''
796
- UPDATE notes SET Modified = ?, Title = ?, Input = ?, Output = ?
797
- WHERE id = ?
798
- ''', (now, title, input_content, output_content, self.current_item))
799
- if self.current_item in self.note_cache:
800
- del self.note_cache[self.current_item]
801
- else:
802
- # Create new note
803
- cursor = conn.execute('''
804
- INSERT INTO notes (Created, Modified, Title, Input, Output)
805
- VALUES (?, ?, ?, ?, ?)
806
- ''', (now, now, title, input_content, output_content))
807
- self.current_item = cursor.lastrowid
808
- conn.commit()
809
-
810
- self.exit_editing_mode()
811
- self.perform_search(select_item_id=self.current_item)
812
- self.logger.info(f"Saved note {self.current_item}")
813
- except Exception as e:
814
- self.logger.error(f"Error saving edits: {e}")
815
- if self.dialog_manager:
816
- self.dialog_manager.show_error("Save Error", f"Failed to save changes: {e}")
817
- else:
818
- messagebox.showerror("Save Error", f"Failed to save changes: {e}", parent=self.parent)
819
-
820
- def cancel_edits(self) -> None:
821
- """Cancel in-place editing and restore original content."""
822
- if self.editing_mode:
823
- if self.has_unsaved_changes:
824
- if self.dialog_manager:
825
- result = self.dialog_manager.ask_yes_no("Unsaved Changes",
826
- "You have unsaved changes. Are you sure you want to cancel?", "confirmation", parent=self.parent)
827
- else:
828
- result = messagebox.askyesno("Unsaved Changes",
829
- "You have unsaved changes. Are you sure you want to cancel?", parent=self.parent)
830
- if not result:
831
- return
832
- self.exit_editing_mode()
833
- if self.current_item:
834
- self.update_item_display(force_refresh=True)
835
- else:
836
- self.clear_item_display()
837
-
838
- def enter_editing_mode(self) -> None:
839
- """Switch the UI to in-place editing mode."""
840
- if self.editing_mode:
841
- return
842
-
843
- try:
844
- if self.current_item:
845
- with self.get_db_connection() as conn:
846
- row = conn.execute('SELECT * FROM notes WHERE id = ?', (self.current_item,)).fetchone()
847
- if not row:
848
- return
849
-
850
- self.original_data = {
851
- 'Title': row['Title'] or "",
852
- 'Input': row['Input'] or "",
853
- 'Output': row['Output'] or ""
854
- }
855
- else:
856
- self.original_data = {'Title': "", 'Input': "", 'Output': ""}
857
-
858
- self.editing_mode = True
859
- self.has_unsaved_changes = False
860
- self.update_action_buttons()
861
-
862
- self.tree.configure(selectmode='none')
863
- if hasattr(self, 'status_bar'):
864
- self.status_bar.config(text="EDITING MODE - Selection locked")
865
-
866
- for widget in [self.input_display, self.output_display]:
867
- widget.config(state='normal')
868
-
869
- # Replace title display with entry
870
- self.title_display.pack_forget()
871
- self.title_entry = ttk.Entry(self.title_frame, font=('TkDefaultFont', 9, 'bold'))
872
- if self.current_item:
873
- self.title_entry.insert(0, self.original_data['Title'])
874
- self.title_entry.pack(side=tk.LEFT, padx=(5, 0), fill=tk.X, expand=True)
875
- self.title_entry.bind('<KeyRelease>', self.on_edit_change)
876
-
877
- # Bind change events to text widgets
878
- for widget in [self.input_display, self.output_display]:
879
- widget.bind('<KeyRelease>', self.on_edit_change)
880
- widget.bind('<Button-1>', self.on_edit_change)
881
-
882
- except Exception as e:
883
- self.logger.error(f"Error entering editing mode: {e}")
884
- if self.dialog_manager:
885
- self.dialog_manager.show_error("Edit Error", f"Failed to enter editing mode: {e}")
886
- else:
887
- messagebox.showerror("Edit Error", f"Failed to enter editing mode: {e}", parent=self.parent)
888
- self.exit_editing_mode()
889
-
890
- def on_edit_change(self, event=None) -> None:
891
- """Track changes in editing mode and update status bar."""
892
- if not self.editing_mode or not self.original_data:
893
- return
894
-
895
- current_data = {
896
- 'Title': self.title_entry.get() if hasattr(self, 'title_entry') else "",
897
- 'Input': self.input_display.get(1.0, tk.END).rstrip('\n'),
898
- 'Output': self.output_display.get(1.0, tk.END).rstrip('\n')
899
- }
900
-
901
- has_changes = any(current_data[key] != self.original_data[key] for key in current_data)
902
-
903
- if has_changes != self.has_unsaved_changes:
904
- self.has_unsaved_changes = has_changes
905
- if hasattr(self, 'status_bar'):
906
- if has_changes:
907
- self.status_bar.config(text="EDITING MODE - NOTE NEEDS TO BE SAVED", font=('TkDefaultFont', 9, 'bold'))
908
- else:
909
- self.status_bar.config(text="EDITING MODE - Selection locked", font=('TkDefaultFont', 9, 'normal'))
910
-
911
- # Update statistics in real-time
912
- if hasattr(self, 'input_display'):
913
- input_text = self.input_display.get(1.0, tk.END)
914
- self.update_line_numbers(input_text, self.input_line_numbers)
915
- self.update_status(input_text, self.input_status_label)
916
-
917
- if hasattr(self, 'output_display'):
918
- output_text = self.output_display.get(1.0, tk.END)
919
- self.update_line_numbers(output_text, self.output_line_numbers)
920
- self.update_status(output_text, self.output_status_label)
921
-
922
- def exit_editing_mode(self) -> None:
923
- """Exit editing mode and restore the view-only UI."""
924
- if not self.editing_mode:
925
- return
926
-
927
- self.editing_mode = False
928
- self.has_unsaved_changes = False
929
- self.original_data = None
930
-
931
- self.tree.configure(selectmode='extended')
932
-
933
- self.update_action_buttons()
934
- if hasattr(self, 'status_bar'):
935
- self.status_bar.config(text="Ready", font=('TkDefaultFont', 9, 'normal'))
936
-
937
- for widget in [self.input_display, self.output_display]:
938
- widget.config(state='disabled')
939
- widget.unbind('<KeyRelease>')
940
- widget.unbind('<Button-1>')
941
-
942
- if hasattr(self, 'title_entry'):
943
- self.title_entry.destroy()
944
- delattr(self, 'title_entry')
945
- self.title_display.pack(side=tk.LEFT, padx=(5, 0))
946
-
947
- def save_note(self, title: str, input_content: str, output_content: str) -> Optional[int]:
948
- """
949
- Save a new note to the database.
950
-
951
- Args:
952
- title: Note title
953
- input_content: INPUT tab content
954
- output_content: OUTPUT tab content
955
-
956
- Returns:
957
- The ID of the created note, or None on error
958
- """
959
- try:
960
- now = datetime.now().isoformat()
961
- with self.get_db_connection() as conn:
962
- cursor = conn.execute('''
963
- INSERT INTO notes (Created, Modified, Title, Input, Output)
964
- VALUES (?, ?, ?, ?, ?)
965
- ''', (now, now, title, input_content, output_content))
966
- note_id = cursor.lastrowid
967
- conn.commit()
968
-
969
- self.logger.info(f"Saved new note with ID {note_id}")
970
- return note_id
971
- except Exception as e:
972
- self.logger.error(f"Error saving note: {e}")
973
- return None
1
+ """
2
+ Notes Widget Module for Pomera
3
+
4
+ This module provides a Notes widget that allows saving INPUT/OUTPUT tab content
5
+ to a local SQLite database. The UI is modeled after prompt_mini but simplified
6
+ to only include Date Created, Date Modified, Title, INPUT, and OUTPUT fields.
7
+
8
+ Features:
9
+ - Save INPUT/OUTPUT tab content as notes
10
+ - Full-text search (FTS5) across Title, Input, Output
11
+ - Sortable treeview (ID, Created, Modified, Title)
12
+ - Send To feature for sending note content back to input tabs
13
+ - In-place editing mode
14
+ - Text statistics display
15
+
16
+ Author: Pomera AI Commander
17
+ """
18
+
19
+ import tkinter as tk
20
+ from tkinter import ttk, messagebox, scrolledtext
21
+ import sqlite3
22
+ import logging
23
+ import threading
24
+ from datetime import datetime
25
+ from typing import Optional, List, Dict, Any, Tuple, Generator
26
+ from contextlib import contextmanager
27
+ from concurrent.futures import ThreadPoolExecutor, Future
28
+ import os
29
+ import re
30
+
31
+
32
+ class NotesWidget:
33
+ """Notes widget for saving and managing INPUT/OUTPUT tab content."""
34
+
35
+ def __init__(self, parent, logger=None, send_to_input_callback=None, dialog_manager=None):
36
+ """
37
+ Initialize the Notes widget.
38
+
39
+ Args:
40
+ parent: Parent tkinter widget
41
+ logger: Logger instance for debugging
42
+ send_to_input_callback: Callback function to send content to input tabs
43
+ dialog_manager: DialogManager instance for configurable dialogs
44
+ """
45
+ self.parent = parent
46
+ self.logger = logger or logging.getLogger(__name__)
47
+ self.send_to_input_callback = send_to_input_callback
48
+ self.dialog_manager = dialog_manager
49
+
50
+ # Database path - use same directory as Pomera
51
+ db_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
52
+ self.db_path = os.path.join(db_dir, 'notes.db')
53
+
54
+ # State management
55
+ self.search_debounce_timer: Optional[str] = None
56
+ self.selected_items: List[int] = []
57
+ self.current_item: Optional[int] = None
58
+ self.editing_mode: bool = False
59
+ self.has_unsaved_changes: bool = False
60
+ self.original_data: Optional[Dict] = None
61
+ self.sort_column: Optional[str] = None
62
+ self.sort_direction: Optional[str] = None
63
+ self.note_cache: Dict[int, Tuple] = {}
64
+
65
+ # Thread pool for cancellable searches
66
+ self.search_executor = ThreadPoolExecutor(max_workers=1)
67
+ self.current_search_future: Optional[Future] = None
68
+
69
+ # Initialize database
70
+ self.init_database()
71
+
72
+ # Create UI
73
+ self.create_ui()
74
+
75
+ # Perform initial search
76
+ self.perform_search(select_first=True)
77
+
78
+ @contextmanager
79
+ def get_db_connection(self) -> Generator[sqlite3.Connection, None, None]:
80
+ """Provide a managed database connection."""
81
+ conn = None
82
+ try:
83
+ conn = sqlite3.connect(self.db_path, timeout=10.0)
84
+ conn.row_factory = sqlite3.Row
85
+ conn.execute('PRAGMA foreign_keys = ON')
86
+ conn.execute('PRAGMA journal_mode=WAL')
87
+ yield conn
88
+ except Exception as e:
89
+ self.logger.error(f"Database connection error: {e}")
90
+ if conn:
91
+ conn.rollback()
92
+ raise
93
+ finally:
94
+ if conn:
95
+ conn.close()
96
+
97
+ def init_database(self) -> None:
98
+ """Initialize the SQLite database and Full-Text Search (FTS5) table."""
99
+ try:
100
+ with self.get_db_connection() as conn:
101
+ conn.execute('''
102
+ CREATE TABLE IF NOT EXISTS notes (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ Created DATETIME DEFAULT CURRENT_TIMESTAMP,
105
+ Modified DATETIME DEFAULT CURRENT_TIMESTAMP,
106
+ Title TEXT(255),
107
+ Input TEXT,
108
+ Output TEXT
109
+ )
110
+ ''')
111
+
112
+ # Drop legacy triggers and FTS table to ensure schema is correct
113
+ for trigger in ['notes_after_insert', 'notes_after_delete', 'notes_after_update']:
114
+ conn.execute(f'DROP TRIGGER IF EXISTS {trigger}')
115
+ conn.execute('DROP TABLE IF EXISTS notes_fts')
116
+
117
+ conn.execute('''
118
+ CREATE VIRTUAL TABLE notes_fts USING fts5(
119
+ Title, Input, Output,
120
+ content='notes',
121
+ content_rowid='id'
122
+ )
123
+ ''')
124
+
125
+ conn.executescript('''
126
+ CREATE TRIGGER IF NOT EXISTS notes_after_insert AFTER INSERT ON notes BEGIN
127
+ INSERT INTO notes_fts(rowid, Title, Input, Output)
128
+ VALUES (new.id, new.Title, new.Input, new.Output);
129
+ END;
130
+ CREATE TRIGGER IF NOT EXISTS notes_after_delete AFTER DELETE ON notes BEGIN
131
+ INSERT INTO notes_fts(notes_fts, rowid, Title, Input, Output)
132
+ VALUES ('delete', old.id, old.Title, old.Input, old.Output);
133
+ END;
134
+ CREATE TRIGGER IF NOT EXISTS notes_after_update AFTER UPDATE ON notes BEGIN
135
+ INSERT INTO notes_fts(notes_fts, rowid, Title, Input, Output)
136
+ VALUES ('delete', old.id, old.Title, old.Input, old.Output);
137
+ INSERT INTO notes_fts(rowid, Title, Input, Output)
138
+ VALUES (new.id, new.Title, new.Input, new.Output);
139
+ END;
140
+ ''')
141
+
142
+ conn.execute('INSERT INTO notes_fts(notes_fts) VALUES("rebuild")')
143
+ conn.commit()
144
+ self.logger.info("Notes database initialized successfully")
145
+ except Exception as e:
146
+ self.logger.error(f"Database initialization error: {e}")
147
+ if self.dialog_manager:
148
+ self.dialog_manager.show_error("Database Error", f"Failed to initialize database: {e}")
149
+ else:
150
+ messagebox.showerror("Database Error", f"Failed to initialize database: {e}", parent=self.parent)
151
+
152
+ def create_ui(self) -> None:
153
+ """Create the main user interface components."""
154
+ # Search frame
155
+ search_frame = ttk.Frame(self.parent)
156
+ search_frame.pack(fill=tk.X, padx=10, pady=5)
157
+
158
+ help_btn = ttk.Button(search_frame, text="?", width=3, command=self.show_search_help)
159
+ help_btn.pack(side=tk.LEFT, padx=(0, 5))
160
+
161
+ self.search_var = tk.StringVar()
162
+ self.search_var.trace_add('write', self.on_search_change)
163
+ self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var, width=80)
164
+ self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
165
+
166
+ search_btn = ttk.Button(search_frame, text="Search", command=lambda: self.perform_search())
167
+ search_btn.pack(side=tk.RIGHT, padx=(5, 0))
168
+
169
+ # Main frame with paned window
170
+ main_frame = ttk.Frame(self.parent)
171
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
172
+
173
+ try:
174
+ self.paned_window = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL, sashwidth=8, sashrelief=tk.RAISED)
175
+ except tk.TclError:
176
+ self.paned_window = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL)
177
+ self.paned_window.pack(fill=tk.BOTH, expand=True)
178
+
179
+ # Left frame - treeview
180
+ left_frame = ttk.Frame(self.paned_window)
181
+ self.paned_window.add(left_frame, weight=7)
182
+
183
+ columns = ('ID', 'Created', 'Modified', 'Title')
184
+ self.tree = ttk.Treeview(left_frame, columns=columns, show='headings', selectmode='extended')
185
+
186
+ for col in columns:
187
+ self.tree.heading(col, text=col, command=lambda c=col: self.sort_by_column(c))
188
+
189
+ self.tree.column('ID', width=40, minwidth=30, stretch=False)
190
+ self.tree.column('Created', width=120, minwidth=100, stretch=False)
191
+ self.tree.column('Modified', width=120, minwidth=100, stretch=False)
192
+ self.tree.column('Title', width=200, minwidth=120)
193
+
194
+ tree_scroll = ttk.Scrollbar(left_frame, orient=tk.VERTICAL, command=self.tree.yview)
195
+ self.tree.configure(yscrollcommand=tree_scroll.set)
196
+
197
+ self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
198
+ tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
199
+
200
+ self.tree.bind('<<TreeviewSelect>>', self.on_tree_select)
201
+ self.tree.bind('<Double-1>', self.on_tree_double_click)
202
+
203
+ # Right frame - detail view
204
+ right_frame = ttk.Frame(self.paned_window)
205
+ self.paned_window.add(right_frame, weight=3)
206
+
207
+ # Button frame
208
+ self.btn_frame = ttk.Frame(right_frame)
209
+ self.btn_frame.pack(fill=tk.X, pady=(0, 10))
210
+
211
+ self.new_btn = ttk.Button(self.btn_frame, text="New Note", command=self.new_note)
212
+ self.duplicate_btn = ttk.Button(self.btn_frame, text="Duplicate", command=self.duplicate_note)
213
+ self.change_btn = ttk.Button(self.btn_frame, text="Change", command=self.change_note)
214
+ self.delete_btn = ttk.Button(self.btn_frame, text="Delete", command=self.delete_notes)
215
+ self.save_btn = ttk.Button(self.btn_frame, text="Save", command=self.save_edits)
216
+ self.cancel_btn = ttk.Button(self.btn_frame, text="Cancel", command=self.cancel_edits)
217
+
218
+ self.update_action_buttons()
219
+
220
+ # Display frame
221
+ display_frame = ttk.Frame(right_frame)
222
+ display_frame.pack(fill=tk.BOTH, expand=True)
223
+
224
+ self.create_item_display(display_frame)
225
+
226
+ # Status bar
227
+ self.status_bar = ttk.Label(self.parent, text="Ready", relief=tk.SUNKEN, anchor=tk.W)
228
+ self.status_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
229
+
230
+ def create_item_display(self, parent: ttk.Frame) -> None:
231
+ """Create the widgets for displaying a single note item."""
232
+ # Date frame
233
+ date_frame = ttk.Frame(parent)
234
+ date_frame.pack(fill=tk.X, pady=(0, 5))
235
+ self.created_label = ttk.Label(date_frame, text="Created: ", foreground="green")
236
+ self.created_label.pack(side=tk.LEFT)
237
+ self.modified_label = ttk.Label(date_frame, text="Modified: ", foreground="blue")
238
+ self.modified_label.pack(side=tk.RIGHT)
239
+
240
+ # Title frame
241
+ self.title_frame = ttk.Frame(parent)
242
+ self.title_frame.pack(fill=tk.X, pady=(0, 5))
243
+ ttk.Label(self.title_frame, text="Title:").pack(side=tk.LEFT)
244
+ self.title_display = ttk.Label(self.title_frame, text="", font=('TkDefaultFont', 9, 'bold'))
245
+ self.title_display.pack(side=tk.LEFT, padx=(5, 0))
246
+
247
+ # INPUT section
248
+ input_label_frame = ttk.Frame(parent)
249
+ input_label_frame.pack(fill=tk.X, pady=(0, 2))
250
+ ttk.Label(input_label_frame, text="INPUT:", font=('TkDefaultFont', 9, 'bold')).pack(side=tk.LEFT)
251
+
252
+ # Send To dropdown for INPUT
253
+ self.input_send_var = tk.StringVar(value="Send To")
254
+ self.input_send_menu_btn = ttk.Menubutton(input_label_frame, textvariable=self.input_send_var, direction="below")
255
+ self.input_send_menu_btn.pack(side=tk.RIGHT, padx=(5, 0))
256
+ self.input_send_dropdown = tk.Menu(self.input_send_menu_btn, tearoff=0)
257
+ self.input_send_menu_btn.config(menu=self.input_send_dropdown)
258
+ self._build_send_to_menu(self.input_send_dropdown, "input")
259
+
260
+ input_frame = ttk.Frame(parent)
261
+ input_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
262
+
263
+ line_frame = ttk.Frame(input_frame)
264
+ line_frame.pack(side=tk.LEFT, fill=tk.Y)
265
+ self.input_line_numbers = tk.Text(line_frame, width=4, padx=3, takefocus=0, border=0, state='disabled', wrap='none')
266
+ self.input_line_numbers.pack(fill=tk.Y, expand=True)
267
+
268
+ text_frame = ttk.Frame(input_frame)
269
+ text_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
270
+ self.input_display = tk.Text(text_frame, wrap=tk.WORD, state='disabled', undo=True, maxundo=50)
271
+ input_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL)
272
+ self.input_display.config(yscrollcommand=lambda *args: self.sync_scroll(input_scrollbar, self.input_line_numbers, *args))
273
+ input_scrollbar.config(command=lambda *args: self.sync_scroll_command(self.input_display, self.input_line_numbers, *args))
274
+ self.input_display.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
275
+ input_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
276
+
277
+ input_status_frame = ttk.Frame(parent)
278
+ input_status_frame.pack(fill=tk.X, pady=(0, 5))
279
+ self.input_status_label = ttk.Label(input_status_frame, text="Char: 0 | Word: 0 | Line: 0")
280
+ self.input_status_label.pack(side=tk.LEFT)
281
+
282
+ # OUTPUT section
283
+ output_label_frame = ttk.Frame(parent)
284
+ output_label_frame.pack(fill=tk.X, pady=(0, 2))
285
+ ttk.Label(output_label_frame, text="OUTPUT:", font=('TkDefaultFont', 9, 'bold')).pack(side=tk.LEFT)
286
+
287
+ # Send To dropdown for OUTPUT
288
+ self.output_send_var = tk.StringVar(value="Send To")
289
+ self.output_send_menu_btn = ttk.Menubutton(output_label_frame, textvariable=self.output_send_var, direction="below")
290
+ self.output_send_menu_btn.pack(side=tk.RIGHT, padx=(5, 0))
291
+ self.output_send_dropdown = tk.Menu(self.output_send_menu_btn, tearoff=0)
292
+ self.output_send_menu_btn.config(menu=self.output_send_dropdown)
293
+ self._build_send_to_menu(self.output_send_dropdown, "output")
294
+
295
+ output_frame = ttk.Frame(parent)
296
+ output_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
297
+
298
+ line_frame = ttk.Frame(output_frame)
299
+ line_frame.pack(side=tk.LEFT, fill=tk.Y)
300
+ self.output_line_numbers = tk.Text(line_frame, width=4, padx=3, takefocus=0, border=0, state='disabled', wrap='none')
301
+ self.output_line_numbers.pack(fill=tk.Y, expand=True)
302
+
303
+ text_frame = ttk.Frame(output_frame)
304
+ text_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
305
+ self.output_display = tk.Text(text_frame, wrap=tk.WORD, state='disabled', undo=True, maxundo=50)
306
+ output_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL)
307
+ self.output_display.config(yscrollcommand=lambda *args: self.sync_scroll(output_scrollbar, self.output_line_numbers, *args))
308
+ output_scrollbar.config(command=lambda *args: self.sync_scroll_command(self.output_display, self.output_line_numbers, *args))
309
+ self.output_display.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
310
+ output_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
311
+
312
+ output_status_frame = ttk.Frame(parent)
313
+ output_status_frame.pack(fill=tk.X, pady=(0, 5))
314
+ self.output_status_label = ttk.Label(output_status_frame, text="Char: 0 | Word: 0 | Line: 0")
315
+ self.output_status_label.pack(side=tk.LEFT)
316
+
317
+ def _build_send_to_menu(self, menu: tk.Menu, content_type: str) -> None:
318
+ """Build the Send To dropdown menu with Tab 1-7 options."""
319
+ menu.delete(0, tk.END)
320
+ for i in range(7):
321
+ # Use a factory function to properly capture the variables
322
+ def make_command(tab_idx, content_t):
323
+ return lambda: self._send_to_input_tab(tab_idx, content_t)
324
+ menu.add_command(
325
+ label=f"Tab {i+1}",
326
+ command=make_command(i, content_type)
327
+ )
328
+
329
+ def _send_to_input_tab(self, tab_index: int, content_type: str) -> None:
330
+ """Send note content to a specific input tab."""
331
+ if not self.send_to_input_callback:
332
+ if self.dialog_manager:
333
+ self.dialog_manager.show_warning("Warning", "Send to Input functionality is not available.")
334
+ else:
335
+ messagebox.showwarning("Warning", "Send to Input functionality is not available.", parent=self.parent)
336
+ return
337
+
338
+ # Get content based on type - ONLY get the specified type
339
+ content = ""
340
+ if content_type == "input":
341
+ content = self.input_display.get("1.0", tk.END).strip()
342
+ self.logger.debug(f"Sending INPUT content to tab {tab_index + 1} (length: {len(content)})")
343
+ elif content_type == "output":
344
+ content = self.output_display.get("1.0", tk.END).strip()
345
+ self.logger.debug(f"Sending OUTPUT content to tab {tab_index + 1} (length: {len(content)})")
346
+ else:
347
+ self.logger.warning(f"Unknown content_type: {content_type}")
348
+ return
349
+
350
+ if not content:
351
+ if self.dialog_manager:
352
+ self.dialog_manager.show_warning("Warning", f"No {content_type.upper()} content available to send.")
353
+ else:
354
+ messagebox.showwarning("Warning", f"No {content_type.upper()} content available to send.", parent=self.parent)
355
+ return
356
+
357
+ # Send to input tab using callback - ONLY the selected content
358
+ self.send_to_input_callback(tab_index, content)
359
+
360
+ # Show success message
361
+ content_name = content_type.upper()
362
+ if self.dialog_manager:
363
+ self.dialog_manager.show_info("Success", f"{content_name} content sent to Input Tab {tab_index + 1}")
364
+ else:
365
+ messagebox.showinfo("Success", f"{content_name} content sent to Input Tab {tab_index + 1}", parent=self.parent)
366
+ self.logger.info(f"{content_name} content sent to input tab {tab_index + 1}")
367
+
368
+ def sync_scroll(self, scrollbar: ttk.Scrollbar, line_numbers: tk.Text, *args: str) -> None:
369
+ """Synchronize scrolling between a text widget and its line numbers."""
370
+ scrollbar.set(*args)
371
+ if len(args) >= 2:
372
+ top = float(args[0])
373
+ line_numbers.yview_moveto(top)
374
+
375
+ def sync_scroll_command(self, main_text: tk.Text, line_numbers: tk.Text, *args: str) -> None:
376
+ """Handle scrollbar commands to sync two text widgets."""
377
+ main_text.yview(*args)
378
+ line_numbers.yview(*args)
379
+
380
+ def on_search_change(self, *args: Any) -> None:
381
+ """Handle search input changes with debouncing."""
382
+ if self.search_debounce_timer:
383
+ self.parent.after_cancel(self.search_debounce_timer)
384
+ self.search_debounce_timer = self.parent.after(300, lambda: self.perform_search())
385
+
386
+ def perform_search(self, select_item_id: Optional[int] = None, select_first: bool = False) -> None:
387
+ """Perform a cancellable search using a thread pool."""
388
+ search_term = self.search_var.get().strip()
389
+
390
+ if self.current_search_future and not self.current_search_future.done():
391
+ self.current_search_future.cancel()
392
+
393
+ if hasattr(self, 'status_bar'):
394
+ self.status_bar.config(text="Searching...")
395
+ self.parent.config(cursor="wait")
396
+ self.parent.update_idletasks()
397
+
398
+ def search_worker(term: str) -> List[Tuple]:
399
+ try:
400
+ with self.get_db_connection() as conn:
401
+ if term:
402
+ cursor = conn.execute('''
403
+ SELECT n.id, n.Created, n.Modified, n.Title, n.Input, n.Output
404
+ FROM notes n JOIN notes_fts fts ON n.id = fts.rowid
405
+ WHERE notes_fts MATCH ? ORDER BY rank
406
+ ''', (term + '*',))
407
+ else:
408
+ cursor = conn.execute('''
409
+ SELECT id, Created, Modified, Title, Input, Output
410
+ FROM notes ORDER BY Modified DESC
411
+ ''')
412
+ return cursor.fetchall()
413
+ except Exception as e:
414
+ self.logger.error(f"Search worker error: {e}")
415
+ return []
416
+
417
+ self.current_search_future = self.search_executor.submit(search_worker, search_term)
418
+ self.current_search_future.add_done_callback(
419
+ lambda future: self._safe_after(lambda: self._handle_search_results(future, select_item_id, select_first))
420
+ )
421
+
422
+ def _safe_after(self, callback, delay: int = 0) -> None:
423
+ """Schedule a callback only if the parent widget still exists."""
424
+ try:
425
+ if self.parent.winfo_exists():
426
+ self.parent.after(delay, callback)
427
+ except Exception:
428
+ # Widget was destroyed, ignore
429
+ pass
430
+
431
+ def _handle_search_results(self, future: Future, select_item_id: Optional[int] = None, select_first: bool = False) -> None:
432
+ """Process search results in the main UI thread."""
433
+ if future.cancelled():
434
+ return
435
+
436
+ # Check if widget still exists before updating
437
+ try:
438
+ if not self.parent.winfo_exists():
439
+ return
440
+ except Exception:
441
+ return
442
+
443
+ if hasattr(self, 'status_bar'):
444
+ try:
445
+ self.status_bar.config(text="Ready")
446
+ self.parent.config(cursor="")
447
+ except Exception:
448
+ # Widget was destroyed, ignore
449
+ return
450
+
451
+ error = future.exception()
452
+ if error:
453
+ self.logger.error(f"Search failed: {error}")
454
+ if self.dialog_manager:
455
+ self.dialog_manager.show_error("Search Error", f"Search failed: {error}")
456
+ else:
457
+ messagebox.showerror("Search Error", f"Search failed: {error}", parent=self.parent)
458
+ self.search_results = []
459
+ else:
460
+ self.search_results = future.result()
461
+
462
+ self.refresh_search_view()
463
+
464
+ if select_item_id:
465
+ self._safe_after_idle(lambda: self._select_item_in_tree(select_item_id))
466
+ elif select_first:
467
+ self._safe_after_idle(self._select_first_item_in_tree)
468
+
469
+ def _safe_after_idle(self, callback) -> None:
470
+ """Schedule an idle callback only if the parent widget still exists."""
471
+ try:
472
+ if self.parent.winfo_exists():
473
+ self.parent.after_idle(callback)
474
+ except Exception:
475
+ # Widget was destroyed, ignore
476
+ pass
477
+
478
+ def sort_by_column(self, column: str) -> None:
479
+ """Sort the treeview by a specified column, cycling through directions."""
480
+ if self.sort_column == column:
481
+ if self.sort_direction == 'asc':
482
+ self.sort_direction = 'desc'
483
+ else:
484
+ self.sort_column = None
485
+ self.sort_direction = None
486
+ else:
487
+ self.sort_column = column
488
+ self.sort_direction = 'asc'
489
+
490
+ self.update_column_headers()
491
+ self.refresh_search_view()
492
+
493
+ def update_column_headers(self) -> None:
494
+ """Update treeview column headers with sort direction indicators."""
495
+ for col in ['ID', 'Created', 'Modified', 'Title']:
496
+ text = col
497
+ if col == self.sort_column:
498
+ if self.sort_direction == 'asc':
499
+ text += " ↑"
500
+ elif self.sort_direction == 'desc':
501
+ text += " ↓"
502
+ self.tree.heading(col, text=text)
503
+
504
+ def refresh_search_view(self) -> None:
505
+ """Refresh the search results treeview, applying sorting if active."""
506
+ self.tree.unbind('<<TreeviewSelect>>')
507
+ try:
508
+ self.tree.delete(*self.tree.get_children())
509
+
510
+ display_results = getattr(self, 'search_results', [])
511
+
512
+ if self.sort_column and self.sort_direction and display_results:
513
+ col_index = self.tree['columns'].index(self.sort_column)
514
+ reverse = (self.sort_direction == 'desc')
515
+
516
+ def sort_key(row):
517
+ val = row[col_index]
518
+ if self.sort_column == 'ID':
519
+ return int(val) if val else 0
520
+ return (val or '').lower()
521
+
522
+ display_results = sorted(display_results, key=sort_key, reverse=reverse)
523
+
524
+ if display_results:
525
+ for row in display_results:
526
+ self.tree.insert('', 'end', values=(
527
+ row['id'],
528
+ self.format_datetime(row['Created']),
529
+ self.format_datetime(row['Modified']),
530
+ (row['Title'] or '')[:50] + ("..." if len(row['Title'] or '') > 50 else "")
531
+ ))
532
+ finally:
533
+ self.tree.bind('<<TreeviewSelect>>', self.on_tree_select)
534
+
535
+ def format_datetime(self, dt_str: Optional[str]) -> str:
536
+ """Format a datetime string for display."""
537
+ if not dt_str:
538
+ return ""
539
+ try:
540
+ dt_str = dt_str.replace('Z', '+00:00')
541
+ dt = datetime.fromisoformat(dt_str)
542
+ return dt.strftime('%Y-%m-%d %I:%M %p')
543
+ except (ValueError, TypeError):
544
+ return dt_str
545
+
546
+ def on_tree_select(self, event: Optional[tk.Event]) -> None:
547
+ """Handle selection changes in the results treeview."""
548
+ if self.editing_mode:
549
+ if self.current_item:
550
+ self._select_item_in_tree(self.current_item)
551
+ return
552
+
553
+ selection = self.tree.selection()
554
+ self.selected_items = [self.tree.item(item)['values'][0] for item in selection]
555
+
556
+ if len(self.selected_items) == 1:
557
+ self.current_item = self.selected_items[0]
558
+ self.update_item_display()
559
+ else:
560
+ self.current_item = None
561
+ self.clear_item_display()
562
+
563
+ self.update_action_buttons()
564
+
565
+ def on_tree_double_click(self, event: tk.Event) -> None:
566
+ """Handle double-click on a tree item to enter edit mode."""
567
+ if self.current_item:
568
+ self.change_note()
569
+
570
+ def _select_item_in_tree(self, item_id: int) -> None:
571
+ """Select an item in the tree by its ID."""
572
+ for item in self.tree.get_children():
573
+ if str(self.tree.item(item)['values'][0]) == str(item_id):
574
+ self.tree.selection_set(item)
575
+ self.tree.focus(item)
576
+ self.tree.see(item)
577
+ self.on_tree_select(None)
578
+ return
579
+ self._select_first_item_in_tree()
580
+
581
+ def _select_first_item_in_tree(self) -> None:
582
+ """Select the first item in the tree."""
583
+ children = self.tree.get_children()
584
+ if children:
585
+ self.tree.selection_set(children[0])
586
+ self.tree.focus(children[0])
587
+ self.tree.see(children[0])
588
+ self.on_tree_select(None)
589
+
590
+ def update_item_display(self, force_refresh: bool = False) -> None:
591
+ """Update the item display panel, using a cache for performance."""
592
+ if not self.current_item:
593
+ return
594
+
595
+ try:
596
+ row = self.note_cache.get(self.current_item) if not force_refresh else None
597
+ if row:
598
+ self.logger.debug(f"Using cached data for item {self.current_item}")
599
+ else:
600
+ with self.get_db_connection() as conn:
601
+ row = conn.execute('SELECT * FROM notes WHERE id = ?', (self.current_item,)).fetchone()
602
+ if not row:
603
+ return
604
+
605
+ if len(self.note_cache) > 50:
606
+ del self.note_cache[next(iter(self.note_cache))]
607
+
608
+ self.note_cache[self.current_item] = row
609
+ self.logger.debug(f"Fetched and cached data for item {self.current_item}")
610
+
611
+ self.created_label.config(text=f"Created: {self.format_datetime(row['Created'])}")
612
+ self.modified_label.config(text=f"Modified: {self.format_datetime(row['Modified'])}")
613
+ self.title_display.config(text=row['Title'] or "")
614
+
615
+ for widget in [self.input_display, self.output_display]:
616
+ widget.config(state='normal')
617
+ widget.delete(1.0, tk.END)
618
+
619
+ if row['Input']:
620
+ self.input_display.insert(1.0, row['Input'])
621
+ if row['Output']:
622
+ self.output_display.insert(1.0, row['Output'])
623
+
624
+ for widget in [self.input_display, self.output_display]:
625
+ widget.config(state='disabled')
626
+
627
+ self.update_line_numbers(row['Input'] or "", self.input_line_numbers)
628
+ self.update_line_numbers(row['Output'] or "", self.output_line_numbers)
629
+ self.update_status(row['Input'] or "", self.input_status_label)
630
+ self.update_status(row['Output'] or "", self.output_status_label)
631
+
632
+ except Exception as e:
633
+ self.logger.error(f"Error updating item display: {e}")
634
+
635
+ def clear_item_display(self) -> None:
636
+ """Clear all fields in the item display panel."""
637
+ self.created_label.config(text="Created: ")
638
+ self.modified_label.config(text="Modified: ")
639
+ self.title_display.config(text="")
640
+
641
+ for widget in [self.input_display, self.output_display, self.input_line_numbers, self.output_line_numbers]:
642
+ widget.config(state='normal')
643
+ widget.delete(1.0, tk.END)
644
+ widget.config(state='disabled')
645
+
646
+ self.input_status_label.config(text="Char: 0 | Word: 0 | Line: 0")
647
+ self.output_status_label.config(text="Char: 0 | Word: 0 | Line: 0")
648
+
649
+ def update_line_numbers(self, text: str, line_numbers_widget: tk.Text) -> None:
650
+ """Update the line numbers displayed next to the text."""
651
+ line_numbers_widget.config(state='normal')
652
+ line_numbers_widget.delete(1.0, tk.END)
653
+ if text:
654
+ line_count = text.count('\n') + 1
655
+ line_nums = '\n'.join(map(str, range(1, line_count + 1)))
656
+ line_numbers_widget.insert(1.0, line_nums)
657
+ line_numbers_widget.config(state='disabled')
658
+
659
+ def _get_text_statistics(self, text: str) -> Dict[str, int]:
660
+ """Calculate statistics for a given block of text."""
661
+ if not text:
662
+ return {'char_count': 0, 'word_count': 0, 'line_count': 0}
663
+
664
+ char_count = len(text)
665
+ word_count = len(text.split())
666
+ line_count = text.count('\n') + 1
667
+
668
+ return {'char_count': char_count, 'word_count': word_count, 'line_count': line_count}
669
+
670
+ def update_status(self, text: str, status_label: ttk.Label) -> None:
671
+ """Update the status label with text statistics."""
672
+ stats = self._get_text_statistics(text)
673
+ status_label.config(
674
+ text=f"Char: {stats['char_count']} | Word: {stats['word_count']} | Line: {stats['line_count']}"
675
+ )
676
+
677
+ def show_search_help(self) -> None:
678
+ """Show a dialog with FTS5 search syntax help."""
679
+ help_text = """Search Tips:
680
+ • Use simple keywords to search all fields.
681
+ • Use "quotes" for exact phrases: "machine learning".
682
+ • Use AND/OR/NOT operators: python AND tutorial.
683
+ • Use wildcards: web* (matches web, website, etc.).
684
+ • Search specific columns: Title:refactor OR Input:code.
685
+ • Leave empty to show all records."""
686
+ if self.dialog_manager:
687
+ self.dialog_manager.show_info("Search Help", help_text)
688
+ else:
689
+ messagebox.showinfo("Search Help", help_text, parent=self.parent)
690
+
691
+ def new_note(self) -> None:
692
+ """Create a new note."""
693
+ self.current_item = None
694
+ self.clear_item_display()
695
+ self.enter_editing_mode()
696
+
697
+ def duplicate_note(self) -> None:
698
+ """Duplicate the currently selected note."""
699
+ if self.current_item:
700
+ try:
701
+ with self.get_db_connection() as conn:
702
+ row = conn.execute('SELECT * FROM notes WHERE id = ?', (self.current_item,)).fetchone()
703
+ if row:
704
+ now = datetime.now().isoformat()
705
+ conn.execute('''
706
+ INSERT INTO notes (Created, Modified, Title, Input, Output)
707
+ VALUES (?, ?, ?, ?, ?)
708
+ ''', (now, now, row['Title'], row['Input'], row['Output']))
709
+ conn.commit()
710
+ self.perform_search(select_first=True)
711
+ self.logger.info(f"Duplicated note {self.current_item}")
712
+ except Exception as e:
713
+ self.logger.error(f"Error duplicating note: {e}")
714
+ if self.dialog_manager:
715
+ self.dialog_manager.show_error("Error", f"Failed to duplicate note: {e}")
716
+ else:
717
+ messagebox.showerror("Error", f"Failed to duplicate note: {e}", parent=self.parent)
718
+
719
+ def change_note(self) -> None:
720
+ """Enter in-place editing mode for the selected note."""
721
+ if self.current_item and not self.editing_mode:
722
+ self.enter_editing_mode()
723
+
724
+ def delete_notes(self) -> None:
725
+ """Delete one or more selected notes."""
726
+ if not self.selected_items:
727
+ return
728
+
729
+ count = len(self.selected_items)
730
+ # Ensure dialog is modal to the Notes widget window
731
+ if self.dialog_manager:
732
+ confirmed = self.dialog_manager.ask_yes_no("Confirm Delete", f"Delete {count} item(s)? This cannot be undone.", "confirmation", parent=self.parent)
733
+ else:
734
+ confirmed = messagebox.askyesno("Confirm Delete", f"Delete {count} item(s)? This cannot be undone.", parent=self.parent)
735
+
736
+ if confirmed:
737
+ try:
738
+ with self.get_db_connection() as conn:
739
+ item_ids = tuple(self.selected_items)
740
+ conn.execute(f"DELETE FROM notes WHERE id IN ({','.join('?' for _ in item_ids)})", item_ids)
741
+ for item_id in item_ids:
742
+ if item_id in self.note_cache:
743
+ del self.note_cache[item_id]
744
+ conn.commit()
745
+
746
+ self.current_item = None
747
+ self.selected_items = []
748
+ self.clear_item_display()
749
+ self.perform_search(select_first=True)
750
+ self.logger.info(f"Deleted {count} items")
751
+ except Exception as e:
752
+ self.logger.error(f"Delete error: {e}")
753
+ if self.dialog_manager:
754
+ self.dialog_manager.show_error("Delete Error", f"Failed to delete: {e}")
755
+ else:
756
+ messagebox.showerror("Delete Error", f"Failed to delete: {e}", parent=self.parent)
757
+
758
+ def update_action_buttons(self) -> None:
759
+ """Centralized state machine for managing action buttons."""
760
+ for btn in [self.new_btn, self.duplicate_btn, self.change_btn, self.delete_btn, self.save_btn, self.cancel_btn]:
761
+ btn.pack_forget()
762
+
763
+ if self.editing_mode:
764
+ self.save_btn.pack(side=tk.LEFT, padx=(0, 5))
765
+ self.cancel_btn.pack(side=tk.LEFT, padx=5)
766
+ else:
767
+ self.new_btn.pack(side=tk.LEFT, padx=(0, 5))
768
+
769
+ num_selected = len(self.selected_items)
770
+
771
+ self.duplicate_btn.config(state='normal' if num_selected == 1 else 'disabled')
772
+ self.change_btn.config(state='normal' if num_selected == 1 else 'disabled')
773
+ self.duplicate_btn.pack(side=tk.LEFT, padx=5)
774
+ self.change_btn.pack(side=tk.LEFT, padx=5)
775
+
776
+ self.delete_btn.config(state='normal' if num_selected > 0 else 'disabled')
777
+ self.delete_btn.config(text=f"Delete ({num_selected})" if num_selected > 1 else "Delete")
778
+ self.delete_btn.pack(side=tk.LEFT, padx=(20, 0))
779
+
780
+ def save_edits(self) -> None:
781
+ """Save in-place edits to the database."""
782
+ if not self.editing_mode:
783
+ return
784
+
785
+ try:
786
+ title = self.title_entry.get() if hasattr(self, 'title_entry') else ""
787
+ input_content = self.input_display.get(1.0, tk.END).strip()
788
+ output_content = self.output_display.get(1.0, tk.END).strip()
789
+
790
+ now = datetime.now().isoformat()
791
+
792
+ with self.get_db_connection() as conn:
793
+ if self.current_item:
794
+ # Update existing note
795
+ conn.execute('''
796
+ UPDATE notes SET Modified = ?, Title = ?, Input = ?, Output = ?
797
+ WHERE id = ?
798
+ ''', (now, title, input_content, output_content, self.current_item))
799
+ if self.current_item in self.note_cache:
800
+ del self.note_cache[self.current_item]
801
+ else:
802
+ # Create new note
803
+ cursor = conn.execute('''
804
+ INSERT INTO notes (Created, Modified, Title, Input, Output)
805
+ VALUES (?, ?, ?, ?, ?)
806
+ ''', (now, now, title, input_content, output_content))
807
+ self.current_item = cursor.lastrowid
808
+ conn.commit()
809
+
810
+ self.exit_editing_mode()
811
+ self.perform_search(select_item_id=self.current_item)
812
+ self.logger.info(f"Saved note {self.current_item}")
813
+ except Exception as e:
814
+ self.logger.error(f"Error saving edits: {e}")
815
+ if self.dialog_manager:
816
+ self.dialog_manager.show_error("Save Error", f"Failed to save changes: {e}")
817
+ else:
818
+ messagebox.showerror("Save Error", f"Failed to save changes: {e}", parent=self.parent)
819
+
820
+ def cancel_edits(self) -> None:
821
+ """Cancel in-place editing and restore original content."""
822
+ if self.editing_mode:
823
+ if self.has_unsaved_changes:
824
+ if self.dialog_manager:
825
+ result = self.dialog_manager.ask_yes_no("Unsaved Changes",
826
+ "You have unsaved changes. Are you sure you want to cancel?", "confirmation", parent=self.parent)
827
+ else:
828
+ result = messagebox.askyesno("Unsaved Changes",
829
+ "You have unsaved changes. Are you sure you want to cancel?", parent=self.parent)
830
+ if not result:
831
+ return
832
+ self.exit_editing_mode()
833
+ if self.current_item:
834
+ self.update_item_display(force_refresh=True)
835
+ else:
836
+ self.clear_item_display()
837
+
838
+ def enter_editing_mode(self) -> None:
839
+ """Switch the UI to in-place editing mode."""
840
+ if self.editing_mode:
841
+ return
842
+
843
+ try:
844
+ if self.current_item:
845
+ with self.get_db_connection() as conn:
846
+ row = conn.execute('SELECT * FROM notes WHERE id = ?', (self.current_item,)).fetchone()
847
+ if not row:
848
+ return
849
+
850
+ self.original_data = {
851
+ 'Title': row['Title'] or "",
852
+ 'Input': row['Input'] or "",
853
+ 'Output': row['Output'] or ""
854
+ }
855
+ else:
856
+ self.original_data = {'Title': "", 'Input': "", 'Output': ""}
857
+
858
+ self.editing_mode = True
859
+ self.has_unsaved_changes = False
860
+ self.update_action_buttons()
861
+
862
+ self.tree.configure(selectmode='none')
863
+ if hasattr(self, 'status_bar'):
864
+ self.status_bar.config(text="EDITING MODE - Selection locked")
865
+
866
+ for widget in [self.input_display, self.output_display]:
867
+ widget.config(state='normal')
868
+
869
+ # Replace title display with entry
870
+ self.title_display.pack_forget()
871
+ self.title_entry = ttk.Entry(self.title_frame, font=('TkDefaultFont', 9, 'bold'))
872
+ if self.current_item:
873
+ self.title_entry.insert(0, self.original_data['Title'])
874
+ self.title_entry.pack(side=tk.LEFT, padx=(5, 0), fill=tk.X, expand=True)
875
+ self.title_entry.bind('<KeyRelease>', self.on_edit_change)
876
+
877
+ # Bind change events to text widgets
878
+ for widget in [self.input_display, self.output_display]:
879
+ widget.bind('<KeyRelease>', self.on_edit_change)
880
+ widget.bind('<Button-1>', self.on_edit_change)
881
+
882
+ except Exception as e:
883
+ self.logger.error(f"Error entering editing mode: {e}")
884
+ if self.dialog_manager:
885
+ self.dialog_manager.show_error("Edit Error", f"Failed to enter editing mode: {e}")
886
+ else:
887
+ messagebox.showerror("Edit Error", f"Failed to enter editing mode: {e}", parent=self.parent)
888
+ self.exit_editing_mode()
889
+
890
+ def on_edit_change(self, event=None) -> None:
891
+ """Track changes in editing mode and update status bar."""
892
+ if not self.editing_mode or not self.original_data:
893
+ return
894
+
895
+ current_data = {
896
+ 'Title': self.title_entry.get() if hasattr(self, 'title_entry') else "",
897
+ 'Input': self.input_display.get(1.0, tk.END).rstrip('\n'),
898
+ 'Output': self.output_display.get(1.0, tk.END).rstrip('\n')
899
+ }
900
+
901
+ has_changes = any(current_data[key] != self.original_data[key] for key in current_data)
902
+
903
+ if has_changes != self.has_unsaved_changes:
904
+ self.has_unsaved_changes = has_changes
905
+ if hasattr(self, 'status_bar'):
906
+ if has_changes:
907
+ self.status_bar.config(text="EDITING MODE - NOTE NEEDS TO BE SAVED", font=('TkDefaultFont', 9, 'bold'))
908
+ else:
909
+ self.status_bar.config(text="EDITING MODE - Selection locked", font=('TkDefaultFont', 9, 'normal'))
910
+
911
+ # Update statistics in real-time
912
+ if hasattr(self, 'input_display'):
913
+ input_text = self.input_display.get(1.0, tk.END)
914
+ self.update_line_numbers(input_text, self.input_line_numbers)
915
+ self.update_status(input_text, self.input_status_label)
916
+
917
+ if hasattr(self, 'output_display'):
918
+ output_text = self.output_display.get(1.0, tk.END)
919
+ self.update_line_numbers(output_text, self.output_line_numbers)
920
+ self.update_status(output_text, self.output_status_label)
921
+
922
+ def exit_editing_mode(self) -> None:
923
+ """Exit editing mode and restore the view-only UI."""
924
+ if not self.editing_mode:
925
+ return
926
+
927
+ self.editing_mode = False
928
+ self.has_unsaved_changes = False
929
+ self.original_data = None
930
+
931
+ self.tree.configure(selectmode='extended')
932
+
933
+ self.update_action_buttons()
934
+ if hasattr(self, 'status_bar'):
935
+ self.status_bar.config(text="Ready", font=('TkDefaultFont', 9, 'normal'))
936
+
937
+ for widget in [self.input_display, self.output_display]:
938
+ widget.config(state='disabled')
939
+ widget.unbind('<KeyRelease>')
940
+ widget.unbind('<Button-1>')
941
+
942
+ if hasattr(self, 'title_entry'):
943
+ self.title_entry.destroy()
944
+ delattr(self, 'title_entry')
945
+ self.title_display.pack(side=tk.LEFT, padx=(5, 0))
946
+
947
+ def save_note(self, title: str, input_content: str, output_content: str) -> Optional[int]:
948
+ """
949
+ Save a new note to the database.
950
+
951
+ Args:
952
+ title: Note title
953
+ input_content: INPUT tab content
954
+ output_content: OUTPUT tab content
955
+
956
+ Returns:
957
+ The ID of the created note, or None on error
958
+ """
959
+ try:
960
+ now = datetime.now().isoformat()
961
+ with self.get_db_connection() as conn:
962
+ cursor = conn.execute('''
963
+ INSERT INTO notes (Created, Modified, Title, Input, Output)
964
+ VALUES (?, ?, ?, ?, ?)
965
+ ''', (now, now, title, input_content, output_content))
966
+ note_id = cursor.lastrowid
967
+ conn.commit()
968
+
969
+ self.logger.info(f"Saved new note with ID {note_id}")
970
+ return note_id
971
+ except Exception as e:
972
+ self.logger.error(f"Error saving note: {e}")
973
+ return None