diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py
index aa94877..3f6e25a 100644
--- a/src/portkeydrop/app.py
+++ b/src/portkeydrop/app.py
@@ -12,6 +12,7 @@
from portkeydrop.dialogs.properties import PropertiesDialog
from portkeydrop.dialogs.quick_connect import QuickConnectDialog
from portkeydrop.dialogs.settings import SettingsDialog
+from portkeydrop.dialogs.import_connections import ImportConnectionsDialog
from portkeydrop.dialogs.site_manager import SiteManagerDialog
from portkeydrop.dialogs.transfer import (
TransferDirection,
@@ -62,6 +63,7 @@
ID_FILTER = wx.NewIdRef()
ID_SAVE_CONNECTION = wx.NewIdRef()
ID_SETTINGS = wx.NewIdRef()
+ID_IMPORT_CONNECTIONS = wx.NewIdRef()
class MainFrame(wx.Frame):
@@ -152,6 +154,12 @@ def _build_menu(self) -> None:
sites_menu.Append(
ID_SAVE_CONNECTION, "Sa&ve Current Connection...", "Save active connection as a site"
)
+ sites_menu.AppendSeparator()
+ sites_menu.Append(
+ ID_IMPORT_CONNECTIONS,
+ "&Import Sites...",
+ "Import sites from other FTP/SFTP clients",
+ )
menubar.Append(sites_menu, "S&ites")
# Help menu
@@ -330,6 +338,7 @@ def _bind_events(self) -> None:
self.Bind(wx.EVT_MENU, self._on_properties, id=ID_PROPERTIES)
self.Bind(wx.EVT_MENU, self._on_transfer_queue, id=ID_TRANSFER_QUEUE)
self.Bind(wx.EVT_MENU, self._on_settings, id=ID_SETTINGS)
+ self.Bind(wx.EVT_MENU, self._on_import_connections, id=ID_IMPORT_CONNECTIONS)
self.Bind(wx.EVT_MENU, self._on_about, id=wx.ID_ABOUT)
self.Bind(get_transfer_event_binder(), self._on_transfer_update)
@@ -424,6 +433,70 @@ def _on_save_connection(self, event: wx.CommandEvent) -> None:
self._announce(f"Site '{name}' saved")
dlg.Destroy()
+ def _on_import_connections(self, event: wx.CommandEvent) -> None:
+ dlg = ImportConnectionsDialog(self)
+ result = dlg.ShowModal()
+ selected_sites = dlg.selected_sites if result == wx.ID_OK else []
+ dlg.Destroy()
+
+ if not selected_sites:
+ return
+
+ duplicate_names: list[str] = []
+ imported_count = 0
+
+ existing = {
+ (
+ site.host.strip().lower(),
+ self._effective_site_port(site.protocol, site.port),
+ site.username.strip().lower(),
+ )
+ for site in self._site_manager.sites
+ }
+
+ for imported in selected_sites:
+ key = (
+ imported.host.strip().lower(),
+ self._effective_site_port(imported.protocol, imported.port),
+ imported.username.strip().lower(),
+ )
+ if key in existing:
+ duplicate_names.append(imported.name or imported.host)
+ continue
+
+ site = Site(
+ name=imported.name or imported.host,
+ protocol=imported.protocol,
+ host=imported.host,
+ port=imported.port,
+ username=imported.username,
+ password=imported.password,
+ key_path=imported.key_path,
+ initial_dir=imported.initial_dir or "/",
+ notes=imported.notes,
+ )
+ self._site_manager.add(site)
+ existing.add(key)
+ imported_count += 1
+
+ message = f"Imported {imported_count} connection{'s' if imported_count != 1 else ''}."
+ if duplicate_names:
+ dup_preview = ", ".join(duplicate_names[:5])
+ if len(duplicate_names) > 5:
+ dup_preview += ", ..."
+ message += (
+ f"\nSkipped {len(duplicate_names)} duplicate"
+ f"{'s' if len(duplicate_names) != 1 else ''}: {dup_preview}"
+ )
+
+ wx.MessageBox(message, "Import Sites", wx.OK | wx.ICON_INFORMATION, self)
+
+ def _effective_site_port(self, protocol: str, port: int) -> int:
+ if port > 0:
+ return port
+ defaults = {"sftp": 22, "ftp": 21, "ftps": 990}
+ return defaults.get(protocol, 22)
+
def _host_key_policy(self) -> HostKeyPolicy:
"""Map the verify_host_keys setting string to a HostKeyPolicy enum value."""
mapping = {
diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py
new file mode 100644
index 0000000..9b46422
--- /dev/null
+++ b/src/portkeydrop/dialogs/import_connections.py
@@ -0,0 +1,339 @@
+"""Wizard dialog for importing saved connection profiles."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import wx
+
+from portkeydrop.importers import (
+ SOURCES,
+ WINSCP_REGISTRY_SENTINEL,
+ available_sources,
+ detect_default_path,
+ load_from_source,
+)
+from portkeydrop.importers.models import ImportedSite
+
+
+class ImportConnectionsDialog(wx.Dialog):
+ """Wizard-style dialog for importing connection profiles."""
+
+ def __init__(self, parent: wx.Window | None) -> None:
+ super().__init__(
+ parent,
+ title="Import Sites",
+ size=(680, 480),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
+ )
+
+ self._step = 0
+ self._source = SOURCES[0].key
+ self._loaded_sites: list[ImportedSite] = []
+ self._selected_sites: list[ImportedSite] = []
+
+ self._build_ui()
+ self._update_step_ui()
+
+ def _build_ui(self) -> None:
+ root = wx.BoxSizer(wx.VERTICAL)
+
+ self.step_title = wx.StaticText(self, label="")
+ root.Add(self.step_title, 0, wx.ALL, 8)
+
+ self._available_sources = available_sources()
+ self._auto_advance = False
+ self.pages = [
+ self._build_source_page(),
+ self._build_path_page(),
+ self._build_preview_page(),
+ ]
+ for page in self.pages:
+ root.Add(page, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
+
+ nav = wx.BoxSizer(wx.HORIZONTAL)
+ self.back_btn = wx.Button(self, label="< &Back")
+ self.next_btn = wx.Button(self, label="&Next >")
+ self.import_btn = wx.Button(self, label="&Import")
+ cancel_btn = wx.Button(self, wx.ID_CANCEL)
+
+ nav.Add(self.back_btn, 0, wx.RIGHT, 6)
+ nav.Add(self.next_btn, 0, wx.RIGHT, 6)
+ nav.Add(self.import_btn, 0, wx.RIGHT, 6)
+ nav.AddStretchSpacer(1)
+ nav.Add(cancel_btn, 0)
+ root.Add(nav, 0, wx.EXPAND | wx.ALL, 8)
+
+ self.back_btn.Bind(wx.EVT_BUTTON, self._on_back)
+ self.next_btn.Bind(wx.EVT_BUTTON, self._on_next)
+ self.import_btn.Bind(wx.EVT_BUTTON, self._on_import)
+ self.source_radio.Bind(wx.EVT_RADIOBOX, self._on_source_change)
+ self.autodetect_btn.Bind(wx.EVT_BUTTON, self._on_autodetect)
+ self.browse_file_btn.Bind(wx.EVT_BUTTON, self._on_browse_file)
+ self.browse_folder_btn.Bind(wx.EVT_BUTTON, self._on_browse_folder)
+ self.select_all_btn.Bind(wx.EVT_BUTTON, self._on_select_all)
+ self.select_none_btn.Bind(wx.EVT_BUTTON, self._on_select_none)
+
+ self.SetSizer(root)
+
+ def _build_source_page(self) -> wx.Panel:
+ panel = wx.Panel(self)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ self._available_sources = available_sources()
+ choices = [source.label for source in self._available_sources]
+
+ if not choices:
+ choices = ["From file..."]
+
+ if (
+ len(choices) == 1
+ and self._available_sources
+ and self._available_sources[0].key != "from_file"
+ ):
+ # Only one client detected — note it for auto-skip
+ self._auto_advance = True
+ else:
+ self._auto_advance = False
+
+ self.source_radio = wx.RadioBox(
+ panel,
+ label="Choose source client",
+ choices=choices,
+ majorDimension=1,
+ style=wx.RA_SPECIFY_ROWS,
+ )
+ sizer.Add(self.source_radio, 0, wx.EXPAND | wx.ALL, 4)
+
+ if not self._available_sources or all(
+ s.key == "from_file" for s in self._available_sources
+ ):
+ note = wx.StaticText(
+ panel,
+ label="No supported FTP clients detected. You can still import from a file.",
+ )
+ sizer.Add(note, 0, wx.ALL, 4)
+
+ panel.SetSizer(sizer)
+ return panel
+
+ def _build_path_page(self) -> wx.Panel:
+ panel = wx.Panel(self)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ description = wx.StaticText(
+ panel,
+ label=(
+ "Auto-detect the configuration path, or browse manually. "
+ "For Cyberduck you can select either a .duck file or Bookmarks folder."
+ ),
+ )
+ sizer.Add(description, 0, wx.EXPAND | wx.ALL, 4)
+
+ row = wx.BoxSizer(wx.HORIZONTAL)
+ self.path_text = wx.TextCtrl(panel)
+ row.Add(self.path_text, 1, wx.RIGHT | wx.EXPAND, 6)
+
+ self.autodetect_btn = wx.Button(panel, label="&Auto-Detect")
+ self.browse_file_btn = wx.Button(panel, label="Browse &File...")
+ self.browse_folder_btn = wx.Button(panel, label="Browse &Folder...")
+ row.Add(self.autodetect_btn, 0, wx.RIGHT, 4)
+ row.Add(self.browse_file_btn, 0, wx.RIGHT, 4)
+ row.Add(self.browse_folder_btn, 0)
+ sizer.Add(row, 0, wx.EXPAND | wx.ALL, 4)
+
+ panel.SetSizer(sizer)
+ return panel
+
+ def _build_preview_page(self) -> wx.Panel:
+ panel = wx.Panel(self)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ preview_note = wx.StaticText(panel, label="Select connections to import")
+ sizer.Add(preview_note, 0, wx.ALL, 4)
+
+ self.preview_list = wx.CheckListBox(panel)
+ sizer.Add(self.preview_list, 1, wx.EXPAND | wx.ALL, 4)
+
+ actions = wx.BoxSizer(wx.HORIZONTAL)
+ self.select_all_btn = wx.Button(panel, label="Select &All")
+ self.select_none_btn = wx.Button(panel, label="Select &None")
+ actions.Add(self.select_all_btn, 0, wx.RIGHT, 4)
+ actions.Add(self.select_none_btn, 0)
+ sizer.Add(actions, 0, wx.ALL, 4)
+
+ panel.SetSizer(sizer)
+ return panel
+
+ def _on_source_change(self, event: wx.CommandEvent) -> None:
+ self._source = SOURCES[self.source_radio.GetSelection()].key
+ self._run_autodetect()
+
+ def _on_autodetect(self, event: wx.CommandEvent) -> None:
+ self._run_autodetect()
+
+ def _run_autodetect(self) -> None:
+ source = SOURCES[self.source_radio.GetSelection()].key
+ default_path = detect_default_path(source)
+ if default_path is not None:
+ self.path_text.SetValue(str(default_path))
+
+ def _on_browse_file(self, event: wx.CommandEvent) -> None:
+ source = SOURCES[self.source_radio.GetSelection()].key
+ wildcard = self._file_wildcard_for_source(source)
+ with wx.FileDialog(
+ self,
+ "Select Configuration File",
+ wildcard=wildcard,
+ style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
+ ) as dlg:
+ if dlg.ShowModal() == wx.ID_OK:
+ self.path_text.SetValue(dlg.GetPath())
+
+ def _on_browse_folder(self, event: wx.CommandEvent) -> None:
+ with wx.DirDialog(self, "Select Configuration Folder", style=wx.DD_DIR_MUST_EXIST) as dlg:
+ if dlg.ShowModal() == wx.ID_OK:
+ self.path_text.SetValue(dlg.GetPath())
+
+ def _on_select_all(self, event: wx.CommandEvent) -> None:
+ for i in range(self.preview_list.GetCount()):
+ self.preview_list.Check(i, True)
+
+ def _on_select_none(self, event: wx.CommandEvent) -> None:
+ for i in range(self.preview_list.GetCount()):
+ self.preview_list.Check(i, False)
+
+ def _on_back(self, event: wx.CommandEvent) -> None:
+ if self._step > 0:
+ self._step -= 1
+ self._update_step_ui()
+
+ def _on_next(self, event: wx.CommandEvent) -> None:
+ if self._step == 0:
+ self._source = self._available_sources[self.source_radio.GetSelection()].key
+ self._step = 1
+ self._update_step_ui()
+ return
+
+ if self._step == 1:
+ if not self._load_preview():
+ return
+ self._step = 2
+ self._update_step_ui()
+
+ def _on_import(self, event: wx.CommandEvent) -> None:
+ selected: list[ImportedSite] = []
+ for i, site in enumerate(self._loaded_sites):
+ if self.preview_list.IsChecked(i):
+ selected.append(site)
+
+ if not selected:
+ wx.MessageBox(
+ "Select at least one connection to import.",
+ "Import Sites",
+ wx.OK | wx.ICON_INFORMATION,
+ self,
+ )
+ return
+
+ self._selected_sites = selected
+ self.EndModal(wx.ID_OK)
+
+ def _load_preview(self) -> bool:
+ input_path = self.path_text.GetValue().strip()
+ use_registry = input_path == WINSCP_REGISTRY_SENTINEL
+ path = None if use_registry else (Path(input_path).expanduser() if input_path else None)
+
+ source = SOURCES[self.source_radio.GetSelection()].key
+ if source == "from_file" and not path:
+ wx.MessageBox(
+ "Choose a file or folder for 'From file...' import.",
+ "Import Sites",
+ wx.OK | wx.ICON_WARNING,
+ self,
+ )
+ return False
+
+ if source in {"filezilla", "cyberduck"} and path is None:
+ default_path = detect_default_path(source)
+ if default_path is not None:
+ path = default_path
+
+ if path and not path.exists():
+ wx.MessageBox(
+ f"Path does not exist:\n{path}",
+ "Import Sites",
+ wx.OK | wx.ICON_WARNING,
+ self,
+ )
+ return False
+
+ try:
+ self._loaded_sites = load_from_source(source, path)
+ except Exception as exc:
+ wx.MessageBox(
+ f"Failed to parse configuration: {exc}",
+ "Import Sites",
+ wx.OK | wx.ICON_ERROR,
+ self,
+ )
+ return False
+
+ if not self._loaded_sites:
+ wx.MessageBox(
+ "No connections were found in the selected source.",
+ "Import Sites",
+ wx.OK | wx.ICON_INFORMATION,
+ self,
+ )
+ return False
+
+ self._populate_preview(self._loaded_sites)
+ return True
+
+ def _populate_preview(self, sites: list[ImportedSite]) -> None:
+ self.preview_list.Clear()
+ for site in sites:
+ port = f":{site.port}" if site.port else ""
+ username = f" ({site.username})" if site.username else ""
+ label = f"{site.name} - {site.protocol}://{site.host}{port}{username}"
+ self.preview_list.Append(label)
+ self.preview_list.Check(self.preview_list.GetCount() - 1, True)
+
+ def _update_step_ui(self) -> None:
+ titles = [
+ "Step 1 of 3: Choose source client",
+ "Step 2 of 3: Detect or choose configuration path",
+ "Step 3 of 3: Select connections to import",
+ ]
+ self.step_title.SetLabel(titles[self._step])
+
+ for i, page in enumerate(self.pages):
+ page.Show(i == self._step)
+
+ self.back_btn.Enable(self._step > 0)
+ self.next_btn.Show(self._step < 2)
+ self.import_btn.Show(self._step == 2)
+ self.Layout()
+
+ # Move focus to the first meaningful control on each page so screen
+ # readers announce the new step without the user having to navigate.
+ if self._step == 0:
+ self.source_radio.SetFocus()
+ elif self._step == 1:
+ self.path_text.SetFocus()
+ elif self._step == 2:
+ self.preview_list.SetFocus()
+
+ def _file_wildcard_for_source(self, source: str) -> str:
+ if source == "filezilla":
+ return "FileZilla XML (*.xml)|*.xml|All files (*.*)|*.*"
+ if source == "winscp":
+ return "WinSCP INI (*.ini)|*.ini|All files (*.*)|*.*"
+ if source == "cyberduck":
+ return "Cyberduck bookmarks (*.duck)|*.duck|All files (*.*)|*.*"
+ return "Supported files (*.xml;*.ini;*.duck)|*.xml;*.ini;*.duck|All files (*.*)|*.*"
+
+ @property
+ def selected_sites(self) -> list[ImportedSite]:
+ return list(self._selected_sites)
diff --git a/src/portkeydrop/importers/__init__.py b/src/portkeydrop/importers/__init__.py
new file mode 100644
index 0000000..118fb52
--- /dev/null
+++ b/src/portkeydrop/importers/__init__.py
@@ -0,0 +1,131 @@
+"""Connection profile importers for external FTP/SFTP clients."""
+
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+from . import cyberduck, filezilla, winscp
+from .models import ImportedSite
+
+WINSCP_REGISTRY_SENTINEL = r"Registry (HKCU\Software\Martin Prikryl\WinSCP 2\Sessions)"
+
+
+@dataclass(frozen=True)
+class ImportSource:
+ key: str
+ label: str
+
+
+SOURCES = [
+ ImportSource("filezilla", "FileZilla"),
+ ImportSource("winscp", "WinSCP"),
+ ImportSource("cyberduck", "Cyberduck"),
+ ImportSource("from_file", "From file..."),
+]
+
+
+def _winscp_registry_available() -> bool:
+ """Check whether WinSCP sessions exist in the Windows Registry."""
+ if sys.platform != "win32":
+ return False
+ try:
+ import winreg
+
+ key_path = r"Software\Martin Prikryl\WinSCP 2\Sessions"
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path):
+ return True
+ except Exception:
+ return False
+
+
+def is_source_available(source: str) -> bool:
+ """Return True if the given import source has detectable config on this machine."""
+ if source == "filezilla":
+ return filezilla.detect_path().exists()
+ if source == "winscp":
+ return _winscp_registry_available() or winscp.detect_ini_path().exists()
+ if source == "cyberduck":
+ return cyberduck.detect_bookmarks_dir().exists()
+ if source == "from_file":
+ return True
+ return False
+
+
+def available_sources() -> list[ImportSource]:
+ """Return only the sources that have detectable config on this machine."""
+ return [s for s in SOURCES if is_source_available(s.key)]
+
+
+def detect_default_path(source: str) -> Path | str | None:
+ """Return the default path (or sentinel) for the requested source client."""
+ if source == "filezilla":
+ return filezilla.detect_path()
+ if source == "winscp":
+ if _winscp_registry_available():
+ return WINSCP_REGISTRY_SENTINEL
+ ini_path = winscp.detect_ini_path()
+ return ini_path
+ if source == "cyberduck":
+ return cyberduck.detect_bookmarks_dir()
+ return None
+
+
+def load_from_source(source: str, path: Path | None = None) -> list[ImportedSite]:
+ """Load imported profiles for a specific source and path."""
+ if source == "filezilla":
+ if not path:
+ path = filezilla.detect_path()
+ return filezilla.parse_file(path)
+
+ if source == "winscp":
+ if path:
+ return winscp.parse_ini_file(path)
+
+ ini_path = winscp.detect_ini_path()
+ if ini_path.exists():
+ return winscp.parse_ini_file(ini_path)
+ return winscp.parse_registry_sessions()
+
+ if source == "cyberduck":
+ if not path:
+ path = cyberduck.detect_bookmarks_dir()
+ if path.is_dir():
+ return cyberduck.parse_bookmarks_dir(path)
+ return [cyberduck.parse_bookmark_file(path)]
+
+ if source == "from_file":
+ if not path:
+ raise ValueError("Path is required for 'from_file' import")
+ return _load_from_unknown_path(path)
+
+ raise ValueError(f"Unknown import source: {source}")
+
+
+def _load_from_unknown_path(path: Path) -> list[ImportedSite]:
+ if path.is_dir():
+ sites = cyberduck.parse_bookmarks_dir(path)
+ if sites:
+ return sites
+
+ suffix = path.suffix.lower()
+ if suffix == ".ini":
+ return winscp.parse_ini_file(path)
+ if suffix == ".duck":
+ return [cyberduck.parse_bookmark_file(path)]
+
+ parse_attempts = (
+ lambda: filezilla.parse_file(path),
+ lambda: winscp.parse_ini_file(path),
+ lambda: [cyberduck.parse_bookmark_file(path)],
+ )
+ for parse_fn in parse_attempts:
+ try:
+ sites = parse_fn()
+ except Exception:
+ continue
+ if sites:
+ return sites
+
+ return []
diff --git a/src/portkeydrop/importers/cyberduck.py b/src/portkeydrop/importers/cyberduck.py
new file mode 100644
index 0000000..3328326
--- /dev/null
+++ b/src/portkeydrop/importers/cyberduck.py
@@ -0,0 +1,80 @@
+"""Cyberduck / Mountain Duck bookmark importer."""
+
+from __future__ import annotations
+
+import os
+import plistlib
+from pathlib import Path
+
+from .models import ImportedSite
+
+
+_PROTOCOL_MAP = {
+ "ftp": "ftp",
+ "ftps": "ftps",
+ "sftp": "sftp",
+ "ssh": "sftp",
+}
+
+
+def detect_bookmarks_dir() -> Path:
+ """Return default Cyberduck bookmarks directory for current platform."""
+ appdata = os.environ.get("APPDATA", "")
+ if appdata:
+ return Path(appdata) / "Cyberduck" / "Bookmarks"
+
+ mac_dir = Path.home() / "Library" / "Application Support" / "Cyberduck" / "Bookmarks"
+ if mac_dir.exists():
+ return mac_dir
+
+ return Path.home() / ".config" / "Cyberduck" / "Bookmarks"
+
+
+def parse_bookmark_file(path: Path) -> ImportedSite:
+ """Parse a single `.duck` bookmark file."""
+ with path.open("rb") as handle:
+ data = plistlib.load(handle)
+
+ host = str(data.get("Hostname", data.get("Host", ""))).strip()
+ protocol = _map_protocol(str(data.get("Protocol", "sftp")).strip())
+
+ raw_port = data.get("Port", 0)
+ try:
+ port = int(raw_port)
+ except (TypeError, ValueError):
+ port = 0
+
+ username = str(data.get("Username", "")).strip()
+ initial_dir = str(data.get("Path", "/")).strip() or "/"
+ nickname = str(data.get("Nickname", "")).strip()
+
+ name = nickname or (f"{username}@{host}" if username else host)
+ return ImportedSite(
+ name=name,
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ initial_dir=initial_dir,
+ )
+
+
+def parse_bookmarks_dir(path: Path) -> list[ImportedSite]:
+ """Parse all Cyberduck bookmarks in a directory."""
+ sites: list[ImportedSite] = []
+ if not path.exists():
+ return sites
+
+ for bookmark_path in sorted(path.glob("*.duck")):
+ try:
+ site = parse_bookmark_file(bookmark_path)
+ except Exception:
+ continue
+ if site.host:
+ sites.append(site)
+
+ return sites
+
+
+def _map_protocol(protocol: str) -> str:
+ return _PROTOCOL_MAP.get(protocol.lower(), "sftp")
diff --git a/src/portkeydrop/importers/filezilla.py b/src/portkeydrop/importers/filezilla.py
new file mode 100644
index 0000000..1a5930f
--- /dev/null
+++ b/src/portkeydrop/importers/filezilla.py
@@ -0,0 +1,111 @@
+"""FileZilla Site Manager importer."""
+
+from __future__ import annotations
+
+import base64
+import os
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from .models import ImportedSite
+
+_PROTOCOL_MAP = {
+ "0": "ftp",
+ "1": "sftp",
+ "3": "ftps",
+ "4": "ftps",
+}
+
+
+def detect_path() -> Path:
+ """Return default FileZilla Site Manager path for current platform."""
+ appdata = os.environ.get("APPDATA", "")
+ if appdata:
+ return Path(appdata) / "FileZilla" / "sitemanager.xml"
+ return Path.home() / ".config" / "filezilla" / "sitemanager.xml"
+
+
+def parse_file(path: Path) -> list[ImportedSite]:
+ """Parse a FileZilla `sitemanager.xml` file."""
+ root = ET.parse(path).getroot()
+ return _parse_root(root)
+
+
+def _parse_root(root: ET.Element) -> list[ImportedSite]:
+ sites: list[ImportedSite] = []
+ for server in root.findall(".//Server"):
+ host = (server.findtext("Host") or "").strip()
+ if not host:
+ continue
+
+ raw_protocol = (server.findtext("Protocol") or "1").strip()
+ protocol = _PROTOCOL_MAP.get(raw_protocol, "sftp")
+
+ raw_port = (server.findtext("Port") or "").strip()
+ port = int(raw_port) if raw_port.isdigit() else 0
+
+ username = (server.findtext("User") or "").strip()
+ password = _decode_password(server)
+
+ raw_remote_dir = (server.findtext("RemoteDir") or "").strip()
+ initial_dir = _parse_remote_dir(raw_remote_dir)
+
+ name = (server.findtext("Name") or f"{username}@{host}" or host).strip() or host
+ notes = (server.findtext("Comments") or "").strip()
+
+ sites.append(
+ ImportedSite(
+ name=name,
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ password=password,
+ initial_dir=initial_dir,
+ notes=notes,
+ )
+ )
+ return sites
+
+
+def _decode_password(server: ET.Element) -> str:
+ raw_password = (server.findtext("Pass") or "").strip()
+ if not raw_password:
+ return ""
+
+ pass_element = server.find("Pass")
+ encoding = pass_element.get("encoding", "") if pass_element is not None else ""
+ if encoding.lower() == "base64":
+ try:
+ return base64.b64decode(raw_password).decode("utf-8")
+ except Exception:
+ return ""
+ return raw_password
+
+
+def _parse_remote_dir(raw_remote_dir: str) -> str:
+ if not raw_remote_dir:
+ return "/"
+
+ # FileZilla stores path segments in an integer-prefixed format:
+ # e.g. "1 0 4 home 4 user" => "/home/user".
+ tokens = raw_remote_dir.split()
+ if len(tokens) >= 2 and tokens[0].isdigit() and tokens[1].isdigit():
+ segments: list[str] = []
+ i = 2
+ while i < len(tokens):
+ if not tokens[i].isdigit():
+ break
+ length = int(tokens[i])
+ i += 1
+ if i >= len(tokens):
+ break
+ segment = tokens[i]
+ i += 1
+ segments.append(segment[:length])
+ if segments:
+ return "/" + "/".join(segment.strip("/") for segment in segments if segment)
+
+ if raw_remote_dir.startswith("/"):
+ return raw_remote_dir
+ return "/" + raw_remote_dir.lstrip("/")
diff --git a/src/portkeydrop/importers/models.py b/src/portkeydrop/importers/models.py
new file mode 100644
index 0000000..3471012
--- /dev/null
+++ b/src/portkeydrop/importers/models.py
@@ -0,0 +1,20 @@
+"""Shared models for connection profile importers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ImportedSite:
+ """Normalized imported site profile."""
+
+ name: str
+ protocol: str
+ host: str
+ port: int
+ username: str = ""
+ password: str = ""
+ key_path: str = ""
+ initial_dir: str = "/"
+ notes: str = ""
diff --git a/src/portkeydrop/importers/winscp.py b/src/portkeydrop/importers/winscp.py
new file mode 100644
index 0000000..391001c
--- /dev/null
+++ b/src/portkeydrop/importers/winscp.py
@@ -0,0 +1,158 @@
+"""WinSCP profile importer (INI + optional Windows Registry)."""
+
+from __future__ import annotations
+
+import configparser
+import os
+import sys
+from pathlib import Path
+from urllib.parse import unquote
+
+from .models import ImportedSite
+
+
+_NUMERIC_PROTOCOL_MAP = {
+ "0": "sftp",
+ "1": "scp",
+ "5": "ftp",
+ "6": "ftps",
+}
+
+
+def detect_ini_path() -> Path:
+ """Return likely WinSCP INI path."""
+ appdata = os.environ.get("APPDATA", "")
+ if appdata:
+ return Path(appdata) / "WinSCP.ini"
+ return Path.home() / "WinSCP.ini"
+
+
+def parse_ini_file(path: Path) -> list[ImportedSite]:
+ """Parse WinSCP exported INI file."""
+ parser = configparser.RawConfigParser(interpolation=None)
+ parser.optionxform = str
+ parser.read(path, encoding="utf-8")
+
+ sites: list[ImportedSite] = []
+ for section in parser.sections():
+ if not section.startswith("Sessions\\"):
+ continue
+
+ cfg = parser[section]
+ host = cfg.get("HostName", "").strip()
+ if not host:
+ continue
+
+ raw_port = cfg.get("PortNumber", "").strip()
+ port = int(raw_port) if raw_port.isdigit() else 0
+
+ protocol = _detect_protocol(cfg)
+ if protocol == "scp":
+ protocol = "sftp"
+
+ username = cfg.get("UserName", "").strip()
+ initial_dir = cfg.get("RemoteDirectory", "").strip() or "/"
+ key_path = cfg.get("PublicKeyFile", "").strip()
+ name = _decode_name(section.removeprefix("Sessions\\"))
+
+ sites.append(
+ ImportedSite(
+ name=name,
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ key_path=key_path,
+ initial_dir=initial_dir,
+ )
+ )
+ return sites
+
+
+def parse_registry_sessions() -> list[ImportedSite]:
+ """Parse WinSCP session data from Windows Registry if available."""
+ if sys.platform != "win32":
+ return []
+
+ try:
+ import winreg
+ except Exception:
+ return []
+
+ key_path = r"Software\Martin Prikryl\WinSCP 2\Sessions"
+ sites: list[ImportedSite] = []
+
+ try:
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as sessions_key:
+ index = 0
+ while True:
+ try:
+ session_name = winreg.EnumKey(sessions_key, index)
+ except OSError:
+ break
+ index += 1
+
+ with winreg.OpenKey(sessions_key, session_name) as session_key:
+ values = _read_reg_values(winreg, session_key)
+ host = values.get("HostName", "").strip()
+ if not host:
+ continue
+
+ raw_port = values.get("PortNumber", "").strip()
+ port = int(raw_port) if raw_port.isdigit() else 0
+
+ protocol = _detect_protocol(values)
+ if protocol == "scp":
+ protocol = "sftp"
+
+ username = values.get("UserName", "").strip()
+ initial_dir = values.get("RemoteDirectory", "").strip() or "/"
+ key_path_value = values.get("PublicKeyFile", "").strip()
+
+ sites.append(
+ ImportedSite(
+ name=_decode_name(session_name),
+ protocol=protocol,
+ host=host,
+ port=port,
+ username=username,
+ key_path=key_path_value,
+ initial_dir=initial_dir,
+ )
+ )
+ except OSError:
+ return []
+
+ return sites
+
+
+def _read_reg_values(winreg, key) -> dict[str, str]:
+ values: dict[str, str] = {}
+ idx = 0
+ while True:
+ try:
+ value_name, value, _ = winreg.EnumValue(key, idx)
+ except OSError:
+ break
+ idx += 1
+ values[value_name] = str(value)
+ return values
+
+
+def _detect_protocol(cfg: configparser.SectionProxy | dict[str, str]) -> str:
+ protocol_value = str(cfg.get("FSProtocol", "")).strip()
+ if protocol_value in _NUMERIC_PROTOCOL_MAP:
+ return _NUMERIC_PROTOCOL_MAP[protocol_value]
+
+ file_protocol = str(cfg.get("FileProtocol", "")).strip().lower()
+ if file_protocol in {"ftp", "ftps", "sftp", "scp"}:
+ return file_protocol
+
+ if str(cfg.get("Ftps", "")).strip() in {"1", "true", "True"}:
+ return "ftps"
+
+ return "sftp"
+
+
+def _decode_name(raw_name: str) -> str:
+ return unquote(raw_name).replace("%5C", "\\")
diff --git a/tests/_wx_stub.py b/tests/_wx_stub.py
index 69deb01..126275b 100644
--- a/tests/_wx_stub.py
+++ b/tests/_wx_stub.py
@@ -126,6 +126,7 @@ def Close() -> None:
fake_wx.YES_NO = 102
fake_wx.ICON_WARNING = 103
fake_wx.ICON_ERROR = 104
+ fake_wx.ICON_INFORMATION = 105
fake_wx.ID_EXIT = 200
fake_wx.ID_ABOUT = 201
diff --git a/tests/fixtures/importers/cyberduck_bookmark.duck b/tests/fixtures/importers/cyberduck_bookmark.duck
new file mode 100644
index 0000000..5a2f669
--- /dev/null
+++ b/tests/fixtures/importers/cyberduck_bookmark.duck
@@ -0,0 +1,18 @@
+
+
+
+
+ Hostname
+ sftp.example.com
+ Port
+ 22
+ Protocol
+ sftp
+ Username
+ alice
+ Nickname
+ My SFTP
+ Path
+ /uploads
+
+
diff --git a/tests/fixtures/importers/filezilla_sitemanager.xml b/tests/fixtures/importers/filezilla_sitemanager.xml
new file mode 100644
index 0000000..d76f0d0
--- /dev/null
+++ b/tests/fixtures/importers/filezilla_sitemanager.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ Prod SFTP
+ sftp.example.com
+ 2222
+ 1
+ alice
+ c2VjcmV0
+ 1 0 4 home 5 alice
+ Main production host
+
+
+ Legacy FTP
+ ftp.example.com
+ 21
+ 0
+ bob
+ plaintext
+ /incoming
+
+
+
diff --git a/tests/fixtures/importers/winscp_sessions.ini b/tests/fixtures/importers/winscp_sessions.ini
new file mode 100644
index 0000000..229d5fe
--- /dev/null
+++ b/tests/fixtures/importers/winscp_sessions.ini
@@ -0,0 +1,17 @@
+[Configuration\Interface]
+RandomValue=1
+
+[Sessions\Prod%20Server]
+HostName=sftp.example.com
+PortNumber=22
+FSProtocol=0
+UserName=alice
+RemoteDirectory=/home/alice
+PublicKeyFile=C:\keys\id_ed25519.ppk
+
+[Sessions\FTP%20Server]
+HostName=ftp.example.com
+PortNumber=21
+FileProtocol=ftp
+UserName=bob
+RemoteDirectory=/incoming
diff --git a/tests/test_app.py b/tests/test_app.py
index ca90bfb..478a317 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -260,6 +260,77 @@ def test_mkdir_remote_reports_error(app_module):
fake_wx.MessageBox.assert_called()
+def test_import_connections_adds_non_duplicates(app_module):
+ app, fake_wx = app_module
+ frame = _hydrate_frame(app_module)
+
+ existing = SimpleNamespace(host="dup.example.com", port=22, username="alice", protocol="sftp")
+ frame._site_manager = MagicMock(sites=[existing], add=MagicMock())
+
+ imported_site = SimpleNamespace(
+ name="New Site",
+ protocol="ftp",
+ host="new.example.com",
+ port=21,
+ username="bob",
+ password="pw",
+ key_path="",
+ initial_dir="/",
+ notes="",
+ )
+
+ dialog = MagicMock(
+ ShowModal=MagicMock(return_value=fake_wx.ID_OK),
+ selected_sites=[imported_site],
+ Destroy=MagicMock(),
+ )
+ fake_wx.MessageBox.reset_mock()
+
+ with patch.object(app, "ImportConnectionsDialog", return_value=dialog):
+ frame._on_import_connections(None)
+
+ frame._site_manager.add.assert_called_once()
+ fake_wx.MessageBox.assert_called_once()
+ message = fake_wx.MessageBox.call_args.args[0]
+ assert "Imported 1 connection" in message
+
+
+def test_import_connections_skips_duplicates(app_module):
+ app, fake_wx = app_module
+ frame = _hydrate_frame(app_module)
+
+ existing = SimpleNamespace(host="dup.example.com", port=22, username="alice", protocol="sftp")
+ frame._site_manager = MagicMock(sites=[existing], add=MagicMock())
+
+ duplicate = SimpleNamespace(
+ name="Duplicate Site",
+ protocol="sftp",
+ host="dup.example.com",
+ port=22,
+ username="alice",
+ password="pw",
+ key_path="",
+ initial_dir="/",
+ notes="",
+ )
+
+ dialog = MagicMock(
+ ShowModal=MagicMock(return_value=fake_wx.ID_OK),
+ selected_sites=[duplicate],
+ Destroy=MagicMock(),
+ )
+ fake_wx.MessageBox.reset_mock()
+
+ with patch.object(app, "ImportConnectionsDialog", return_value=dialog):
+ frame._on_import_connections(None)
+
+ frame._site_manager.add.assert_not_called()
+ fake_wx.MessageBox.assert_called_once()
+ message = fake_wx.MessageBox.call_args.args[0]
+ assert "Imported 0 connections" in message
+ assert "Skipped 1 duplicate" in message
+
+
def test_on_transfer_update_reports_latest_status(app_module):
import importlib
diff --git a/tests/test_import_connections_dialog.py b/tests/test_import_connections_dialog.py
new file mode 100644
index 0000000..e18c281
--- /dev/null
+++ b/tests/test_import_connections_dialog.py
@@ -0,0 +1,272 @@
+"""Tests for ImportConnectionsDialog (headless, wx-stubbed)."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+import types
+from unittest.mock import patch
+
+
+# ---------------------------------------------------------------------------
+# Minimal wx stubs for the ImportConnectionsDialog
+# ---------------------------------------------------------------------------
+
+
+class _Window:
+ def __init__(self, parent=None, **_kw):
+ self.parent = parent
+ self.children: list[_Window] = []
+ self._bound: list[tuple] = []
+ if parent is not None and hasattr(parent, "children"):
+ parent.children.append(self)
+
+ def Bind(self, event, handler):
+ self._bound.append((event, handler))
+
+ def Show(self, show=True) -> None:
+ self._shown = show
+
+ def SetFocus(self) -> None:
+ pass
+
+ def Enable(self, enable=True) -> None:
+ self._enabled = enable
+
+
+class _Dialog(_Window):
+ def __init__(self, parent=None, title: str = "", size=None, style: int = 0, **_kw):
+ super().__init__(parent)
+ self.title = title
+
+ def SetSizer(self, sizer) -> None:
+ pass
+
+ def Layout(self) -> None:
+ pass
+
+ def EndModal(self, result: int) -> None:
+ self._modal_result = result
+
+
+class _Panel(_Window):
+ def SetSizer(self, sizer) -> None:
+ pass
+
+
+class _StaticText(_Window):
+ def __init__(self, parent=None, label: str = "", **_kw):
+ super().__init__(parent)
+ self._label = label
+
+ def SetLabel(self, label: str) -> None:
+ self._label = label
+
+
+class _TextCtrl(_Window):
+ def __init__(self, parent=None, **_kw):
+ super().__init__(parent)
+ self._value = ""
+
+ def GetValue(self) -> str:
+ return self._value
+
+ def SetValue(self, value: str) -> None:
+ self._value = value
+
+
+class _RadioBox(_Window):
+ def __init__(self, parent=None, label: str = "", choices=None, **_kw):
+ super().__init__(parent)
+ self._choices = choices or []
+ self._selection = 0
+
+ def GetSelection(self) -> int:
+ return self._selection
+
+ def SetSelection(self, index: int) -> None:
+ self._selection = index
+
+
+class _CheckListBox(_Window):
+ def __init__(self, parent=None, **_kw):
+ super().__init__(parent)
+ self._items: list[str] = []
+ self._checked: dict[int, bool] = {}
+
+ def Clear(self) -> None:
+ self._items.clear()
+ self._checked.clear()
+
+ def Append(self, label: str) -> None:
+ self._items.append(label)
+
+ def GetCount(self) -> int:
+ return len(self._items)
+
+ def Check(self, index: int, check: bool = True) -> None:
+ self._checked[index] = check
+
+ def IsChecked(self, index: int) -> bool:
+ return self._checked.get(index, False)
+
+
+class _Button(_Window):
+ def __init__(self, parent=None, id: int = -1, label: str = "", **_kw):
+ super().__init__(parent)
+ self.label = label
+
+
+class _BoxSizer:
+ def __init__(self, orient=0):
+ pass
+
+ def Add(self, *args, **kwargs):
+ pass
+
+ def AddStretchSpacer(self, *args, **kwargs):
+ pass
+
+
+class _CommandEvent:
+ pass
+
+
+def _make_wx_module():
+ fake_wx = types.ModuleType("wx")
+ fake_wx.Dialog = _Dialog
+ fake_wx.Panel = _Panel
+ fake_wx.StaticText = _StaticText
+ fake_wx.TextCtrl = _TextCtrl
+ fake_wx.RadioBox = _RadioBox
+ fake_wx.CheckListBox = _CheckListBox
+ fake_wx.Button = _Button
+ fake_wx.BoxSizer = _BoxSizer
+ fake_wx.CommandEvent = _CommandEvent
+ fake_wx.VERTICAL = 0
+ fake_wx.HORIZONTAL = 1
+ fake_wx.DEFAULT_DIALOG_STYLE = 1
+ fake_wx.RESIZE_BORDER = 2
+ fake_wx.ALL = 0x10
+ fake_wx.LEFT = 0x20
+ fake_wx.RIGHT = 0x40
+ fake_wx.EXPAND = 0x80
+ fake_wx.RA_SPECIFY_ROWS = 0
+ fake_wx.ID_CANCEL = 5101
+ fake_wx.ID_OK = 5100
+ fake_wx.OK = 0x04
+ fake_wx.ICON_WARNING = 0x100
+ fake_wx.ICON_ERROR = 0x200
+ fake_wx.ICON_INFORMATION = 0x800
+ fake_wx.FD_OPEN = 0x01
+ fake_wx.FD_FILE_MUST_EXIST = 0x02
+ fake_wx.DD_DIR_MUST_EXIST = 0x04
+ fake_wx.EVT_RADIOBOX = object()
+ fake_wx.EVT_BUTTON = object()
+ fake_wx.MessageBox = lambda *a, **kw: None
+ return fake_wx
+
+
+def _load_dialog(monkeypatch):
+ """Import ImportConnectionsDialog with fake wx and return the class."""
+ fake_wx = _make_wx_module()
+ monkeypatch.setitem(sys.modules, "wx", fake_wx)
+
+ sys.modules.pop("portkeydrop.dialogs.import_connections", None)
+ mod = importlib.import_module("portkeydrop.dialogs.import_connections")
+ return mod.ImportConnectionsDialog
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestAutoDetectOnSourceChange:
+ """Auto-detect should fire automatically when the client selection changes."""
+
+ def test_source_change_triggers_autodetect(self, monkeypatch):
+ """Selecting a client from the dropdown should auto-detect and pre-fill the path."""
+ DialogCls = _load_dialog(monkeypatch)
+ dlg = DialogCls(None)
+
+ # Select WinSCP (index 1)
+ dlg.source_radio.SetSelection(1)
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value="/fake/WinSCP.ini",
+ ):
+ dlg._on_source_change(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == "/fake/WinSCP.ini"
+
+ def test_source_change_clears_path_when_no_default(self, monkeypatch):
+ """When no default is detected, the path should stay empty."""
+ DialogCls = _load_dialog(monkeypatch)
+ dlg = DialogCls(None)
+
+ dlg.source_radio.SetSelection(3) # "From file..." — no auto-detect
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value=None,
+ ):
+ dlg._on_source_change(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == ""
+
+ def test_autodetect_button_still_works_as_manual_retry(self, monkeypatch):
+ """The Auto-Detect button should work as a manual retry."""
+ DialogCls = _load_dialog(monkeypatch)
+ dlg = DialogCls(None)
+
+ dlg.source_radio.SetSelection(0) # FileZilla
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value="/fake/filezilla.xml",
+ ):
+ dlg._on_autodetect(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == "/fake/filezilla.xml"
+
+
+class TestWinSCPRegistrySentinel:
+ """The dialog should handle the WinSCP registry sentinel correctly."""
+
+ def test_source_change_to_winscp_shows_registry_sentinel(self, monkeypatch):
+ """When WinSCP is selected and registry is available, show registry sentinel."""
+ DialogCls = _load_dialog(monkeypatch)
+ from portkeydrop.importers import WINSCP_REGISTRY_SENTINEL
+
+ dlg = DialogCls(None)
+ dlg.source_radio.SetSelection(1) # WinSCP
+
+ with patch(
+ "portkeydrop.dialogs.import_connections.detect_default_path",
+ return_value=WINSCP_REGISTRY_SENTINEL,
+ ):
+ dlg._on_source_change(_CommandEvent())
+
+ assert dlg.path_text.GetValue() == WINSCP_REGISTRY_SENTINEL
+
+ def test_load_preview_with_registry_sentinel_passes_none_path(self, monkeypatch):
+ """When the path text contains the registry sentinel, load_from_source gets path=None."""
+ DialogCls = _load_dialog(monkeypatch)
+ from portkeydrop.importers import WINSCP_REGISTRY_SENTINEL
+ from portkeydrop.importers.models import ImportedSite
+
+ dlg = DialogCls(None)
+ dlg.source_radio.SetSelection(1) # WinSCP
+ dlg.path_text.SetValue(WINSCP_REGISTRY_SENTINEL)
+
+ fake_site = ImportedSite(name="test", protocol="sftp", host="example.com", port=22)
+ with patch(
+ "portkeydrop.dialogs.import_connections.load_from_source",
+ return_value=[fake_site],
+ ) as mock_load:
+ result = dlg._load_preview()
+
+ assert result is True
+ mock_load.assert_called_once_with("winscp", None)
diff --git a/tests/test_importers_cyberduck.py b/tests/test_importers_cyberduck.py
new file mode 100644
index 0000000..de5406e
--- /dev/null
+++ b/tests/test_importers_cyberduck.py
@@ -0,0 +1,118 @@
+from pathlib import Path
+
+from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+
+def test_parse_cyberduck_bookmark_fixture():
+ fixture = Path("tests/fixtures/importers/cyberduck_bookmark.duck")
+ site = parse_bookmark_file(fixture)
+
+ assert site.name == "My SFTP"
+ assert site.protocol == "sftp"
+ assert site.host == "sftp.example.com"
+ assert site.port == 22
+ assert site.username == "alice"
+ assert site.initial_dir == "/uploads"
+
+
+def test_detect_bookmarks_dir_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ from portkeydrop.importers.cyberduck import detect_bookmarks_dir
+
+ assert "Cyberduck" in str(detect_bookmarks_dir())
+
+
+def test_detect_bookmarks_dir_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ from portkeydrop.importers.cyberduck import detect_bookmarks_dir
+
+ assert "Cyberduck" in str(detect_bookmarks_dir())
+
+
+def test_parse_bookmark_ssh_protocol(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "ssh",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert parse_bookmark_file(duck).protocol == "sftp"
+
+
+def test_parse_bookmark_invalid_port(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": "bad",
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert parse_bookmark_file(duck).port == 0
+
+
+def test_parse_bookmark_no_nickname_user_at_host(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Username": "alice", "Path": "/"}
+ )
+ )
+ assert parse_bookmark_file(duck).name == "alice@h.com"
+
+
+def test_parse_bookmark_no_nickname_no_user(tmp_path):
+ import plistlib
+ from portkeydrop.importers.cyberduck import parse_bookmark_file
+
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps({"Hostname": "h.com", "Protocol": "sftp", "Port": 22, "Path": "/"})
+ )
+ assert parse_bookmark_file(duck).name == "h.com"
+
+
+def test_parse_bookmarks_dir_skips_bad_files(tmp_path):
+ from portkeydrop.importers.cyberduck import parse_bookmarks_dir
+
+ (tmp_path / "bad.duck").write_text("not a plist")
+ assert parse_bookmarks_dir(tmp_path) == []
+
+
+def test_parse_bookmarks_dir_nonexistent(tmp_path):
+ from portkeydrop.importers.cyberduck import parse_bookmarks_dir
+
+ assert parse_bookmarks_dir(tmp_path / "nonexistent") == []
+
+
+def test_map_protocol_unknown():
+ from portkeydrop.importers.cyberduck import _map_protocol
+
+ assert _map_protocol("unknown") == "sftp"
+
+
+def test_map_protocol_ftps():
+ from portkeydrop.importers.cyberduck import _map_protocol
+
+ assert _map_protocol("ftps") == "ftps"
diff --git a/tests/test_importers_filezilla.py b/tests/test_importers_filezilla.py
new file mode 100644
index 0000000..83c46b4
--- /dev/null
+++ b/tests/test_importers_filezilla.py
@@ -0,0 +1,117 @@
+from pathlib import Path
+
+from portkeydrop.importers.filezilla import parse_file
+
+
+def test_parse_filezilla_sites_fixture():
+ fixture = Path("tests/fixtures/importers/filezilla_sitemanager.xml")
+ sites = parse_file(fixture)
+
+ assert len(sites) == 2
+
+ first = sites[0]
+ assert first.name == "Prod SFTP"
+ assert first.protocol == "sftp"
+ assert first.host == "sftp.example.com"
+ assert first.port == 2222
+ assert first.username == "alice"
+ assert first.password == "secret"
+ assert first.initial_dir == "/home/alice"
+ assert first.notes == "Main production host"
+
+ second = sites[1]
+ assert second.protocol == "ftp"
+ assert second.password == "plaintext"
+ assert second.initial_dir == "/incoming"
+
+
+def test_detect_path_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ from portkeydrop.importers.filezilla import detect_path
+
+ assert "sitemanager.xml" in str(detect_path())
+
+
+def test_detect_path_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ from portkeydrop.importers.filezilla import detect_path
+
+ assert detect_path().name == "sitemanager.xml"
+
+
+def test_parse_file_skips_no_host(tmp_path):
+ from portkeydrop.importers.filezilla import parse_file
+
+ xml = '122uX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f) == []
+
+
+def test_parse_file_ftps_protocol(tmp_path):
+ from portkeydrop.importers.filezilla import parse_file
+
+ xml = 'h.com321uX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f)[0].protocol == "ftps"
+
+
+def test_parse_file_invalid_port(tmp_path):
+ from portkeydrop.importers.filezilla import parse_file
+
+ xml = 'h.com1notanumberuX'
+ f = tmp_path / "sm.xml"
+ f.write_text(xml)
+ assert parse_file(f)[0].port == 0
+
+
+def test_decode_password_base64_invalid():
+ import xml.etree.ElementTree as ET
+ from portkeydrop.importers.filezilla import _decode_password
+
+ elem = ET.fromstring('!!!notbase64!!!')
+ server = ET.Element("Server")
+ server.append(elem)
+ assert _decode_password(server) == ""
+
+
+def test_decode_password_plaintext():
+ import xml.etree.ElementTree as ET
+ from portkeydrop.importers.filezilla import _decode_password
+
+ elem = ET.fromstring("mypassword")
+ server = ET.Element("Server")
+ server.append(elem)
+ assert _decode_password(server) == "mypassword"
+
+
+def test_decode_password_empty():
+ import xml.etree.ElementTree as ET
+ from portkeydrop.importers.filezilla import _decode_password
+
+ assert _decode_password(ET.Element("Server")) == ""
+
+
+def test_parse_remote_dir_absolute():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("/home/user") == "/home/user"
+
+
+def test_parse_remote_dir_relative():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("uploads") == "/uploads"
+
+
+def test_parse_remote_dir_empty():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("") == "/"
+
+
+def test_parse_remote_dir_filezilla_format():
+ from portkeydrop.importers.filezilla import _parse_remote_dir
+
+ assert _parse_remote_dir("1 0 4 home 4 user") == "/home/user"
diff --git a/tests/test_importers_init.py b/tests/test_importers_init.py
new file mode 100644
index 0000000..2d0f7f7
--- /dev/null
+++ b/tests/test_importers_init.py
@@ -0,0 +1,277 @@
+"""Tests for portkeydrop.importers __init__ — source dispatch and path detection."""
+
+from __future__ import annotations
+
+import plistlib
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from portkeydrop.importers import (
+ _load_from_unknown_path,
+ _winscp_registry_available,
+ detect_default_path,
+ load_from_source,
+)
+
+
+def test_winscp_registry_available_non_windows():
+ with patch.object(sys, "platform", "linux"):
+ assert _winscp_registry_available() is False
+
+
+def test_detect_default_path_filezilla():
+ assert "sitemanager.xml" in str(detect_default_path("filezilla"))
+
+
+def test_detect_default_path_cyberduck():
+ result = str(detect_default_path("cyberduck"))
+ assert "Cyberduck" in result or "cyberduck" in result.lower()
+
+
+def test_detect_default_path_winscp_no_registry():
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ result = detect_default_path("winscp")
+ assert isinstance(result, Path)
+ assert result.name == "WinSCP.ini"
+
+
+def test_detect_default_path_unknown_returns_none():
+ assert detect_default_path("unknown_client") is None
+
+
+def test_load_from_source_filezilla(tmp_path):
+ xml = 'example.com122bobc2VjcmV0Test'
+ f = tmp_path / "sitemanager.xml"
+ f.write_text(xml)
+ sites = load_from_source("filezilla", f)
+ assert len(sites) == 1
+ assert sites[0].host == "example.com"
+
+
+def test_load_from_source_filezilla_auto_detect(tmp_path):
+ xml = 'auto.com122uAuto'
+ f = tmp_path / "sitemanager.xml"
+ f.write_text(xml)
+ with patch("portkeydrop.importers.filezilla.detect_path", return_value=f):
+ sites = load_from_source("filezilla", None)
+ assert len(sites) == 1
+
+
+def test_load_from_source_winscp_with_explicit_path(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text(
+ "[Sessions\\MyServer]\nHostName=sftp.example.com\nPortNumber=22\nUserName=alice\n"
+ )
+ sites = load_from_source("winscp", ini)
+ assert len(sites) == 1
+ assert sites[0].host == "sftp.example.com"
+
+
+def test_load_from_source_winscp_auto_detect_ini(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\Server1]\nHostName=host1.com\nPortNumber=22\nUserName=user\n")
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ sites = load_from_source("winscp", None)
+ assert any(s.host == "host1.com" for s in sites)
+
+
+def test_load_from_source_winscp_falls_back_to_registry(tmp_path):
+ ini = tmp_path / "WinSCP.ini" # does not exist
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ with patch(
+ "portkeydrop.importers.winscp.parse_registry_sessions", return_value=[]
+ ) as mock_reg:
+ load_from_source("winscp", None)
+ mock_reg.assert_called_once()
+
+
+def test_load_from_source_cyberduck_directory(tmp_path):
+ duck = tmp_path / "test.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "sftp.example.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "alice",
+ "Path": "/uploads",
+ "Nickname": "Test",
+ }
+ )
+ )
+ sites = load_from_source("cyberduck", tmp_path)
+ assert len(sites) == 1
+
+
+def test_load_from_source_cyberduck_single_file(tmp_path):
+ duck = tmp_path / "test.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "host.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ sites = load_from_source("cyberduck", duck)
+ assert len(sites) == 1
+
+
+def test_load_from_source_cyberduck_auto_detect(tmp_path):
+ duck = tmp_path / "b.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "cd.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "B",
+ }
+ )
+ )
+ with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
+ sites = load_from_source("cyberduck", None)
+ assert len(sites) >= 1
+
+
+def test_load_from_source_from_file_requires_path():
+ with pytest.raises(ValueError, match="Path is required"):
+ load_from_source("from_file", None)
+
+
+def test_load_from_source_unknown_source_raises():
+ with pytest.raises(ValueError, match="Unknown import source"):
+ load_from_source("bogus", None)
+
+
+def test_load_from_unknown_path_winscp_ini(tmp_path):
+ ini = tmp_path / "WinSCP.ini"
+ ini.write_text("[Sessions\\S]\nHostName=h.com\nPortNumber=22\nUserName=u\n")
+ sites = _load_from_unknown_path(ini)
+ assert len(sites) >= 1
+
+
+def test_load_from_unknown_path_duck_file(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert len(_load_from_unknown_path(duck)) == 1
+
+
+def test_load_from_unknown_path_xml_file(tmp_path):
+ xml = 'h.com122uX'
+ f = tmp_path / "sites.xml"
+ f.write_text(xml)
+ assert len(_load_from_unknown_path(f)) >= 1
+
+
+def test_load_from_unknown_path_directory_with_ducks(tmp_path):
+ duck = tmp_path / "x.duck"
+ duck.write_bytes(
+ plistlib.dumps(
+ {
+ "Hostname": "h.com",
+ "Protocol": "sftp",
+ "Port": 22,
+ "Username": "u",
+ "Path": "/",
+ "Nickname": "X",
+ }
+ )
+ )
+ assert len(_load_from_unknown_path(tmp_path)) == 1
+
+
+def test_load_from_unknown_path_empty_directory(tmp_path):
+ assert _load_from_unknown_path(tmp_path) == []
+
+
+def test_is_source_available_from_file_always_true():
+ from portkeydrop.importers import is_source_available
+
+ assert is_source_available("from_file") is True
+
+
+def test_is_source_available_unknown_returns_false():
+ from portkeydrop.importers import is_source_available
+
+ assert is_source_available("bogus") is False
+
+
+def test_is_source_available_filezilla_exists(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ f = tmp_path / "sitemanager.xml"
+ f.touch()
+ with patch("portkeydrop.importers.filezilla.detect_path", return_value=f):
+ assert is_source_available("filezilla") is True
+
+
+def test_is_source_available_filezilla_missing(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ with patch(
+ "portkeydrop.importers.filezilla.detect_path", return_value=tmp_path / "missing.xml"
+ ):
+ assert is_source_available("filezilla") is False
+
+
+def test_is_source_available_winscp_registry():
+ from portkeydrop.importers import is_source_available
+
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=True):
+ assert is_source_available("winscp") is True
+
+
+def test_is_source_available_winscp_ini(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ ini = tmp_path / "WinSCP.ini"
+ ini.touch()
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ with patch("portkeydrop.importers.winscp.detect_ini_path", return_value=ini):
+ assert is_source_available("winscp") is True
+
+
+def test_is_source_available_winscp_nothing(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ with patch(
+ "portkeydrop.importers.winscp.detect_ini_path", return_value=tmp_path / "missing.ini"
+ ):
+ assert is_source_available("winscp") is False
+
+
+def test_is_source_available_cyberduck_exists(tmp_path):
+ from portkeydrop.importers import is_source_available
+
+ with patch("portkeydrop.importers.cyberduck.detect_bookmarks_dir", return_value=tmp_path):
+ assert is_source_available("cyberduck") is True
+
+
+def test_available_sources_returns_from_file_always():
+ from portkeydrop.importers import available_sources
+
+ with patch("portkeydrop.importers.is_source_available", side_effect=lambda s: s == "from_file"):
+ sources = available_sources()
+ assert any(s.key == "from_file" for s in sources)
diff --git a/tests/test_importers_winscp.py b/tests/test_importers_winscp.py
new file mode 100644
index 0000000..3cdccfc
--- /dev/null
+++ b/tests/test_importers_winscp.py
@@ -0,0 +1,164 @@
+from pathlib import Path
+from unittest.mock import patch
+
+from portkeydrop.importers import (
+ WINSCP_REGISTRY_SENTINEL,
+ detect_default_path,
+ load_from_source,
+)
+from portkeydrop.importers.winscp import parse_ini_file
+
+
+def test_parse_winscp_ini_fixture():
+ fixture = Path("tests/fixtures/importers/winscp_sessions.ini")
+ sites = parse_ini_file(fixture)
+
+ assert len(sites) == 2
+
+ first = sites[0]
+ assert first.name == "Prod Server"
+ assert first.protocol == "sftp"
+ assert first.host == "sftp.example.com"
+ assert first.port == 22
+ assert first.username == "alice"
+ assert first.initial_dir == "/home/alice"
+ assert first.key_path == "C:\\keys\\id_ed25519.ppk"
+
+ second = sites[1]
+ assert second.name == "FTP Server"
+ assert second.protocol == "ftp"
+ assert second.host == "ftp.example.com"
+ assert second.port == 21
+
+
+def test_detect_default_path_returns_sentinel_when_registry_available():
+ """detect_default_path should return the registry sentinel when the registry is available."""
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=True):
+ result = detect_default_path("winscp")
+ assert result == WINSCP_REGISTRY_SENTINEL
+
+
+def test_detect_default_path_falls_back_to_ini_when_no_registry():
+ """detect_default_path should return the INI path when registry is not available."""
+ with patch("portkeydrop.importers._winscp_registry_available", return_value=False):
+ result = detect_default_path("winscp")
+ assert isinstance(result, Path)
+ assert result.name == "WinSCP.ini"
+
+
+def test_load_from_source_winscp_none_path_tries_registry():
+ """load_from_source with path=None should try INI then fall back to registry."""
+ with patch("portkeydrop.importers.winscp.detect_ini_path") as mock_ini:
+ mock_ini.return_value = Path("/nonexistent/WinSCP.ini")
+ with patch("portkeydrop.importers.winscp.parse_registry_sessions") as mock_reg:
+ mock_reg.return_value = []
+ load_from_source("winscp", None)
+ mock_reg.assert_called_once()
+
+
+def test_detect_ini_path_appdata(monkeypatch):
+ monkeypatch.setenv("APPDATA", "/fake/appdata")
+ from portkeydrop.importers.winscp import detect_ini_path
+
+ assert str(detect_ini_path()) == "/fake/appdata/WinSCP.ini"
+
+
+def test_detect_ini_path_no_appdata(monkeypatch):
+ monkeypatch.delenv("APPDATA", raising=False)
+ from portkeydrop.importers.winscp import detect_ini_path
+
+ assert detect_ini_path().name == "WinSCP.ini"
+
+
+def test_parse_ini_skips_non_session_sections(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text(
+ "[Configuration]\nKey=Value\n[Sessions\\MyHost]\nHostName=h.com\nPortNumber=22\nUserName=u\n"
+ )
+ assert len(parse_ini_file(ini)) == 1
+
+
+def test_parse_ini_skips_missing_hostname(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\NoHost]\nPortNumber=22\nUserName=u\n")
+ assert parse_ini_file(ini) == []
+
+
+def test_parse_ini_scp_mapped_to_sftp(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text(
+ "[Sessions\\H]\nHostName=scp.example.com\nPortNumber=22\nUserName=u\nFSProtocol=1\n"
+ )
+ assert parse_ini_file(ini)[0].protocol == "sftp"
+
+
+def test_parse_ini_invalid_port(tmp_path):
+ from portkeydrop.importers.winscp import parse_ini_file
+
+ ini = tmp_path / "w.ini"
+ ini.write_text("[Sessions\\H]\nHostName=h.com\nPortNumber=notanumber\nUserName=u\n")
+ assert parse_ini_file(ini)[0].port == 0
+
+
+def test_parse_registry_sessions_non_windows():
+ import sys
+ from unittest.mock import patch
+ from portkeydrop.importers.winscp import parse_registry_sessions
+
+ with patch.object(sys, "platform", "linux"):
+ assert parse_registry_sessions() == []
+
+
+def test_parse_registry_sessions_key_missing():
+ import sys
+ from unittest.mock import MagicMock, patch
+ from portkeydrop.importers.winscp import parse_registry_sessions
+
+ winreg_mock = MagicMock()
+ winreg_mock.OpenKey.side_effect = OSError
+ winreg_mock.HKEY_CURRENT_USER = 0
+ with patch.dict("sys.modules", {"winreg": winreg_mock}):
+ with patch.object(sys, "platform", "win32"):
+ assert parse_registry_sessions() == []
+
+
+def test_detect_protocol_ftps_flag():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"Ftps": "1", "FSProtocol": "", "FileProtocol": ""}) == "ftps"
+
+
+def test_detect_protocol_file_protocol_ftp():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"FileProtocol": "ftp", "FSProtocol": "", "Ftps": ""}) == "ftp"
+
+
+def test_detect_protocol_default_sftp():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"FSProtocol": "", "FileProtocol": "", "Ftps": ""}) == "sftp"
+
+
+def test_detect_protocol_numeric_ftp():
+ from portkeydrop.importers.winscp import _detect_protocol
+
+ assert _detect_protocol({"FSProtocol": "5", "FileProtocol": "", "Ftps": ""}) == "ftp"
+
+
+def test_decode_name_url_encoded():
+ from portkeydrop.importers.winscp import _decode_name
+
+ assert _decode_name("My%20Server") == "My Server"
+
+
+def test_decode_name_backslash():
+ from portkeydrop.importers.winscp import _decode_name
+
+ assert _decode_name("path%5Cto") == "path\\to"