From ee77a43322d3ce36052cbd17f9238555d6e3e194 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 3 Feb 2026 10:29:11 +0100 Subject: [PATCH] Initial commit: Video Montage Linker PyQt6 application to create sequenced symlinks for image files. Features: - GUI and CLI modes - Multiple source folder support with drag & drop - SQLite database for tracking symlink sessions - Multi-select and reorder files - Two-column file view (filename + source path) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 + symlink.py | 931 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 935 insertions(+) create mode 100644 .gitignore create mode 100755 symlink.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9460c99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/symlink.py b/symlink.py new file mode 100755 index 0000000..3e4eb01 --- /dev/null +++ b/symlink.py @@ -0,0 +1,931 @@ +#!/usr/bin/env python3 +"""Video Montage Linker - Create sequenced symlinks for image files. + +Supports both GUI and CLI modes for creating numbered symlinks from one or more +source directories into a single destination directory. +""" + +# --- Imports --- +import argparse +import os +import sqlite3 +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QDragEnterEvent, QDropEvent +from PyQt6.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + QFileDialog, + QLineEdit, + QHBoxLayout, + QMessageBox, + QListWidget, + QTreeWidget, + QTreeWidgetItem, + QAbstractItemView, + QGroupBox, + QHeaderView, +) + +# --- Configuration --- +SUPPORTED_EXTENSIONS = ('.png', '.webp', '.jpg', '.jpeg') +DB_PATH = Path.home() / '.config' / 'video-montage-linker' / 'symlinks.db' + + +# --- Exceptions --- +class SymlinkError(Exception): + """Base exception for symlink operations.""" + + +class PathValidationError(SymlinkError): + """Error validating file paths.""" + + +class SourceNotFoundError(PathValidationError): + """Source directory does not exist.""" + + +class DestinationError(PathValidationError): + """Error with destination directory.""" + + +class CleanupError(SymlinkError): + """Error during cleanup of existing symlinks.""" + + +class DatabaseError(SymlinkError): + """Error with database operations.""" + + +# --- Data Classes --- +@dataclass +class LinkResult: + """Result of a symlink creation operation.""" + + source_path: Path + link_path: Path + sequence_number: int + success: bool + error: Optional[str] = None + + +@dataclass +class SymlinkRecord: + """Database record of a created symlink.""" + + id: int + session_id: int + source_path: str + link_path: str + original_filename: str + sequence_number: int + created_at: datetime + + +@dataclass +class SessionRecord: + """Database record of a symlink session.""" + + id: int + created_at: datetime + destination: str + link_count: int = 0 + + +# --- Database --- +class DatabaseManager: + """Manages SQLite database for tracking symlink sessions and links.""" + + def __init__(self, db_path: Path = DB_PATH) -> None: + """Initialize database manager. + + Args: + db_path: Path to the SQLite database file. + """ + self.db_path = db_path + self._ensure_db_exists() + + def _ensure_db_exists(self) -> None: + """Create database and tables if they don't exist.""" + self.db_path.parent.mkdir(parents=True, exist_ok=True) + + with self._connect() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS symlink_sessions ( + id INTEGER PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + destination TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS symlinks ( + id INTEGER PRIMARY KEY, + session_id INTEGER REFERENCES symlink_sessions(id) ON DELETE CASCADE, + source_path TEXT NOT NULL, + link_path TEXT NOT NULL, + original_filename TEXT NOT NULL, + sequence_number INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + def _connect(self) -> sqlite3.Connection: + """Create a database connection with foreign keys enabled.""" + conn = sqlite3.connect(self.db_path) + conn.execute("PRAGMA foreign_keys = ON") + return conn + + def create_session(self, destination: str) -> int: + """Create a new linking session. + + Args: + destination: The destination directory path. + + Returns: + The ID of the created session. + + Raises: + DatabaseError: If session creation fails. + """ + try: + with self._connect() as conn: + cursor = conn.execute( + "INSERT INTO symlink_sessions (destination) VALUES (?)", + (destination,) + ) + return cursor.lastrowid + except sqlite3.Error as e: + raise DatabaseError(f"Failed to create session: {e}") from e + + def record_symlink( + self, + session_id: int, + source: str, + link: str, + filename: str, + seq: int + ) -> int: + """Record a created symlink. + + Args: + session_id: The session this symlink belongs to. + source: Full path to the source file. + link: Full path to the created symlink. + filename: Original filename. + seq: Sequence number in the destination. + + Returns: + The ID of the created record. + + Raises: + DatabaseError: If recording fails. + """ + try: + with self._connect() as conn: + cursor = conn.execute( + """INSERT INTO symlinks + (session_id, source_path, link_path, original_filename, sequence_number) + VALUES (?, ?, ?, ?, ?)""", + (session_id, source, link, filename, seq) + ) + return cursor.lastrowid + except sqlite3.Error as e: + raise DatabaseError(f"Failed to record symlink: {e}") from e + + def get_sessions(self) -> list[SessionRecord]: + """List all sessions with link counts. + + Returns: + List of session records. + """ + with self._connect() as conn: + rows = conn.execute(""" + SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count + FROM symlink_sessions s + LEFT JOIN symlinks l ON s.id = l.session_id + GROUP BY s.id + ORDER BY s.created_at DESC + """).fetchall() + + return [ + SessionRecord( + id=row[0], + created_at=datetime.fromisoformat(row[1]), + destination=row[2], + link_count=row[3] + ) + for row in rows + ] + + def get_symlinks_by_session(self, session_id: int) -> list[SymlinkRecord]: + """Get all symlinks for a session. + + Args: + session_id: The session ID to query. + + Returns: + List of symlink records. + """ + with self._connect() as conn: + rows = conn.execute( + """SELECT id, session_id, source_path, link_path, + original_filename, sequence_number, created_at + FROM symlinks WHERE session_id = ? + ORDER BY sequence_number""", + (session_id,) + ).fetchall() + + return [ + SymlinkRecord( + id=row[0], + session_id=row[1], + source_path=row[2], + link_path=row[3], + original_filename=row[4], + sequence_number=row[5], + created_at=datetime.fromisoformat(row[6]) + ) + for row in rows + ] + + def get_symlinks_by_destination(self, dest: str) -> list[SymlinkRecord]: + """Get all symlinks for a destination directory. + + Args: + dest: The destination directory path. + + Returns: + List of symlink records. + """ + with self._connect() as conn: + rows = conn.execute( + """SELECT l.id, l.session_id, l.source_path, l.link_path, + l.original_filename, l.sequence_number, l.created_at + FROM symlinks l + JOIN symlink_sessions s ON l.session_id = s.id + WHERE s.destination = ? + ORDER BY l.sequence_number""", + (dest,) + ).fetchall() + + return [ + SymlinkRecord( + id=row[0], + session_id=row[1], + source_path=row[2], + link_path=row[3], + original_filename=row[4], + sequence_number=row[5], + created_at=datetime.fromisoformat(row[6]) + ) + for row in rows + ] + + def delete_session(self, session_id: int) -> None: + """Delete a session and all its symlink records. + + Args: + session_id: The session ID to delete. + + Raises: + DatabaseError: If deletion fails. + """ + try: + with self._connect() as conn: + conn.execute("DELETE FROM symlinks WHERE session_id = ?", (session_id,)) + conn.execute("DELETE FROM symlink_sessions WHERE id = ?", (session_id,)) + except sqlite3.Error as e: + raise DatabaseError(f"Failed to delete session: {e}") from e + + def get_sessions_by_destination(self, dest: str) -> list[SessionRecord]: + """Get all sessions for a destination directory. + + Args: + dest: The destination directory path. + + Returns: + List of session records. + """ + with self._connect() as conn: + rows = conn.execute(""" + SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count + FROM symlink_sessions s + LEFT JOIN symlinks l ON s.id = l.session_id + WHERE s.destination = ? + GROUP BY s.id + ORDER BY s.created_at DESC + """, (dest,)).fetchall() + + return [ + SessionRecord( + id=row[0], + created_at=datetime.fromisoformat(row[1]), + destination=row[2], + link_count=row[3] + ) + for row in rows + ] + + +# --- Business Logic --- +class SymlinkManager: + """Manages symlink creation and cleanup operations.""" + + def __init__(self, db: Optional[DatabaseManager] = None) -> None: + """Initialize the symlink manager. + + Args: + db: Optional database manager for tracking operations. + """ + self.db = db + + @staticmethod + def get_supported_files(directories: list[Path]) -> list[tuple[Path, str]]: + """Get all supported image files from multiple directories. + + Files are returned sorted by directory order (as provided), then + alphabetically by filename within each directory. + + Args: + directories: List of source directories to scan. + + Returns: + List of (directory, filename) tuples. + """ + files: list[tuple[Path, str]] = [] + + for directory in directories: + if not directory.is_dir(): + continue + dir_files = [] + for item in directory.iterdir(): + if item.is_file() and item.suffix.lower() in SUPPORTED_EXTENSIONS: + dir_files.append((directory, item.name)) + # Sort files within this directory alphabetically + dir_files.sort(key=lambda x: x[1].lower()) + files.extend(dir_files) + + return files + + @staticmethod + def validate_paths(sources: list[Path], dest: Path) -> None: + """Validate source and destination paths. + + Args: + sources: List of source directories. + dest: Destination directory. + + Raises: + SourceNotFoundError: If any source directory doesn't exist. + DestinationError: If destination cannot be created or accessed. + """ + if not sources: + raise SourceNotFoundError("No source directories specified") + + for source in sources: + if not source.exists(): + raise SourceNotFoundError(f"Source directory not found: {source}") + if not source.is_dir(): + raise SourceNotFoundError(f"Source is not a directory: {source}") + + try: + dest.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise DestinationError(f"Cannot create destination directory: {e}") from e + + if not dest.is_dir(): + raise DestinationError(f"Destination is not a directory: {dest}") + + @staticmethod + def cleanup_old_links(directory: Path) -> int: + """Remove existing seq_* symlinks from a directory. + + Args: + directory: Directory to clean up. + + Returns: + Number of files removed. + + Raises: + CleanupError: If cleanup fails. + """ + removed = 0 + try: + for item in directory.iterdir(): + if item.name.startswith("seq_") and item.is_symlink(): + item.unlink() + removed += 1 + except OSError as e: + raise CleanupError(f"Failed to clean up old links: {e}") from e + + return removed + + def create_sequence_links( + self, + sources: list[Path], + dest: Path, + files: list[tuple[Path, str]], + ) -> list[LinkResult]: + """Create sequenced symlinks from source files to destination. + + Args: + sources: List of source directories (for validation). + dest: Destination directory. + files: List of (source_dir, filename) tuples in desired order. + + Returns: + List of LinkResult objects for each operation. + """ + self.validate_paths(sources, dest) + self.cleanup_old_links(dest) + + session_id = None + if self.db: + session_id = self.db.create_session(str(dest)) + + results: list[LinkResult] = [] + + for i, (source_dir, filename) in enumerate(files): + source_path = source_dir / filename + ext = source_path.suffix + link_name = f"seq_{i:04d}{ext}" + link_path = dest / link_name + + # Calculate relative path from destination to source + rel_source = Path(os.path.relpath(source_path.resolve(), dest.resolve())) + + try: + link_path.symlink_to(rel_source) + + if self.db and session_id: + self.db.record_symlink( + session_id=session_id, + source=str(source_path.resolve()), + link=str(link_path), + filename=filename, + seq=i + ) + + results.append(LinkResult( + source_path=source_path, + link_path=link_path, + sequence_number=i, + success=True + )) + except OSError as e: + results.append(LinkResult( + source_path=source_path, + link_path=link_path, + sequence_number=i, + success=False, + error=str(e) + )) + + return results + + +# --- GUI --- +class SequenceLinkerUI(QWidget): + """PyQt6 GUI for the Video Montage Linker.""" + + def __init__(self) -> None: + """Initialize the UI.""" + super().__init__() + self.source_folders: list[Path] = [] + self.last_directory: Optional[str] = None + self.db = DatabaseManager() + self.manager = SymlinkManager(self.db) + self._setup_window() + self._create_widgets() + self._create_layout() + self._connect_signals() + self.setAcceptDrops(True) + + def _setup_window(self) -> None: + """Configure the main window properties.""" + self.setWindowTitle('Video Montage Linker') + self.setMinimumSize(700, 600) + + def _create_widgets(self) -> None: + """Create all UI widgets.""" + # Source folders group + self.source_group = QGroupBox("Source Folders (drag & drop folders here)") + self.source_list = QListWidget() + self.source_list.setMaximumHeight(100) + self.source_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.add_source_btn = QPushButton("Add Folder") + self.remove_source_btn = QPushButton("Remove Folder") + + # Destination + self.dst_label = QLabel("Destination Folder:") + self.dst_path = QLineEdit(placeholderText="Select destination folder") + self.dst_btn = QPushButton("Browse") + + # File list + self.files_label = QLabel("Sequence Order (Drag to reorder, Del to remove):") + self.file_list = QTreeWidget() + self.file_list.setHeaderLabels(["Filename", "Source Folder"]) + self.file_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.file_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.file_list.setRootIsDecorated(False) + self.file_list.header().setStretchLastSection(True) + self.file_list.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) + + # Action buttons + self.remove_files_btn = QPushButton("Remove Files") + self.refresh_btn = QPushButton("Refresh Files") + self.run_btn = QPushButton("Generate Virtual Sequence") + self.run_btn.setStyleSheet( + "background-color: #3498db; color: white; " + "height: 40px; font-weight: bold;" + ) + + def _create_layout(self) -> None: + """Arrange widgets in layouts.""" + main_layout = QVBoxLayout() + + # Source folders group layout + source_group_layout = QVBoxLayout() + source_btn_layout = QHBoxLayout() + source_btn_layout.addWidget(self.add_source_btn) + source_btn_layout.addWidget(self.remove_source_btn) + source_btn_layout.addStretch() + source_group_layout.addWidget(self.source_list) + source_group_layout.addLayout(source_btn_layout) + self.source_group.setLayout(source_group_layout) + + # Destination layout + dst_layout = QHBoxLayout() + dst_layout.addWidget(self.dst_path) + dst_layout.addWidget(self.dst_btn) + + # Button layout + btn_layout = QHBoxLayout() + btn_layout.addWidget(self.remove_files_btn) + btn_layout.addWidget(self.refresh_btn) + btn_layout.addStretch() + + # Assemble main layout + main_layout.addWidget(self.source_group) + main_layout.addWidget(self.dst_label) + main_layout.addLayout(dst_layout) + main_layout.addWidget(self.files_label) + main_layout.addWidget(self.file_list) + main_layout.addLayout(btn_layout) + main_layout.addWidget(self.run_btn) + + self.setLayout(main_layout) + + def _connect_signals(self) -> None: + """Connect widget signals to slots.""" + self.add_source_btn.clicked.connect(self._add_source_folder) + self.remove_source_btn.clicked.connect(self._remove_source_folder) + self.dst_btn.clicked.connect(self._browse_destination) + self.remove_files_btn.clicked.connect(self._remove_selected_files) + self.refresh_btn.clicked.connect(self._refresh_files) + self.run_btn.clicked.connect(self._process_links) + + def _add_source_folder(self, folder_path: Optional[str] = None) -> None: + """Add a source folder via file dialog or direct path. + + Args: + folder_path: Optional path to add directly (for drag-drop). + """ + if folder_path: + path = folder_path + else: + start_dir = self.last_directory or "" + path = QFileDialog.getExistingDirectory( + self, "Select Source Folder", start_dir + ) + + if path: + folder = Path(path) + if folder.is_dir() and folder not in self.source_folders: + self.source_folders.append(folder) + self.source_list.addItem(str(folder)) + self.last_directory = str(folder.parent) + self._refresh_files() + + def _remove_source_folder(self) -> None: + """Remove selected source folder(s).""" + selected = self.source_list.selectedItems() + if not selected: + return + + # Remove in reverse order to maintain correct indices + rows = sorted([self.source_list.row(item) for item in selected], reverse=True) + for row in rows: + self.source_list.takeItem(row) + del self.source_folders[row] + self._refresh_files() + + def _remove_selected_files(self) -> None: + """Remove selected files from the file list.""" + selected = self.file_list.selectedItems() + if not selected: + return + + # Remove in reverse order to maintain correct indices + rows = sorted([self.file_list.indexOfTopLevelItem(item) for item in selected], reverse=True) + for row in rows: + self.file_list.takeTopLevelItem(row) + + def _browse_destination(self) -> None: + """Select destination folder via file dialog.""" + start_dir = self.last_directory or "" + path = QFileDialog.getExistingDirectory( + self, "Select Destination Folder", start_dir + ) + if path: + self.dst_path.setText(path) + self.last_directory = str(Path(path).parent) + + def keyPressEvent(self, event) -> None: + """Handle key press events for deletion.""" + if event.key() == Qt.Key.Key_Delete: + # Check which widget has focus + if self.file_list.hasFocus(): + self._remove_selected_files() + elif self.source_list.hasFocus(): + self._remove_source_folder() + else: + super().keyPressEvent(event) + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + """Accept drag events with URLs (folders).""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event: QDropEvent) -> None: + """Handle dropped folders.""" + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path and Path(path).is_dir(): + self._add_source_folder(path) + + def _refresh_files(self) -> None: + """Refresh the file list from all source folders.""" + self.file_list.clear() + if not self.source_folders: + return + + files = self.manager.get_supported_files(self.source_folders) + for source_dir, filename in files: + item = QTreeWidgetItem([filename, str(source_dir)]) + item.setData(0, Qt.ItemDataRole.UserRole, (source_dir, filename)) + self.file_list.addTopLevelItem(item) + + def _get_files_in_order(self) -> list[tuple[Path, str]]: + """Get files in the current list order. + + Returns: + List of (source_dir, filename) tuples in display order. + """ + files = [] + for i in range(self.file_list.topLevelItemCount()): + item = self.file_list.topLevelItem(i) + data = item.data(0, Qt.ItemDataRole.UserRole) + if data: + files.append(data) + return files + + def _process_links(self) -> None: + """Create symlinks based on current configuration.""" + dst = self.dst_path.text() + + if not self.source_folders: + QMessageBox.warning(self, "Error", "Add at least one source folder!") + return + + if not dst: + QMessageBox.warning(self, "Error", "Select a destination folder!") + return + + files = self._get_files_in_order() + if not files: + QMessageBox.warning(self, "Error", "No files to process!") + return + + try: + results = self.manager.create_sequence_links( + sources=self.source_folders, + dest=Path(dst), + files=files + ) + + successful = sum(1 for r in results if r.success) + failed = sum(1 for r in results if not r.success) + + if failed > 0: + QMessageBox.warning( + self, "Partial Success", + f"Linked {successful} files, {failed} failed.\n" + f"Destination: {dst}" + ) + else: + QMessageBox.information( + self, "Success", + f"Linked {successful} files to {dst}" + ) + + except SymlinkError as e: + QMessageBox.critical(self, "Error", str(e)) + except Exception as e: + QMessageBox.critical(self, "Unexpected Error", str(e)) + + +# --- CLI --- +def create_parser() -> argparse.ArgumentParser: + """Create the argument parser for CLI mode. + + Returns: + Configured ArgumentParser instance. + """ + parser = argparse.ArgumentParser( + prog='symlink', + description='Video Montage Linker - Create sequenced symlinks for image files.', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s Launch GUI + %(prog)s --gui Launch GUI + %(prog)s --src /path/to/images --dst /path/to/dest + Create symlinks from CLI + %(prog)s --src /folder1 --src /folder2 --dst /path/to/dest + Merge multiple source folders + %(prog)s --list List tracked symlink sessions + %(prog)s --clean /path/to/dest Remove symlinks and session for destination + """ + ) + + parser.add_argument( + '--gui', + action='store_true', + help='Launch the graphical interface' + ) + + parser.add_argument( + '--src', + action='append', + metavar='PATH', + help='Source folder(s) containing images (can be used multiple times)' + ) + + parser.add_argument( + '--dst', + metavar='PATH', + help='Destination folder for symlinks' + ) + + parser.add_argument( + '--list', + action='store_true', + help='List all tracked symlink sessions' + ) + + parser.add_argument( + '--clean', + metavar='PATH', + help='Clean up symlinks and remove session for the specified destination' + ) + + return parser + + +def run_cli(args: argparse.Namespace) -> int: + """Execute CLI commands. + + Args: + args: Parsed command line arguments. + + Returns: + Exit code (0 for success, non-zero for errors). + """ + db = DatabaseManager() + + # List sessions + if args.list: + sessions = db.get_sessions() + if not sessions: + print("No symlink sessions found.") + return 0 + + print(f"{'ID':<6} {'Created':<20} {'Links':<8} Destination") + print("-" * 80) + for session in sessions: + created = session.created_at.strftime("%Y-%m-%d %H:%M:%S") + print(f"{session.id:<6} {created:<20} {session.link_count:<8} {session.destination}") + return 0 + + # Clean up destination + if args.clean: + dest = Path(args.clean).resolve() + + # Remove symlinks from filesystem + if dest.exists(): + try: + removed = SymlinkManager.cleanup_old_links(dest) + print(f"Removed {removed} symlinks from {dest}") + except CleanupError as e: + print(f"Error cleaning up files: {e}", file=sys.stderr) + return 1 + + # Remove from database + sessions = db.get_sessions_by_destination(str(dest)) + for session in sessions: + db.delete_session(session.id) + print(f"Removed session {session.id} from database") + + if not sessions: + print("No sessions found for this destination in database.") + + return 0 + + # Create symlinks + if args.src and args.dst: + sources = [Path(s).resolve() for s in args.src] + dest = Path(args.dst).resolve() + + manager = SymlinkManager(db) + + try: + files = manager.get_supported_files(sources) + if not files: + print("No supported image files found in source folders.") + return 1 + + print(f"Found {len(files)} files in {len(sources)} source folder(s)") + + results = manager.create_sequence_links( + sources=sources, + dest=dest, + files=files + ) + + successful = sum(1 for r in results if r.success) + failed = sum(1 for r in results if not r.success) + + print(f"Created {successful} symlinks in {dest}") + + if failed > 0: + print(f"Warning: {failed} operations failed", file=sys.stderr) + for r in results: + if not r.success: + print(f" - {r.source_path.name}: {r.error}", file=sys.stderr) + return 1 + + return 0 + + except SymlinkError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + # If src without dst or dst without src + if args.src or args.dst: + print("Error: Both --src and --dst are required for creating symlinks.", + file=sys.stderr) + return 1 + + # No CLI args, show help + create_parser().print_help() + return 0 + + +# --- Entry Point --- +def main() -> int: + """Main entry point for the application. + + Returns: + Exit code (0 for success, non-zero for errors). + """ + parser = create_parser() + args = parser.parse_args() + + # Determine if we should launch GUI + # GUI is launched if: --gui flag, OR no arguments at all + launch_gui = args.gui or ( + not args.src and + not args.dst and + not args.list and + not args.clean + ) + + if launch_gui: + app = QApplication(sys.argv) + window = SequenceLinkerUI() + window.show() + return app.exec() + + return run_cli(args) + + +if __name__ == '__main__': + sys.exit(main())