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.

@@ -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()
@@ -0,0 +1,116 @@
1
+ """Transcript viewer screen for Meeting Noter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Container, Horizontal
10
+ from textual.screen import Screen
11
+ from textual.widgets import Button, Label, Static, TextArea
12
+
13
+ from meeting_noter.config import get_config
14
+
15
+
16
+ class ViewerScreen(Screen):
17
+ """Transcript viewer screen."""
18
+
19
+ BINDINGS = [
20
+ ("f", "toggle_favorite", "Favorite"),
21
+ ("escape", "go_back", "Back"),
22
+ ]
23
+
24
+ def __init__(self, transcript_path: Path, *args, **kwargs):
25
+ super().__init__(*args, **kwargs)
26
+ self.transcript_path = transcript_path
27
+
28
+ def compose(self) -> ComposeResult:
29
+ """Compose the viewer screen layout."""
30
+ config = get_config()
31
+ is_favorite = config.is_favorite(self.transcript_path.name)
32
+ star = "★ " if is_favorite else ""
33
+
34
+ yield Container(
35
+ Static(
36
+ f"[bold]{star}{self.transcript_path.name}[/bold]",
37
+ classes="title",
38
+ ),
39
+ Label("", id="file-info"),
40
+ Static("", classes="spacer"),
41
+ TextArea(id="transcript-content", read_only=True),
42
+ Static("", classes="spacer"),
43
+ Horizontal(
44
+ Button(
45
+ "★ Favorite" if not is_favorite else "☆ Unfavorite",
46
+ id="favorite-btn",
47
+ variant="warning" if is_favorite else "default",
48
+ ),
49
+ Button("Back", id="back-btn"),
50
+ classes="button-row",
51
+ ),
52
+ id="viewer-container",
53
+ )
54
+
55
+ def on_mount(self) -> None:
56
+ """Load transcript content on mount."""
57
+ self._load_transcript()
58
+
59
+ def _load_transcript(self) -> None:
60
+ """Load the transcript content."""
61
+ info_label = self.query_one("#file-info", Label)
62
+ content_area = self.query_one("#transcript-content", TextArea)
63
+
64
+ if not self.transcript_path.exists():
65
+ info_label.update("[red]File not found[/red]")
66
+ content_area.text = "Transcript file does not exist."
67
+ return
68
+
69
+ # Get file info
70
+ stat = self.transcript_path.stat()
71
+ mod_time = datetime.fromtimestamp(stat.st_mtime)
72
+ date_str = mod_time.strftime("%Y-%m-%d %H:%M")
73
+ size_kb = stat.st_size / 1024
74
+
75
+ info_label.update(f"[dim]Modified: {date_str} | Size: {size_kb:.1f} KB[/dim]")
76
+
77
+ # Load content
78
+ try:
79
+ content = self.transcript_path.read_text(encoding="utf-8")
80
+ content_area.text = content
81
+ except Exception as e:
82
+ content_area.text = f"Error loading file: {e}"
83
+
84
+ def on_button_pressed(self, event: Button.Pressed) -> None:
85
+ """Handle button presses."""
86
+ if event.button.id == "favorite-btn":
87
+ self.action_toggle_favorite()
88
+ elif event.button.id == "back-btn":
89
+ self.action_go_back()
90
+
91
+ def action_toggle_favorite(self) -> None:
92
+ """Toggle favorite status."""
93
+ config = get_config()
94
+ filename = self.transcript_path.name
95
+
96
+ if config.is_favorite(filename):
97
+ config.remove_favorite(filename)
98
+ self.notify(f"Removed from favorites: {filename}")
99
+ else:
100
+ config.add_favorite(filename)
101
+ self.notify(f"Added to favorites: {filename}")
102
+
103
+ # Update the button
104
+ btn = self.query_one("#favorite-btn", Button)
105
+ is_favorite = config.is_favorite(filename)
106
+ btn.label = "★ Favorite" if not is_favorite else "☆ Unfavorite"
107
+ btn.variant = "warning" if is_favorite else "default"
108
+
109
+ # Update title
110
+ title = self.query_one(".title", Static)
111
+ star = "★ " if is_favorite else ""
112
+ title.update(f"[bold]{star}{filename}[/bold]")
113
+
114
+ def action_go_back(self) -> None:
115
+ """Go back to the previous screen."""
116
+ self.app.pop_screen()