meeting-noter 1.3.0__py3-none-any.whl → 3.0.0__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.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +103 -0
- meeting_noter/daemon.py +38 -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/mic_monitor.py +29 -1
- meeting_noter/ui/__init__.py +5 -0
- meeting_noter/ui/app.py +68 -0
- meeting_noter/ui/screens/__init__.py +17 -0
- meeting_noter/ui/screens/logs.py +166 -0
- meeting_noter/ui/screens/main.py +346 -0
- meeting_noter/ui/screens/recordings.py +241 -0
- meeting_noter/ui/screens/search.py +191 -0
- meeting_noter/ui/screens/settings.py +184 -0
- meeting_noter/ui/screens/viewer.py +116 -0
- meeting_noter/ui/styles/app.tcss +257 -0
- meeting_noter/ui/widgets/__init__.py +1 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/METADATA +4 -1
- meeting_noter-3.0.0.dist-info/RECORD +65 -0
- meeting_noter-1.3.0.dist-info/RECORD +0 -35
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Recordings browser screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.containers import Container, Horizontal
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Button, DataTable, Label, Static
|
|
14
|
+
|
|
15
|
+
from meeting_noter.config import get_config
|
|
16
|
+
from meeting_noter.output.writer import format_duration, format_size, get_audio_duration
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RecordingInfo:
|
|
21
|
+
"""Information about a recording."""
|
|
22
|
+
|
|
23
|
+
filepath: Path
|
|
24
|
+
date: datetime
|
|
25
|
+
duration: Optional[float]
|
|
26
|
+
size: int
|
|
27
|
+
has_transcript: bool
|
|
28
|
+
is_favorite: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_recordings_list(output_dir: Path, limit: int = 50) -> list[RecordingInfo]:
|
|
32
|
+
"""Get list of recordings with metadata."""
|
|
33
|
+
config = get_config()
|
|
34
|
+
|
|
35
|
+
if not output_dir.exists():
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
mp3_files = sorted(
|
|
39
|
+
output_dir.glob("*.mp3"),
|
|
40
|
+
key=lambda p: p.stat().st_mtime,
|
|
41
|
+
reverse=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Get transcripts directory from config
|
|
45
|
+
transcripts_dir = config.transcripts_dir
|
|
46
|
+
|
|
47
|
+
recordings = []
|
|
48
|
+
for mp3 in mp3_files[:limit]:
|
|
49
|
+
stat = mp3.stat()
|
|
50
|
+
transcript_name = mp3.stem + ".txt"
|
|
51
|
+
|
|
52
|
+
# Check for transcript in transcripts directory
|
|
53
|
+
transcript_path = transcripts_dir / transcript_name
|
|
54
|
+
has_transcript = transcript_path.exists()
|
|
55
|
+
|
|
56
|
+
recordings.append(
|
|
57
|
+
RecordingInfo(
|
|
58
|
+
filepath=mp3,
|
|
59
|
+
date=datetime.fromtimestamp(stat.st_mtime),
|
|
60
|
+
duration=get_audio_duration(mp3),
|
|
61
|
+
size=stat.st_size,
|
|
62
|
+
has_transcript=has_transcript,
|
|
63
|
+
is_favorite=config.is_favorite(transcript_name),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return recordings
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RecordingsScreen(Screen):
|
|
71
|
+
"""Recordings browser screen."""
|
|
72
|
+
|
|
73
|
+
BINDINGS = [
|
|
74
|
+
("t", "transcribe", "Transcribe"),
|
|
75
|
+
("enter", "view", "View"),
|
|
76
|
+
("f", "toggle_favorite", "Favorite"),
|
|
77
|
+
("r", "refresh", "Refresh"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def compose(self) -> ComposeResult:
|
|
81
|
+
"""Compose the recordings screen layout."""
|
|
82
|
+
yield Container(
|
|
83
|
+
Static("[bold]Recordings[/bold]", classes="title"),
|
|
84
|
+
Static("", classes="spacer"),
|
|
85
|
+
DataTable(id="recordings-table", cursor_type="row"),
|
|
86
|
+
Static("", classes="spacer"),
|
|
87
|
+
Horizontal(
|
|
88
|
+
Button("View", id="view-btn"),
|
|
89
|
+
Button("Transcribe", id="transcribe-btn", variant="primary"),
|
|
90
|
+
Button("Toggle Favorite", id="favorite-btn"),
|
|
91
|
+
Button("Refresh", id="refresh-btn"),
|
|
92
|
+
classes="button-row",
|
|
93
|
+
),
|
|
94
|
+
Label("", id="status-label"),
|
|
95
|
+
id="recordings-container",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def on_mount(self) -> None:
|
|
99
|
+
"""Load recordings on mount."""
|
|
100
|
+
self._setup_table()
|
|
101
|
+
self._load_recordings()
|
|
102
|
+
|
|
103
|
+
def _setup_table(self) -> None:
|
|
104
|
+
"""Set up the recordings table."""
|
|
105
|
+
table = self.query_one("#recordings-table", DataTable)
|
|
106
|
+
table.add_columns("★", "Date", "Duration", "Size", "Transcript", "File")
|
|
107
|
+
|
|
108
|
+
def _load_recordings(self) -> None:
|
|
109
|
+
"""Load recordings into the table."""
|
|
110
|
+
config = get_config()
|
|
111
|
+
table = self.query_one("#recordings-table", DataTable)
|
|
112
|
+
status = self.query_one("#status-label", Label)
|
|
113
|
+
|
|
114
|
+
table.clear()
|
|
115
|
+
|
|
116
|
+
recordings = get_recordings_list(config.recordings_dir)
|
|
117
|
+
|
|
118
|
+
if not recordings:
|
|
119
|
+
status.update("[yellow]No recordings found[/yellow]")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
for rec in recordings:
|
|
123
|
+
star = "★" if rec.is_favorite else ""
|
|
124
|
+
date_str = rec.date.strftime("%Y-%m-%d %H:%M")
|
|
125
|
+
duration_str = format_duration(rec.duration) if rec.duration else "?"
|
|
126
|
+
size_str = format_size(rec.size)
|
|
127
|
+
transcript_str = "Yes" if rec.has_transcript else "No"
|
|
128
|
+
|
|
129
|
+
table.add_row(
|
|
130
|
+
star,
|
|
131
|
+
date_str,
|
|
132
|
+
duration_str,
|
|
133
|
+
size_str,
|
|
134
|
+
transcript_str,
|
|
135
|
+
rec.filepath.name,
|
|
136
|
+
key=str(rec.filepath),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
status.update(f"[dim]{len(recordings)} recordings[/dim]")
|
|
140
|
+
|
|
141
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
142
|
+
"""Handle button presses."""
|
|
143
|
+
button_id = event.button.id
|
|
144
|
+
|
|
145
|
+
if button_id == "view-btn":
|
|
146
|
+
self.action_view()
|
|
147
|
+
elif button_id == "transcribe-btn":
|
|
148
|
+
self.action_transcribe()
|
|
149
|
+
elif button_id == "favorite-btn":
|
|
150
|
+
self.action_toggle_favorite()
|
|
151
|
+
elif button_id == "refresh-btn":
|
|
152
|
+
self.action_refresh()
|
|
153
|
+
|
|
154
|
+
def _get_selected_filepath(self) -> Optional[Path]:
|
|
155
|
+
"""Get the filepath of the currently selected recording."""
|
|
156
|
+
table = self.query_one("#recordings-table", DataTable)
|
|
157
|
+
|
|
158
|
+
if table.row_count == 0:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
|
|
162
|
+
if row_key:
|
|
163
|
+
return Path(row_key.value)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def action_view(self) -> None:
|
|
167
|
+
"""View the selected transcript."""
|
|
168
|
+
filepath = self._get_selected_filepath()
|
|
169
|
+
if not filepath:
|
|
170
|
+
self.notify("No recording selected", severity="warning")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
config = get_config()
|
|
174
|
+
transcript_name = filepath.stem + ".txt"
|
|
175
|
+
transcript = config.transcripts_dir / transcript_name
|
|
176
|
+
|
|
177
|
+
if not transcript.exists():
|
|
178
|
+
self.notify("No transcript available. Transcribe first.", severity="warning")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# Push the viewer screen with the transcript path
|
|
182
|
+
from meeting_noter.ui.screens.viewer import ViewerScreen
|
|
183
|
+
|
|
184
|
+
self.app.push_screen(ViewerScreen(transcript))
|
|
185
|
+
|
|
186
|
+
def action_transcribe(self) -> None:
|
|
187
|
+
"""Transcribe the selected recording."""
|
|
188
|
+
import subprocess
|
|
189
|
+
import sys
|
|
190
|
+
|
|
191
|
+
filepath = self._get_selected_filepath()
|
|
192
|
+
if not filepath:
|
|
193
|
+
self.notify("No recording selected", severity="warning")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
config = get_config()
|
|
197
|
+
transcript_name = filepath.stem + ".txt"
|
|
198
|
+
transcript = config.transcripts_dir / transcript_name
|
|
199
|
+
|
|
200
|
+
if transcript.exists():
|
|
201
|
+
self.notify("Transcript already exists", severity="warning")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Run transcription in background
|
|
205
|
+
subprocess.Popen(
|
|
206
|
+
[
|
|
207
|
+
sys.executable,
|
|
208
|
+
"-m",
|
|
209
|
+
"meeting_noter.cli",
|
|
210
|
+
"transcribe",
|
|
211
|
+
str(filepath),
|
|
212
|
+
],
|
|
213
|
+
stdout=subprocess.DEVNULL,
|
|
214
|
+
stderr=subprocess.DEVNULL,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
self.notify(f"Transcribing: {filepath.name}", severity="information")
|
|
218
|
+
|
|
219
|
+
def action_toggle_favorite(self) -> None:
|
|
220
|
+
"""Toggle favorite status for the selected recording."""
|
|
221
|
+
filepath = self._get_selected_filepath()
|
|
222
|
+
if not filepath:
|
|
223
|
+
self.notify("No recording selected", severity="warning")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
config = get_config()
|
|
227
|
+
transcript_name = filepath.with_suffix(".txt").name
|
|
228
|
+
|
|
229
|
+
if config.is_favorite(transcript_name):
|
|
230
|
+
config.remove_favorite(transcript_name)
|
|
231
|
+
self.notify(f"Removed from favorites: {transcript_name}")
|
|
232
|
+
else:
|
|
233
|
+
config.add_favorite(transcript_name)
|
|
234
|
+
self.notify(f"Added to favorites: {transcript_name}")
|
|
235
|
+
|
|
236
|
+
self._load_recordings()
|
|
237
|
+
|
|
238
|
+
def action_refresh(self) -> None:
|
|
239
|
+
"""Refresh the recordings list."""
|
|
240
|
+
self._load_recordings()
|
|
241
|
+
self.notify("Refreshed")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Search screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
10
|
+
from textual.screen import Screen
|
|
11
|
+
from textual.widgets import Button, Checkbox, Input, Label, ListView, ListItem, Static
|
|
12
|
+
|
|
13
|
+
from meeting_noter.config import get_config
|
|
14
|
+
from meeting_noter.output.searcher import _search_file, FileSearchResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SearchResultItem(ListItem):
|
|
18
|
+
"""A search result list item."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, filepath: Path, match_text: str, timestamp: Optional[str] = None):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.filepath = filepath
|
|
23
|
+
self.match_text = match_text
|
|
24
|
+
self.timestamp = timestamp
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
"""Compose the list item."""
|
|
28
|
+
if self.timestamp:
|
|
29
|
+
yield Static(f"[cyan][{self.timestamp}][/cyan] {self.match_text[:70]}...")
|
|
30
|
+
else:
|
|
31
|
+
yield Static(f"{self.match_text[:80]}...")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SearchScreen(Screen):
|
|
35
|
+
"""Search screen."""
|
|
36
|
+
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
("slash", "focus_search", "Search"),
|
|
39
|
+
("enter", "view_selected", "View"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def compose(self) -> ComposeResult:
|
|
43
|
+
"""Compose the search screen layout."""
|
|
44
|
+
yield Container(
|
|
45
|
+
Static("[bold]Search Transcripts[/bold]", classes="title"),
|
|
46
|
+
Static("", classes="spacer"),
|
|
47
|
+
Horizontal(
|
|
48
|
+
Input(placeholder="Enter search query...", id="search-input"),
|
|
49
|
+
Button("Search", id="search-btn", variant="primary"),
|
|
50
|
+
classes="search-row",
|
|
51
|
+
),
|
|
52
|
+
Horizontal(
|
|
53
|
+
Checkbox("Case sensitive", id="case-sensitive"),
|
|
54
|
+
classes="options-row",
|
|
55
|
+
),
|
|
56
|
+
Static("", classes="spacer"),
|
|
57
|
+
Label("", id="results-header"),
|
|
58
|
+
ListView(id="results-list"),
|
|
59
|
+
Label("", id="results-footer"),
|
|
60
|
+
id="search-container",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def on_mount(self) -> None:
|
|
64
|
+
"""Focus search input on mount."""
|
|
65
|
+
self.query_one("#search-input", Input).focus()
|
|
66
|
+
|
|
67
|
+
def action_focus_search(self) -> None:
|
|
68
|
+
"""Focus the search input."""
|
|
69
|
+
self.query_one("#search-input", Input).focus()
|
|
70
|
+
|
|
71
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
72
|
+
"""Handle button presses."""
|
|
73
|
+
if event.button.id == "search-btn":
|
|
74
|
+
self._do_search()
|
|
75
|
+
|
|
76
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
77
|
+
"""Handle Enter in search input."""
|
|
78
|
+
if event.input.id == "search-input":
|
|
79
|
+
self._do_search()
|
|
80
|
+
|
|
81
|
+
def _do_search(self) -> None:
|
|
82
|
+
"""Perform the search."""
|
|
83
|
+
config = get_config()
|
|
84
|
+
query = self.query_one("#search-input", Input).value.strip()
|
|
85
|
+
case_sensitive = self.query_one("#case-sensitive", Checkbox).value
|
|
86
|
+
|
|
87
|
+
results_list = self.query_one("#results-list", ListView)
|
|
88
|
+
results_header = self.query_one("#results-header", Label)
|
|
89
|
+
results_footer = self.query_one("#results-footer", Label)
|
|
90
|
+
|
|
91
|
+
results_list.clear()
|
|
92
|
+
|
|
93
|
+
if not query:
|
|
94
|
+
results_header.update("[yellow]Enter a search query[/yellow]")
|
|
95
|
+
results_footer.update("")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
transcripts_dir = config.transcripts_dir
|
|
99
|
+
if not transcripts_dir.exists():
|
|
100
|
+
results_header.update("[red]Transcripts directory not found[/red]")
|
|
101
|
+
results_footer.update("")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Find all transcript files
|
|
105
|
+
txt_files = sorted(
|
|
106
|
+
transcripts_dir.glob("*.txt"),
|
|
107
|
+
key=lambda p: p.stat().st_mtime,
|
|
108
|
+
reverse=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not txt_files:
|
|
112
|
+
results_header.update("[yellow]No transcripts found[/yellow]")
|
|
113
|
+
results_footer.update("")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Search all files
|
|
117
|
+
results: list[FileSearchResult] = []
|
|
118
|
+
for txt_file in txt_files:
|
|
119
|
+
result = _search_file(txt_file, query, case_sensitive)
|
|
120
|
+
if result:
|
|
121
|
+
results.append(result)
|
|
122
|
+
|
|
123
|
+
if not results:
|
|
124
|
+
results_header.update(f"[yellow]No results for '{query}'[/yellow]")
|
|
125
|
+
results_footer.update(f"[dim]Searched {len(txt_files)} transcripts[/dim]")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Sort by match count
|
|
129
|
+
results.sort(key=lambda r: r.match_count, reverse=True)
|
|
130
|
+
|
|
131
|
+
# Count totals
|
|
132
|
+
total_matches = sum(r.match_count for r in results)
|
|
133
|
+
total_files = len(results)
|
|
134
|
+
|
|
135
|
+
results_header.update(
|
|
136
|
+
f"[green]Found {total_matches} matches in {total_files} transcripts[/green]"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Add results to list (limit for performance)
|
|
140
|
+
max_items = 50
|
|
141
|
+
items_added = 0
|
|
142
|
+
|
|
143
|
+
for result in results:
|
|
144
|
+
if items_added >= max_items:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# Add file header
|
|
148
|
+
results_list.append(
|
|
149
|
+
ListItem(
|
|
150
|
+
Static(f"[bold green]{result.filepath.name}[/bold green] ({result.match_count} matches)")
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Add matches (limit per file)
|
|
155
|
+
for match in result.matches[:5]:
|
|
156
|
+
results_list.append(
|
|
157
|
+
SearchResultItem(
|
|
158
|
+
result.filepath,
|
|
159
|
+
match.line.strip(),
|
|
160
|
+
match.timestamp,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
items_added += 1
|
|
164
|
+
if items_added >= max_items:
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if total_matches > max_items:
|
|
168
|
+
results_footer.update(
|
|
169
|
+
f"[dim]Showing {items_added} of {total_matches} matches. "
|
|
170
|
+
f"Searched {len(txt_files)} transcripts.[/dim]"
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
results_footer.update(f"[dim]Searched {len(txt_files)} transcripts[/dim]")
|
|
174
|
+
|
|
175
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
176
|
+
"""Handle list item selection."""
|
|
177
|
+
item = event.item
|
|
178
|
+
if isinstance(item, SearchResultItem):
|
|
179
|
+
from meeting_noter.ui.screens.viewer import ViewerScreen
|
|
180
|
+
|
|
181
|
+
self.app.push_screen(ViewerScreen(item.filepath))
|
|
182
|
+
|
|
183
|
+
def action_view_selected(self) -> None:
|
|
184
|
+
"""View the selected result's transcript."""
|
|
185
|
+
results_list = self.query_one("#results-list", ListView)
|
|
186
|
+
if results_list.highlighted_child:
|
|
187
|
+
item = results_list.highlighted_child
|
|
188
|
+
if isinstance(item, SearchResultItem):
|
|
189
|
+
from meeting_noter.ui.screens.viewer import ViewerScreen
|
|
190
|
+
|
|
191
|
+
self.app.push_screen(ViewerScreen(item.filepath))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Settings screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import Button, Input, Label, Select, Static, Switch
|
|
9
|
+
|
|
10
|
+
from meeting_noter.config import get_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
WHISPER_MODELS = [
|
|
14
|
+
("tiny.en (fastest)", "tiny.en"),
|
|
15
|
+
("base.en (balanced)", "base.en"),
|
|
16
|
+
("small.en (better)", "small.en"),
|
|
17
|
+
("medium.en (high quality)", "medium.en"),
|
|
18
|
+
("large-v3 (best, multilingual)", "large-v3"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SettingsScreen(Screen):
|
|
23
|
+
"""Settings configuration screen."""
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
("s", "save", "Save"),
|
|
27
|
+
("escape", "go_back", "Back"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
"""Compose the settings screen layout."""
|
|
32
|
+
config = get_config()
|
|
33
|
+
|
|
34
|
+
yield Container(
|
|
35
|
+
Static("[bold]Settings[/bold]", classes="title"),
|
|
36
|
+
Static("", classes="spacer"),
|
|
37
|
+
Vertical(
|
|
38
|
+
# Directories
|
|
39
|
+
Static("[dim]Directories[/dim]", classes="section-header"),
|
|
40
|
+
Horizontal(
|
|
41
|
+
Label("Recordings: ", classes="setting-label"),
|
|
42
|
+
Input(
|
|
43
|
+
str(config.recordings_dir),
|
|
44
|
+
id="recordings-dir",
|
|
45
|
+
classes="setting-input",
|
|
46
|
+
),
|
|
47
|
+
classes="setting-row",
|
|
48
|
+
),
|
|
49
|
+
Horizontal(
|
|
50
|
+
Label("Transcripts: ", classes="setting-label"),
|
|
51
|
+
Input(
|
|
52
|
+
str(config.transcripts_dir),
|
|
53
|
+
id="transcripts-dir",
|
|
54
|
+
classes="setting-input",
|
|
55
|
+
),
|
|
56
|
+
classes="setting-row",
|
|
57
|
+
),
|
|
58
|
+
Static("", classes="spacer"),
|
|
59
|
+
# Whisper model
|
|
60
|
+
Static("[dim]Transcription[/dim]", classes="section-header"),
|
|
61
|
+
Horizontal(
|
|
62
|
+
Label("Whisper model: ", classes="setting-label"),
|
|
63
|
+
Select(
|
|
64
|
+
WHISPER_MODELS,
|
|
65
|
+
value=config.whisper_model,
|
|
66
|
+
id="whisper-model",
|
|
67
|
+
),
|
|
68
|
+
classes="setting-row",
|
|
69
|
+
),
|
|
70
|
+
Static("", classes="spacer"),
|
|
71
|
+
# Toggles
|
|
72
|
+
Static("[dim]Options[/dim]", classes="section-header"),
|
|
73
|
+
Horizontal(
|
|
74
|
+
Label("Auto-transcribe: ", classes="setting-label"),
|
|
75
|
+
Switch(value=config.auto_transcribe, id="auto-transcribe"),
|
|
76
|
+
classes="setting-row",
|
|
77
|
+
),
|
|
78
|
+
Horizontal(
|
|
79
|
+
Label("Capture system audio: ", classes="setting-label"),
|
|
80
|
+
Switch(value=config.capture_system_audio, id="capture-system-audio"),
|
|
81
|
+
classes="setting-row",
|
|
82
|
+
),
|
|
83
|
+
Horizontal(
|
|
84
|
+
Label("Auto-update: ", classes="setting-label"),
|
|
85
|
+
Switch(value=config.auto_update, id="auto-update"),
|
|
86
|
+
classes="setting-row",
|
|
87
|
+
),
|
|
88
|
+
Static("", classes="spacer"),
|
|
89
|
+
# Silence timeout
|
|
90
|
+
Horizontal(
|
|
91
|
+
Label("Silence timeout (min): ", classes="setting-label"),
|
|
92
|
+
Input(
|
|
93
|
+
str(config.silence_timeout),
|
|
94
|
+
id="silence-timeout",
|
|
95
|
+
type="integer",
|
|
96
|
+
),
|
|
97
|
+
classes="setting-row",
|
|
98
|
+
),
|
|
99
|
+
classes="settings-form",
|
|
100
|
+
),
|
|
101
|
+
Static("", classes="spacer"),
|
|
102
|
+
Horizontal(
|
|
103
|
+
Button("Save", id="save-btn", variant="success"),
|
|
104
|
+
Button("Reset", id="reset-btn"),
|
|
105
|
+
Button("Back", id="back-btn"),
|
|
106
|
+
classes="button-row",
|
|
107
|
+
),
|
|
108
|
+
Label("", id="status-label"),
|
|
109
|
+
id="settings-container",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
113
|
+
"""Handle button presses."""
|
|
114
|
+
button_id = event.button.id
|
|
115
|
+
|
|
116
|
+
if button_id == "save-btn":
|
|
117
|
+
self.action_save()
|
|
118
|
+
elif button_id == "reset-btn":
|
|
119
|
+
self._reset_to_current()
|
|
120
|
+
elif button_id == "back-btn":
|
|
121
|
+
self.action_go_back()
|
|
122
|
+
|
|
123
|
+
def action_save(self) -> None:
|
|
124
|
+
"""Save settings."""
|
|
125
|
+
from pathlib import Path
|
|
126
|
+
|
|
127
|
+
config = get_config()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Get values from inputs
|
|
131
|
+
recordings_dir = self.query_one("#recordings-dir", Input).value
|
|
132
|
+
transcripts_dir = self.query_one("#transcripts-dir", Input).value
|
|
133
|
+
whisper_model = self.query_one("#whisper-model", Select).value
|
|
134
|
+
auto_transcribe = self.query_one("#auto-transcribe", Switch).value
|
|
135
|
+
capture_system_audio = self.query_one("#capture-system-audio", Switch).value
|
|
136
|
+
auto_update = self.query_one("#auto-update", Switch).value
|
|
137
|
+
silence_timeout = self.query_one("#silence-timeout", Input).value
|
|
138
|
+
|
|
139
|
+
# Validate and set
|
|
140
|
+
recordings_path = Path(recordings_dir).expanduser()
|
|
141
|
+
transcripts_path = Path(transcripts_dir).expanduser()
|
|
142
|
+
|
|
143
|
+
# Create directories if they don't exist
|
|
144
|
+
recordings_path.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
transcripts_path.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
# Update config
|
|
148
|
+
config.recordings_dir = recordings_path
|
|
149
|
+
config.transcripts_dir = transcripts_path
|
|
150
|
+
config.whisper_model = whisper_model
|
|
151
|
+
config.auto_transcribe = auto_transcribe
|
|
152
|
+
config.capture_system_audio = capture_system_audio
|
|
153
|
+
config.auto_update = auto_update
|
|
154
|
+
config.silence_timeout = int(silence_timeout) if silence_timeout else 5
|
|
155
|
+
|
|
156
|
+
config.save()
|
|
157
|
+
|
|
158
|
+
self.notify("Settings saved", severity="information")
|
|
159
|
+
self.query_one("#status-label", Label).update(
|
|
160
|
+
"[green]Settings saved successfully[/green]"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self.notify(f"Error saving settings: {e}", severity="error")
|
|
165
|
+
self.query_one("#status-label", Label).update(f"[red]Error: {e}[/red]")
|
|
166
|
+
|
|
167
|
+
def _reset_to_current(self) -> None:
|
|
168
|
+
"""Reset form to current config values."""
|
|
169
|
+
config = get_config()
|
|
170
|
+
config.load() # Reload from disk
|
|
171
|
+
|
|
172
|
+
self.query_one("#recordings-dir", Input).value = str(config.recordings_dir)
|
|
173
|
+
self.query_one("#transcripts-dir", Input).value = str(config.transcripts_dir)
|
|
174
|
+
self.query_one("#whisper-model", Select).value = config.whisper_model
|
|
175
|
+
self.query_one("#auto-transcribe", Switch).value = config.auto_transcribe
|
|
176
|
+
self.query_one("#capture-system-audio", Switch).value = config.capture_system_audio
|
|
177
|
+
self.query_one("#auto-update", Switch).value = config.auto_update
|
|
178
|
+
self.query_one("#silence-timeout", Input).value = str(config.silence_timeout)
|
|
179
|
+
|
|
180
|
+
self.notify("Reset to saved values")
|
|
181
|
+
|
|
182
|
+
def action_go_back(self) -> None:
|
|
183
|
+
"""Go back to the previous screen."""
|
|
184
|
+
self.app.pop_screen()
|