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.
- package/LICENSE +21 -21
- package/README.md +105 -680
- package/bin/pomera-ai-commander.js +62 -62
- package/core/__init__.py +65 -65
- package/core/app_context.py +482 -482
- package/core/async_text_processor.py +421 -421
- package/core/backup_manager.py +655 -655
- package/core/backup_recovery_manager.py +1199 -1033
- package/core/content_hash_cache.py +508 -508
- package/core/context_menu.py +313 -313
- package/core/data_directory.py +549 -0
- package/core/data_validator.py +1066 -1066
- package/core/database_connection_manager.py +744 -744
- package/core/database_curl_settings_manager.py +608 -608
- package/core/database_promera_ai_settings_manager.py +446 -446
- package/core/database_schema.py +411 -411
- package/core/database_schema_manager.py +395 -395
- package/core/database_settings_manager.py +1507 -1507
- package/core/database_settings_manager_interface.py +456 -456
- package/core/dialog_manager.py +734 -734
- package/core/diff_utils.py +239 -0
- package/core/efficient_line_numbers.py +540 -510
- package/core/error_handler.py +746 -746
- package/core/error_service.py +431 -431
- package/core/event_consolidator.py +511 -511
- package/core/mcp/__init__.py +43 -43
- package/core/mcp/find_replace_diff.py +334 -0
- package/core/mcp/protocol.py +288 -288
- package/core/mcp/schema.py +251 -251
- package/core/mcp/server_stdio.py +299 -299
- package/core/mcp/tool_registry.py +2699 -2345
- package/core/memento.py +275 -0
- package/core/memory_efficient_text_widget.py +711 -711
- package/core/migration_manager.py +914 -914
- package/core/migration_test_suite.py +1085 -1085
- package/core/migration_validator.py +1143 -1143
- package/core/optimized_find_replace.py +714 -714
- package/core/optimized_pattern_engine.py +424 -424
- package/core/optimized_search_highlighter.py +552 -552
- package/core/performance_monitor.py +674 -674
- package/core/persistence_manager.py +712 -712
- package/core/progressive_stats_calculator.py +632 -632
- package/core/regex_pattern_cache.py +529 -529
- package/core/regex_pattern_library.py +350 -350
- package/core/search_operation_manager.py +434 -434
- package/core/settings_defaults_registry.py +1087 -1087
- package/core/settings_integrity_validator.py +1111 -1111
- package/core/settings_serializer.py +557 -557
- package/core/settings_validator.py +1823 -1823
- package/core/smart_stats_calculator.py +709 -709
- package/core/statistics_update_manager.py +619 -619
- package/core/stats_config_manager.py +858 -858
- package/core/streaming_text_handler.py +723 -723
- package/core/task_scheduler.py +596 -596
- package/core/update_pattern_library.py +168 -168
- package/core/visibility_monitor.py +596 -596
- package/core/widget_cache.py +498 -498
- package/mcp.json +51 -61
- package/migrate_data.py +127 -0
- package/package.json +64 -57
- package/pomera.py +7883 -7482
- package/pomera_mcp_server.py +183 -144
- package/requirements.txt +33 -0
- package/scripts/Dockerfile.alpine +43 -0
- package/scripts/Dockerfile.gui-test +54 -0
- package/scripts/Dockerfile.linux +43 -0
- package/scripts/Dockerfile.test-linux +80 -0
- package/scripts/Dockerfile.ubuntu +39 -0
- package/scripts/README.md +53 -0
- package/scripts/build-all.bat +113 -0
- package/scripts/build-docker.bat +53 -0
- package/scripts/build-docker.sh +55 -0
- package/scripts/build-optimized.bat +101 -0
- package/scripts/build.sh +78 -0
- package/scripts/docker-compose.test.yml +27 -0
- package/scripts/docker-compose.yml +32 -0
- package/scripts/postinstall.js +62 -0
- package/scripts/requirements-minimal.txt +33 -0
- package/scripts/test-linux-simple.bat +28 -0
- package/scripts/validate-release-workflow.py +450 -0
- package/tools/__init__.py +4 -4
- package/tools/ai_tools.py +2891 -2891
- package/tools/ascii_art_generator.py +352 -352
- package/tools/base64_tools.py +183 -183
- package/tools/base_tool.py +511 -511
- package/tools/case_tool.py +308 -308
- package/tools/column_tools.py +395 -395
- package/tools/cron_tool.py +884 -884
- package/tools/curl_history.py +600 -600
- package/tools/curl_processor.py +1207 -1207
- package/tools/curl_settings.py +502 -502
- package/tools/curl_tool.py +5467 -5467
- package/tools/diff_viewer.py +1817 -1072
- package/tools/email_extraction_tool.py +248 -248
- package/tools/email_header_analyzer.py +425 -425
- package/tools/extraction_tools.py +250 -250
- package/tools/find_replace.py +2289 -1750
- package/tools/folder_file_reporter.py +1463 -1463
- package/tools/folder_file_reporter_adapter.py +480 -480
- package/tools/generator_tools.py +1216 -1216
- package/tools/hash_generator.py +255 -255
- package/tools/html_tool.py +656 -656
- package/tools/jsonxml_tool.py +729 -729
- package/tools/line_tools.py +419 -419
- package/tools/markdown_tools.py +561 -561
- package/tools/mcp_widget.py +1417 -1417
- package/tools/notes_widget.py +978 -973
- package/tools/number_base_converter.py +372 -372
- package/tools/regex_extractor.py +571 -571
- package/tools/slug_generator.py +310 -310
- package/tools/sorter_tools.py +458 -458
- package/tools/string_escape_tool.py +392 -392
- package/tools/text_statistics_tool.py +365 -365
- package/tools/text_wrapper.py +430 -430
- package/tools/timestamp_converter.py +421 -421
- package/tools/tool_loader.py +710 -710
- package/tools/translator_tools.py +522 -522
- package/tools/url_link_extractor.py +261 -261
- package/tools/url_parser.py +204 -204
- package/tools/whitespace_tools.py +355 -355
- package/tools/word_frequency_counter.py +146 -146
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/app_context.cpython-313.pyc +0 -0
- package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
- package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
- package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
- package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/error_service.cpython-313.pyc +0 -0
- package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
- package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
- package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
- package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
- package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
- package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
- package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
- package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
- package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
- package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
- package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
- package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
package/tools/notes_widget.py
CHANGED
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
self.
|
|
61
|
-
self.
|
|
62
|
-
self.
|
|
63
|
-
self.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
self.
|
|
67
|
-
self.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
self.tree.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
self.tree.
|
|
196
|
-
|
|
197
|
-
self.tree.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
self.tree.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
self.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
self.
|
|
214
|
-
self.
|
|
215
|
-
|
|
216
|
-
self.
|
|
217
|
-
|
|
218
|
-
self.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
self.
|
|
242
|
-
self.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
self.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
self.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
self.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
self.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
self.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
self.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
self.logger.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def
|
|
381
|
-
"""Handle
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
self.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
''')
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
self.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
self.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
self.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
def
|
|
571
|
-
"""
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
self.
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
self.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
line_numbers_widget.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
•
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
def
|
|
725
|
-
"""
|
|
726
|
-
if not self.
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if self.
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
if
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
self.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
self.
|
|
773
|
-
|
|
774
|
-
self.
|
|
775
|
-
|
|
776
|
-
self.
|
|
777
|
-
self.
|
|
778
|
-
self.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
self.
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
self.
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
self.
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
self.
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if hasattr(self, '
|
|
918
|
-
|
|
919
|
-
self.update_line_numbers(
|
|
920
|
-
self.update_status(
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
self.
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
self.
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
conn.
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1
|
+
"""
|
|
2
|
+
Notes Widget Module for Pomera
|
|
3
|
+
|
|
4
|
+
This module provides a Notes widget that allows saving INPUT/OUTPUT tab content
|
|
5
|
+
to a local SQLite database. The UI is modeled after prompt_mini but simplified
|
|
6
|
+
to only include Date Created, Date Modified, Title, INPUT, and OUTPUT fields.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Save INPUT/OUTPUT tab content as notes
|
|
10
|
+
- Full-text search (FTS5) across Title, Input, Output
|
|
11
|
+
- Sortable treeview (ID, Created, Modified, Title)
|
|
12
|
+
- Send To feature for sending note content back to input tabs
|
|
13
|
+
- In-place editing mode
|
|
14
|
+
- Text statistics display
|
|
15
|
+
|
|
16
|
+
Author: Pomera AI Commander
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import tkinter as tk
|
|
20
|
+
from tkinter import ttk, messagebox, scrolledtext
|
|
21
|
+
import sqlite3
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Optional, List, Dict, Any, Tuple, Generator
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor, Future
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotesWidget:
|
|
33
|
+
"""Notes widget for saving and managing INPUT/OUTPUT tab content."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, parent, logger=None, send_to_input_callback=None, dialog_manager=None):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the Notes widget.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
parent: Parent tkinter widget
|
|
41
|
+
logger: Logger instance for debugging
|
|
42
|
+
send_to_input_callback: Callback function to send content to input tabs
|
|
43
|
+
dialog_manager: DialogManager instance for configurable dialogs
|
|
44
|
+
"""
|
|
45
|
+
self.parent = parent
|
|
46
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
47
|
+
self.send_to_input_callback = send_to_input_callback
|
|
48
|
+
self.dialog_manager = dialog_manager
|
|
49
|
+
|
|
50
|
+
# Database path - use 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
|