ntermqt 0.1.9__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Example nterm application.
4
+
5
+ Demonstrates basic usage of the terminal widget with different session types:
6
+ - SSHSession: Paramiko-based (for password/key auth)
7
+ - AskpassSSHSession: Native SSH with GUI prompts (recommended for YubiKey)
8
+ - InteractiveSSHSession: Native SSH with PTY
9
+ """
10
+
11
+ import sys
12
+ import logging
13
+ from PyQt6.QtWidgets import (
14
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
15
+ QComboBox, QLabel, QPushButton, QStatusBar, QLineEdit, QSpinBox,
16
+ QGroupBox, QFormLayout, QMessageBox, QDialog, QDialogButtonBox,
17
+ QInputDialog
18
+ )
19
+ from PyQt6.QtCore import Qt, pyqtSignal, QObject
20
+
21
+ from nterm import (
22
+ ConnectionProfile, AuthConfig, AuthMethod, JumpHostConfig,
23
+ SSHSession, SessionState, TerminalWidget, Theme, ThemeEngine,
24
+ InteractiveSSHSession, is_pty_available
25
+ )
26
+ from nterm.session import AskpassSSHSession
27
+
28
+ logging.basicConfig(level=logging.DEBUG)
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class YubiKeyDialog(QDialog):
33
+ """Dialog shown when YubiKey touch is required."""
34
+
35
+ def __init__(self, prompt: str, parent=None):
36
+ super().__init__(parent)
37
+ self.setWindowTitle("YubiKey Authentication")
38
+ self.setModal(True)
39
+ self.setMinimumWidth(350)
40
+
41
+ layout = QVBoxLayout(self)
42
+
43
+ # Icon/visual indicator
44
+ icon_label = QLabel("🔑")
45
+ icon_label.setStyleSheet("font-size: 48px;")
46
+ icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
47
+ layout.addWidget(icon_label)
48
+
49
+ # Prompt
50
+ prompt_label = QLabel(prompt)
51
+ prompt_label.setWordWrap(True)
52
+ prompt_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
53
+ prompt_label.setStyleSheet("font-size: 14px; margin: 10px;")
54
+ layout.addWidget(prompt_label)
55
+
56
+ # Instructions
57
+ instructions = QLabel("Touch your YubiKey to authenticate...")
58
+ instructions.setAlignment(Qt.AlignmentFlag.AlignCenter)
59
+ instructions.setStyleSheet("color: gray;")
60
+ layout.addWidget(instructions)
61
+
62
+ # Cancel button
63
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
64
+ buttons.rejected.connect(self.reject)
65
+ layout.addWidget(buttons)
66
+
67
+
68
+ class NTermWindow(QMainWindow):
69
+ """Main application window."""
70
+
71
+ def __init__(self):
72
+ super().__init__()
73
+ self.setWindowTitle("nterm - SSH Terminal")
74
+ self.resize(1200, 800)
75
+
76
+ self._session = None
77
+ self._theme_engine = ThemeEngine()
78
+ self._yubikey_dialog = None
79
+
80
+ self._setup_ui()
81
+ self._apply_theme("default")
82
+
83
+ def _setup_ui(self):
84
+ """Set up the user interface."""
85
+ central = QWidget()
86
+ self.setCentralWidget(central)
87
+ layout = QVBoxLayout(central)
88
+ layout.setContentsMargins(0, 0, 0, 0)
89
+ layout.setSpacing(0)
90
+
91
+ # Toolbar
92
+ toolbar = self._create_toolbar()
93
+ layout.addWidget(toolbar)
94
+
95
+ # Terminal
96
+ self._terminal = TerminalWidget()
97
+ self._terminal.session_state_changed.connect(self._on_state_changed)
98
+ self._terminal.interaction_required.connect(self._on_interaction)
99
+ layout.addWidget(self._terminal, 1)
100
+
101
+ # Status bar
102
+ self._status = QStatusBar()
103
+ self.setStatusBar(self._status)
104
+ self._status.showMessage("Disconnected")
105
+
106
+ def _create_toolbar(self) -> QWidget:
107
+ """Create connection toolbar."""
108
+ toolbar = QWidget()
109
+ toolbar.setFixedHeight(100)
110
+ layout = QHBoxLayout(toolbar)
111
+ layout.setContentsMargins(8, 8, 8, 8)
112
+
113
+ # Connection group
114
+ conn_group = QGroupBox("Connection")
115
+ conn_layout = QFormLayout(conn_group)
116
+ conn_layout.setContentsMargins(8, 4, 8, 4)
117
+
118
+ self._host_input = QLineEdit()
119
+ self._host_input.setPlaceholderText("hostname or IP")
120
+ self._host_input.setText("localhost")
121
+ conn_layout.addRow("Host:", self._host_input)
122
+
123
+ port_layout = QHBoxLayout()
124
+ self._port_input = QSpinBox()
125
+ self._port_input.setRange(1, 65535)
126
+ self._port_input.setValue(22)
127
+ port_layout.addWidget(self._port_input)
128
+
129
+ self._user_input = QLineEdit()
130
+ self._user_input.setPlaceholderText("username")
131
+ port_layout.addWidget(QLabel("User:"))
132
+ port_layout.addWidget(self._user_input)
133
+ conn_layout.addRow("Port:", port_layout)
134
+
135
+ layout.addWidget(conn_group)
136
+
137
+ # Session type group
138
+ session_group = QGroupBox("Session Type")
139
+ session_layout = QVBoxLayout(session_group)
140
+ session_layout.setContentsMargins(8, 4, 8, 4)
141
+
142
+ self._session_combo = QComboBox()
143
+ self._session_combo.addItem("Askpass (YubiKey GUI)", "askpass")
144
+ self._session_combo.addItem("Interactive (PTY)", "interactive")
145
+ self._session_combo.addItem("Paramiko", "paramiko")
146
+ self._session_combo.currentIndexChanged.connect(self._on_session_type_changed)
147
+ session_layout.addWidget(self._session_combo)
148
+
149
+ # Status indicator
150
+ self._pty_label = QLabel("✓ GUI auth prompts" if is_pty_available() else "⚠ Limited")
151
+ self._pty_label.setStyleSheet("color: green;" if is_pty_available() else "color: orange;")
152
+ session_layout.addWidget(self._pty_label)
153
+
154
+ layout.addWidget(session_group)
155
+
156
+ # Auth group (for Paramiko mode)
157
+ self._auth_group = QGroupBox("Authentication")
158
+ auth_layout = QFormLayout(self._auth_group)
159
+ auth_layout.setContentsMargins(8, 4, 8, 4)
160
+
161
+ self._auth_combo = QComboBox()
162
+ self._auth_combo.addItems(["Agent", "Password", "Key File"])
163
+ auth_layout.addRow("Method:", self._auth_combo)
164
+
165
+ self._password_input = QLineEdit()
166
+ self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
167
+ self._password_input.setPlaceholderText("(for password auth)")
168
+ auth_layout.addRow("Password:", self._password_input)
169
+
170
+ self._auth_group.setVisible(False)
171
+ layout.addWidget(self._auth_group)
172
+
173
+ # Jump host group
174
+ jump_group = QGroupBox("Jump Host (Optional)")
175
+ jump_layout = QFormLayout(jump_group)
176
+ jump_layout.setContentsMargins(8, 4, 8, 4)
177
+
178
+ self._jump_host_input = QLineEdit()
179
+ self._jump_host_input.setPlaceholderText("bastion.example.com")
180
+ jump_layout.addRow("Host:", self._jump_host_input)
181
+
182
+ self._jump_user_input = QLineEdit()
183
+ self._jump_user_input.setPlaceholderText("(same as main if empty)")
184
+ jump_layout.addRow("User:", self._jump_user_input)
185
+
186
+ layout.addWidget(jump_group)
187
+
188
+ # Theme selector
189
+ theme_group = QGroupBox("Theme")
190
+ theme_layout = QVBoxLayout(theme_group)
191
+ theme_layout.setContentsMargins(8, 4, 8, 4)
192
+
193
+ self._theme_combo = QComboBox()
194
+ self._theme_combo.addItems(self._theme_engine.list_themes())
195
+ self._theme_combo.currentTextChanged.connect(self._apply_theme)
196
+ theme_layout.addWidget(self._theme_combo)
197
+
198
+ layout.addWidget(theme_group)
199
+
200
+ # Buttons
201
+ btn_layout = QVBoxLayout()
202
+
203
+ self._connect_btn = QPushButton("Connect")
204
+ self._connect_btn.clicked.connect(self._connect)
205
+ self._connect_btn.setDefault(True)
206
+ btn_layout.addWidget(self._connect_btn)
207
+
208
+ self._disconnect_btn = QPushButton("Disconnect")
209
+ self._disconnect_btn.clicked.connect(self._disconnect)
210
+ self._disconnect_btn.setEnabled(False)
211
+ btn_layout.addWidget(self._disconnect_btn)
212
+
213
+ layout.addLayout(btn_layout)
214
+ layout.addStretch()
215
+
216
+ return toolbar
217
+
218
+ def _on_session_type_changed(self, index: int):
219
+ """Handle session type change."""
220
+ session_type = self._session_combo.currentData()
221
+ self._auth_group.setVisible(session_type == "paramiko")
222
+
223
+ # Update status label
224
+ if session_type == "askpass":
225
+ self._pty_label.setText("✓ GUI auth prompts")
226
+ self._pty_label.setStyleSheet("color: green;")
227
+ elif session_type == "interactive":
228
+ self._pty_label.setText("⚠ Console prompts")
229
+ self._pty_label.setStyleSheet("color: orange;")
230
+ else:
231
+ self._pty_label.setText("✓ Programmatic auth")
232
+ self._pty_label.setStyleSheet("color: green;")
233
+
234
+ def _apply_theme(self, theme_name: str):
235
+ """Apply selected theme."""
236
+ theme = self._theme_engine.get_theme(theme_name)
237
+ if theme:
238
+ self._terminal.set_theme(theme)
239
+
240
+ def _connect(self):
241
+ """Establish connection."""
242
+ hostname = self._host_input.text().strip()
243
+ port = self._port_input.value()
244
+ username = self._user_input.text().strip()
245
+ session_type = self._session_combo.currentData()
246
+
247
+ if not hostname:
248
+ QMessageBox.warning(self, "Error", "Please enter a hostname")
249
+ return
250
+
251
+ if not username:
252
+ QMessageBox.warning(self, "Error", "Please enter a username")
253
+ return
254
+
255
+ # Build auth config
256
+ if session_type in ("askpass", "interactive"):
257
+ auth = AuthConfig.agent_auth(username)
258
+ else:
259
+ auth_method = self._auth_combo.currentText()
260
+ if auth_method == "Agent":
261
+ auth = AuthConfig.agent_auth(username)
262
+ elif auth_method == "Password":
263
+ password = self._password_input.text()
264
+ if not password:
265
+ QMessageBox.warning(self, "Error", "Please enter a password")
266
+ return
267
+ auth = AuthConfig.password_auth(username, password)
268
+ else:
269
+ auth = AuthConfig.agent_auth(username, allow_fallback=True)
270
+
271
+ # Build jump host config if specified
272
+ jump_hosts = []
273
+ jump_host = self._jump_host_input.text().strip()
274
+ if jump_host:
275
+ jump_user = self._jump_user_input.text().strip() or username
276
+ jump_hosts.append(JumpHostConfig(
277
+ hostname=jump_host,
278
+ auth=AuthConfig.agent_auth(jump_user),
279
+ ))
280
+
281
+ # Create profile
282
+ profile = ConnectionProfile(
283
+ name=f"{username}@{hostname}",
284
+ hostname=hostname,
285
+ port=port,
286
+ auth_methods=[auth],
287
+ jump_hosts=jump_hosts,
288
+ auto_reconnect=False, # Disable for testing
289
+ )
290
+
291
+ # Create appropriate session type
292
+ if session_type == "askpass":
293
+ if not is_pty_available():
294
+ QMessageBox.warning(self, "Error", "PTY support required")
295
+ return
296
+ self._session = AskpassSSHSession(profile)
297
+ elif session_type == "interactive":
298
+ if not is_pty_available():
299
+ QMessageBox.warning(self, "Error", "PTY support required")
300
+ return
301
+ self._session = InteractiveSSHSession(profile)
302
+ else:
303
+ self._session = SSHSession(profile)
304
+
305
+ self._terminal.attach_session(self._session)
306
+
307
+ # Connect
308
+ self._session.connect()
309
+ self._connect_btn.setEnabled(False)
310
+ self._disconnect_btn.setEnabled(True)
311
+
312
+ def _disconnect(self):
313
+ """Disconnect session."""
314
+ # Close any open dialogs
315
+ if self._yubikey_dialog:
316
+ self._yubikey_dialog.close()
317
+ self._yubikey_dialog = None
318
+
319
+ if self._session:
320
+ self._session.disconnect()
321
+ self._terminal.detach_session()
322
+ self._session = None
323
+
324
+ self._connect_btn.setEnabled(True)
325
+ self._disconnect_btn.setEnabled(False)
326
+
327
+ def _on_state_changed(self, state: SessionState, message: str):
328
+ """Handle session state changes."""
329
+ status_text = {
330
+ SessionState.DISCONNECTED: "Disconnected",
331
+ SessionState.CONNECTING: "Connecting...",
332
+ SessionState.AUTHENTICATING: "Authenticating...",
333
+ SessionState.CONNECTED: "Connected",
334
+ SessionState.RECONNECTING: f"Reconnecting: {message}",
335
+ SessionState.FAILED: f"Failed: {message}",
336
+ }.get(state, str(state))
337
+
338
+ self._status.showMessage(status_text)
339
+
340
+ # Close YubiKey dialog on connect/disconnect
341
+ if state in (SessionState.CONNECTED, SessionState.DISCONNECTED, SessionState.FAILED):
342
+ if self._yubikey_dialog:
343
+ self._yubikey_dialog.close()
344
+ self._yubikey_dialog = None
345
+
346
+ if state == SessionState.CONNECTED:
347
+ self._connect_btn.setEnabled(False)
348
+ self._disconnect_btn.setEnabled(True)
349
+ self._terminal.focus()
350
+ elif state in (SessionState.DISCONNECTED, SessionState.FAILED):
351
+ self._connect_btn.setEnabled(True)
352
+ self._disconnect_btn.setEnabled(False)
353
+
354
+ def _on_interaction(self, prompt: str, interaction_type: str):
355
+ """Handle SSH authentication prompts."""
356
+ logger.info(f"Interaction required: {interaction_type} - {prompt}")
357
+
358
+ if not isinstance(self._session, AskpassSSHSession):
359
+ return
360
+
361
+ if interaction_type == "yubikey_touch":
362
+ # Show YubiKey dialog
363
+ self._yubikey_dialog = YubiKeyDialog(prompt, self)
364
+ result = self._yubikey_dialog.exec()
365
+ self._yubikey_dialog = None
366
+
367
+ if result == QDialog.DialogCode.Rejected:
368
+ # User cancelled
369
+ self._session.provide_askpass_response(False, error="Cancelled by user")
370
+ else:
371
+ # YubiKey was touched (dialog closed by external event)
372
+ self._session.provide_askpass_response(True, value="")
373
+
374
+ elif interaction_type == "password":
375
+ # Show password dialog
376
+ password, ok = QInputDialog.getText(
377
+ self, "SSH Authentication", prompt,
378
+ QLineEdit.EchoMode.Password
379
+ )
380
+
381
+ if ok and password:
382
+ self._session.provide_askpass_response(True, value=password)
383
+ else:
384
+ self._session.provide_askpass_response(False, error="Cancelled by user")
385
+
386
+ else:
387
+ # Generic input
388
+ text, ok = QInputDialog.getText(
389
+ self, "SSH Authentication", prompt
390
+ )
391
+
392
+ if ok:
393
+ self._session.provide_askpass_response(True, value=text)
394
+ else:
395
+ self._session.provide_askpass_response(False, error="Cancelled by user")
396
+
397
+ def closeEvent(self, event):
398
+ """Handle window close."""
399
+ if self._session:
400
+ self._session.disconnect()
401
+ event.accept()
402
+
403
+
404
+ def main():
405
+ app = QApplication(sys.argv)
406
+ app.setApplicationName("nterm")
407
+
408
+ window = NTermWindow()
409
+ window.show()
410
+
411
+ sys.exit(app.exec())
412
+
413
+
414
+ if __name__ == "__main__":
415
+ main()
nterm/manager/io.py CHANGED
@@ -3,11 +3,14 @@ Session import/export functionality.
3
3
 
4
4
  Supports JSON format for portability.
5
5
  Also supports importing from TerminalTelemetry YAML format.
6
+ Also supports simple CSV import for quick session lists.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
10
+ import csv
9
11
  import json
10
12
  import yaml
13
+ from io import StringIO
11
14
  from pathlib import Path
12
15
  from datetime import datetime
13
16
  from typing import Optional
@@ -17,9 +20,10 @@ from PyQt6.QtWidgets import (
17
20
  QWidget, QFileDialog, QMessageBox, QDialog,
18
21
  QVBoxLayout, QHBoxLayout, QLabel, QCheckBox,
19
22
  QPushButton, QDialogButtonBox, QTreeWidget,
20
- QTreeWidgetItem, QGroupBox
23
+ QTreeWidgetItem, QGroupBox, QComboBox, QTextEdit
21
24
  )
22
25
  from PyQt6.QtCore import Qt
26
+ from PyQt6.QtGui import QFont
23
27
 
24
28
  from .models import SessionStore, SavedSession, SessionFolder
25
29
 
@@ -189,6 +193,133 @@ def import_sessions(
189
193
  return imported, skipped
190
194
 
191
195
 
196
+ def import_sessions_csv(
197
+ store: SessionStore,
198
+ path: Path,
199
+ merge: bool = True,
200
+ folder_name: Optional[str] = None
201
+ ) -> tuple[int, int, int]:
202
+ """
203
+ Import sessions from CSV file.
204
+
205
+ Supports flexible column names:
206
+ - name/display_name/hostname → session name
207
+ - hostname/host/ip/address → connection hostname
208
+ - port → port (default 22)
209
+ - description/desc → description
210
+ - folder/folder_name/group → folder assignment
211
+
212
+ Args:
213
+ store: Session store instance
214
+ path: Input CSV file path
215
+ merge: If True, merge with existing. If False, skip duplicates.
216
+ folder_name: Override folder for all imported sessions (optional)
217
+
218
+ Returns:
219
+ Tuple of (folders_created, sessions_imported, sessions_skipped)
220
+ """
221
+ with open(path, newline='', encoding='utf-8-sig') as f:
222
+ # Sniff dialect and read
223
+ sample = f.read(4096)
224
+ f.seek(0)
225
+
226
+ try:
227
+ dialect = csv.Sniffer().sniff(sample)
228
+ except csv.Error:
229
+ dialect = csv.excel # Default to standard CSV
230
+
231
+ reader = csv.DictReader(f, dialect=dialect)
232
+
233
+ # Normalize header names (lowercase, strip whitespace)
234
+ if reader.fieldnames:
235
+ reader.fieldnames = [h.lower().strip() for h in reader.fieldnames]
236
+
237
+ rows = list(reader)
238
+
239
+ if not rows:
240
+ return 0, 0, 0
241
+
242
+ # Column name mappings (first match wins)
243
+ name_cols = ['name', 'display_name', 'session_name', 'device_name', 'device']
244
+ host_cols = ['hostname', 'host', 'ip', 'ip_address', 'address', 'mgmt_ip']
245
+ port_cols = ['port', 'ssh_port']
246
+ desc_cols = ['description', 'desc', 'notes', 'comment']
247
+ folder_cols = ['folder', 'folder_name', 'group', 'site', 'location']
248
+
249
+ def find_col(row: dict, candidates: list[str]) -> Optional[str]:
250
+ """Find first matching column value."""
251
+ for col in candidates:
252
+ if col in row and row[col]:
253
+ return row[col].strip()
254
+ return None
255
+
256
+ # Track folders and sessions
257
+ folders_created = 0
258
+ sessions_imported = 0
259
+ sessions_skipped = 0
260
+
261
+ existing_sessions = {s.hostname: s for s in store.list_all_sessions()}
262
+ folder_cache: dict[str, int] = {} # folder_name -> folder_id
263
+
264
+ for row in rows:
265
+ # Extract fields with fallbacks
266
+ hostname = find_col(row, host_cols)
267
+ if not hostname:
268
+ continue # Skip rows without hostname
269
+
270
+ name = find_col(row, name_cols) or hostname
271
+ port_str = find_col(row, port_cols)
272
+ port = int(port_str) if port_str and port_str.isdigit() else 22
273
+ description = find_col(row, desc_cols) or ""
274
+
275
+ # Determine folder
276
+ row_folder = folder_name or find_col(row, folder_cols)
277
+ folder_id = None
278
+
279
+ if row_folder:
280
+ if row_folder in folder_cache:
281
+ folder_id = folder_cache[row_folder]
282
+ else:
283
+ # Check if folder exists
284
+ existing_folders = store.list_folders(None)
285
+ existing = next((f for f in existing_folders if f.name == row_folder), None)
286
+
287
+ if existing:
288
+ folder_id = existing.id
289
+ else:
290
+ folder_id = store.add_folder(row_folder)
291
+ folders_created += 1
292
+
293
+ folder_cache[row_folder] = folder_id
294
+
295
+ # Check for duplicate
296
+ if hostname in existing_sessions and not merge:
297
+ sessions_skipped += 1
298
+ continue
299
+
300
+ session = SavedSession(
301
+ name=name,
302
+ description=description,
303
+ hostname=hostname,
304
+ port=port,
305
+ credential_name=None,
306
+ folder_id=folder_id,
307
+ )
308
+
309
+ # Update or insert
310
+ if hostname in existing_sessions and merge:
311
+ existing = existing_sessions[hostname]
312
+ session.id = existing.id
313
+ store.update_session(session)
314
+ else:
315
+ store.add_session(session)
316
+ existing_sessions[hostname] = session
317
+
318
+ sessions_imported += 1
319
+
320
+ return folders_created, sessions_imported, sessions_skipped
321
+
322
+
192
323
  def import_terminal_telemetry(
193
324
  store: SessionStore,
194
325
  path: Path,
@@ -359,6 +490,49 @@ class ExportDialog(QDialog):
359
490
  )
360
491
 
361
492
 
493
+ # =============================================================================
494
+ # Format Help Text
495
+ # =============================================================================
496
+
497
+ CSV_HELP_TEXT = """\
498
+ <b>CSV Format</b><br><br>
499
+ Simple comma-separated format for quick imports from spreadsheets or other tools.<br><br>
500
+
501
+ <b>Supported Columns:</b>
502
+ <table cellspacing="4">
503
+ <tr><td><code>name</code></td><td>Session display name (falls back to hostname)</td></tr>
504
+ <tr><td><code>hostname</code></td><td><b>Required.</b> IP address or DNS name</td></tr>
505
+ <tr><td><code>port</code></td><td>SSH port (default: 22)</td></tr>
506
+ <tr><td><code>description</code></td><td>Optional notes</td></tr>
507
+ <tr><td><code>folder</code></td><td>Folder name (created if missing)</td></tr>
508
+ </table>
509
+ <br>
510
+ <b>Example:</b><br>
511
+ <code>name,hostname,port,folder<br>
512
+ core-rtr-01,10.0.0.1,22,Core<br>
513
+ core-rtr-02,10.0.0.2,22,Core<br>
514
+ edge-sw-01,10.1.0.1,22,Edge</code><br><br>
515
+
516
+ <i>Column names are flexible: "host", "ip", "address" also work for hostname.</i>
517
+ """
518
+
519
+ JSON_HELP_TEXT = """\
520
+ <b>JSON Format</b><br><br>
521
+ Native nterm export format. Preserves folders, hierarchy, and all session metadata.<br><br>
522
+
523
+ <b>Structure:</b><br>
524
+ <code>{<br>
525
+ &nbsp;&nbsp;"version": 1,<br>
526
+ &nbsp;&nbsp;"folders": [{"id": 1, "name": "Site A", ...}],<br>
527
+ &nbsp;&nbsp;"sessions": [<br>
528
+ &nbsp;&nbsp;&nbsp;&nbsp;{"name": "router-01", "hostname": "10.0.0.1", "port": 22, "folder_id": 1}<br>
529
+ &nbsp;&nbsp;]<br>
530
+ }</code><br><br>
531
+
532
+ <b>Tip:</b> Use <i>Export Sessions</i> to create a template, then edit and re-import.
533
+ """
534
+
535
+
362
536
  class ImportDialog(QDialog):
363
537
  """Dialog for import options and preview."""
364
538
 
@@ -366,14 +540,45 @@ class ImportDialog(QDialog):
366
540
  super().__init__(parent)
367
541
  self.store = store
368
542
  self._import_path: Optional[Path] = None
369
- self._import_data: Optional[dict] = None
543
+ self._import_data = None # Can be dict (JSON) or list of rows (CSV)
544
+ self._import_format: str = "json"
370
545
 
371
546
  self.setWindowTitle("Import Sessions")
372
- self.setMinimumWidth(500)
373
- self.setMinimumHeight(400)
547
+ self.setMinimumWidth(600)
548
+ self.setMinimumHeight(500)
374
549
 
375
550
  layout = QVBoxLayout(self)
376
551
 
552
+ # Format selection row
553
+ format_row = QHBoxLayout()
554
+ format_row.addWidget(QLabel("Format:"))
555
+
556
+ self._format_combo = QComboBox()
557
+ self._format_combo.addItem("JSON (nterm native)", "json")
558
+ self._format_combo.addItem("CSV (spreadsheet)", "csv")
559
+ self._format_combo.currentIndexChanged.connect(self._on_format_changed)
560
+ self._format_combo.setMinimumWidth(180)
561
+ format_row.addWidget(self._format_combo)
562
+
563
+ format_row.addStretch()
564
+
565
+ # Help toggle
566
+ self._help_btn = QPushButton("? Help")
567
+ self._help_btn.setCheckable(True)
568
+ self._help_btn.setMaximumWidth(80)
569
+ self._help_btn.toggled.connect(self._toggle_help)
570
+ format_row.addWidget(self._help_btn)
571
+
572
+ layout.addLayout(format_row)
573
+
574
+ # Help panel (hidden by default)
575
+ self._help_panel = QTextEdit()
576
+ self._help_panel.setReadOnly(True)
577
+ self._help_panel.setMaximumHeight(180)
578
+ self._help_panel.setHtml(JSON_HELP_TEXT)
579
+ self._help_panel.hide()
580
+ layout.addWidget(self._help_panel)
581
+
377
582
  # File selection
378
583
  file_row = QHBoxLayout()
379
584
  self._file_label = QLabel("No file selected")
@@ -392,6 +597,7 @@ class ImportDialog(QDialog):
392
597
  self._preview_tree = QTreeWidget()
393
598
  self._preview_tree.setHeaderLabels(["Name", "Host", "Port"])
394
599
  self._preview_tree.setRootIsDecorated(True)
600
+ self._preview_tree.setAlternatingRowColors(True)
395
601
  preview_layout.addWidget(self._preview_tree)
396
602
 
397
603
  layout.addWidget(preview_group)
@@ -421,13 +627,41 @@ class ImportDialog(QDialog):
421
627
  self._button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
422
628
  layout.addWidget(self._button_box)
423
629
 
630
+ def _on_format_changed(self, index: int) -> None:
631
+ """Handle format selection change."""
632
+ self._import_format = self._format_combo.currentData()
633
+
634
+ # Update help text
635
+ if self._import_format == "csv":
636
+ self._help_panel.setHtml(CSV_HELP_TEXT)
637
+ else:
638
+ self._help_panel.setHtml(JSON_HELP_TEXT)
639
+
640
+ # Clear preview if format changed after file loaded
641
+ if self._import_path:
642
+ self._preview_tree.clear()
643
+ self._import_path = None
644
+ self._import_data = None
645
+ self._file_label.setText("No file selected")
646
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
647
+
648
+ def _toggle_help(self, show: bool) -> None:
649
+ """Show/hide help panel."""
650
+ self._help_panel.setVisible(show)
651
+ self._help_btn.setText("▼ Help" if show else "? Help")
652
+
424
653
  def _browse_file(self) -> None:
425
654
  """Browse for import file."""
655
+ if self._import_format == "csv":
656
+ filter_str = "CSV Files (*.csv);;All Files (*)"
657
+ else:
658
+ filter_str = "JSON Files (*.json);;All Files (*)"
659
+
426
660
  path, _ = QFileDialog.getOpenFileName(
427
661
  self,
428
662
  "Import Sessions",
429
663
  "",
430
- "JSON Files (*.json);;All Files (*)"
664
+ filter_str
431
665
  )
432
666
 
433
667
  if path:
@@ -436,47 +670,19 @@ class ImportDialog(QDialog):
436
670
  def _load_preview(self, path: Path) -> None:
437
671
  """Load and preview import file."""
438
672
  try:
439
- with open(path) as f:
440
- data = json.load(f)
673
+ self._preview_tree.clear()
674
+
675
+ if self._import_format == "csv":
676
+ self._load_csv_preview(path)
677
+ else:
678
+ self._load_json_preview(path)
441
679
 
442
680
  self._import_path = path
443
- self._import_data = data
444
681
  self._file_label.setText(path.name)
445
682
 
446
- # Build preview tree
447
- self._preview_tree.clear()
448
-
449
- # Create folder items
450
- folder_items: dict[int, QTreeWidgetItem] = {}
451
- for folder_data in data.get("folders", []):
452
- item = QTreeWidgetItem()
453
- item.setText(0, f"📁 {folder_data['name']}")
454
- folder_items[folder_data["id"]] = item
455
-
456
- # Parent folders
457
- for folder_data in data.get("folders", []):
458
- item = folder_items[folder_data["id"]]
459
- parent_id = folder_data.get("parent_id")
460
- if parent_id and parent_id in folder_items:
461
- folder_items[parent_id].addChild(item)
462
- else:
463
- self._preview_tree.addTopLevelItem(item)
464
-
465
- # Add sessions
466
- for session_data in data.get("sessions", []):
467
- item = QTreeWidgetItem()
468
- item.setText(0, session_data.get("name", ""))
469
- item.setText(1, session_data.get("hostname", ""))
470
- item.setText(2, str(session_data.get("port", 22)))
471
-
472
- folder_id = session_data.get("folder_id")
473
- if folder_id and folder_id in folder_items:
474
- folder_items[folder_id].addChild(item)
475
- else:
476
- self._preview_tree.addTopLevelItem(item)
477
-
478
683
  self._preview_tree.expandAll()
479
- self._preview_tree.resizeColumnToContents(0)
684
+ for i in range(3):
685
+ self._preview_tree.resizeColumnToContents(i)
480
686
 
481
687
  # Enable import button
482
688
  self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True)
@@ -488,19 +694,126 @@ class ImportDialog(QDialog):
488
694
  f"Failed to load file:\n{e}"
489
695
  )
490
696
 
697
+ def _load_csv_preview(self, path: Path) -> None:
698
+ """Load CSV file and populate preview."""
699
+ with open(path, newline='', encoding='utf-8-sig') as f:
700
+ sample = f.read(4096)
701
+ f.seek(0)
702
+
703
+ try:
704
+ dialect = csv.Sniffer().sniff(sample)
705
+ except csv.Error:
706
+ dialect = csv.excel
707
+
708
+ reader = csv.DictReader(f, dialect=dialect)
709
+ if reader.fieldnames:
710
+ reader.fieldnames = [h.lower().strip() for h in reader.fieldnames]
711
+
712
+ rows = list(reader)
713
+
714
+ self._import_data = rows
715
+
716
+ # Column mappings
717
+ name_cols = ['name', 'display_name', 'session_name', 'device_name', 'device']
718
+ host_cols = ['hostname', 'host', 'ip', 'ip_address', 'address', 'mgmt_ip']
719
+ port_cols = ['port', 'ssh_port']
720
+ folder_cols = ['folder', 'folder_name', 'group', 'site', 'location']
721
+
722
+ def find_col(row: dict, candidates: list[str]) -> Optional[str]:
723
+ for col in candidates:
724
+ if col in row and row[col]:
725
+ return row[col].strip()
726
+ return None
727
+
728
+ # Group by folder for preview
729
+ folder_items: dict[str, QTreeWidgetItem] = {}
730
+ root_sessions: list[QTreeWidgetItem] = []
731
+
732
+ for row in rows:
733
+ hostname = find_col(row, host_cols)
734
+ if not hostname:
735
+ continue
736
+
737
+ name = find_col(row, name_cols) or hostname
738
+ port = find_col(row, port_cols) or "22"
739
+ folder = find_col(row, folder_cols)
740
+
741
+ item = QTreeWidgetItem()
742
+ item.setText(0, name)
743
+ item.setText(1, hostname)
744
+ item.setText(2, port)
745
+
746
+ if folder:
747
+ if folder not in folder_items:
748
+ folder_item = QTreeWidgetItem()
749
+ folder_item.setText(0, f"📁 {folder}")
750
+ self._preview_tree.addTopLevelItem(folder_item)
751
+ folder_items[folder] = folder_item
752
+ folder_items[folder].addChild(item)
753
+ else:
754
+ root_sessions.append(item)
755
+
756
+ # Add ungrouped sessions at root
757
+ for item in root_sessions:
758
+ self._preview_tree.addTopLevelItem(item)
759
+
760
+ def _load_json_preview(self, path: Path) -> None:
761
+ """Load JSON file and populate preview."""
762
+ with open(path) as f:
763
+ data = json.load(f)
764
+
765
+ self._import_data = data
766
+
767
+ # Create folder items
768
+ folder_items: dict[int, QTreeWidgetItem] = {}
769
+ for folder_data in data.get("folders", []):
770
+ item = QTreeWidgetItem()
771
+ item.setText(0, f"📁 {folder_data['name']}")
772
+ folder_items[folder_data["id"]] = item
773
+
774
+ # Parent folders
775
+ for folder_data in data.get("folders", []):
776
+ item = folder_items[folder_data["id"]]
777
+ parent_id = folder_data.get("parent_id")
778
+ if parent_id and parent_id in folder_items:
779
+ folder_items[parent_id].addChild(item)
780
+ else:
781
+ self._preview_tree.addTopLevelItem(item)
782
+
783
+ # Add sessions
784
+ for session_data in data.get("sessions", []):
785
+ item = QTreeWidgetItem()
786
+ item.setText(0, session_data.get("name", ""))
787
+ item.setText(1, session_data.get("hostname", ""))
788
+ item.setText(2, str(session_data.get("port", 22)))
789
+
790
+ folder_id = session_data.get("folder_id")
791
+ if folder_id and folder_id in folder_items:
792
+ folder_items[folder_id].addChild(item)
793
+ else:
794
+ self._preview_tree.addTopLevelItem(item)
795
+
491
796
  def _on_import(self) -> None:
492
797
  """Perform import."""
493
798
  if not self._import_path:
494
799
  return
495
800
 
496
801
  try:
497
- imported, skipped = import_sessions(
498
- self.store,
499
- self._import_path,
500
- merge=self._merge_check.isChecked()
501
- )
802
+ if self._import_format == "csv":
803
+ folders, imported, skipped = import_sessions_csv(
804
+ self.store,
805
+ self._import_path,
806
+ merge=self._merge_check.isChecked()
807
+ )
808
+ msg = f"Created {folders} folders.\nImported {imported} sessions."
809
+ else:
810
+ imported, skipped = import_sessions(
811
+ self.store,
812
+ self._import_path,
813
+ merge=self._merge_check.isChecked()
814
+ )
815
+ msg = f"Imported {imported} sessions."
502
816
 
503
- msg = f"Imported {imported} sessions."
504
817
  if skipped:
505
818
  msg += f"\nSkipped {skipped} duplicates."
506
819
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntermqt
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
5
5
  Author: Scott Peterman
6
6
  License: GPL-3.0
@@ -6,10 +6,11 @@ nterm/askpass/__init__.py,sha256=UpJBk0EOm0nkRwMVv7YdIB4v75ZJpSYmNsU_GlgzbUg,495
6
6
  nterm/askpass/server.py,sha256=5tvjYryyfu-n8Cw2KbucwaZfWiqYnFk-iBAVBI8FMfw,12873
7
7
  nterm/connection/__init__.py,sha256=2qQ9LGxUxmwem8deOD2WZVkeD6rIVlTlx5Zh2cUEmxY,261
8
8
  nterm/connection/profile.py,sha256=4RMgnRNKCc-dFGEIpmQc_bob5MtzxO04_PljP-qUGLs,9450
9
+ nterm/examples/basic_terminal.py,sha256=vbDI1xl-Radv6GYZ0yC6QUafQp_tSX2pWIf7tk58W8E,15256
9
10
  nterm/manager/__init__.py,sha256=_QIeTap5CTL3jdTS1Q16fAt-PrqcNPUVr9gtJ22f0ng,774
10
11
  nterm/manager/connect_dialog.py,sha256=yd8g_gYttT_UdflRxSfyss8OQTfrvKLUOMg4Kj8FPNo,11711
11
12
  nterm/manager/editor.py,sha256=Fn2YWHJ1EwPYrhKhsi4GTBYwRfCYsHsqgKkLY-LQ8JI,8469
12
- nterm/manager/io.py,sha256=R5ksWgpEz0VdVCokcgTN5G3PFgp5QYhjjt40OypSWkY,21687
13
+ nterm/manager/io.py,sha256=59ehTfnS1sAKEEEwMxujHuccV7rYSoRx4vfS1ExDqW4,32572
13
14
  nterm/manager/models.py,sha256=cvC2HzCRadNG1EYsnZN4C9YS6uolHGcUGGZtt-wzGF4,12237
14
15
  nterm/manager/settings.py,sha256=r6MTw_9r1Wl2UX_ALpXIuPbDvJ0D91Y8wRKq6Bfr_3g,9210
15
16
  nterm/manager/tree.py,sha256=I78wSjkSuyM6903II-XNyPug9saMSODUNBCHCDrq4ls,22397
@@ -66,8 +67,8 @@ nterm/vault/manager_ui.py,sha256=qle-W40j6L_pOR0AaOCeyU8myizFTRkISNrloCn0H_Y,345
66
67
  nterm/vault/profile.py,sha256=qM9TJf68RKdjtxo-sJehO7wS4iTi2G26BKbmlmHLA5M,6246
67
68
  nterm/vault/resolver.py,sha256=GWB2YR9H1MH98RGQBKvitIsjWT_-wSMLuddZNz4wbns,7800
68
69
  nterm/vault/store.py,sha256=_0Lfe0WKjm3uSAtxgn9qAPlpBOLCuq9SVgzqsE_qaGQ,21199
69
- ntermqt-0.1.9.dist-info/METADATA,sha256=ICYH7EjefVJLe_Hp-prpK8GPQE2TTqEaoX7-x2M0cOo,16040
70
- ntermqt-0.1.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
71
- ntermqt-0.1.9.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
72
- ntermqt-0.1.9.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
73
- ntermqt-0.1.9.dist-info/RECORD,,
70
+ ntermqt-0.1.10.dist-info/METADATA,sha256=Pa4Oytg8jq0W5pU0bmmJR9nryjKRwewy54qejJValjM,16041
71
+ ntermqt-0.1.10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
72
+ ntermqt-0.1.10.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
73
+ ntermqt-0.1.10.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
74
+ ntermqt-0.1.10.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5