meeting-noter 1.2.0__py3-none-any.whl → 2.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.
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 +190 -11
- meeting_noter/config.py +34 -0
- meeting_noter/daemon.py +38 -0
- meeting_noter/mic_monitor.py +29 -1
- meeting_noter/output/favorites.py +189 -0
- meeting_noter/output/searcher.py +218 -0
- meeting_noter/transcription/live_transcription.py +17 -13
- 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.2.0.dist-info → meeting_noter-2.0.0.dist-info}/METADATA +2 -1
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/RECORD +24 -11
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.2.0.dist-info → meeting_noter-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Search functionality for meeting transcripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import click
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SearchMatch:
|
|
14
|
+
"""A single match within a transcript file."""
|
|
15
|
+
|
|
16
|
+
line_number: int
|
|
17
|
+
line: str
|
|
18
|
+
timestamp: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FileSearchResult:
|
|
23
|
+
"""Search results for a single transcript file."""
|
|
24
|
+
|
|
25
|
+
filepath: Path
|
|
26
|
+
matches: list[SearchMatch]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def match_count(self) -> int:
|
|
30
|
+
return len(self.matches)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_timestamp(line: str) -> Optional[str]:
|
|
34
|
+
"""Extract timestamp from line if present (e.g., [05:32] or [01:23:45])."""
|
|
35
|
+
match = re.match(r"^\[(\d{1,2}:\d{2}(?::\d{2})?)\]", line.strip())
|
|
36
|
+
if match:
|
|
37
|
+
return match.group(1)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _truncate_line(line: str, max_length: int = 80) -> str:
|
|
42
|
+
"""Truncate line to max length with ellipsis."""
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if len(line) <= max_length:
|
|
45
|
+
return line
|
|
46
|
+
return line[: max_length - 3] + "..."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _highlight_match(line: str, query: str, case_sensitive: bool) -> str:
|
|
50
|
+
"""Highlight matching text in the line."""
|
|
51
|
+
if case_sensitive:
|
|
52
|
+
pattern = re.escape(query)
|
|
53
|
+
else:
|
|
54
|
+
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
def replace_with_highlight(match):
|
|
57
|
+
return click.style(match.group(0), bold=True, fg="yellow")
|
|
58
|
+
|
|
59
|
+
if case_sensitive:
|
|
60
|
+
return re.sub(pattern, replace_with_highlight, line)
|
|
61
|
+
else:
|
|
62
|
+
return pattern.sub(replace_with_highlight, line)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _search_file(
|
|
66
|
+
filepath: Path,
|
|
67
|
+
query: str,
|
|
68
|
+
case_sensitive: bool,
|
|
69
|
+
context_lines: int = 1,
|
|
70
|
+
) -> Optional[FileSearchResult]:
|
|
71
|
+
"""Search a single file for the query.
|
|
72
|
+
|
|
73
|
+
Returns FileSearchResult if matches found, None otherwise.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
content = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
lines = content.splitlines()
|
|
81
|
+
matches: list[SearchMatch] = []
|
|
82
|
+
|
|
83
|
+
search_query = query if case_sensitive else query.lower()
|
|
84
|
+
|
|
85
|
+
for i, line in enumerate(lines):
|
|
86
|
+
search_line = line if case_sensitive else line.lower()
|
|
87
|
+
|
|
88
|
+
if search_query in search_line:
|
|
89
|
+
timestamp = _extract_timestamp(line)
|
|
90
|
+
matches.append(
|
|
91
|
+
SearchMatch(
|
|
92
|
+
line_number=i + 1,
|
|
93
|
+
line=line,
|
|
94
|
+
timestamp=timestamp,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if matches:
|
|
99
|
+
return FileSearchResult(filepath=filepath, matches=matches)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def search_transcripts(
|
|
104
|
+
transcripts_dir: Path,
|
|
105
|
+
query: str,
|
|
106
|
+
case_sensitive: bool = False,
|
|
107
|
+
limit: int = 20,
|
|
108
|
+
context_lines: int = 1,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Search across all meeting transcripts.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
transcripts_dir: Directory containing transcript files
|
|
114
|
+
query: Search query string
|
|
115
|
+
case_sensitive: Whether to perform case-sensitive search
|
|
116
|
+
limit: Maximum number of matches to display
|
|
117
|
+
context_lines: Number of context lines around matches (not yet implemented)
|
|
118
|
+
"""
|
|
119
|
+
if not query.strip():
|
|
120
|
+
click.echo(click.style("Error: Search query cannot be empty.", fg="red"))
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if not transcripts_dir.exists():
|
|
124
|
+
click.echo(click.style(f"Directory not found: {transcripts_dir}", fg="red"))
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Find all transcript files
|
|
128
|
+
txt_files = sorted(
|
|
129
|
+
transcripts_dir.glob("*.txt"),
|
|
130
|
+
key=lambda p: p.stat().st_mtime,
|
|
131
|
+
reverse=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not txt_files:
|
|
135
|
+
click.echo(click.style("No transcripts found.", fg="yellow"))
|
|
136
|
+
click.echo(f"\nTranscripts directory: {transcripts_dir}")
|
|
137
|
+
click.echo("Record and transcribe meetings to search them.")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Search all files
|
|
141
|
+
results: list[FileSearchResult] = []
|
|
142
|
+
for txt_file in txt_files:
|
|
143
|
+
result = _search_file(txt_file, query, case_sensitive, context_lines)
|
|
144
|
+
if result:
|
|
145
|
+
results.append(result)
|
|
146
|
+
|
|
147
|
+
if not results:
|
|
148
|
+
click.echo(click.style(f'No results found for "{query}"', fg="yellow"))
|
|
149
|
+
click.echo(f"\nSearched {len(txt_files)} transcripts in {transcripts_dir}")
|
|
150
|
+
if not case_sensitive:
|
|
151
|
+
click.echo("Tip: Use --case-sensitive for exact matching.")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Sort by match count (most matches first)
|
|
155
|
+
results.sort(key=lambda r: r.match_count, reverse=True)
|
|
156
|
+
|
|
157
|
+
# Count total matches
|
|
158
|
+
total_matches = sum(r.match_count for r in results)
|
|
159
|
+
total_files = len(results)
|
|
160
|
+
|
|
161
|
+
# Display header
|
|
162
|
+
click.echo()
|
|
163
|
+
matches_word = "match" if total_matches == 1 else "matches"
|
|
164
|
+
files_word = "transcript" if total_files == 1 else "transcripts"
|
|
165
|
+
click.echo(
|
|
166
|
+
click.style(
|
|
167
|
+
f"Found {total_matches} {matches_word} in {total_files} {files_word}:",
|
|
168
|
+
bold=True,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
click.echo()
|
|
172
|
+
|
|
173
|
+
# Display results
|
|
174
|
+
matches_shown = 0
|
|
175
|
+
limit_reached = False
|
|
176
|
+
for result in results:
|
|
177
|
+
if matches_shown >= limit:
|
|
178
|
+
limit_reached = True
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
# File header
|
|
182
|
+
match_word = "match" if result.match_count == 1 else "matches"
|
|
183
|
+
click.echo(
|
|
184
|
+
click.style(f"{result.filepath.name}", fg="green", bold=True)
|
|
185
|
+
+ f" ({result.match_count} {match_word})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Show matches (limited)
|
|
189
|
+
for match in result.matches:
|
|
190
|
+
if matches_shown >= limit:
|
|
191
|
+
limit_reached = True
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
# Format the line
|
|
195
|
+
prefix = f" [{match.timestamp}] " if match.timestamp else " "
|
|
196
|
+
line_text = match.line
|
|
197
|
+
if match.timestamp:
|
|
198
|
+
# Remove timestamp from line since we're showing it in prefix
|
|
199
|
+
line_text = re.sub(r"^\[\d{1,2}:\d{2}(?::\d{2})?\]\s*", "", line_text)
|
|
200
|
+
|
|
201
|
+
truncated = _truncate_line(line_text, 70)
|
|
202
|
+
highlighted = _highlight_match(truncated, query, case_sensitive)
|
|
203
|
+
|
|
204
|
+
click.echo(f"{prefix}...{highlighted}...")
|
|
205
|
+
matches_shown += 1
|
|
206
|
+
|
|
207
|
+
click.echo()
|
|
208
|
+
|
|
209
|
+
# Show remaining count if limit was reached
|
|
210
|
+
if limit_reached and matches_shown < total_matches:
|
|
211
|
+
remaining = total_matches - matches_shown
|
|
212
|
+
click.echo(
|
|
213
|
+
click.style(f"... and {remaining} more matches", fg="cyan")
|
|
214
|
+
)
|
|
215
|
+
click.echo()
|
|
216
|
+
|
|
217
|
+
# Footer
|
|
218
|
+
click.echo(f"Searched {len(txt_files)} transcripts in {transcripts_dir}")
|
|
@@ -4,7 +4,7 @@ Buffers audio chunks and transcribes them in a background thread,
|
|
|
4
4
|
writing segments to a .live.txt file that can be tailed by the CLI.
|
|
5
5
|
|
|
6
6
|
Uses overlapping windows for lower latency: keeps a 5-second context window
|
|
7
|
-
but transcribes every
|
|
7
|
+
but transcribes every 1 second, only outputting new content.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -24,7 +24,7 @@ class LiveTranscriber:
|
|
|
24
24
|
|
|
25
25
|
Uses overlapping windows approach:
|
|
26
26
|
- Maintains a rolling window of audio (default 5 seconds)
|
|
27
|
-
- Transcribes every `slide_seconds` (default
|
|
27
|
+
- Transcribes every `slide_seconds` (default 1 second)
|
|
28
28
|
- Only outputs new segments to avoid duplicates
|
|
29
29
|
"""
|
|
30
30
|
|
|
@@ -34,7 +34,7 @@ class LiveTranscriber:
|
|
|
34
34
|
sample_rate: int = 48000,
|
|
35
35
|
channels: int = 2,
|
|
36
36
|
window_seconds: float = 5.0,
|
|
37
|
-
slide_seconds: float =
|
|
37
|
+
slide_seconds: float = 1.0,
|
|
38
38
|
model_size: str = "tiny.en",
|
|
39
39
|
):
|
|
40
40
|
"""Initialize the live transcriber.
|
|
@@ -115,15 +115,20 @@ class LiveTranscriber:
|
|
|
115
115
|
except ImportError:
|
|
116
116
|
pass
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
# Try GPU acceleration first, fall back to CPU if not supported
|
|
119
|
+
model_path = str(bundled_path) if (bundled_path and self.model_size == "tiny.en") else self.model_size
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Try GPU with float16 first
|
|
119
123
|
self._model = WhisperModel(
|
|
120
|
-
|
|
121
|
-
device="
|
|
122
|
-
compute_type="
|
|
124
|
+
model_path,
|
|
125
|
+
device="cuda",
|
|
126
|
+
compute_type="float16",
|
|
123
127
|
)
|
|
124
|
-
|
|
128
|
+
except Exception:
|
|
129
|
+
# Fall back to CPU with int8 (fastest CPU option)
|
|
125
130
|
self._model = WhisperModel(
|
|
126
|
-
|
|
131
|
+
model_path,
|
|
127
132
|
device="cpu",
|
|
128
133
|
compute_type="int8",
|
|
129
134
|
)
|
|
@@ -150,11 +155,10 @@ class LiveTranscriber:
|
|
|
150
155
|
try:
|
|
151
156
|
# Collect audio chunks
|
|
152
157
|
try:
|
|
153
|
-
chunk = self._audio_queue.get(timeout=0.
|
|
158
|
+
chunk = self._audio_queue.get(timeout=0.1)
|
|
154
159
|
|
|
155
|
-
# Add samples to rolling buffer
|
|
156
|
-
|
|
157
|
-
rolling_buffer.append(sample)
|
|
160
|
+
# Add samples to rolling buffer (batch extend is faster than per-sample append)
|
|
161
|
+
rolling_buffer.extend(chunk)
|
|
158
162
|
|
|
159
163
|
samples_since_last_transcribe += len(chunk)
|
|
160
164
|
self._recording_offset += len(chunk) / self.sample_rate
|
meeting_noter/ui/app.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Main Textual application for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.widgets import Footer, Header
|
|
8
|
+
|
|
9
|
+
from meeting_noter.ui.screens.main import MainScreen
|
|
10
|
+
from meeting_noter.ui.screens.recordings import RecordingsScreen
|
|
11
|
+
from meeting_noter.ui.screens.search import SearchScreen
|
|
12
|
+
from meeting_noter.ui.screens.settings import SettingsScreen
|
|
13
|
+
from meeting_noter.ui.screens.logs import LogsScreen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MeetingNoterApp(App):
|
|
17
|
+
"""Meeting Noter Terminal UI Application."""
|
|
18
|
+
|
|
19
|
+
TITLE = "Meeting Noter"
|
|
20
|
+
CSS_PATH = "styles/app.tcss"
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("q", "quit", "Quit"),
|
|
24
|
+
Binding("question_mark", "help", "Help", key_display="?"),
|
|
25
|
+
Binding("1", "switch_screen('main')", "Dashboard", show=True),
|
|
26
|
+
Binding("2", "switch_screen('recordings')", "Recordings", show=True),
|
|
27
|
+
Binding("3", "switch_screen('search')", "Search", show=True),
|
|
28
|
+
Binding("4", "switch_screen('settings')", "Settings", show=True),
|
|
29
|
+
Binding("5", "switch_screen('logs')", "Logs", show=True),
|
|
30
|
+
Binding("escape", "go_back", "Back", show=False),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
SCREENS = {
|
|
34
|
+
"main": MainScreen,
|
|
35
|
+
"recordings": RecordingsScreen,
|
|
36
|
+
"search": SearchScreen,
|
|
37
|
+
"settings": SettingsScreen,
|
|
38
|
+
"logs": LogsScreen,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
"""Compose the app layout."""
|
|
43
|
+
yield Header()
|
|
44
|
+
yield Footer()
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
"""Handle app mount - show the main screen."""
|
|
48
|
+
self.push_screen("main")
|
|
49
|
+
|
|
50
|
+
def action_switch_screen(self, screen_name: str) -> None:
|
|
51
|
+
"""Switch to a named screen."""
|
|
52
|
+
# Pop all screens except the base and push the new one
|
|
53
|
+
while len(self.screen_stack) > 1:
|
|
54
|
+
self.pop_screen()
|
|
55
|
+
self.push_screen(screen_name)
|
|
56
|
+
|
|
57
|
+
def action_go_back(self) -> None:
|
|
58
|
+
"""Go back to the previous screen."""
|
|
59
|
+
if len(self.screen_stack) > 1:
|
|
60
|
+
self.pop_screen()
|
|
61
|
+
|
|
62
|
+
def action_help(self) -> None:
|
|
63
|
+
"""Show help information."""
|
|
64
|
+
self.notify(
|
|
65
|
+
"Keys: 1-5 switch screens, q quit, ? help",
|
|
66
|
+
title="Meeting Noter Help",
|
|
67
|
+
timeout=5,
|
|
68
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""UI Screens for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from meeting_noter.ui.screens.main import MainScreen
|
|
4
|
+
from meeting_noter.ui.screens.recordings import RecordingsScreen
|
|
5
|
+
from meeting_noter.ui.screens.search import SearchScreen
|
|
6
|
+
from meeting_noter.ui.screens.viewer import ViewerScreen
|
|
7
|
+
from meeting_noter.ui.screens.settings import SettingsScreen
|
|
8
|
+
from meeting_noter.ui.screens.logs import LogsScreen
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MainScreen",
|
|
12
|
+
"RecordingsScreen",
|
|
13
|
+
"SearchScreen",
|
|
14
|
+
"ViewerScreen",
|
|
15
|
+
"SettingsScreen",
|
|
16
|
+
"LogsScreen",
|
|
17
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Logs viewer screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container, Horizontal
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Button, Label, RichLog, Static, Switch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOG_FILE = Path.home() / ".meeting-noter.log"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LogsScreen(Screen):
|
|
17
|
+
"""Logs viewer screen."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("l", "toggle_follow", "Follow"),
|
|
21
|
+
("c", "clear_display", "Clear"),
|
|
22
|
+
("r", "refresh", "Refresh"),
|
|
23
|
+
("escape", "go_back", "Back"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self._follow_mode = True
|
|
29
|
+
self._last_size = 0
|
|
30
|
+
|
|
31
|
+
def compose(self) -> ComposeResult:
|
|
32
|
+
"""Compose the logs screen layout."""
|
|
33
|
+
yield Container(
|
|
34
|
+
Static("[bold]Logs[/bold]", classes="title"),
|
|
35
|
+
Label(f"[dim]{LOG_FILE}[/dim]", id="log-path"),
|
|
36
|
+
Static("", classes="spacer"),
|
|
37
|
+
RichLog(id="log-content", highlight=True, markup=True),
|
|
38
|
+
Static("", classes="spacer"),
|
|
39
|
+
Horizontal(
|
|
40
|
+
Label("Follow: "),
|
|
41
|
+
Switch(value=True, id="follow-switch"),
|
|
42
|
+
Button("Refresh", id="refresh-btn"),
|
|
43
|
+
Button("Clear", id="clear-btn"),
|
|
44
|
+
Button("Back", id="back-btn"),
|
|
45
|
+
classes="button-row",
|
|
46
|
+
),
|
|
47
|
+
id="logs-container",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def on_mount(self) -> None:
|
|
51
|
+
"""Start log refresh on mount."""
|
|
52
|
+
self._load_logs()
|
|
53
|
+
self.set_interval(1.0, self._check_for_updates)
|
|
54
|
+
|
|
55
|
+
def _load_logs(self, lines: int = 100) -> None:
|
|
56
|
+
"""Load the last N lines from the log file."""
|
|
57
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
58
|
+
|
|
59
|
+
if not LOG_FILE.exists():
|
|
60
|
+
log_content.write("[yellow]No log file found[/yellow]")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
content = LOG_FILE.read_text()
|
|
65
|
+
all_lines = content.splitlines()
|
|
66
|
+
recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
|
67
|
+
|
|
68
|
+
log_content.clear()
|
|
69
|
+
for line in recent_lines:
|
|
70
|
+
# Color-code different log levels
|
|
71
|
+
if "Error" in line or "error" in line:
|
|
72
|
+
log_content.write(f"[red]{line}[/red]")
|
|
73
|
+
elif "Warning" in line or "warning" in line:
|
|
74
|
+
log_content.write(f"[yellow]{line}[/yellow]")
|
|
75
|
+
elif "Recording started" in line:
|
|
76
|
+
log_content.write(f"[green]{line}[/green]")
|
|
77
|
+
elif "Recording saved" in line:
|
|
78
|
+
log_content.write(f"[cyan]{line}[/cyan]")
|
|
79
|
+
else:
|
|
80
|
+
log_content.write(line)
|
|
81
|
+
|
|
82
|
+
self._last_size = LOG_FILE.stat().st_size
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
log_content.write(f"[red]Error reading log: {e}[/red]")
|
|
86
|
+
|
|
87
|
+
def _check_for_updates(self) -> None:
|
|
88
|
+
"""Check for new log content if in follow mode."""
|
|
89
|
+
if not self._follow_mode:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
follow_switch = self.query_one("#follow-switch", Switch)
|
|
93
|
+
self._follow_mode = follow_switch.value
|
|
94
|
+
|
|
95
|
+
if not self._follow_mode:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if not LOG_FILE.exists():
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
current_size = LOG_FILE.stat().st_size
|
|
103
|
+
if current_size > self._last_size:
|
|
104
|
+
# Read new content
|
|
105
|
+
with open(LOG_FILE, "r") as f:
|
|
106
|
+
f.seek(self._last_size)
|
|
107
|
+
new_content = f.read()
|
|
108
|
+
|
|
109
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
110
|
+
for line in new_content.splitlines():
|
|
111
|
+
if line.strip():
|
|
112
|
+
if "Error" in line or "error" in line:
|
|
113
|
+
log_content.write(f"[red]{line}[/red]")
|
|
114
|
+
elif "Warning" in line or "warning" in line:
|
|
115
|
+
log_content.write(f"[yellow]{line}[/yellow]")
|
|
116
|
+
elif "Recording started" in line:
|
|
117
|
+
log_content.write(f"[green]{line}[/green]")
|
|
118
|
+
elif "Recording saved" in line:
|
|
119
|
+
log_content.write(f"[cyan]{line}[/cyan]")
|
|
120
|
+
else:
|
|
121
|
+
log_content.write(line)
|
|
122
|
+
|
|
123
|
+
self._last_size = current_size
|
|
124
|
+
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
129
|
+
"""Handle button presses."""
|
|
130
|
+
button_id = event.button.id
|
|
131
|
+
|
|
132
|
+
if button_id == "refresh-btn":
|
|
133
|
+
self.action_refresh()
|
|
134
|
+
elif button_id == "clear-btn":
|
|
135
|
+
self.action_clear_display()
|
|
136
|
+
elif button_id == "back-btn":
|
|
137
|
+
self.action_go_back()
|
|
138
|
+
|
|
139
|
+
def on_switch_changed(self, event: Switch.Changed) -> None:
|
|
140
|
+
"""Handle follow switch changes."""
|
|
141
|
+
if event.switch.id == "follow-switch":
|
|
142
|
+
self._follow_mode = event.value
|
|
143
|
+
if event.value:
|
|
144
|
+
self.notify("Follow mode enabled")
|
|
145
|
+
else:
|
|
146
|
+
self.notify("Follow mode disabled")
|
|
147
|
+
|
|
148
|
+
def action_toggle_follow(self) -> None:
|
|
149
|
+
"""Toggle follow mode."""
|
|
150
|
+
switch = self.query_one("#follow-switch", Switch)
|
|
151
|
+
switch.value = not switch.value
|
|
152
|
+
|
|
153
|
+
def action_clear_display(self) -> None:
|
|
154
|
+
"""Clear the log display."""
|
|
155
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
156
|
+
log_content.clear()
|
|
157
|
+
self.notify("Display cleared")
|
|
158
|
+
|
|
159
|
+
def action_refresh(self) -> None:
|
|
160
|
+
"""Refresh the log display."""
|
|
161
|
+
self._load_logs()
|
|
162
|
+
self.notify("Refreshed")
|
|
163
|
+
|
|
164
|
+
def action_go_back(self) -> None:
|
|
165
|
+
"""Go back to the previous screen."""
|
|
166
|
+
self.app.pop_screen()
|