ntermqt 0.1.8__tar.gz → 0.1.11__tar.gz
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.
- {ntermqt-0.1.8/ntermqt.egg-info → ntermqt-0.1.11}/PKG-INFO +2 -2
- {ntermqt-0.1.8 → ntermqt-0.1.11}/README.md +1 -1
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/__main__.py +20 -4
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/io.py +360 -47
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/api.py +199 -35
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/platform_utils.py +269 -3
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/repl.py +230 -71
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/repl_interactive.py +27 -2
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/widget.py +2 -2
- {ntermqt-0.1.8 → ntermqt-0.1.11/ntermqt.egg-info}/PKG-INFO +2 -2
- {ntermqt-0.1.8 → ntermqt-0.1.11}/pyproject.toml +1 -1
- {ntermqt-0.1.8 → ntermqt-0.1.11}/MANIFEST.in +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/askpass/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/askpass/server.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/config.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/connection/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/connection/profile.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/connect_dialog.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/editor.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/models.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/settings.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/manager/tree.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/parser/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/parser/api_help_dialog.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/parser/ntc_download_dialog.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/parser/tfsm_engine.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/parser/tfsm_fire.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/parser/tfsm_fire_tester.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/resources.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/cli.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/models.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/platform_data.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/ssh_connection.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/scripting/test_api_repl.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/askpass_ssh.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/base.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/interactive_ssh.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/local_terminal.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/pty_transport.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/session/ssh.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/bridge.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/terminal.html +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/terminal.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/xterm.css +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/terminal/resources/xterm.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/engine.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/stylesheet.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/clean.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/default.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/dracula.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/enterprise_dark.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/enterprise_light.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/gruvbox_light.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/theme/themes/nord_hybrid.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/credential_manager.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/keychain.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/manager_ui.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/profile.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/resolver.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/nterm/vault/store.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/ntermqt.egg-info/SOURCES.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/ntermqt.egg-info/dependency_links.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/ntermqt.egg-info/entry_points.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/ntermqt.egg-info/requires.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/ntermqt.egg-info/top_level.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.11}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntermqt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.11
|
|
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
|
|
@@ -112,7 +112,7 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
112
112
|
|
|
113
113
|

|
|
114
114
|
|
|
115
|
-

|
|
116
116
|
|
|
117
117
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
118
118
|
|
|
@@ -67,7 +67,7 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
67
67
|
|
|
68
68
|

|
|
69
69
|
|
|
70
|
-

|
|
71
71
|
|
|
72
72
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
73
73
|
|
|
@@ -197,11 +197,28 @@ class TerminalTab(QWidget):
|
|
|
197
197
|
self.terminal = TerminalWidget()
|
|
198
198
|
layout.addWidget(self.terminal)
|
|
199
199
|
|
|
200
|
-
#
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
# Create initial session
|
|
201
|
+
self._create_and_attach_session()
|
|
202
|
+
|
|
203
|
+
# Handle reconnect by creating fresh session
|
|
204
|
+
self.terminal.reconnect_requested.connect(self._on_reconnect)
|
|
205
|
+
|
|
206
|
+
def _create_and_attach_session(self):
|
|
207
|
+
"""Create fresh SSHSession and attach to terminal."""
|
|
208
|
+
vault = self.credential_resolver if self.credential_resolver else None
|
|
209
|
+
self.ssh_session = SSHSession(self.profile, vault=vault)
|
|
203
210
|
self.terminal.attach_session(self.ssh_session)
|
|
204
211
|
|
|
212
|
+
def _on_reconnect(self):
|
|
213
|
+
"""Handle reconnect by building fresh session."""
|
|
214
|
+
print(f"Reconnecting: {self.session.name}")
|
|
215
|
+
try:
|
|
216
|
+
self.ssh_session.disconnect()
|
|
217
|
+
except Exception:
|
|
218
|
+
pass # Old session may already be dead
|
|
219
|
+
self._create_and_attach_session()
|
|
220
|
+
self.ssh_session.connect()
|
|
221
|
+
|
|
205
222
|
def connect(self):
|
|
206
223
|
"""Start the SSH connection."""
|
|
207
224
|
self.ssh_session.connect()
|
|
@@ -227,7 +244,6 @@ class TerminalTab(QWidget):
|
|
|
227
244
|
# Default to True if we can't determine - safer to warn
|
|
228
245
|
return True
|
|
229
246
|
|
|
230
|
-
|
|
231
247
|
class LocalTerminalTab(QWidget):
|
|
232
248
|
"""A terminal tab for local processes (shell, IPython, etc.)."""
|
|
233
249
|
|
|
@@ -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
|
+
"version": 1,<br>
|
|
526
|
+
"folders": [{"id": 1, "name": "Site A", ...}],<br>
|
|
527
|
+
"sessions": [<br>
|
|
528
|
+
{"name": "router-01", "hostname": "10.0.0.1", "port": 22, "folder_id": 1}<br>
|
|
529
|
+
]<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
|
|
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(
|
|
373
|
-
self.setMinimumHeight(
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|