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.

@@ -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()