""" Schedule Editor Dialog for Python Scheduler Create and edit schedules for individual jobs or groups (with inline group creation) """ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit, QTextEdit, QComboBox, QPushButton, QMessageBox, QGroupBox, QRadioButton, QButtonGroup, QStackedWidget, QListWidget, QListWidgetItem, QAbstractItemView, QCheckBox, QSpinBox, QWidget ) from PyQt6.QtCore import Qt from loguru import logger import json from core.database import SchedulerDatabase from core.config import SchedulerConfig class ScheduleEditorDialog(QDialog): """Dialog for creating/editing schedules (jobs or groups)""" def __init__(self, parent, db: SchedulerDatabase, config: SchedulerConfig, schedule_id: int = None, pre_selected_job_id: int = None): super().__init__(parent) self.db = db self.config = config self.schedule_id = schedule_id self.pre_selected_job_id = pre_selected_job_id self.is_edit_mode = schedule_id is not None # Window setup self.setWindowTitle("Edit Schedule" if self.is_edit_mode else "New Schedule") self.setMinimumSize(700, 600) self.setModal(True) self._setup_ui() if self.is_edit_mode: self._load_schedule_data() def _setup_ui(self): """Setup dialog UI""" layout = QVBoxLayout(self) # === Basic Information === basic_group = QGroupBox("Basic Information") basic_layout = QFormLayout() self.name_input = QLineEdit() self.name_input.setPlaceholderText("e.g., Daily ETL Pipeline") basic_layout.addRow("Schedule Name:", self.name_input) self.description_input = QTextEdit() self.description_input.setPlaceholderText("Optional description...") self.description_input.setMaximumHeight(60) basic_layout.addRow("Description:", self.description_input) basic_group.setLayout(basic_layout) layout.addWidget(basic_group) # === Target Selection === target_group = QGroupBox("What to Execute") target_layout = QVBoxLayout() # Radio buttons for job vs group radio_layout = QHBoxLayout() self.target_button_group = QButtonGroup(self) self.job_radio = QRadioButton("Execute Single Job") self.job_radio.setChecked(True) self.job_radio.toggled.connect(self._on_target_type_changed) self.target_button_group.addButton(self.job_radio, 0) radio_layout.addWidget(self.job_radio) self.group_radio = QRadioButton("Execute Job Group (Chain)") self.group_radio.toggled.connect(self._on_target_type_changed) self.target_button_group.addButton(self.group_radio, 1) radio_layout.addWidget(self.group_radio) self.backup_radio = QRadioButton("Execute Backup Profile") self.backup_radio.toggled.connect(self._on_target_type_changed) self.target_button_group.addButton(self.backup_radio, 2) radio_layout.addWidget(self.backup_radio) self.sync_radio = QRadioButton("Execute Sync Profile") self.sync_radio.toggled.connect(self._on_target_type_changed) self.target_button_group.addButton(self.sync_radio, 3) radio_layout.addWidget(self.sync_radio) radio_layout.addStretch() target_layout.addLayout(radio_layout) # Stacked widget for job vs group selection self.target_stack = QStackedWidget() # Job selection page job_page = QWidget() job_page_layout = QVBoxLayout(job_page) self.job_combo = QComboBox() self.job_combo.setPlaceholderText("Select a job...") self.job_combo.currentIndexChanged.connect(self._on_job_selected) job_page_layout.addWidget(QLabel("Select Job:")) job_page_layout.addWidget(self.job_combo) job_page_layout.addStretch() self.target_stack.addWidget(job_page) # Group selection/creation page group_page = QWidget() group_page_layout = QVBoxLayout(group_page) # Existing group vs new group group_radio_layout = QHBoxLayout() self.group_selection_button_group = QButtonGroup(self) self.existing_group_radio = QRadioButton("Use Existing Group") self.existing_group_radio.setChecked(True) self.existing_group_radio.toggled.connect(self._on_group_selection_changed) self.group_selection_button_group.addButton(self.existing_group_radio, 0) group_radio_layout.addWidget(self.existing_group_radio) self.new_group_radio = QRadioButton("Create New Group") self.new_group_radio.toggled.connect(self._on_group_selection_changed) self.group_selection_button_group.addButton(self.new_group_radio, 1) group_radio_layout.addWidget(self.new_group_radio) group_page_layout.addLayout(group_radio_layout) # Stack for existing vs new group self.group_stack = QStackedWidget() # Existing group page existing_group_widget = QWidget() existing_group_layout = QVBoxLayout(existing_group_widget) self.existing_group_combo = QComboBox() self.existing_group_combo.setPlaceholderText("Select a group...") existing_group_layout.addWidget(QLabel("Select Group:")) existing_group_layout.addWidget(self.existing_group_combo) existing_group_layout.addStretch() self.group_stack.addWidget(existing_group_widget) # New group page (inline creation) new_group_widget = QWidget() new_group_layout = QVBoxLayout(new_group_widget) # Group name self.group_name_input = QLineEdit() self.group_name_input.setPlaceholderText("e.g., Data Processing Chain") new_group_layout.addWidget(QLabel("Group Name:")) new_group_layout.addWidget(self.group_name_input) # Error handling error_layout = QHBoxLayout() error_layout.addWidget(QLabel("Error Handling:")) self.group_error_combo = QComboBox() self.group_error_combo.addItems([ "stop_on_error", "continue_on_error", "skip_remaining" ]) error_layout.addWidget(self.group_error_combo) error_layout.addStretch() new_group_layout.addLayout(error_layout) # Job selection for group new_group_layout.addWidget(QLabel("Available Jobs:")) self.available_jobs_list = QListWidget() self.available_jobs_list.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.available_jobs_list.setMaximumHeight(100) new_group_layout.addWidget(self.available_jobs_list) # Add/Remove buttons add_remove_layout = QHBoxLayout() self.add_to_group_btn = QPushButton("Add Selected ↓") self.add_to_group_btn.clicked.connect(self._add_jobs_to_group) add_remove_layout.addWidget(self.add_to_group_btn) self.remove_from_group_btn = QPushButton("Remove Selected ↑") self.remove_from_group_btn.clicked.connect(self._remove_jobs_from_group) add_remove_layout.addWidget(self.remove_from_group_btn) add_remove_layout.addStretch() new_group_layout.addLayout(add_remove_layout) # Selected jobs list new_group_layout.addWidget(QLabel("Jobs in Group (execution order):")) self.group_jobs_list = QListWidget() self.group_jobs_list.setMaximumHeight(100) new_group_layout.addWidget(self.group_jobs_list) # Move up/down buttons order_layout = QHBoxLayout() self.move_up_btn = QPushButton("Move Up ↑") self.move_up_btn.clicked.connect(self._move_job_up) order_layout.addWidget(self.move_up_btn) self.move_down_btn = QPushButton("Move Down ↓") self.move_down_btn.clicked.connect(self._move_job_down) order_layout.addWidget(self.move_down_btn) order_layout.addStretch() new_group_layout.addLayout(order_layout) self.group_stack.addWidget(new_group_widget) group_page_layout.addWidget(self.group_stack) self.target_stack.addWidget(group_page) # Backup profile selection page backup_page = QWidget() backup_page_layout = QVBoxLayout(backup_page) self.backup_combo = QComboBox() self.backup_combo.setPlaceholderText("Select a backup profile...") backup_page_layout.addWidget(QLabel("Select Backup Profile:")) backup_page_layout.addWidget(self.backup_combo) backup_page_layout.addStretch() self.target_stack.addWidget(backup_page) # Sync profile selection page sync_page = QWidget() sync_page_layout = QVBoxLayout(sync_page) self.sync_combo = QComboBox() self.sync_combo.setPlaceholderText("Select a sync profile...") sync_page_layout.addWidget(QLabel("Select Sync Profile:")) sync_page_layout.addWidget(self.sync_combo) sync_page_layout.addStretch() self.target_stack.addWidget(sync_page) target_layout.addWidget(self.target_stack) target_group.setLayout(target_layout) layout.addWidget(target_group) # === Schedule Configuration === schedule_group = QGroupBox("When to Execute") schedule_layout = QVBoxLayout() # Schedule type selection type_layout = QHBoxLayout() type_layout.addWidget(QLabel("Schedule Type:")) self.schedule_type_combo = QComboBox() self.schedule_type_combo.addItems(["cron", "interval", "manual"]) self.schedule_type_combo.currentTextChanged.connect(self._on_schedule_type_changed) type_layout.addWidget(self.schedule_type_combo) type_layout.addStretch() schedule_layout.addLayout(type_layout) # Stack for different schedule configurations self.schedule_config_stack = QStackedWidget() # Cron page cron_widget = QWidget() cron_layout = QVBoxLayout(cron_widget) self.cron_input = QLineEdit() self.cron_input.setPlaceholderText("e.g., 0 2 * * * or 30 14 * * mon-fri") cron_layout.addWidget(QLabel("Cron Expression:")) cron_layout.addWidget(self.cron_input) cron_help = QLabel("Format: minute hour day month day_of_week\nExamples:\n• 0 8 * * * = Daily at 8:00 AM\n• 30 14 * * mon-fri = Weekdays at 2:30 PM\n• 0 9 * * mon,wed,fri = Mon/Wed/Fri at 9:00 AM") cron_help.setStyleSheet("color: #666; font-size: 10px;") cron_layout.addWidget(cron_help) # Cron reference link cron_link = QLabel('Cron Expression Helper (crontab.guru)') cron_link.setOpenExternalLinks(True) cron_link.setStyleSheet("color: #2196F3; font-size: 10px;") cron_layout.addWidget(cron_link) cron_layout.addStretch() self.schedule_config_stack.addWidget(cron_widget) # Interval page interval_widget = QWidget() interval_layout = QFormLayout(interval_widget) self.interval_weeks = QSpinBox() self.interval_weeks.setMinimum(0) self.interval_weeks.setMaximum(52) interval_layout.addRow("Weeks:", self.interval_weeks) self.interval_days = QSpinBox() self.interval_days.setMinimum(0) self.interval_days.setMaximum(365) interval_layout.addRow("Days:", self.interval_days) self.interval_hours = QSpinBox() self.interval_hours.setMinimum(0) self.interval_hours.setMaximum(23) interval_layout.addRow("Hours:", self.interval_hours) self.interval_minutes = QSpinBox() self.interval_minutes.setMinimum(0) self.interval_minutes.setMaximum(59) interval_layout.addRow("Minutes:", self.interval_minutes) self.interval_seconds = QSpinBox() self.interval_seconds.setMinimum(0) self.interval_seconds.setMaximum(59) interval_layout.addRow("Seconds:", self.interval_seconds) # Help text for interval interval_help = QLabel("Specify the interval duration.\nAt least one value must be non-zero.\nExample: 2 weeks = execute every 2 weeks from first run") interval_help.setStyleSheet("color: #666; font-size: 10px;") interval_help.setWordWrap(True) interval_layout.addRow("", interval_help) self.schedule_config_stack.addWidget(interval_widget) # Manual page manual_widget = QWidget() manual_layout = QVBoxLayout(manual_widget) manual_label = QLabel("Manual execution only - no automatic scheduling.\nUse 'Run Now' button to execute on demand.") manual_label.setStyleSheet("color: #666; font-style: italic;") manual_label.setWordWrap(True) manual_layout.addWidget(manual_label) manual_layout.addStretch() self.schedule_config_stack.addWidget(manual_widget) schedule_layout.addWidget(self.schedule_config_stack) # Enabled checkbox self.enabled_checkbox = QCheckBox("Enabled (schedule will run automatically)") self.enabled_checkbox.setChecked(True) schedule_layout.addWidget(self.enabled_checkbox) schedule_group.setLayout(schedule_layout) layout.addWidget(schedule_group) # === Action Buttons === actions_layout = QHBoxLayout() actions_layout.addStretch() self.save_btn = QPushButton("Save" if self.is_edit_mode else "Create") self.save_btn.setStyleSheet("background-color: #4CAF50; color: white; padding: 8px 20px; font-weight: bold;") self.save_btn.clicked.connect(self._save_schedule) actions_layout.addWidget(self.save_btn) cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) actions_layout.addWidget(cancel_btn) layout.addLayout(actions_layout) # Load data self._load_jobs() self._load_groups() self._load_backup_profiles() self._load_sync_profiles() def _load_jobs(self): """Load all jobs for selection""" try: jobs = self.db.get_all_jobs() # Populate job combo for job in jobs: self.job_combo.addItem(job['name'], job['id']) # Populate available jobs list for group creation for job in jobs: item = QListWidgetItem(f"{job['name']} (ID: {job['id']})") item.setData(Qt.ItemDataRole.UserRole, job['id']) self.available_jobs_list.addItem(item) # If job pre-selected, load its settings if self.pre_selected_job_id: self._pre_populate_from_job(self.pre_selected_job_id) except Exception as e: logger.error(f"Error loading jobs: {e}") def _load_groups(self): """Load existing groups""" try: with self.db._get_connection() as conn: cursor = conn.execute("SELECT * FROM job_groups ORDER BY name") groups = [dict(row) for row in cursor.fetchall()] for group in groups: members = self.db.get_job_group_members(group['id']) self.existing_group_combo.addItem( f"{group['name']} ({len(members)} jobs)", group['id'] ) except Exception as e: logger.error(f"Error loading groups: {e}") def _load_backup_profiles(self): """Load backup profiles for selection""" try: profiles = self.db.get_all_backup_profiles() for profile in profiles: if profile['enabled']: self.backup_combo.addItem(profile['name'], profile['id']) if self.backup_combo.count() == 0: self.backup_combo.addItem("(No enabled profiles)", None) self.backup_combo.setEnabled(False) except Exception as e: logger.error(f"Error loading backup profiles: {e}") def _load_sync_profiles(self): """Load sync profiles for selection""" try: self.sync_combo.clear() profiles = self.db.get_all_sync_profiles() for profile in profiles: if profile['enabled']: self.sync_combo.addItem(profile['name'], profile['id']) if self.sync_combo.count() == 0: self.sync_combo.addItem("(No enabled profiles)", None) self.sync_combo.setEnabled(False) else: self.sync_combo.setEnabled(True) except Exception as e: logger.error(f"Error loading sync profiles: {e}") self.sync_combo.addItem("(Error loading profiles)", None) self.sync_combo.setEnabled(False) def _on_target_type_changed(self): """Handle target type change""" if self.job_radio.isChecked(): self.target_stack.setCurrentIndex(0) elif self.group_radio.isChecked(): self.target_stack.setCurrentIndex(1) elif self.backup_radio.isChecked(): self.target_stack.setCurrentIndex(2) elif self.sync_radio.isChecked(): self.target_stack.setCurrentIndex(3) def _on_group_selection_changed(self): """Handle group selection type change""" if self.existing_group_radio.isChecked(): self.group_stack.setCurrentIndex(0) else: self.group_stack.setCurrentIndex(1) def _on_schedule_type_changed(self, schedule_type: str): """Handle schedule type change""" type_map = {"cron": 0, "interval": 1, "manual": 2} index = type_map.get(schedule_type, 0) logger.debug(f"Schedule type changed to '{schedule_type}', switching to stack index {index}") self.schedule_config_stack.setCurrentIndex(index) # Force layout update self.schedule_config_stack.currentWidget().updateGeometry() logger.debug(f"Current widget: {self.schedule_config_stack.currentWidget().__class__.__name__}") def _on_job_selected(self): """When job is selected from combo, pre-populate schedule settings""" # Only if not in edit mode and not already pre-populated if self.is_edit_mode or self.pre_selected_job_id: return job_id = self.job_combo.currentData() if job_id: self._pre_populate_from_job(job_id) def _add_jobs_to_group(self): """Add selected jobs to group""" selected_items = self.available_jobs_list.selectedItems() if not selected_items: return for item in selected_items: new_item = QListWidgetItem(item.text()) new_item.setData(Qt.ItemDataRole.UserRole, item.data(Qt.ItemDataRole.UserRole)) self.group_jobs_list.addItem(new_item) row = self.available_jobs_list.row(item) self.available_jobs_list.takeItem(row) def _remove_jobs_from_group(self): """Remove selected jobs from group""" selected_items = self.group_jobs_list.selectedItems() if not selected_items: return for item in selected_items: new_item = QListWidgetItem(item.text()) new_item.setData(Qt.ItemDataRole.UserRole, item.data(Qt.ItemDataRole.UserRole)) self.available_jobs_list.addItem(new_item) row = self.group_jobs_list.row(item) self.group_jobs_list.takeItem(row) def _move_job_up(self): """Move selected job up""" current_row = self.group_jobs_list.currentRow() if current_row > 0: item = self.group_jobs_list.takeItem(current_row) self.group_jobs_list.insertItem(current_row - 1, item) self.group_jobs_list.setCurrentRow(current_row - 1) def _move_job_down(self): """Move selected job down""" current_row = self.group_jobs_list.currentRow() if current_row < self.group_jobs_list.count() - 1 and current_row >= 0: item = self.group_jobs_list.takeItem(current_row) self.group_jobs_list.insertItem(current_row + 1, item) self.group_jobs_list.setCurrentRow(current_row + 1) def _load_schedule_data(self): """Load existing schedule data for editing""" try: schedule = self.db.get_schedule(self.schedule_id) if not schedule: QMessageBox.warning(self, "Error", f"Schedule {self.schedule_id} not found") self.reject() return # Basic info self.name_input.setText(schedule['name']) if schedule['description']: self.description_input.setPlainText(schedule['description']) # Target if schedule['target_type'] == 'job': self.job_radio.setChecked(True) index = self.job_combo.findData(schedule['target_id']) if index >= 0: self.job_combo.setCurrentIndex(index) elif schedule['target_type'] == 'backup_profile': self.backup_radio.setChecked(True) index = self.backup_combo.findData(schedule['target_id']) if index >= 0: self.backup_combo.setCurrentIndex(index) elif schedule['target_type'] == 'sync_profile': self.sync_radio.setChecked(True) index = self.sync_combo.findData(schedule['target_id']) if index >= 0: self.sync_combo.setCurrentIndex(index) else: # group self.group_radio.setChecked(True) self.existing_group_radio.setChecked(True) index = self.existing_group_combo.findData(schedule['target_id']) if index >= 0: self.existing_group_combo.setCurrentIndex(index) # Schedule config self.schedule_type_combo.setCurrentText(schedule['schedule_type']) config = json.loads(schedule['schedule_config']) if isinstance(schedule['schedule_config'], str) else schedule['schedule_config'] if schedule['schedule_type'] == 'cron': self.cron_input.setText(config.get('cron', '')) elif schedule['schedule_type'] == 'interval': self.interval_weeks.setValue(config.get('weeks', 0)) self.interval_days.setValue(config.get('days', 0)) self.interval_hours.setValue(config.get('hours', 0)) self.interval_minutes.setValue(config.get('minutes', 0)) self.interval_seconds.setValue(config.get('seconds', 0)) self.enabled_checkbox.setChecked(schedule['enabled']) except Exception as e: logger.error(f"Error loading schedule data: {e}") QMessageBox.critical(self, "Error", f"Failed to load schedule: {e}") self.reject() def _validate_inputs(self) -> bool: """Validate form inputs""" if not self.name_input.text().strip(): QMessageBox.warning(self, "Validation Error", "Schedule name is required") self.name_input.setFocus() return False # Validate target if self.job_radio.isChecked(): if self.job_combo.currentIndex() < 0: QMessageBox.warning(self, "Validation Error", "Please select a job") return False elif self.backup_radio.isChecked(): if self.backup_combo.currentIndex() < 0 or self.backup_combo.currentData() is None: QMessageBox.warning(self, "Validation Error", "Please select a backup profile") return False elif self.sync_radio.isChecked(): if self.sync_combo.currentIndex() < 0 or self.sync_combo.currentData() is None: QMessageBox.warning(self, "Validation Error", "Please select a sync profile") return False else: if self.existing_group_radio.isChecked(): if self.existing_group_combo.currentIndex() < 0: QMessageBox.warning(self, "Validation Error", "Please select a group") return False else: if not self.group_name_input.text().strip(): QMessageBox.warning(self, "Validation Error", "Group name is required") return False if self.group_jobs_list.count() == 0: QMessageBox.warning(self, "Validation Error", "Please add at least one job to the group") return False # Validate schedule config schedule_type = self.schedule_type_combo.currentText() if schedule_type == 'cron': if not self.cron_input.text().strip(): QMessageBox.warning(self, "Validation Error", "Cron expression is required") return False elif schedule_type == 'interval': if (self.interval_weeks.value() == 0 and self.interval_days.value() == 0 and self.interval_hours.value() == 0 and self.interval_minutes.value() == 0 and self.interval_seconds.value() == 0): QMessageBox.warning(self, "Validation Error", "Please specify at least one interval value") return False return True def _pre_populate_from_job(self, job_id: int): """Pre-populate schedule settings from an existing job""" try: job = self.db.get_job(job_id) if not job: logger.warning(f"Job {job_id} not found for pre-population") return # Set job radio and select the job self.job_radio.setChecked(True) index = self.job_combo.findData(job_id) if index >= 0: self.job_combo.setCurrentIndex(index) # Pre-populate schedule name if not self.name_input.text(): # Only if empty self.name_input.setText(f"Schedule for {job['name']}") # Pre-populate schedule type and config from job if job['schedule_type']: self.schedule_type_combo.setCurrentText(job['schedule_type']) # Parse schedule config try: config = json.loads(job['schedule_config']) if isinstance(job['schedule_config'], str) else job['schedule_config'] if job['schedule_type'] == 'cron': cron_expr = config.get('cron', '') if cron_expr: self.cron_input.setText(cron_expr) elif job['schedule_type'] == 'interval': self.interval_weeks.setValue(config.get('weeks', 0)) self.interval_days.setValue(config.get('days', 0)) self.interval_hours.setValue(config.get('hours', 0)) self.interval_minutes.setValue(config.get('minutes', 0)) self.interval_seconds.setValue(config.get('seconds', 0)) except Exception as e: logger.warning(f"Could not parse schedule config from job: {e}") logger.info(f"Pre-populated schedule editor from job {job_id}") except Exception as e: logger.error(f"Error pre-populating from job: {e}") def _save_schedule(self): """Save schedule to database""" if not self._validate_inputs(): return try: # Determine target if self.job_radio.isChecked(): target_type = 'job' target_id = self.job_combo.currentData() elif self.backup_radio.isChecked(): target_type = 'backup_profile' target_id = self.backup_combo.currentData() elif self.sync_radio.isChecked(): target_type = 'sync_profile' target_id = self.sync_combo.currentData() if target_id is None: QMessageBox.warning(self, "Validation Error", "Please select a sync profile") return else: if self.existing_group_radio.isChecked(): target_type = 'group' target_id = self.existing_group_combo.currentData() else: # Create new group inline group_data = { 'name': self.group_name_input.text().strip(), 'description': f"Auto-created for schedule: {self.name_input.text().strip()}", 'error_handling': self.group_error_combo.currentText() } target_id = self.db.create_job_group(**group_data) # Add members for i in range(self.group_jobs_list.count()): item = self.group_jobs_list.item(i) job_id = item.data(Qt.ItemDataRole.UserRole) self.db.add_job_to_group(target_id, job_id, execution_order=i+1) logger.info(f"Created inline group {target_id}: {group_data['name']}") target_type = 'group' # Build schedule config schedule_type = self.schedule_type_combo.currentText() if schedule_type == 'cron': schedule_config = {'cron': self.cron_input.text().strip()} elif schedule_type == 'interval': schedule_config = { 'weeks': self.interval_weeks.value(), 'days': self.interval_days.value(), 'hours': self.interval_hours.value(), 'minutes': self.interval_minutes.value(), 'seconds': self.interval_seconds.value() } else: # manual schedule_config = {} # Save schedule schedule_data = { 'name': self.name_input.text().strip(), 'description': self.description_input.toPlainText().strip() or None, 'target_type': target_type, 'target_id': target_id, 'schedule_type': schedule_type, 'schedule_config': schedule_config, 'enabled': self.enabled_checkbox.isChecked() } if self.is_edit_mode: self.db.update_schedule(self.schedule_id, **schedule_data) logger.info(f"Updated schedule {self.schedule_id}: {schedule_data['name']}") QMessageBox.information(self, "Success", f"Schedule '{schedule_data['name']}' updated successfully") else: schedule_id = self.db.create_schedule(**schedule_data) logger.info(f"Created schedule {schedule_id}: {schedule_data['name']}") QMessageBox.information(self, "Success", f"Schedule '{schedule_data['name']}' created successfully (ID: {schedule_id})") self.accept() except Exception as e: logger.error(f"Error saving schedule: {e}") QMessageBox.critical(self, "Error", f"Failed to save schedule: {e}")