mirror of
https://github.com/chidiwilliams/buzz.git
synced 2026-03-15 07:05:48 +01:00
Adding option to export live recordings to csv
This commit is contained in:
parent
3f9bc67ec7
commit
bf48eff215
6 changed files with 264 additions and 20 deletions
|
|
@ -27,6 +27,9 @@ class Settings:
|
|||
RECORDING_TRANSCRIBER_MODE = "recording-transcriber/mode"
|
||||
RECORDING_TRANSCRIBER_SILENCE_THRESHOLD = "recording-transcriber/silence-threshold"
|
||||
RECORDING_TRANSCRIBER_LINE_SEPARATOR = "recording-transcriber/line-separator"
|
||||
RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE = "recording-transcriber/export-file-type"
|
||||
RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES = "recording-transcriber/export-max-entries"
|
||||
RECORDING_TRANSCRIBER_EXPORT_FILE_NAME = "recording-transcriber/export-file-name"
|
||||
|
||||
PRESENTATION_WINDOW_TEXT_COLOR = "presentation-window/text-color"
|
||||
PRESENTATION_WINDOW_BACKGROUND_COLOR = "presentation-window/background-color"
|
||||
|
|
|
|||
|
|
@ -406,8 +406,6 @@ class RecordingTranscriber(QObject):
|
|||
"--threads", str(os.getenv("BUZZ_WHISPERCPP_N_THREADS", (os.cpu_count() or 8) // 2)),
|
||||
"--model", self.model_path,
|
||||
"--no-timestamps",
|
||||
# on Windows context causes duplications of last message
|
||||
"--no-context",
|
||||
# Protections against hallucinated repetition. Seems to be problem on macOS
|
||||
# https://github.com/ggml-org/whisper.cpp/issues/1507
|
||||
"--max-context", "64",
|
||||
|
|
|
|||
|
|
@ -188,6 +188,13 @@ class GeneralPreferencesWidget(QWidget):
|
|||
|
||||
layout.addRow(_("Live recording mode"), self.recording_transcriber_mode)
|
||||
|
||||
export_note_label = QLabel(
|
||||
_("Note: Live recording export settings will be moved to the Advanced Settings in the Live Recording screen in a future version."),
|
||||
self,
|
||||
)
|
||||
export_note_label.setWordWrap(True)
|
||||
layout.addRow("", export_note_label)
|
||||
|
||||
self.reduce_gpu_memory_enabled = self.settings.value(
|
||||
key=Settings.Key.REDUCE_GPU_MEMORY, default_value=False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import enum
|
||||
|
|
@ -451,7 +453,17 @@ class RecordingTranscriberWidget(QWidget):
|
|||
|
||||
date_time_now = datetime.datetime.now().strftime("%d-%b-%Y %H-%M-%S")
|
||||
|
||||
export_file_name_template = Settings().get_default_export_file_template()
|
||||
custom_template = self.settings.value(
|
||||
key=Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_NAME,
|
||||
default_value="",
|
||||
)
|
||||
export_file_name_template = custom_template if custom_template else Settings().get_default_export_file_template()
|
||||
|
||||
export_file_type = self.settings.value(
|
||||
key=Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE,
|
||||
default_value="txt",
|
||||
)
|
||||
ext = ".csv" if export_file_type == "csv" else ".txt"
|
||||
|
||||
export_file_name = (
|
||||
export_file_name_template.replace("{{ input_file_name }}", "live recording")
|
||||
|
|
@ -460,14 +472,21 @@ class RecordingTranscriberWidget(QWidget):
|
|||
.replace("{{ model_type }}", self.transcription_options.model.model_type.value)
|
||||
.replace("{{ model_size }}", self.transcription_options.model.whisper_model_size or "")
|
||||
.replace("{{ date_time }}", date_time_now)
|
||||
+ ".txt"
|
||||
+ ext
|
||||
)
|
||||
|
||||
translated_ext = ".translated" + ext
|
||||
|
||||
if not os.path.isdir(export_folder):
|
||||
self.export_enabled = False
|
||||
|
||||
self.transcript_export_file = os.path.join(export_folder, export_file_name)
|
||||
self.translation_export_file = self.transcript_export_file.replace(".txt", ".translated.txt")
|
||||
self.translation_export_file = self.transcript_export_file.replace(ext, translated_ext)
|
||||
|
||||
# Clear export files at the start of each recording session
|
||||
for path in (self.transcript_export_file, self.translation_export_file):
|
||||
if os.path.isfile(path):
|
||||
self.write_to_export_file(path, "", mode="w")
|
||||
|
||||
def on_transcription_options_changed(
|
||||
self, transcription_options: TranscriptionOptions
|
||||
|
|
@ -706,6 +725,64 @@ class RecordingTranscriberWidget(QWidget):
|
|||
logging.warning("Export write failed: %s", e)
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def write_csv_export(file_path: str, text: str, max_entries: int):
|
||||
"""Append a new column to a single-row CSV export file, applying max_entries limit."""
|
||||
existing_columns = []
|
||||
if os.path.isfile(file_path):
|
||||
raw = RecordingTranscriberWidget.read_export_file(file_path)
|
||||
if raw.strip():
|
||||
reader = csv.reader(io.StringIO(raw))
|
||||
for row in reader:
|
||||
existing_columns = row
|
||||
break
|
||||
existing_columns.append(text)
|
||||
if max_entries > 0:
|
||||
existing_columns = existing_columns[-max_entries:]
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(existing_columns)
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with open(file_path, "w", encoding='utf-8-sig') as f:
|
||||
f.write(buf.getvalue())
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt < 4:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
logging.warning("CSV export write failed after retries: %s", file_path)
|
||||
except OSError as e:
|
||||
logging.warning("CSV export write failed: %s", e)
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def write_txt_export(file_path: str, text: str, mode: str, max_entries: int, line_separator: str):
|
||||
"""Write to a TXT export file, applying max_entries limit when needed."""
|
||||
if mode == "a":
|
||||
RecordingTranscriberWidget.write_to_export_file(file_path, text + line_separator)
|
||||
if max_entries > 0 and os.path.isfile(file_path):
|
||||
raw = RecordingTranscriberWidget.read_export_file(file_path)
|
||||
parts = [p for p in raw.split(line_separator) if p]
|
||||
if len(parts) > max_entries:
|
||||
parts = parts[-max_entries:]
|
||||
RecordingTranscriberWidget.write_to_export_file(
|
||||
file_path, line_separator.join(parts) + line_separator, mode="w"
|
||||
)
|
||||
elif mode == "prepend":
|
||||
existing_content = ""
|
||||
if os.path.isfile(file_path):
|
||||
existing_content = RecordingTranscriberWidget.read_export_file(file_path)
|
||||
new_content = text + line_separator + existing_content
|
||||
if max_entries > 0:
|
||||
parts = [p for p in new_content.split(line_separator) if p]
|
||||
if len(parts) > max_entries:
|
||||
parts = parts[:max_entries]
|
||||
new_content = line_separator.join(parts) + line_separator
|
||||
RecordingTranscriberWidget.write_to_export_file(file_path, new_content, mode="w")
|
||||
else:
|
||||
RecordingTranscriberWidget.write_to_export_file(file_path, text, mode=mode)
|
||||
|
||||
@staticmethod
|
||||
def read_export_file(file_path: str, retries: int = 5, delay: float = 0.2) -> str:
|
||||
"""Read an export file with retry logic for Windows file locking."""
|
||||
|
|
@ -777,7 +854,15 @@ class RecordingTranscriberWidget(QWidget):
|
|||
text_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
if self.export_enabled and export_file:
|
||||
self.write_to_export_file(export_file, merged_texts, mode="w")
|
||||
export_file_type = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt"
|
||||
)
|
||||
if export_file_type == "csv":
|
||||
# For APPEND_AND_CORRECT mode, rewrite the whole CSV with all merged text as a single entry
|
||||
self.write_to_export_file(export_file, "", mode="w")
|
||||
self.write_csv_export(export_file, merged_texts, 0)
|
||||
else:
|
||||
self.write_to_export_file(export_file, merged_texts, mode="w")
|
||||
|
||||
def on_next_transcription(self, text: str):
|
||||
text = self.filter_text(text)
|
||||
|
|
@ -788,6 +873,13 @@ class RecordingTranscriberWidget(QWidget):
|
|||
if self.translator is not None:
|
||||
self.translator.enqueue(text)
|
||||
|
||||
export_file_type = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt"
|
||||
)
|
||||
max_entries = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, 0, int
|
||||
)
|
||||
|
||||
if self.transcriber_mode == RecordingTranscriberMode.APPEND_BELOW:
|
||||
self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
if len(self.transcription_text_box.toPlainText()) > 0:
|
||||
|
|
@ -796,7 +888,10 @@ class RecordingTranscriberWidget(QWidget):
|
|||
self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
if self.export_enabled and self.transcript_export_file:
|
||||
self.write_to_export_file(self.transcript_export_file, text + self.transcription_options.line_separator)
|
||||
if export_file_type == "csv":
|
||||
self.write_csv_export(self.transcript_export_file, text, max_entries)
|
||||
else:
|
||||
self.write_txt_export(self.transcript_export_file, text, "a", max_entries, self.transcription_options.line_separator)
|
||||
|
||||
elif self.transcriber_mode == RecordingTranscriberMode.APPEND_ABOVE:
|
||||
self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.Start)
|
||||
|
|
@ -805,11 +900,25 @@ class RecordingTranscriberWidget(QWidget):
|
|||
self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.Start)
|
||||
|
||||
if self.export_enabled and self.transcript_export_file:
|
||||
existing_content = ""
|
||||
if os.path.isfile(self.transcript_export_file):
|
||||
existing_content = self.read_export_file(self.transcript_export_file)
|
||||
new_content = text + self.transcription_options.line_separator + existing_content
|
||||
self.write_to_export_file(self.transcript_export_file, new_content, mode="w")
|
||||
if export_file_type == "csv":
|
||||
# For APPEND_ABOVE, prepend in CSV means inserting at beginning of columns
|
||||
existing_columns = []
|
||||
if os.path.isfile(self.transcript_export_file):
|
||||
raw = self.read_export_file(self.transcript_export_file)
|
||||
if raw.strip():
|
||||
reader = csv.reader(io.StringIO(raw))
|
||||
for row in reader:
|
||||
existing_columns = row
|
||||
break
|
||||
new_columns = [text] + existing_columns
|
||||
if max_entries > 0:
|
||||
new_columns = new_columns[:max_entries]
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(new_columns)
|
||||
self.write_to_export_file(self.transcript_export_file, buf.getvalue(), mode="w")
|
||||
else:
|
||||
self.write_txt_export(self.transcript_export_file, text, "prepend", max_entries, self.transcription_options.line_separator)
|
||||
|
||||
elif self.transcriber_mode == RecordingTranscriberMode.APPEND_AND_CORRECT:
|
||||
self.process_transcription_merge(text, self.transcripts, self.transcription_text_box, self.transcript_export_file)
|
||||
|
|
@ -836,6 +945,13 @@ class RecordingTranscriberWidget(QWidget):
|
|||
if len(text) == 0:
|
||||
return
|
||||
|
||||
export_file_type = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt"
|
||||
)
|
||||
max_entries = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, 0, int
|
||||
)
|
||||
|
||||
if self.transcriber_mode == RecordingTranscriberMode.APPEND_BELOW:
|
||||
self.translation_text_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
if len(self.translation_text_box.toPlainText()) > 0:
|
||||
|
|
@ -844,7 +960,10 @@ class RecordingTranscriberWidget(QWidget):
|
|||
self.translation_text_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
if self.export_enabled and self.translation_export_file:
|
||||
self.write_to_export_file(self.translation_export_file, text + self.transcription_options.line_separator)
|
||||
if export_file_type == "csv":
|
||||
self.write_csv_export(self.translation_export_file, text, max_entries)
|
||||
else:
|
||||
self.write_txt_export(self.translation_export_file, text, "a", max_entries, self.transcription_options.line_separator)
|
||||
|
||||
elif self.transcriber_mode == RecordingTranscriberMode.APPEND_ABOVE:
|
||||
self.translation_text_box.moveCursor(QTextCursor.MoveOperation.Start)
|
||||
|
|
@ -853,11 +972,24 @@ class RecordingTranscriberWidget(QWidget):
|
|||
self.translation_text_box.moveCursor(QTextCursor.MoveOperation.Start)
|
||||
|
||||
if self.export_enabled and self.translation_export_file:
|
||||
existing_content = ""
|
||||
if os.path.isfile(self.translation_export_file):
|
||||
existing_content = self.read_export_file(self.translation_export_file)
|
||||
new_content = text + self.transcription_options.line_separator + existing_content
|
||||
self.write_to_export_file(self.translation_export_file, new_content, mode="w")
|
||||
if export_file_type == "csv":
|
||||
existing_columns = []
|
||||
if os.path.isfile(self.translation_export_file):
|
||||
raw = self.read_export_file(self.translation_export_file)
|
||||
if raw.strip():
|
||||
reader = csv.reader(io.StringIO(raw))
|
||||
for row in reader:
|
||||
existing_columns = row
|
||||
break
|
||||
new_columns = [text] + existing_columns
|
||||
if max_entries > 0:
|
||||
new_columns = new_columns[:max_entries]
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(new_columns)
|
||||
self.write_to_export_file(self.translation_export_file, buf.getvalue(), mode="w")
|
||||
else:
|
||||
self.write_txt_export(self.translation_export_file, text, "prepend", max_entries, self.transcription_options.line_separator)
|
||||
|
||||
elif self.transcriber_mode == RecordingTranscriberMode.APPEND_AND_CORRECT:
|
||||
self.process_transcription_merge(text, self.translations, self.translation_text_box, self.translation_export_file)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ from PyQt6.QtWidgets import (
|
|||
QLabel,
|
||||
QDoubleSpinBox,
|
||||
QLineEdit,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QFileDialog,
|
||||
)
|
||||
|
||||
from buzz.locale import _
|
||||
from buzz.model_loader import ModelType
|
||||
from buzz.transcriber.transcriber import TranscriptionOptions
|
||||
from buzz.settings.settings import Settings
|
||||
from buzz.settings.recording_transcriber_mode import RecordingTranscriberMode
|
||||
from buzz.widgets.line_edit import LineEdit
|
||||
from buzz.widgets.transcriber.initial_prompt_text_edit import InitialPromptTextEdit
|
||||
from buzz.widgets.transcriber.temperature_validator import TemperatureValidator
|
||||
|
|
@ -94,7 +100,7 @@ class AdvancedSettingsDialog(QDialog):
|
|||
self.llm_prompt_text_edit = QPlainTextEdit(default_llm_prompt)
|
||||
self.llm_prompt_text_edit.setEnabled(self.transcription_options.enable_llm_translation)
|
||||
self.llm_prompt_text_edit.setMinimumWidth(170)
|
||||
self.llm_prompt_text_edit.setFixedHeight(115)
|
||||
self.llm_prompt_text_edit.setFixedHeight(80)
|
||||
self.llm_prompt_text_edit.textChanged.connect(self.on_llm_prompt_changed)
|
||||
layout.addRow(_("Instructions for AI:"), self.llm_prompt_text_edit)
|
||||
|
||||
|
|
@ -117,6 +123,74 @@ class AdvancedSettingsDialog(QDialog):
|
|||
self.line_separator_line_edit.textChanged.connect(self.on_line_separator_changed)
|
||||
layout.addRow(_("Line separator:"), self.line_separator_line_edit)
|
||||
|
||||
# Live recording mode
|
||||
self.recording_mode_combo = QComboBox(self)
|
||||
for mode in RecordingTranscriberMode:
|
||||
self.recording_mode_combo.addItem(mode.value)
|
||||
self.recording_mode_combo.setCurrentIndex(
|
||||
self.settings.value(Settings.Key.RECORDING_TRANSCRIBER_MODE, 0)
|
||||
)
|
||||
self.recording_mode_combo.currentIndexChanged.connect(self.on_recording_mode_changed)
|
||||
layout.addRow(_("Live recording mode:"), self.recording_mode_combo)
|
||||
|
||||
# Export enabled checkbox
|
||||
self._export_enabled = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_ENABLED, False
|
||||
)
|
||||
self.export_enabled_checkbox = QCheckBox(_("Enable live recording export"))
|
||||
self.export_enabled_checkbox.setChecked(self._export_enabled)
|
||||
self.export_enabled_checkbox.stateChanged.connect(self.on_export_enabled_changed)
|
||||
layout.addRow("", self.export_enabled_checkbox)
|
||||
|
||||
# Export folder
|
||||
export_folder = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FOLDER, ""
|
||||
)
|
||||
self.export_folder_line_edit = LineEdit(export_folder, self)
|
||||
self.export_folder_line_edit.setEnabled(self._export_enabled)
|
||||
self.export_folder_line_edit.textChanged.connect(self.on_export_folder_changed)
|
||||
self.export_folder_browse_button = QPushButton(_("Browse"), self)
|
||||
self.export_folder_browse_button.setEnabled(self._export_enabled)
|
||||
self.export_folder_browse_button.clicked.connect(self.on_browse_export_folder)
|
||||
export_folder_row = QHBoxLayout()
|
||||
export_folder_row.addWidget(self.export_folder_line_edit)
|
||||
export_folder_row.addWidget(self.export_folder_browse_button)
|
||||
layout.addRow(_("Export folder:"), export_folder_row)
|
||||
|
||||
# Export file name template
|
||||
export_file_name = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_NAME, ""
|
||||
)
|
||||
self.export_file_name_line_edit = LineEdit(export_file_name, self)
|
||||
self.export_file_name_line_edit.setEnabled(self._export_enabled)
|
||||
self.export_file_name_line_edit.textChanged.connect(self.on_export_file_name_changed)
|
||||
layout.addRow(_("Export file name:"), self.export_file_name_line_edit)
|
||||
|
||||
# Export file type
|
||||
self.export_file_type_combo = QComboBox(self)
|
||||
self.export_file_type_combo.addItem(_("Text file (.txt)"), "txt")
|
||||
self.export_file_type_combo.addItem(_("CSV (.csv)"), "csv")
|
||||
current_type = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt"
|
||||
)
|
||||
type_index = self.export_file_type_combo.findData(current_type)
|
||||
if type_index >= 0:
|
||||
self.export_file_type_combo.setCurrentIndex(type_index)
|
||||
self.export_file_type_combo.setEnabled(self._export_enabled)
|
||||
self.export_file_type_combo.currentIndexChanged.connect(self.on_export_file_type_changed)
|
||||
layout.addRow(_("Export file type:"), self.export_file_type_combo)
|
||||
|
||||
# Max entries
|
||||
max_entries = self.settings.value(
|
||||
Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, 0, int
|
||||
)
|
||||
self.export_max_entries_spin = QSpinBox(self)
|
||||
self.export_max_entries_spin.setRange(0, 99)
|
||||
self.export_max_entries_spin.setValue(max_entries)
|
||||
self.export_max_entries_spin.setEnabled(self._export_enabled)
|
||||
self.export_max_entries_spin.valueChanged.connect(self.on_export_max_entries_changed)
|
||||
layout.addRow(_("Limit export entries\n(0 = export all):"), self.export_max_entries_spin)
|
||||
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton(QDialogButtonBox.StandardButton.Ok), self
|
||||
)
|
||||
|
|
@ -168,3 +242,33 @@ class AdvancedSettingsDialog(QDialog):
|
|||
except UnicodeDecodeError:
|
||||
return
|
||||
self.transcription_options_changed.emit(self.transcription_options)
|
||||
|
||||
def on_recording_mode_changed(self, index: int):
|
||||
self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_MODE, index)
|
||||
|
||||
def on_export_enabled_changed(self, state: int):
|
||||
self._export_enabled = state == 2
|
||||
self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_ENABLED, self._export_enabled)
|
||||
self.export_folder_line_edit.setEnabled(self._export_enabled)
|
||||
self.export_folder_browse_button.setEnabled(self._export_enabled)
|
||||
self.export_file_name_line_edit.setEnabled(self._export_enabled)
|
||||
self.export_file_type_combo.setEnabled(self._export_enabled)
|
||||
self.export_max_entries_spin.setEnabled(self._export_enabled)
|
||||
|
||||
def on_export_folder_changed(self, text: str):
|
||||
self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FOLDER, text)
|
||||
|
||||
def on_browse_export_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, _("Select Export Folder"))
|
||||
if folder:
|
||||
self.export_folder_line_edit.setText(folder)
|
||||
|
||||
def on_export_file_name_changed(self, text: str):
|
||||
self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_NAME, text)
|
||||
|
||||
def on_export_file_type_changed(self, index: int):
|
||||
file_type = self.export_file_type_combo.itemData(index)
|
||||
self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, file_type)
|
||||
|
||||
def on_export_max_entries_changed(self, value: int):
|
||||
self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, value)
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@ class InitialPromptTextEdit(QPlainTextEdit):
|
|||
self.setPlaceholderText(_("Enter prompt..."))
|
||||
self.setEnabled(model_type.supports_initial_prompt)
|
||||
self.setMinimumWidth(350)
|
||||
self.setFixedHeight(115)
|
||||
self.setFixedHeight(80)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue