pomera-ai-commander 1.1.1 → 1.2.2

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