meeting-noter 2.0.0__py3-none-any.whl → 3.0.1__py3-none-any.whl
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.
Potentially problematic release.
This version of meeting-noter might be problematic. Click here for more details.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +41 -0
- meeting_noter/gui/__init__.py +51 -0
- meeting_noter/gui/main_window.py +219 -0
- meeting_noter/gui/menubar.py +248 -0
- meeting_noter/gui/screens/__init__.py +17 -0
- meeting_noter/gui/screens/dashboard.py +262 -0
- meeting_noter/gui/screens/logs.py +184 -0
- meeting_noter/gui/screens/recordings.py +279 -0
- meeting_noter/gui/screens/search.py +229 -0
- meeting_noter/gui/screens/settings.py +232 -0
- meeting_noter/gui/screens/viewer.py +140 -0
- meeting_noter/gui/theme/__init__.py +5 -0
- meeting_noter/gui/theme/dark_theme.py +53 -0
- meeting_noter/gui/theme/styles.qss +504 -0
- meeting_noter/gui/utils/__init__.py +15 -0
- meeting_noter/gui/utils/signals.py +82 -0
- meeting_noter/gui/utils/workers.py +258 -0
- meeting_noter/gui/widgets/__init__.py +6 -0
- meeting_noter/gui/widgets/sidebar.py +210 -0
- meeting_noter/gui/widgets/status_indicator.py +108 -0
- {meeting_noter-2.0.0.dist-info → meeting_noter-3.0.1.dist-info}/METADATA +2 -1
- {meeting_noter-2.0.0.dist-info → meeting_noter-3.0.1.dist-info}/RECORD +26 -7
- {meeting_noter-2.0.0.dist-info → meeting_noter-3.0.1.dist-info}/WHEEL +0 -0
- {meeting_noter-2.0.0.dist-info → meeting_noter-3.0.1.dist-info}/entry_points.txt +0 -0
- {meeting_noter-2.0.0.dist-info → meeting_noter-3.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Recordings browser screen for Meeting Noter GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import Qt
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QAbstractItemView,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QHeaderView,
|
|
17
|
+
QLabel,
|
|
18
|
+
QPushButton,
|
|
19
|
+
QTableWidget,
|
|
20
|
+
QTableWidgetItem,
|
|
21
|
+
QVBoxLayout,
|
|
22
|
+
QWidget,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from meeting_noter.config import get_config
|
|
26
|
+
from meeting_noter.gui.utils.signals import get_app_state
|
|
27
|
+
from meeting_noter.output.writer import format_duration, format_size, get_audio_duration
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RecordingInfo:
|
|
32
|
+
"""Information about a recording."""
|
|
33
|
+
|
|
34
|
+
filepath: Path
|
|
35
|
+
date: datetime
|
|
36
|
+
duration: Optional[float]
|
|
37
|
+
size: int
|
|
38
|
+
has_transcript: bool
|
|
39
|
+
is_favorite: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RecordingsScreen(QWidget):
|
|
43
|
+
"""Screen for browsing and managing recordings."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, parent=None):
|
|
46
|
+
super().__init__(parent)
|
|
47
|
+
self._recordings: list[RecordingInfo] = []
|
|
48
|
+
self._setup_ui()
|
|
49
|
+
self._connect_signals()
|
|
50
|
+
|
|
51
|
+
def _setup_ui(self):
|
|
52
|
+
"""Set up the recordings UI."""
|
|
53
|
+
layout = QVBoxLayout(self)
|
|
54
|
+
layout.setContentsMargins(30, 30, 30, 30)
|
|
55
|
+
layout.setSpacing(20)
|
|
56
|
+
|
|
57
|
+
# Title
|
|
58
|
+
title = QLabel("Recordings")
|
|
59
|
+
title.setStyleSheet("font-size: 24px; font-weight: 600; color: #ffffff;")
|
|
60
|
+
layout.addWidget(title)
|
|
61
|
+
|
|
62
|
+
# Table
|
|
63
|
+
self._table = QTableWidget()
|
|
64
|
+
self._table.setColumnCount(6)
|
|
65
|
+
self._table.setHorizontalHeaderLabels(
|
|
66
|
+
["\u2605", "Date", "Duration", "Size", "Transcript", "File"]
|
|
67
|
+
)
|
|
68
|
+
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
69
|
+
self._table.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
70
|
+
self._table.setAlternatingRowColors(True)
|
|
71
|
+
self._table.setSortingEnabled(True)
|
|
72
|
+
self._table.verticalHeader().setVisible(False)
|
|
73
|
+
|
|
74
|
+
# Column widths
|
|
75
|
+
header = self._table.horizontalHeader()
|
|
76
|
+
header.setSectionResizeMode(0, QHeaderView.Fixed)
|
|
77
|
+
header.resizeSection(0, 40)
|
|
78
|
+
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
79
|
+
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
80
|
+
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
81
|
+
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
82
|
+
header.setSectionResizeMode(5, QHeaderView.Stretch)
|
|
83
|
+
|
|
84
|
+
self._table.doubleClicked.connect(self._on_row_double_clicked)
|
|
85
|
+
layout.addWidget(self._table, 1)
|
|
86
|
+
|
|
87
|
+
# Action buttons
|
|
88
|
+
button_row = QHBoxLayout()
|
|
89
|
+
button_row.setSpacing(10)
|
|
90
|
+
|
|
91
|
+
self._view_btn = QPushButton("View Transcript")
|
|
92
|
+
self._transcribe_btn = QPushButton("Transcribe")
|
|
93
|
+
self._favorite_btn = QPushButton("\u2605 Favorite")
|
|
94
|
+
self._refresh_btn = QPushButton("Refresh")
|
|
95
|
+
self._refresh_btn.setProperty("class", "secondary")
|
|
96
|
+
self._refresh_btn.setStyleSheet("background-color: #3c3c3c;")
|
|
97
|
+
|
|
98
|
+
button_row.addWidget(self._view_btn)
|
|
99
|
+
button_row.addWidget(self._transcribe_btn)
|
|
100
|
+
button_row.addWidget(self._favorite_btn)
|
|
101
|
+
button_row.addStretch()
|
|
102
|
+
button_row.addWidget(self._refresh_btn)
|
|
103
|
+
layout.addLayout(button_row)
|
|
104
|
+
|
|
105
|
+
# Status label
|
|
106
|
+
self._status_label = QLabel("")
|
|
107
|
+
self._status_label.setStyleSheet("color: #858585;")
|
|
108
|
+
layout.addWidget(self._status_label)
|
|
109
|
+
|
|
110
|
+
def _connect_signals(self):
|
|
111
|
+
"""Connect button signals."""
|
|
112
|
+
self._view_btn.clicked.connect(self._view_transcript)
|
|
113
|
+
self._transcribe_btn.clicked.connect(self._transcribe_selected)
|
|
114
|
+
self._favorite_btn.clicked.connect(self._toggle_favorite)
|
|
115
|
+
self._refresh_btn.clicked.connect(self.refresh)
|
|
116
|
+
|
|
117
|
+
def refresh(self):
|
|
118
|
+
"""Refresh the recordings list."""
|
|
119
|
+
self._load_recordings()
|
|
120
|
+
self._populate_table()
|
|
121
|
+
|
|
122
|
+
def _load_recordings(self):
|
|
123
|
+
"""Load recordings from disk."""
|
|
124
|
+
config = get_config()
|
|
125
|
+
recordings_dir = config.recordings_dir
|
|
126
|
+
|
|
127
|
+
if not recordings_dir.exists():
|
|
128
|
+
self._recordings = []
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
recordings = []
|
|
132
|
+
mp3_files = sorted(
|
|
133
|
+
recordings_dir.glob("*.mp3"),
|
|
134
|
+
key=lambda p: p.stat().st_mtime,
|
|
135
|
+
reverse=True,
|
|
136
|
+
)[:50] # Limit to 50
|
|
137
|
+
|
|
138
|
+
for mp3_path in mp3_files:
|
|
139
|
+
try:
|
|
140
|
+
stat = mp3_path.stat()
|
|
141
|
+
date = datetime.fromtimestamp(stat.st_mtime)
|
|
142
|
+
size = stat.st_size
|
|
143
|
+
|
|
144
|
+
# Check for transcript
|
|
145
|
+
transcript_name = mp3_path.stem + ".txt"
|
|
146
|
+
transcript_path = config.transcripts_dir / transcript_name
|
|
147
|
+
has_transcript = transcript_path.exists()
|
|
148
|
+
|
|
149
|
+
# Get duration
|
|
150
|
+
try:
|
|
151
|
+
duration = get_audio_duration(mp3_path)
|
|
152
|
+
except Exception:
|
|
153
|
+
duration = None
|
|
154
|
+
|
|
155
|
+
# Check favorite status
|
|
156
|
+
is_favorite = config.is_favorite(mp3_path.name)
|
|
157
|
+
|
|
158
|
+
recordings.append(
|
|
159
|
+
RecordingInfo(
|
|
160
|
+
filepath=mp3_path,
|
|
161
|
+
date=date,
|
|
162
|
+
duration=duration,
|
|
163
|
+
size=size,
|
|
164
|
+
has_transcript=has_transcript,
|
|
165
|
+
is_favorite=is_favorite,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
except Exception:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
self._recordings = recordings
|
|
172
|
+
|
|
173
|
+
def _populate_table(self):
|
|
174
|
+
"""Populate the table with recordings."""
|
|
175
|
+
self._table.setRowCount(len(self._recordings))
|
|
176
|
+
|
|
177
|
+
for row, rec in enumerate(self._recordings):
|
|
178
|
+
# Favorite star
|
|
179
|
+
star_item = QTableWidgetItem("\u2605" if rec.is_favorite else "")
|
|
180
|
+
star_item.setTextAlignment(Qt.AlignCenter)
|
|
181
|
+
star_item.setData(Qt.UserRole, rec.filepath)
|
|
182
|
+
self._table.setItem(row, 0, star_item)
|
|
183
|
+
|
|
184
|
+
# Date
|
|
185
|
+
date_item = QTableWidgetItem(rec.date.strftime("%Y-%m-%d %H:%M"))
|
|
186
|
+
self._table.setItem(row, 1, date_item)
|
|
187
|
+
|
|
188
|
+
# Duration
|
|
189
|
+
if rec.duration:
|
|
190
|
+
duration_str = format_duration(rec.duration)
|
|
191
|
+
else:
|
|
192
|
+
duration_str = "?"
|
|
193
|
+
duration_item = QTableWidgetItem(duration_str)
|
|
194
|
+
duration_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
195
|
+
self._table.setItem(row, 2, duration_item)
|
|
196
|
+
|
|
197
|
+
# Size
|
|
198
|
+
size_item = QTableWidgetItem(format_size(rec.size))
|
|
199
|
+
size_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
200
|
+
self._table.setItem(row, 3, size_item)
|
|
201
|
+
|
|
202
|
+
# Transcript
|
|
203
|
+
transcript_item = QTableWidgetItem("Yes" if rec.has_transcript else "No")
|
|
204
|
+
transcript_item.setTextAlignment(Qt.AlignCenter)
|
|
205
|
+
if rec.has_transcript:
|
|
206
|
+
transcript_item.setForeground(Qt.green)
|
|
207
|
+
self._table.setItem(row, 4, transcript_item)
|
|
208
|
+
|
|
209
|
+
# Filename
|
|
210
|
+
file_item = QTableWidgetItem(rec.filepath.name)
|
|
211
|
+
self._table.setItem(row, 5, file_item)
|
|
212
|
+
|
|
213
|
+
self._status_label.setText(f"{len(self._recordings)} recordings")
|
|
214
|
+
|
|
215
|
+
def _get_selected_recording(self) -> Optional[RecordingInfo]:
|
|
216
|
+
"""Get the currently selected recording."""
|
|
217
|
+
row = self._table.currentRow()
|
|
218
|
+
if 0 <= row < len(self._recordings):
|
|
219
|
+
return self._recordings[row]
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _view_transcript(self):
|
|
223
|
+
"""View the transcript for the selected recording."""
|
|
224
|
+
rec = self._get_selected_recording()
|
|
225
|
+
if not rec:
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if not rec.has_transcript:
|
|
229
|
+
self._status_label.setText("No transcript available. Transcribe first.")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
config = get_config()
|
|
233
|
+
transcript_path = config.transcripts_dir / (rec.filepath.stem + ".txt")
|
|
234
|
+
|
|
235
|
+
if transcript_path.exists():
|
|
236
|
+
app_state = get_app_state()
|
|
237
|
+
app_state.open_transcript.emit(str(transcript_path))
|
|
238
|
+
|
|
239
|
+
def _on_row_double_clicked(self):
|
|
240
|
+
"""Handle double-click on a row."""
|
|
241
|
+
self._view_transcript()
|
|
242
|
+
|
|
243
|
+
def _transcribe_selected(self):
|
|
244
|
+
"""Transcribe the selected recording."""
|
|
245
|
+
rec = self._get_selected_recording()
|
|
246
|
+
if not rec:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
if rec.has_transcript:
|
|
250
|
+
self._status_label.setText("Already transcribed.")
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
self._status_label.setText(f"Transcribing {rec.filepath.name}...")
|
|
254
|
+
|
|
255
|
+
# Run transcription in subprocess
|
|
256
|
+
subprocess.Popen(
|
|
257
|
+
[sys.executable, "-m", "meeting_noter.cli", "transcribe", str(rec.filepath)],
|
|
258
|
+
stdout=subprocess.DEVNULL,
|
|
259
|
+
stderr=subprocess.DEVNULL,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
self._status_label.setText(f"Transcription started for {rec.filepath.name}")
|
|
263
|
+
|
|
264
|
+
def _toggle_favorite(self):
|
|
265
|
+
"""Toggle favorite status for the selected recording."""
|
|
266
|
+
rec = self._get_selected_recording()
|
|
267
|
+
if not rec:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
config = get_config()
|
|
271
|
+
if rec.is_favorite:
|
|
272
|
+
config.remove_favorite(rec.filepath.name)
|
|
273
|
+
self._status_label.setText(f"Removed from favorites: {rec.filepath.name}")
|
|
274
|
+
else:
|
|
275
|
+
config.add_favorite(rec.filepath.name)
|
|
276
|
+
self._status_label.setText(f"Added to favorites: {rec.filepath.name}")
|
|
277
|
+
|
|
278
|
+
# Refresh to update star display
|
|
279
|
+
self.refresh()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Search screen for Meeting Noter GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import Qt
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QCheckBox,
|
|
12
|
+
QHBoxLayout,
|
|
13
|
+
QLabel,
|
|
14
|
+
QLineEdit,
|
|
15
|
+
QListWidget,
|
|
16
|
+
QListWidgetItem,
|
|
17
|
+
QPushButton,
|
|
18
|
+
QVBoxLayout,
|
|
19
|
+
QWidget,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from meeting_noter.config import get_config
|
|
23
|
+
from meeting_noter.gui.utils.signals import get_app_state
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SearchMatch:
|
|
28
|
+
"""A search match result."""
|
|
29
|
+
|
|
30
|
+
filepath: Path
|
|
31
|
+
line_number: int
|
|
32
|
+
timestamp: str
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FileSearchResult:
|
|
38
|
+
"""Search results for a single file."""
|
|
39
|
+
|
|
40
|
+
filepath: Path
|
|
41
|
+
matches: list[SearchMatch]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SearchScreen(QWidget):
|
|
45
|
+
"""Screen for searching transcripts."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, parent=None):
|
|
48
|
+
super().__init__(parent)
|
|
49
|
+
self._results: list[FileSearchResult] = []
|
|
50
|
+
self._setup_ui()
|
|
51
|
+
self._connect_signals()
|
|
52
|
+
|
|
53
|
+
def _setup_ui(self):
|
|
54
|
+
"""Set up the search UI."""
|
|
55
|
+
layout = QVBoxLayout(self)
|
|
56
|
+
layout.setContentsMargins(30, 30, 30, 30)
|
|
57
|
+
layout.setSpacing(20)
|
|
58
|
+
|
|
59
|
+
# Title
|
|
60
|
+
title = QLabel("Search Transcripts")
|
|
61
|
+
title.setStyleSheet("font-size: 24px; font-weight: 600; color: #ffffff;")
|
|
62
|
+
layout.addWidget(title)
|
|
63
|
+
|
|
64
|
+
# Search row
|
|
65
|
+
search_row = QHBoxLayout()
|
|
66
|
+
search_row.setSpacing(10)
|
|
67
|
+
|
|
68
|
+
self._search_input = QLineEdit()
|
|
69
|
+
self._search_input.setPlaceholderText("Enter search query...")
|
|
70
|
+
self._search_input.returnPressed.connect(self._do_search)
|
|
71
|
+
|
|
72
|
+
self._search_btn = QPushButton("Search")
|
|
73
|
+
self._search_btn.setFixedWidth(100)
|
|
74
|
+
|
|
75
|
+
search_row.addWidget(self._search_input, 1)
|
|
76
|
+
search_row.addWidget(self._search_btn)
|
|
77
|
+
layout.addLayout(search_row)
|
|
78
|
+
|
|
79
|
+
# Options row
|
|
80
|
+
options_row = QHBoxLayout()
|
|
81
|
+
self._case_sensitive = QCheckBox("Case sensitive")
|
|
82
|
+
options_row.addWidget(self._case_sensitive)
|
|
83
|
+
options_row.addStretch()
|
|
84
|
+
layout.addLayout(options_row)
|
|
85
|
+
|
|
86
|
+
# Results header
|
|
87
|
+
self._results_header = QLabel("")
|
|
88
|
+
self._results_header.setStyleSheet("color: #4ec9b0; font-weight: 500;")
|
|
89
|
+
layout.addWidget(self._results_header)
|
|
90
|
+
|
|
91
|
+
# Results list
|
|
92
|
+
self._results_list = QListWidget()
|
|
93
|
+
self._results_list.itemDoubleClicked.connect(self._on_result_clicked)
|
|
94
|
+
layout.addWidget(self._results_list, 1)
|
|
95
|
+
|
|
96
|
+
# Results footer
|
|
97
|
+
self._results_footer = QLabel("")
|
|
98
|
+
self._results_footer.setStyleSheet("color: #858585;")
|
|
99
|
+
layout.addWidget(self._results_footer)
|
|
100
|
+
|
|
101
|
+
def _connect_signals(self):
|
|
102
|
+
"""Connect button signals."""
|
|
103
|
+
self._search_btn.clicked.connect(self._do_search)
|
|
104
|
+
|
|
105
|
+
def _do_search(self):
|
|
106
|
+
"""Perform the search."""
|
|
107
|
+
query = self._search_input.text().strip()
|
|
108
|
+
if not query:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
config = get_config()
|
|
112
|
+
transcripts_dir = config.transcripts_dir
|
|
113
|
+
|
|
114
|
+
if not transcripts_dir.exists():
|
|
115
|
+
self._results_header.setText("No transcripts directory found")
|
|
116
|
+
self._results_header.setStyleSheet("color: #f14c4c;")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
case_sensitive = self._case_sensitive.isChecked()
|
|
120
|
+
results = self._search_files(transcripts_dir, query, case_sensitive)
|
|
121
|
+
|
|
122
|
+
self._results = results
|
|
123
|
+
self._display_results(query)
|
|
124
|
+
|
|
125
|
+
def _search_files(
|
|
126
|
+
self, directory: Path, query: str, case_sensitive: bool
|
|
127
|
+
) -> list[FileSearchResult]:
|
|
128
|
+
"""Search all transcript files for the query."""
|
|
129
|
+
results = []
|
|
130
|
+
|
|
131
|
+
txt_files = sorted(
|
|
132
|
+
directory.glob("*.txt"),
|
|
133
|
+
key=lambda p: p.stat().st_mtime,
|
|
134
|
+
reverse=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
search_query = query if case_sensitive else query.lower()
|
|
138
|
+
|
|
139
|
+
for filepath in txt_files:
|
|
140
|
+
try:
|
|
141
|
+
content = filepath.read_text()
|
|
142
|
+
lines = content.splitlines()
|
|
143
|
+
|
|
144
|
+
matches = []
|
|
145
|
+
for i, line in enumerate(lines):
|
|
146
|
+
check_line = line if case_sensitive else line.lower()
|
|
147
|
+
if search_query in check_line:
|
|
148
|
+
# Extract timestamp if present
|
|
149
|
+
timestamp = ""
|
|
150
|
+
text = line
|
|
151
|
+
if line.startswith("[") and "]" in line:
|
|
152
|
+
bracket_end = line.index("]")
|
|
153
|
+
timestamp = line[1:bracket_end]
|
|
154
|
+
text = line[bracket_end + 1 :].strip()
|
|
155
|
+
|
|
156
|
+
matches.append(
|
|
157
|
+
SearchMatch(
|
|
158
|
+
filepath=filepath,
|
|
159
|
+
line_number=i + 1,
|
|
160
|
+
timestamp=timestamp,
|
|
161
|
+
text=text[:80] + "..." if len(text) > 80 else text,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if len(matches) >= 5: # Limit matches per file
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
if matches:
|
|
169
|
+
results.append(FileSearchResult(filepath=filepath, matches=matches))
|
|
170
|
+
|
|
171
|
+
except Exception:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Sort by match count
|
|
175
|
+
results.sort(key=lambda r: len(r.matches), reverse=True)
|
|
176
|
+
return results[:50] # Limit total results
|
|
177
|
+
|
|
178
|
+
def _display_results(self, query: str):
|
|
179
|
+
"""Display search results."""
|
|
180
|
+
self._results_list.clear()
|
|
181
|
+
|
|
182
|
+
total_matches = sum(len(r.matches) for r in self._results)
|
|
183
|
+
|
|
184
|
+
if not self._results:
|
|
185
|
+
self._results_header.setText(f"No matches found for '{query}'")
|
|
186
|
+
self._results_header.setStyleSheet("color: #dcdcaa;")
|
|
187
|
+
self._results_footer.setText("")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
self._results_header.setText(
|
|
191
|
+
f"Found {total_matches} matches in {len(self._results)} transcripts"
|
|
192
|
+
)
|
|
193
|
+
self._results_header.setStyleSheet("color: #4ec9b0;")
|
|
194
|
+
|
|
195
|
+
for result in self._results:
|
|
196
|
+
# File header
|
|
197
|
+
header_item = QListWidgetItem(
|
|
198
|
+
f"\u25b6 {result.filepath.name} ({len(result.matches)} matches)"
|
|
199
|
+
)
|
|
200
|
+
header_item.setData(Qt.UserRole, str(result.filepath))
|
|
201
|
+
header_item.setForeground(Qt.green)
|
|
202
|
+
font = header_item.font()
|
|
203
|
+
font.setBold(True)
|
|
204
|
+
header_item.setFont(font)
|
|
205
|
+
self._results_list.addItem(header_item)
|
|
206
|
+
|
|
207
|
+
# Match items
|
|
208
|
+
for match in result.matches:
|
|
209
|
+
if match.timestamp:
|
|
210
|
+
text = f" [{match.timestamp}] {match.text}"
|
|
211
|
+
else:
|
|
212
|
+
text = f" {match.text}"
|
|
213
|
+
|
|
214
|
+
match_item = QListWidgetItem(text)
|
|
215
|
+
match_item.setData(Qt.UserRole, str(result.filepath))
|
|
216
|
+
self._results_list.addItem(match_item)
|
|
217
|
+
|
|
218
|
+
self._results_footer.setText(f"Searched {len(self._results)} transcripts")
|
|
219
|
+
|
|
220
|
+
def _on_result_clicked(self, item: QListWidgetItem):
|
|
221
|
+
"""Handle click on a result item."""
|
|
222
|
+
filepath = item.data(Qt.UserRole)
|
|
223
|
+
if filepath:
|
|
224
|
+
app_state = get_app_state()
|
|
225
|
+
app_state.open_transcript.emit(filepath)
|
|
226
|
+
|
|
227
|
+
def refresh(self):
|
|
228
|
+
"""Refresh the search (focus the input)."""
|
|
229
|
+
self._search_input.setFocus()
|