Add session lock feature and fix format conversion in transition export

- Add locked column to sessions with toggle in restore dialog, preventing
  accidental deletion of important sessions (padlock icon, DB-level protection)
- Fix transition export copying source files as-is when output format differs
  (e.g. webp sources now convert to png when png format is selected)
- Fix ON CONFLICT clause in save_per_transition_settings to match UNIQUE constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 22:05:31 +01:00
parent 793ba4243d
commit 8dda4f56a0
3 changed files with 143 additions and 39 deletions

View File

@@ -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)

View File

@@ -165,6 +165,7 @@ class SessionRecord:
destination: str
link_count: int = 0
name: Optional[str] = None
locked: bool = False
# --- Exceptions ---

View File

@@ -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