meeting-noter 3.0.1__py3-none-any.whl → 3.1.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 +212 -1
- meeting_noter/config.py +58 -0
- meeting_noter/gui/screens/recordings.py +127 -4
- meeting_noter/output/writer.py +135 -2
- {meeting_noter-3.0.1.dist-info → meeting_noter-3.1.1.dist-info}/METADATA +1 -1
- {meeting_noter-3.0.1.dist-info → meeting_noter-3.1.1.dist-info}/RECORD +10 -10
- {meeting_noter-3.0.1.dist-info → meeting_noter-3.1.1.dist-info}/WHEEL +0 -0
- {meeting_noter-3.0.1.dist-info → meeting_noter-3.1.1.dist-info}/entry_points.txt +0 -0
- {meeting_noter-3.0.1.dist-info → meeting_noter-3.1.1.dist-info}/top_level.txt +0 -0
meeting_noter/__init__.py
CHANGED
meeting_noter/cli.py
CHANGED
|
@@ -445,7 +445,73 @@ def list_recordings(transcripts: bool, output_dir: Optional[str], limit: int):
|
|
|
445
445
|
else:
|
|
446
446
|
from meeting_noter.output.writer import list_recordings as _list_recordings
|
|
447
447
|
path = Path(output_dir) if output_dir else config.recordings_dir
|
|
448
|
-
_list_recordings(path, limit)
|
|
448
|
+
_list_recordings(path, limit, config)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@cli.command("rename")
|
|
452
|
+
@click.argument("file", required=False)
|
|
453
|
+
@click.option(
|
|
454
|
+
"--name", "-n",
|
|
455
|
+
required=True,
|
|
456
|
+
help="New meeting name",
|
|
457
|
+
)
|
|
458
|
+
@require_setup
|
|
459
|
+
def rename(file: Optional[str], name: str):
|
|
460
|
+
"""Rename a meeting recording and its transcript.
|
|
461
|
+
|
|
462
|
+
FILE is the recording filename (optional). If not provided,
|
|
463
|
+
renames the most recent recording.
|
|
464
|
+
|
|
465
|
+
\b
|
|
466
|
+
Examples:
|
|
467
|
+
meeting-noter rename --name "Weekly Standup"
|
|
468
|
+
meeting-noter rename "2025-01-29_143015.mp3" --name "Team Meeting"
|
|
469
|
+
mn rename -n "Sprint Planning"
|
|
470
|
+
"""
|
|
471
|
+
from meeting_noter.output.writer import rename_meeting
|
|
472
|
+
|
|
473
|
+
config = get_config()
|
|
474
|
+
recordings_dir = config.recordings_dir
|
|
475
|
+
|
|
476
|
+
# Find the file to rename
|
|
477
|
+
if file:
|
|
478
|
+
# Try as absolute path first, then relative to recordings dir
|
|
479
|
+
file_path = Path(file)
|
|
480
|
+
if not file_path.exists():
|
|
481
|
+
file_path = recordings_dir / file
|
|
482
|
+
if not file_path.exists():
|
|
483
|
+
click.echo(click.style(f"Error: File not found: {file}", fg="red"))
|
|
484
|
+
raise SystemExit(1)
|
|
485
|
+
else:
|
|
486
|
+
# Find most recent recording
|
|
487
|
+
mp3_files = sorted(
|
|
488
|
+
recordings_dir.glob("*.mp3"),
|
|
489
|
+
key=lambda p: p.stat().st_mtime,
|
|
490
|
+
reverse=True,
|
|
491
|
+
)
|
|
492
|
+
if not mp3_files:
|
|
493
|
+
click.echo(click.style("No recordings found.", fg="red"))
|
|
494
|
+
raise SystemExit(1)
|
|
495
|
+
file_path = mp3_files[0]
|
|
496
|
+
click.echo(f"Renaming most recent recording: {file_path.name}")
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
new_path = rename_meeting(file_path, name, config)
|
|
500
|
+
click.echo(click.style("Renamed successfully:", fg="green"))
|
|
501
|
+
click.echo(f" {file_path.name} → {new_path.name}")
|
|
502
|
+
|
|
503
|
+
# Check if transcript was also renamed
|
|
504
|
+
old_transcript = file_path.with_suffix(".txt")
|
|
505
|
+
new_transcript = new_path.with_suffix(".txt")
|
|
506
|
+
if new_transcript.exists():
|
|
507
|
+
click.echo(f" {old_transcript.name} → {new_transcript.name}")
|
|
508
|
+
|
|
509
|
+
except FileExistsError as e:
|
|
510
|
+
click.echo(click.style(f"Error: {e}", fg="red"))
|
|
511
|
+
raise SystemExit(1)
|
|
512
|
+
except PermissionError:
|
|
513
|
+
click.echo(click.style("Error: Permission denied. Cannot rename files.", fg="red"))
|
|
514
|
+
raise SystemExit(1)
|
|
449
515
|
|
|
450
516
|
|
|
451
517
|
@cli.command("search")
|
|
@@ -538,6 +604,151 @@ def favorites_remove(filename: str):
|
|
|
538
604
|
remove_favorite(filename)
|
|
539
605
|
|
|
540
606
|
|
|
607
|
+
@cli.group("tags", invoke_without_command=True)
|
|
608
|
+
@click.pass_context
|
|
609
|
+
@require_setup
|
|
610
|
+
def tags(ctx):
|
|
611
|
+
"""Manage tags for recordings.
|
|
612
|
+
|
|
613
|
+
\b
|
|
614
|
+
Examples:
|
|
615
|
+
meeting-noter tags # List all unique tags
|
|
616
|
+
meeting-noter tags list file.mp3 # List tags for a file
|
|
617
|
+
meeting-noter tags add file.mp3 "Project X"
|
|
618
|
+
meeting-noter tags remove file.mp3 "Project X"
|
|
619
|
+
meeting-noter tags search "Project X"
|
|
620
|
+
"""
|
|
621
|
+
if ctx.invoked_subcommand is None:
|
|
622
|
+
# Show all unique tags
|
|
623
|
+
config = get_config()
|
|
624
|
+
all_tags = config.get_all_tags()
|
|
625
|
+
if all_tags:
|
|
626
|
+
click.echo("\nAll tags:")
|
|
627
|
+
for tag in sorted(all_tags):
|
|
628
|
+
files = config.get_files_by_tag(tag)
|
|
629
|
+
click.echo(f" • {tag} ({len(files)} recording{'s' if len(files) != 1 else ''})")
|
|
630
|
+
else:
|
|
631
|
+
click.echo("No tags found.")
|
|
632
|
+
click.echo("\nAdd tags with: meeting-noter tags add <file> <tag>")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@tags.command("list")
|
|
636
|
+
@click.argument("file")
|
|
637
|
+
@require_setup
|
|
638
|
+
def tags_list(file: str):
|
|
639
|
+
"""List tags for a specific recording.
|
|
640
|
+
|
|
641
|
+
\b
|
|
642
|
+
Examples:
|
|
643
|
+
meeting-noter tags list recording.mp3
|
|
644
|
+
mn tags list recording.mp3
|
|
645
|
+
"""
|
|
646
|
+
config = get_config()
|
|
647
|
+
|
|
648
|
+
# Find the file
|
|
649
|
+
file_path = Path(file)
|
|
650
|
+
if not file_path.exists():
|
|
651
|
+
file_path = config.recordings_dir / file
|
|
652
|
+
if not file_path.exists():
|
|
653
|
+
click.echo(click.style(f"File not found: {file}", fg="red"))
|
|
654
|
+
raise SystemExit(1)
|
|
655
|
+
|
|
656
|
+
file_tags = config.get_tags(file_path.name)
|
|
657
|
+
if file_tags:
|
|
658
|
+
click.echo(f"\nTags for {file_path.name}:")
|
|
659
|
+
for tag in file_tags:
|
|
660
|
+
click.echo(f" • {tag}")
|
|
661
|
+
else:
|
|
662
|
+
click.echo(f"No tags for {file_path.name}")
|
|
663
|
+
click.echo(f"\nAdd tags with: meeting-noter tags add {file_path.name} <tag>")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@tags.command("add")
|
|
667
|
+
@click.argument("file")
|
|
668
|
+
@click.argument("tag")
|
|
669
|
+
@require_setup
|
|
670
|
+
def tags_add(file: str, tag: str):
|
|
671
|
+
"""Add a tag to a recording.
|
|
672
|
+
|
|
673
|
+
\b
|
|
674
|
+
Examples:
|
|
675
|
+
meeting-noter tags add recording.mp3 "Project X"
|
|
676
|
+
meeting-noter tags add recording.mp3 "Sprint Planning"
|
|
677
|
+
mn tags add recording.mp3 "Q1 Review"
|
|
678
|
+
"""
|
|
679
|
+
config = get_config()
|
|
680
|
+
|
|
681
|
+
# Find the file
|
|
682
|
+
file_path = Path(file)
|
|
683
|
+
if not file_path.exists():
|
|
684
|
+
file_path = config.recordings_dir / file
|
|
685
|
+
if not file_path.exists():
|
|
686
|
+
click.echo(click.style(f"File not found: {file}", fg="red"))
|
|
687
|
+
raise SystemExit(1)
|
|
688
|
+
|
|
689
|
+
if config.add_tag(file_path.name, tag):
|
|
690
|
+
click.echo(click.style(f"Added tag '{tag}' to {file_path.name}", fg="green"))
|
|
691
|
+
else:
|
|
692
|
+
click.echo(f"Tag '{tag}' already exists on {file_path.name}")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@tags.command("remove")
|
|
696
|
+
@click.argument("file")
|
|
697
|
+
@click.argument("tag")
|
|
698
|
+
@require_setup
|
|
699
|
+
def tags_remove(file: str, tag: str):
|
|
700
|
+
"""Remove a tag from a recording.
|
|
701
|
+
|
|
702
|
+
\b
|
|
703
|
+
Examples:
|
|
704
|
+
meeting-noter tags remove recording.mp3 "Project X"
|
|
705
|
+
"""
|
|
706
|
+
config = get_config()
|
|
707
|
+
|
|
708
|
+
# Find the file
|
|
709
|
+
file_path = Path(file)
|
|
710
|
+
if not file_path.exists():
|
|
711
|
+
file_path = config.recordings_dir / file
|
|
712
|
+
if not file_path.exists():
|
|
713
|
+
click.echo(click.style(f"File not found: {file}", fg="red"))
|
|
714
|
+
raise SystemExit(1)
|
|
715
|
+
|
|
716
|
+
if config.remove_tag(file_path.name, tag):
|
|
717
|
+
click.echo(click.style(f"Removed tag '{tag}' from {file_path.name}", fg="green"))
|
|
718
|
+
else:
|
|
719
|
+
click.echo(f"Tag '{tag}' not found on {file_path.name}")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@tags.command("search")
|
|
723
|
+
@click.argument("tag")
|
|
724
|
+
@require_setup
|
|
725
|
+
def tags_search(tag: str):
|
|
726
|
+
"""Search recordings by tag.
|
|
727
|
+
|
|
728
|
+
\b
|
|
729
|
+
Examples:
|
|
730
|
+
meeting-noter tags search "Project X"
|
|
731
|
+
mn tags search "Sprint Planning"
|
|
732
|
+
"""
|
|
733
|
+
config = get_config()
|
|
734
|
+
files = config.get_files_by_tag(tag)
|
|
735
|
+
|
|
736
|
+
if files:
|
|
737
|
+
click.echo(f"\nRecordings with tag '{tag}':\n")
|
|
738
|
+
for filename in sorted(files):
|
|
739
|
+
file_path = config.recordings_dir / filename
|
|
740
|
+
if file_path.exists():
|
|
741
|
+
stat = file_path.stat()
|
|
742
|
+
from datetime import datetime
|
|
743
|
+
date = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
744
|
+
click.echo(f" {date} {filename}")
|
|
745
|
+
else:
|
|
746
|
+
click.echo(f" (missing) {filename}")
|
|
747
|
+
click.echo(f"\nTotal: {len(files)} recording{'s' if len(files) != 1 else ''}")
|
|
748
|
+
else:
|
|
749
|
+
click.echo(f"No recordings found with tag '{tag}'")
|
|
750
|
+
|
|
751
|
+
|
|
541
752
|
@cli.command()
|
|
542
753
|
@click.argument("file", required=False)
|
|
543
754
|
@click.option(
|
meeting_noter/config.py
CHANGED
|
@@ -194,6 +194,64 @@ class Config:
|
|
|
194
194
|
"""Check if a transcript is a favorite."""
|
|
195
195
|
return filename in self.favorites
|
|
196
196
|
|
|
197
|
+
@property
|
|
198
|
+
def tags(self) -> dict[str, list[str]]:
|
|
199
|
+
"""Get tags dictionary mapping filename to list of tags."""
|
|
200
|
+
if "tags" not in self._data:
|
|
201
|
+
self._data["tags"] = {}
|
|
202
|
+
return self._data["tags"]
|
|
203
|
+
|
|
204
|
+
@tags.setter
|
|
205
|
+
def tags(self, value: dict[str, list[str]]) -> None:
|
|
206
|
+
"""Set tags dictionary."""
|
|
207
|
+
self._data["tags"] = value
|
|
208
|
+
|
|
209
|
+
def add_tag(self, filename: str, tag: str) -> bool:
|
|
210
|
+
"""Add a tag to a recording. Returns True if added, False if already exists."""
|
|
211
|
+
tags = self.tags # Now returns the actual dict from _data
|
|
212
|
+
if filename not in tags:
|
|
213
|
+
tags[filename] = []
|
|
214
|
+
tag = tag.strip()
|
|
215
|
+
if tag and tag not in tags[filename]:
|
|
216
|
+
tags[filename].append(tag)
|
|
217
|
+
self.save()
|
|
218
|
+
return True
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def remove_tag(self, filename: str, tag: str) -> bool:
|
|
222
|
+
"""Remove a tag from a recording. Returns True if removed, False if not found."""
|
|
223
|
+
tags = self.tags # Now returns the actual dict from _data
|
|
224
|
+
if filename in tags and tag in tags[filename]:
|
|
225
|
+
tags[filename].remove(tag)
|
|
226
|
+
if not tags[filename]:
|
|
227
|
+
del tags[filename]
|
|
228
|
+
self.save()
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def get_tags(self, filename: str) -> list[str]:
|
|
233
|
+
"""Get tags for a recording."""
|
|
234
|
+
return self.tags.get(filename, [])
|
|
235
|
+
|
|
236
|
+
def get_all_tags(self) -> set[str]:
|
|
237
|
+
"""Get all unique tags across all recordings."""
|
|
238
|
+
all_tags: set[str] = set()
|
|
239
|
+
for tag_list in self.tags.values():
|
|
240
|
+
all_tags.update(tag_list)
|
|
241
|
+
return all_tags
|
|
242
|
+
|
|
243
|
+
def get_files_by_tag(self, tag: str) -> list[str]:
|
|
244
|
+
"""Get all filenames that have a specific tag."""
|
|
245
|
+
return [filename for filename, tag_list in self.tags.items() if tag in tag_list]
|
|
246
|
+
|
|
247
|
+
def rename_file_tags(self, old_filename: str, new_filename: str) -> None:
|
|
248
|
+
"""Update tags when a file is renamed."""
|
|
249
|
+
tags = self.tags
|
|
250
|
+
if old_filename in tags:
|
|
251
|
+
tags[new_filename] = tags.pop(old_filename)
|
|
252
|
+
self.tags = tags
|
|
253
|
+
self.save()
|
|
254
|
+
|
|
197
255
|
def __getitem__(self, key: str) -> Any:
|
|
198
256
|
"""Get config value by key."""
|
|
199
257
|
return self._data.get(key, DEFAULT_CONFIG.get(key))
|
|
@@ -14,7 +14,9 @@ from PySide6.QtWidgets import (
|
|
|
14
14
|
QAbstractItemView,
|
|
15
15
|
QHBoxLayout,
|
|
16
16
|
QHeaderView,
|
|
17
|
+
QInputDialog,
|
|
17
18
|
QLabel,
|
|
19
|
+
QMessageBox,
|
|
18
20
|
QPushButton,
|
|
19
21
|
QTableWidget,
|
|
20
22
|
QTableWidgetItem,
|
|
@@ -37,6 +39,7 @@ class RecordingInfo:
|
|
|
37
39
|
size: int
|
|
38
40
|
has_transcript: bool
|
|
39
41
|
is_favorite: bool
|
|
42
|
+
tags: list[str]
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
class RecordingsScreen(QWidget):
|
|
@@ -61,9 +64,9 @@ class RecordingsScreen(QWidget):
|
|
|
61
64
|
|
|
62
65
|
# Table
|
|
63
66
|
self._table = QTableWidget()
|
|
64
|
-
self._table.setColumnCount(
|
|
67
|
+
self._table.setColumnCount(7)
|
|
65
68
|
self._table.setHorizontalHeaderLabels(
|
|
66
|
-
["\u2605", "Date", "Duration", "Size", "Transcript", "File"]
|
|
69
|
+
["\u2605", "Date", "Duration", "Size", "Transcript", "Tags", "File"]
|
|
67
70
|
)
|
|
68
71
|
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
69
72
|
self._table.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
@@ -79,7 +82,8 @@ class RecordingsScreen(QWidget):
|
|
|
79
82
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
80
83
|
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
81
84
|
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
82
|
-
header.setSectionResizeMode(5, QHeaderView.
|
|
85
|
+
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
|
86
|
+
header.setSectionResizeMode(6, QHeaderView.Stretch)
|
|
83
87
|
|
|
84
88
|
self._table.doubleClicked.connect(self._on_row_double_clicked)
|
|
85
89
|
layout.addWidget(self._table, 1)
|
|
@@ -90,6 +94,8 @@ class RecordingsScreen(QWidget):
|
|
|
90
94
|
|
|
91
95
|
self._view_btn = QPushButton("View Transcript")
|
|
92
96
|
self._transcribe_btn = QPushButton("Transcribe")
|
|
97
|
+
self._rename_btn = QPushButton("Rename")
|
|
98
|
+
self._tags_btn = QPushButton("Tags")
|
|
93
99
|
self._favorite_btn = QPushButton("\u2605 Favorite")
|
|
94
100
|
self._refresh_btn = QPushButton("Refresh")
|
|
95
101
|
self._refresh_btn.setProperty("class", "secondary")
|
|
@@ -97,6 +103,8 @@ class RecordingsScreen(QWidget):
|
|
|
97
103
|
|
|
98
104
|
button_row.addWidget(self._view_btn)
|
|
99
105
|
button_row.addWidget(self._transcribe_btn)
|
|
106
|
+
button_row.addWidget(self._rename_btn)
|
|
107
|
+
button_row.addWidget(self._tags_btn)
|
|
100
108
|
button_row.addWidget(self._favorite_btn)
|
|
101
109
|
button_row.addStretch()
|
|
102
110
|
button_row.addWidget(self._refresh_btn)
|
|
@@ -111,6 +119,8 @@ class RecordingsScreen(QWidget):
|
|
|
111
119
|
"""Connect button signals."""
|
|
112
120
|
self._view_btn.clicked.connect(self._view_transcript)
|
|
113
121
|
self._transcribe_btn.clicked.connect(self._transcribe_selected)
|
|
122
|
+
self._rename_btn.clicked.connect(self._rename_selected)
|
|
123
|
+
self._tags_btn.clicked.connect(self._manage_tags)
|
|
114
124
|
self._favorite_btn.clicked.connect(self._toggle_favorite)
|
|
115
125
|
self._refresh_btn.clicked.connect(self.refresh)
|
|
116
126
|
|
|
@@ -155,6 +165,9 @@ class RecordingsScreen(QWidget):
|
|
|
155
165
|
# Check favorite status
|
|
156
166
|
is_favorite = config.is_favorite(mp3_path.name)
|
|
157
167
|
|
|
168
|
+
# Get tags
|
|
169
|
+
file_tags = config.get_tags(mp3_path.name)
|
|
170
|
+
|
|
158
171
|
recordings.append(
|
|
159
172
|
RecordingInfo(
|
|
160
173
|
filepath=mp3_path,
|
|
@@ -163,6 +176,7 @@ class RecordingsScreen(QWidget):
|
|
|
163
176
|
size=size,
|
|
164
177
|
has_transcript=has_transcript,
|
|
165
178
|
is_favorite=is_favorite,
|
|
179
|
+
tags=file_tags,
|
|
166
180
|
)
|
|
167
181
|
)
|
|
168
182
|
except Exception:
|
|
@@ -206,9 +220,15 @@ class RecordingsScreen(QWidget):
|
|
|
206
220
|
transcript_item.setForeground(Qt.green)
|
|
207
221
|
self._table.setItem(row, 4, transcript_item)
|
|
208
222
|
|
|
223
|
+
# Tags
|
|
224
|
+
tags_str = ", ".join(rec.tags) if rec.tags else ""
|
|
225
|
+
tags_item = QTableWidgetItem(tags_str)
|
|
226
|
+
tags_item.setForeground(Qt.cyan)
|
|
227
|
+
self._table.setItem(row, 5, tags_item)
|
|
228
|
+
|
|
209
229
|
# Filename
|
|
210
230
|
file_item = QTableWidgetItem(rec.filepath.name)
|
|
211
|
-
self._table.setItem(row,
|
|
231
|
+
self._table.setItem(row, 6, file_item)
|
|
212
232
|
|
|
213
233
|
self._status_label.setText(f"{len(self._recordings)} recordings")
|
|
214
234
|
|
|
@@ -277,3 +297,106 @@ class RecordingsScreen(QWidget):
|
|
|
277
297
|
|
|
278
298
|
# Refresh to update star display
|
|
279
299
|
self.refresh()
|
|
300
|
+
|
|
301
|
+
def _rename_selected(self):
|
|
302
|
+
"""Rename the selected recording."""
|
|
303
|
+
rec = self._get_selected_recording()
|
|
304
|
+
if not rec:
|
|
305
|
+
self._status_label.setText("No recording selected.")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
# Get current name (without timestamp prefix and extension)
|
|
309
|
+
from meeting_noter.output.writer import extract_timestamp_prefix
|
|
310
|
+
|
|
311
|
+
stem = rec.filepath.stem
|
|
312
|
+
timestamp = extract_timestamp_prefix(stem)
|
|
313
|
+
if timestamp:
|
|
314
|
+
# Extract the name part after the timestamp
|
|
315
|
+
current_name = stem[len(timestamp) + 1:] if len(stem) > len(timestamp) else ""
|
|
316
|
+
else:
|
|
317
|
+
current_name = stem
|
|
318
|
+
|
|
319
|
+
# Replace underscores with spaces for display
|
|
320
|
+
current_name = current_name.replace("_", " ")
|
|
321
|
+
|
|
322
|
+
# Show input dialog
|
|
323
|
+
new_name, ok = QInputDialog.getText(
|
|
324
|
+
self,
|
|
325
|
+
"Rename Meeting",
|
|
326
|
+
"Enter new meeting name:",
|
|
327
|
+
text=current_name,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if not ok or not new_name.strip():
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
new_name = new_name.strip()
|
|
334
|
+
|
|
335
|
+
# Perform the rename
|
|
336
|
+
from meeting_noter.output.writer import rename_meeting
|
|
337
|
+
|
|
338
|
+
config = get_config()
|
|
339
|
+
try:
|
|
340
|
+
new_path = rename_meeting(rec.filepath, new_name, config)
|
|
341
|
+
self._status_label.setText(f"Renamed to: {new_path.name}")
|
|
342
|
+
self.refresh()
|
|
343
|
+
except FileExistsError:
|
|
344
|
+
QMessageBox.warning(
|
|
345
|
+
self,
|
|
346
|
+
"Rename Failed",
|
|
347
|
+
f"A file with that name already exists.",
|
|
348
|
+
)
|
|
349
|
+
except PermissionError:
|
|
350
|
+
QMessageBox.warning(
|
|
351
|
+
self,
|
|
352
|
+
"Rename Failed",
|
|
353
|
+
"Permission denied. Cannot rename files.",
|
|
354
|
+
)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
QMessageBox.warning(
|
|
357
|
+
self,
|
|
358
|
+
"Rename Failed",
|
|
359
|
+
f"Error renaming file: {e}",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _manage_tags(self):
|
|
363
|
+
"""Manage tags for the selected recording."""
|
|
364
|
+
rec = self._get_selected_recording()
|
|
365
|
+
if not rec:
|
|
366
|
+
self._status_label.setText("No recording selected.")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
config = get_config()
|
|
370
|
+
current_tags = config.get_tags(rec.filepath.name)
|
|
371
|
+
current_tags_str = ", ".join(current_tags) if current_tags else ""
|
|
372
|
+
|
|
373
|
+
# Show input dialog with current tags
|
|
374
|
+
new_tags_str, ok = QInputDialog.getText(
|
|
375
|
+
self,
|
|
376
|
+
"Manage Tags",
|
|
377
|
+
"Enter tags (comma-separated):\n\nExample: Project X, Sprint Planning, Q1 Review",
|
|
378
|
+
text=current_tags_str,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if not ok:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Parse new tags
|
|
385
|
+
new_tags = [tag.strip() for tag in new_tags_str.split(",") if tag.strip()]
|
|
386
|
+
|
|
387
|
+
# Remove old tags that are no longer present
|
|
388
|
+
for old_tag in current_tags:
|
|
389
|
+
if old_tag not in new_tags:
|
|
390
|
+
config.remove_tag(rec.filepath.name, old_tag)
|
|
391
|
+
|
|
392
|
+
# Add new tags
|
|
393
|
+
for new_tag in new_tags:
|
|
394
|
+
if new_tag not in current_tags:
|
|
395
|
+
config.add_tag(rec.filepath.name, new_tag)
|
|
396
|
+
|
|
397
|
+
if new_tags:
|
|
398
|
+
self._status_label.setText(f"Tags updated: {', '.join(new_tags)}")
|
|
399
|
+
else:
|
|
400
|
+
self._status_label.setText("Tags cleared")
|
|
401
|
+
|
|
402
|
+
self.refresh()
|
meeting_noter/output/writer.py
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import click
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from datetime import datetime
|
|
8
|
-
from typing import Optional
|
|
9
|
+
from typing import Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from meeting_noter.config import Config
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
def format_duration(seconds: float) -> str:
|
|
@@ -45,7 +49,7 @@ def get_audio_duration(filepath: Path) -> Optional[float]:
|
|
|
45
49
|
return None
|
|
46
50
|
|
|
47
51
|
|
|
48
|
-
def list_recordings(output_dir: Path, limit: int = 10):
|
|
52
|
+
def list_recordings(output_dir: Path, limit: int = 10, config: Optional["Config"] = None):
|
|
49
53
|
"""List recent meeting recordings."""
|
|
50
54
|
if not output_dir.exists():
|
|
51
55
|
click.echo(click.style(f"Directory not found: {output_dir}", fg="red"))
|
|
@@ -89,8 +93,137 @@ def list_recordings(output_dir: Path, limit: int = 10):
|
|
|
89
93
|
|
|
90
94
|
click.echo(f" {date_str:<20} {duration_str:<12} {size_str:<10} {has_transcript:<12} {mp3.name}")
|
|
91
95
|
|
|
96
|
+
# Show tags if config is provided and file has tags
|
|
97
|
+
if config:
|
|
98
|
+
file_tags = config.get_tags(mp3.name)
|
|
99
|
+
if file_tags:
|
|
100
|
+
tags_str = ", ".join(file_tags)
|
|
101
|
+
click.echo(click.style(f" Tags: {tags_str}", fg="cyan"))
|
|
102
|
+
|
|
92
103
|
if len(mp3_files) > limit:
|
|
93
104
|
click.echo(f"\n ... and {len(mp3_files) - limit} more recordings")
|
|
94
105
|
|
|
95
106
|
click.echo(f"\nTotal: {len(mp3_files)} recordings")
|
|
96
107
|
click.echo("\nTo transcribe: meeting-noter transcribe [filename]")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sanitize_filename(name: str, max_length: int = 50) -> str:
|
|
111
|
+
"""Sanitize a string for use as a filename.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: The name to sanitize
|
|
115
|
+
max_length: Maximum length for the sanitized name
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A safe filename string with spaces replaced by underscores
|
|
119
|
+
"""
|
|
120
|
+
# Replace spaces with underscores
|
|
121
|
+
name = name.replace(" ", "_")
|
|
122
|
+
# Remove any character that isn't alphanumeric, underscore, or hyphen
|
|
123
|
+
name = re.sub(r"[^\w\-]", "", name)
|
|
124
|
+
# Truncate to max length
|
|
125
|
+
if len(name) > max_length:
|
|
126
|
+
name = name[:max_length]
|
|
127
|
+
# Remove trailing underscores/hyphens
|
|
128
|
+
name = name.rstrip("_-")
|
|
129
|
+
return name
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extract_timestamp_prefix(filename: str) -> Optional[str]:
|
|
133
|
+
"""Extract the timestamp prefix from a recording filename.
|
|
134
|
+
|
|
135
|
+
Handles formats like:
|
|
136
|
+
- 2025-01-29_143015_Meeting_Name.mp3 -> 2025-01-29_143015
|
|
137
|
+
- 2025-01-29_143015.mp3 -> 2025-01-29_143015
|
|
138
|
+
- 29_Jan_2026_1430_Meeting_Name.mp3 -> 29_Jan_2026_1430
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The timestamp prefix or None if not found
|
|
142
|
+
"""
|
|
143
|
+
# Pattern 1: YYYY-MM-DD_HHMMSS format
|
|
144
|
+
match = re.match(r"^(\d{4}-\d{2}-\d{2}_\d{6})", filename)
|
|
145
|
+
if match:
|
|
146
|
+
return match.group(1)
|
|
147
|
+
|
|
148
|
+
# Pattern 2: DD_Mon_YYYY_HHMM format
|
|
149
|
+
match = re.match(r"^(\d{2}_[A-Z][a-z]{2}_\d{4}_\d{4})", filename)
|
|
150
|
+
if match:
|
|
151
|
+
return match.group(1)
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def rename_meeting(
|
|
157
|
+
old_path: Path,
|
|
158
|
+
new_name: str,
|
|
159
|
+
config: "Config",
|
|
160
|
+
) -> Path:
|
|
161
|
+
"""Rename a meeting recording and its associated transcript.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
old_path: Path to the current MP3 file
|
|
165
|
+
new_name: New meeting name (will be sanitized)
|
|
166
|
+
config: Config object for updating favorites
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Path to the renamed MP3 file
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
FileNotFoundError: If the recording doesn't exist
|
|
173
|
+
FileExistsError: If the new filename already exists
|
|
174
|
+
PermissionError: If unable to rename files
|
|
175
|
+
"""
|
|
176
|
+
if not old_path.exists():
|
|
177
|
+
raise FileNotFoundError(f"Recording not found: {old_path}")
|
|
178
|
+
|
|
179
|
+
# Extract timestamp prefix from old filename
|
|
180
|
+
old_stem = old_path.stem
|
|
181
|
+
timestamp = extract_timestamp_prefix(old_stem)
|
|
182
|
+
|
|
183
|
+
# Build new filename
|
|
184
|
+
sanitized_name = sanitize_filename(new_name)
|
|
185
|
+
if timestamp:
|
|
186
|
+
new_stem = f"{timestamp}_{sanitized_name}"
|
|
187
|
+
else:
|
|
188
|
+
# No timestamp found, just use the sanitized name
|
|
189
|
+
new_stem = sanitized_name
|
|
190
|
+
|
|
191
|
+
new_mp3_path = old_path.parent / f"{new_stem}.mp3"
|
|
192
|
+
|
|
193
|
+
# Check if target already exists
|
|
194
|
+
if new_mp3_path.exists() and new_mp3_path != old_path:
|
|
195
|
+
raise FileExistsError(f"File already exists: {new_mp3_path}")
|
|
196
|
+
|
|
197
|
+
# Rename MP3 file
|
|
198
|
+
old_path.rename(new_mp3_path)
|
|
199
|
+
|
|
200
|
+
# Rename transcript if it exists (check both same dir and transcripts_dir)
|
|
201
|
+
old_transcript = old_path.with_suffix(".txt")
|
|
202
|
+
if old_transcript.exists():
|
|
203
|
+
new_transcript = new_mp3_path.with_suffix(".txt")
|
|
204
|
+
old_transcript.rename(new_transcript)
|
|
205
|
+
|
|
206
|
+
# Also check transcripts_dir if different from recordings dir
|
|
207
|
+
transcripts_dir = config.transcripts_dir
|
|
208
|
+
if transcripts_dir != old_path.parent:
|
|
209
|
+
old_transcript_in_dir = transcripts_dir / f"{old_stem}.txt"
|
|
210
|
+
if old_transcript_in_dir.exists():
|
|
211
|
+
new_transcript_in_dir = transcripts_dir / f"{new_stem}.txt"
|
|
212
|
+
old_transcript_in_dir.rename(new_transcript_in_dir)
|
|
213
|
+
|
|
214
|
+
# Rename live transcript if it exists
|
|
215
|
+
live_dir = old_path.parent / "live"
|
|
216
|
+
old_live = live_dir / f"{old_stem}.live.txt"
|
|
217
|
+
if old_live.exists():
|
|
218
|
+
new_live = live_dir / f"{new_stem}.live.txt"
|
|
219
|
+
old_live.rename(new_live)
|
|
220
|
+
|
|
221
|
+
# Update favorites if needed
|
|
222
|
+
if config.is_favorite(old_path.name):
|
|
223
|
+
config.remove_favorite(old_path.name)
|
|
224
|
+
config.add_favorite(new_mp3_path.name)
|
|
225
|
+
|
|
226
|
+
# Update tags if needed
|
|
227
|
+
config.rename_file_tags(old_path.name, new_mp3_path.name)
|
|
228
|
+
|
|
229
|
+
return new_mp3_path
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
meeting_noter/__init__.py,sha256=
|
|
1
|
+
meeting_noter/__init__.py,sha256=7NjAkxDn5PvZ_baFmrb-ftDo9SBS16h25wdRYwBK970,103
|
|
2
2
|
meeting_noter/__main__.py,sha256=6sSOqH1o3jvgvkVzsVKmF6-xVGcUAbNVQkRl2CrygdE,120
|
|
3
|
-
meeting_noter/cli.py,sha256=
|
|
4
|
-
meeting_noter/config.py,sha256=
|
|
3
|
+
meeting_noter/cli.py,sha256=x8Ut_H-4ZAqHFjpIv165ZmJenPUdWXs8L6t7O6sTXls,44791
|
|
4
|
+
meeting_noter/config.py,sha256=byIveLb6bPzULuEFmu5e6KDVLJxHIPH64NmwlyXGy1A,9697
|
|
5
5
|
meeting_noter/daemon.py,sha256=i81IXgEnsoAoMewknySO5_ECa1nCGOsVLfXtJXJf0rw,21611
|
|
6
6
|
meeting_noter/meeting_detector.py,sha256=St0qoMkvUERP4BaxnXO1M6fZDJpWqBf9In7z2SgWcWg,10564
|
|
7
7
|
meeting_noter/mic_monitor.py,sha256=SIsgb8X6uhYuA0CgsRhTjYi8aA-oSUm9vL0r_kQki3g,17239
|
|
@@ -16,7 +16,7 @@ meeting_noter/gui/menubar.py,sha256=hyqm74Vp1I0eB57yzvYEvtzk4y-Y5GRCZau-erGYr_I,
|
|
|
16
16
|
meeting_noter/gui/screens/__init__.py,sha256=_ETRs61OD0xm_FkKTvhv286hpSEsmeZ8v15bKUSUs8Y,549
|
|
17
17
|
meeting_noter/gui/screens/dashboard.py,sha256=_5VzZuh4lUDKoW9iGdM9YZZAqliZtK8azYagI6zFimg,9492
|
|
18
18
|
meeting_noter/gui/screens/logs.py,sha256=acqNhx0H2emVDQ4uCrhkVxbExG58Z99mUrMySF321xI,5746
|
|
19
|
-
meeting_noter/gui/screens/recordings.py,sha256=
|
|
19
|
+
meeting_noter/gui/screens/recordings.py,sha256=uNSATrRhTsOF5vvSTAhDO6WKl_zjx4q4jWkMDJ0o9oI,13617
|
|
20
20
|
meeting_noter/gui/screens/search.py,sha256=emdfAGQR3rhtrbj5rfCYjgqPY8i6gzUO4z1wZ6naFsA,7345
|
|
21
21
|
meeting_noter/gui/screens/settings.py,sha256=u-L0l6I6h25Tp2PIBQW8KipKEBc2VYGsxrXwEENLJxU,8016
|
|
22
22
|
meeting_noter/gui/screens/viewer.py,sha256=TEVCO1tcYVO1li1pt846fKx5VxTxk-lWywnoBV2XuUc,4383
|
|
@@ -34,7 +34,7 @@ meeting_noter/install/macos.py,sha256=dO-86zbNKRtt0l4D8naVn7kFWjzI8TufWLWE3FRLHQ
|
|
|
34
34
|
meeting_noter/output/__init__.py,sha256=F7xPlOrqweZcPbZtDrhved1stBI59vnWnLYfGwdu6oY,31
|
|
35
35
|
meeting_noter/output/favorites.py,sha256=kYtEshq5E5xxaqjG36JMY5a-r6C2w98iqmKsGsxpgag,5764
|
|
36
36
|
meeting_noter/output/searcher.py,sha256=ZGbEewodNuqq5mM4XvVtxELv_6UQByjs-LmEwodc-Ug,6448
|
|
37
|
-
meeting_noter/output/writer.py,sha256=
|
|
37
|
+
meeting_noter/output/writer.py,sha256=WHBLtc1GUWpo-EFZTSmYD8aXHp_3iMrvLmdsMBkw9Q4,7328
|
|
38
38
|
meeting_noter/resources/__init__.py,sha256=yzHNxgypkuVDFZWv6xZjUygOVB_Equ9NNX_HGRvN7VM,43
|
|
39
39
|
meeting_noter/resources/icon.icns,sha256=zMWqXCq7pI5acS0tbekFgFDvLt66EKUBP5-5IgztwPM,35146
|
|
40
40
|
meeting_noter/resources/icon.png,sha256=nK0hM6CPMor_xXRFYURpm0muA2O7yzotyydUQx-ukyg,2390
|
|
@@ -58,8 +58,8 @@ meeting_noter/ui/screens/settings.py,sha256=Zb9-1rkPap1oUNcu4SJcOIemI8DmVC_5uqMd
|
|
|
58
58
|
meeting_noter/ui/screens/viewer.py,sha256=5LVhghDcpshlisCrygk9H3uwOh48vmonMd8d4NH77hU,3991
|
|
59
59
|
meeting_noter/ui/styles/app.tcss,sha256=-uE-1mE3oqKBZW1-pySmOZhK9oxjEQqcG7LDprwxBg4,3396
|
|
60
60
|
meeting_noter/ui/widgets/__init__.py,sha256=6aVXLDGFXL9PnX5bCnMAqRqOvsv4VuzK_T2UTySQlTg,36
|
|
61
|
-
meeting_noter-3.
|
|
62
|
-
meeting_noter-3.
|
|
63
|
-
meeting_noter-3.
|
|
64
|
-
meeting_noter-3.
|
|
65
|
-
meeting_noter-3.
|
|
61
|
+
meeting_noter-3.1.1.dist-info/METADATA,sha256=rcJA5fKb154FHFfJJJ8oJwvu_SwlCx44D1hE5JhYWw0,7000
|
|
62
|
+
meeting_noter-3.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
63
|
+
meeting_noter-3.1.1.dist-info/entry_points.txt,sha256=osZoOmm-UBPCJ4b6DGH6JOAm7mofM2fK06eK6blplmg,83
|
|
64
|
+
meeting_noter-3.1.1.dist-info/top_level.txt,sha256=9Tuq04_0SXM0OXOHVbOHkHkB5tG3fqkrMrfzCMpbLpY,14
|
|
65
|
+
meeting_noter-3.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|