diff --git a/core/database.py b/core/database.py index f469461..9f16de3 100644 --- a/core/database.py +++ b/core/database.py @@ -219,6 +219,12 @@ class DatabaseManager: 'session_id, after_folder, frame_count, method, enabled, folder_order', ) + # Migration: add locked column to symlink_sessions + try: + conn.execute("SELECT locked FROM symlink_sessions LIMIT 1") + except sqlite3.OperationalError: + conn.execute("ALTER TABLE symlink_sessions ADD COLUMN locked INTEGER DEFAULT 0") + # Migration: remove overlap_frames from transition_settings (now per-transition) # We'll keep it for backward compatibility but won't use it @@ -374,7 +380,8 @@ class DatabaseManager: """ with self._connect() as conn: rows = conn.execute(""" - SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count, s.name + SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count, + s.name, COALESCE(s.locked, 0) FROM symlink_sessions s LEFT JOIN symlinks l ON s.id = l.session_id GROUP BY s.id @@ -387,7 +394,8 @@ class DatabaseManager: created_at=datetime.fromisoformat(row[1]), destination=row[2], link_count=row[3], - name=row[4] + name=row[4], + locked=bool(row[5]) ) for row in rows ] @@ -474,6 +482,8 @@ class DatabaseManager: def delete_sessions(self, session_ids: list[int]) -> None: """Delete multiple sessions in a single transaction. + Locked sessions are silently skipped. + Args: session_ids: List of session IDs to delete. @@ -486,12 +496,35 @@ class DatabaseManager: with self._connect() as conn: placeholders = ','.join('?' for _ in session_ids) conn.execute( - f"DELETE FROM symlink_sessions WHERE id IN ({placeholders})", + f"DELETE FROM symlink_sessions WHERE id IN ({placeholders}) AND COALESCE(locked, 0) = 0", session_ids ) except sqlite3.Error as e: raise DatabaseError(f"Failed to delete sessions: {e}") from e + def toggle_session_locked(self, session_id: int) -> bool: + """Toggle the locked state of a session. + + Returns: + The new locked state. + """ + try: + with self._connect() as conn: + row = conn.execute( + "SELECT COALESCE(locked, 0) FROM symlink_sessions WHERE id = ?", + (session_id,) + ).fetchone() + if row is None: + raise DatabaseError(f"Session {session_id} not found") + new_val = 0 if row[0] else 1 + conn.execute( + "UPDATE symlink_sessions SET locked = ? WHERE id = ?", + (new_val, session_id) + ) + return bool(new_val) + except sqlite3.Error as e: + raise DatabaseError(f"Failed to toggle session lock: {e}") from e + def get_sessions_by_destination(self, dest: str) -> list[SessionRecord]: """Get all sessions for a destination directory. @@ -503,7 +536,8 @@ class DatabaseManager: """ with self._connect() as conn: rows = conn.execute(""" - SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count, s.name + SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count, + s.name, COALESCE(s.locked, 0) FROM symlink_sessions s LEFT JOIN symlinks l ON s.id = l.session_id WHERE s.destination = ? @@ -517,7 +551,8 @@ class DatabaseManager: created_at=datetime.fromisoformat(row[1]), destination=row[2], link_count=row[3], - name=row[4] + name=row[4], + locked=bool(row[5]) ) for row in rows ] @@ -818,9 +853,8 @@ class DatabaseManager: """INSERT INTO per_transition_settings (session_id, trans_folder, left_overlap, right_overlap, folder_order) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(session_id, folder_order) - DO UPDATE SET trans_folder=excluded.trans_folder, - left_overlap=excluded.left_overlap, + ON CONFLICT(session_id, trans_folder, folder_order) + DO UPDATE SET left_overlap=excluded.left_overlap, right_overlap=excluded.right_overlap""", (session_id, str(settings.trans_folder), settings.left_overlap, settings.right_overlap, folder_order) diff --git a/core/models.py b/core/models.py index 456e66c..91ffec4 100644 --- a/core/models.py +++ b/core/models.py @@ -165,6 +165,7 @@ class SessionRecord: destination: str link_count: int = 0 name: Optional[str] = None + locked: bool = False # --- Exceptions --- diff --git a/ui/main_window.py b/ui/main_window.py index 8196341..dcf9740 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -3409,16 +3409,20 @@ class SequenceLinkerUI(QWidget): layout = QVBoxLayout(dlg) tree = QTreeWidget() - tree.setHeaderLabels(["Date", "Destination", "Files"]) + tree.setHeaderLabels(["", "Date", "Destination", "Files"]) tree.setRootIsDecorated(False) tree.setAlternatingRowColors(True) tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) tree.header().setStretchLastSection(False) tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + tree.header().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(tree) + lock_icon = "\U0001F512" # locked padlock + unlock_icon = "" + def populate_tree(): tree.clear() for s in self.db.get_sessions(): @@ -3428,28 +3432,59 @@ class SequenceLinkerUI(QWidget): dest_short = s.destination if len(dest_short) > 60: dest_short = '...' + dest_short[-57:] - item = QTreeWidgetItem([date_str, dest_short, str(s.link_count)]) + item = QTreeWidgetItem([ + lock_icon if s.locked else unlock_icon, + date_str, dest_short, str(s.link_count), + ]) item.setData(0, Qt.ItemDataRole.UserRole, s) - item.setToolTip(1, s.destination) + item.setToolTip(2, s.destination) + if s.locked: + item.setToolTip(0, "Locked — protected from deletion") tree.addTopLevelItem(item) if tree.topLevelItemCount() > 0: tree.setCurrentItem(tree.topLevelItem(0)) + def toggle_lock(): + selected_items = tree.selectedItems() + if not selected_items: + return + for item in selected_items: + s = item.data(0, Qt.ItemDataRole.UserRole) + if s is not None: + try: + new_state = self.db.toggle_session_locked(s.id) + s.locked = new_state + item.setData(0, Qt.ItemDataRole.UserRole, s) + item.setText(0, lock_icon if new_state else unlock_icon) + item.setToolTip(0, "Locked — protected from deletion" if new_state else "") + except Exception as e: + QMessageBox.critical(dlg, "Error", f"Failed to toggle lock:\n{e}") + def delete_selected(): selected_items = tree.selectedItems() if not selected_items: return - count = len(selected_items) + # Filter out locked sessions + deletable = [it for it in selected_items + if not getattr(it.data(0, Qt.ItemDataRole.UserRole), 'locked', False)] + locked_count = len(selected_items) - len(deletable) + if not deletable: + QMessageBox.information(dlg, "Delete Sessions", + "All selected sessions are locked.") + return + count = len(deletable) label = "session" if count == 1 else "sessions" + msg = f"Delete {count} {label}? This cannot be undone." + if locked_count: + msg += f"\n({locked_count} locked session(s) will be skipped.)" reply = QMessageBox.question( - dlg, "Delete Sessions", - f"Delete {count} {label}? This cannot be undone.", + dlg, "Delete Sessions", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return ids = [] - for item in selected_items: + for item in deletable: s = item.data(0, Qt.ItemDataRole.UserRole) if s is not None: ids.append(s.id) @@ -3464,6 +3499,10 @@ class SequenceLinkerUI(QWidget): populate_tree() btn_layout = QHBoxLayout() + lock_btn = QPushButton("Toggle Lock") + lock_btn.setToolTip("Lock/unlock selected sessions to protect from deletion") + lock_btn.clicked.connect(toggle_lock) + btn_layout.addWidget(lock_btn) delete_btn = QPushButton("Delete Selected") delete_btn.clicked.connect(delete_selected) btn_layout.addWidget(delete_btn) @@ -5844,42 +5883,72 @@ class SequenceLinkerUI(QWidget): output_seq += 1 else: if in_range: - ext = source_path.suffix + out_fmt = settings.output_format.lower() + src_ext = source_path.suffix.lower() + needs_convert = src_ext != f".{out_fmt}" and src_ext not in ( + '.jpg' if out_fmt == 'jpeg' else '', '.jpeg' if out_fmt == 'jpg' else '' + ) + + ext = f".{out_fmt}" if needs_convert else source_path.suffix link_name = f"seq_{output_seq:05d}{ext}" link_path = trans_dest / link_name planned_names.add(link_name) - # Check if existing file already matches — skip if unchanged - already_correct = False - if link_path.exists() or link_path.is_symlink(): - if copy_files: - already_correct = SymlinkManager.copy_matches(link_path, source_path) - else: - already_correct = SymlinkManager.symlink_matches(link_path, source_path) - - if already_correct: - skipped += 1 - symlink_records.append(( - str(source_path.resolve()), - str(link_path), filename, output_seq - )) - else: + if needs_convert: + # Format mismatch — convert via PIL try: if link_path.exists() or link_path.is_symlink(): link_path.unlink() - if copy_files: - shutil.copy2(source_path, link_path) - else: - rel_source = Path(os.path.relpath(source_path.resolve(), trans_dest.resolve())) - link_path.symlink_to(rel_source) + from PIL import Image + img = Image.open(source_path) + save_kwargs = {} + if out_fmt in ('jpg', 'jpeg'): + img = img.convert('RGB') + save_kwargs['quality'] = settings.output_quality + elif out_fmt == 'webp': + save_kwargs['quality'] = settings.output_quality + save_kwargs['method'] = settings.webp_method + img.save(link_path, **save_kwargs) symlink_count += 1 symlink_records.append(( str(source_path.resolve()), str(link_path), filename, output_seq )) except Exception as e: - errors.append(f"Symlink {filename}: {e}") + errors.append(f"Convert {filename}: {e}") + else: + # Check if existing file already matches — skip if unchanged + already_correct = False + if link_path.exists() or link_path.is_symlink(): + if copy_files: + already_correct = SymlinkManager.copy_matches(link_path, source_path) + else: + already_correct = SymlinkManager.symlink_matches(link_path, source_path) + + if already_correct: + skipped += 1 + symlink_records.append(( + str(source_path.resolve()), + str(link_path), filename, output_seq + )) + else: + try: + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + + if copy_files: + shutil.copy2(source_path, link_path) + else: + rel_source = Path(os.path.relpath(source_path.resolve(), trans_dest.resolve())) + link_path.symlink_to(rel_source) + symlink_count += 1 + symlink_records.append(( + str(source_path.resolve()), + str(link_path), filename, output_seq + )) + except Exception as e: + errors.append(f"Symlink {filename}: {e}") output_seq += 1