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:
@@ -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)
|
||||
|
||||
@@ -165,6 +165,7 @@ class SessionRecord:
|
||||
destination: str
|
||||
link_count: int = 0
|
||||
name: Optional[str] = None
|
||||
locked: bool = False
|
||||
|
||||
|
||||
# --- Exceptions ---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user