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.
- nterm/examples/basic_terminal.py +415 -0
- nterm/manager/io.py +360 -47
- {ntermqt-0.1.9.dist-info → ntermqt-0.1.10.dist-info}/METADATA +1 -1
- {ntermqt-0.1.9.dist-info → ntermqt-0.1.10.dist-info}/RECORD +7 -6
- {ntermqt-0.1.9.dist-info → ntermqt-0.1.10.dist-info}/WHEEL +1 -1
- {ntermqt-0.1.9.dist-info → ntermqt-0.1.10.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.9.dist-info → ntermqt-0.1.10.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
"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
|
|
|
@@ -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=
|
|
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.
|
|
70
|
-
ntermqt-0.1.
|
|
71
|
-
ntermqt-0.1.
|
|
72
|
-
ntermqt-0.1.
|
|
73
|
-
ntermqt-0.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|