ntermqt 0.1.0__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/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/manager/io.py
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session import/export functionality.
|
|
3
|
+
|
|
4
|
+
Supports JSON format for portability.
|
|
5
|
+
Also supports importing from TerminalTelemetry YAML format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import json
|
|
10
|
+
import yaml
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from dataclasses import asdict
|
|
15
|
+
|
|
16
|
+
from PyQt6.QtWidgets import (
|
|
17
|
+
QWidget, QFileDialog, QMessageBox, QDialog,
|
|
18
|
+
QVBoxLayout, QHBoxLayout, QLabel, QCheckBox,
|
|
19
|
+
QPushButton, QDialogButtonBox, QTreeWidget,
|
|
20
|
+
QTreeWidgetItem, QGroupBox
|
|
21
|
+
)
|
|
22
|
+
from PyQt6.QtCore import Qt
|
|
23
|
+
|
|
24
|
+
from .models import SessionStore, SavedSession, SessionFolder
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Export format version for future compatibility
|
|
28
|
+
EXPORT_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def export_sessions(
|
|
32
|
+
store: SessionStore,
|
|
33
|
+
path: Path,
|
|
34
|
+
include_stats: bool = False
|
|
35
|
+
) -> int:
|
|
36
|
+
"""
|
|
37
|
+
Export all sessions to JSON file.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
store: Session store instance
|
|
41
|
+
path: Output file path
|
|
42
|
+
include_stats: Include connect_count and last_connected
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Number of sessions exported
|
|
46
|
+
"""
|
|
47
|
+
tree_data = store.get_tree()
|
|
48
|
+
|
|
49
|
+
# Build export structure
|
|
50
|
+
export_data = {
|
|
51
|
+
"version": EXPORT_VERSION,
|
|
52
|
+
"exported_at": datetime.now().isoformat(),
|
|
53
|
+
"folders": [],
|
|
54
|
+
"sessions": [],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Export folders
|
|
58
|
+
for folder in tree_data["folders"]:
|
|
59
|
+
export_data["folders"].append({
|
|
60
|
+
"id": folder.id,
|
|
61
|
+
"name": folder.name,
|
|
62
|
+
"parent_id": folder.parent_id,
|
|
63
|
+
"position": folder.position,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Export sessions
|
|
67
|
+
for session in tree_data["sessions"]:
|
|
68
|
+
session_data = {
|
|
69
|
+
"name": session.name,
|
|
70
|
+
"description": session.description,
|
|
71
|
+
"hostname": session.hostname,
|
|
72
|
+
"port": session.port,
|
|
73
|
+
"credential_name": session.credential_name,
|
|
74
|
+
"folder_id": session.folder_id,
|
|
75
|
+
"position": session.position,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if session.extras:
|
|
79
|
+
session_data["extras"] = session.extras
|
|
80
|
+
|
|
81
|
+
if include_stats:
|
|
82
|
+
session_data["connect_count"] = session.connect_count
|
|
83
|
+
if session.last_connected:
|
|
84
|
+
session_data["last_connected"] = str(session.last_connected)
|
|
85
|
+
|
|
86
|
+
export_data["sessions"].append(session_data)
|
|
87
|
+
|
|
88
|
+
# Write file
|
|
89
|
+
with open(path, "w") as f:
|
|
90
|
+
json.dump(export_data, f, indent=2)
|
|
91
|
+
|
|
92
|
+
return len(export_data["sessions"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def import_sessions(
|
|
96
|
+
store: SessionStore,
|
|
97
|
+
path: Path,
|
|
98
|
+
merge: bool = True
|
|
99
|
+
) -> tuple[int, int]:
|
|
100
|
+
"""
|
|
101
|
+
Import sessions from JSON file.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
store: Session store instance
|
|
105
|
+
path: Input file path
|
|
106
|
+
merge: If True, merge with existing. If False, skip duplicates.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Tuple of (sessions_imported, sessions_skipped)
|
|
110
|
+
"""
|
|
111
|
+
with open(path) as f:
|
|
112
|
+
data = json.load(f)
|
|
113
|
+
|
|
114
|
+
version = data.get("version", 1)
|
|
115
|
+
|
|
116
|
+
# Build folder ID mapping (old ID -> new ID)
|
|
117
|
+
folder_map: dict[int, int] = {}
|
|
118
|
+
|
|
119
|
+
# Import folders first
|
|
120
|
+
if "folders" in data:
|
|
121
|
+
# Sort by parent to ensure parents are created first
|
|
122
|
+
folders = sorted(data["folders"], key=lambda f: (f.get("parent_id") or 0, f.get("position", 0)))
|
|
123
|
+
|
|
124
|
+
for folder_data in folders:
|
|
125
|
+
old_id = folder_data.get("id")
|
|
126
|
+
parent_id = folder_data.get("parent_id")
|
|
127
|
+
|
|
128
|
+
# Map parent ID if it was imported
|
|
129
|
+
if parent_id and parent_id in folder_map:
|
|
130
|
+
parent_id = folder_map[parent_id]
|
|
131
|
+
elif parent_id:
|
|
132
|
+
parent_id = None # Parent not found, put at root
|
|
133
|
+
|
|
134
|
+
# Check if folder with same name exists at same level
|
|
135
|
+
existing = store.list_folders(parent_id)
|
|
136
|
+
existing_folder = next(
|
|
137
|
+
(f for f in existing if f.name == folder_data["name"]),
|
|
138
|
+
None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if existing_folder:
|
|
142
|
+
folder_map[old_id] = existing_folder.id
|
|
143
|
+
else:
|
|
144
|
+
new_id = store.add_folder(folder_data["name"], parent_id)
|
|
145
|
+
folder_map[old_id] = new_id
|
|
146
|
+
|
|
147
|
+
# Import sessions
|
|
148
|
+
imported = 0
|
|
149
|
+
skipped = 0
|
|
150
|
+
|
|
151
|
+
existing_sessions = {s.hostname: s for s in store.list_all_sessions()}
|
|
152
|
+
|
|
153
|
+
for session_data in data.get("sessions", []):
|
|
154
|
+
hostname = session_data.get("hostname")
|
|
155
|
+
name = session_data.get("name")
|
|
156
|
+
|
|
157
|
+
# Check for duplicate by hostname
|
|
158
|
+
if hostname in existing_sessions and not merge:
|
|
159
|
+
skipped += 1
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# Map folder ID
|
|
163
|
+
folder_id = session_data.get("folder_id")
|
|
164
|
+
if folder_id and folder_id in folder_map:
|
|
165
|
+
folder_id = folder_map[folder_id]
|
|
166
|
+
else:
|
|
167
|
+
folder_id = None
|
|
168
|
+
|
|
169
|
+
session = SavedSession(
|
|
170
|
+
name=name or hostname,
|
|
171
|
+
description=session_data.get("description", ""),
|
|
172
|
+
hostname=hostname,
|
|
173
|
+
port=session_data.get("port", 22),
|
|
174
|
+
credential_name=session_data.get("credential_name"),
|
|
175
|
+
folder_id=folder_id,
|
|
176
|
+
extras=session_data.get("extras", {}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Check if we're updating existing
|
|
180
|
+
if hostname in existing_sessions and merge:
|
|
181
|
+
existing = existing_sessions[hostname]
|
|
182
|
+
session.id = existing.id
|
|
183
|
+
store.update_session(session)
|
|
184
|
+
else:
|
|
185
|
+
store.add_session(session)
|
|
186
|
+
|
|
187
|
+
imported += 1
|
|
188
|
+
|
|
189
|
+
return imported, skipped
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def import_terminal_telemetry(
|
|
193
|
+
store: SessionStore,
|
|
194
|
+
path: Path,
|
|
195
|
+
merge: bool = True
|
|
196
|
+
) -> tuple[int, int, int]:
|
|
197
|
+
"""
|
|
198
|
+
Import sessions from TerminalTelemetry YAML format.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
store: Session store instance
|
|
202
|
+
path: Path to TerminalTelemetry sessions.yaml
|
|
203
|
+
merge: If True, merge with existing. If False, skip duplicates.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Tuple of (folders_created, sessions_imported, sessions_skipped)
|
|
207
|
+
"""
|
|
208
|
+
with open(path) as f:
|
|
209
|
+
data = yaml.safe_load(f)
|
|
210
|
+
|
|
211
|
+
if not isinstance(data, list):
|
|
212
|
+
raise ValueError("Invalid TerminalTelemetry format: expected list of folders")
|
|
213
|
+
|
|
214
|
+
folders_created = 0
|
|
215
|
+
sessions_imported = 0
|
|
216
|
+
sessions_skipped = 0
|
|
217
|
+
|
|
218
|
+
existing_sessions = {s.hostname: s for s in store.list_all_sessions()}
|
|
219
|
+
|
|
220
|
+
for folder_entry in data:
|
|
221
|
+
folder_name = folder_entry.get("folder_name", "Imported")
|
|
222
|
+
sessions = folder_entry.get("sessions", [])
|
|
223
|
+
|
|
224
|
+
if not sessions:
|
|
225
|
+
continue # Skip empty folders
|
|
226
|
+
|
|
227
|
+
# Find or create folder
|
|
228
|
+
existing_folders = store.list_folders(None) # Root level
|
|
229
|
+
folder = next((f for f in existing_folders if f.name == folder_name), None)
|
|
230
|
+
|
|
231
|
+
if not folder:
|
|
232
|
+
folder_id = store.add_folder(folder_name)
|
|
233
|
+
folders_created += 1
|
|
234
|
+
else:
|
|
235
|
+
folder_id = folder.id
|
|
236
|
+
|
|
237
|
+
# Import sessions in this folder
|
|
238
|
+
for sess in sessions:
|
|
239
|
+
hostname = sess.get("host", "")
|
|
240
|
+
if not hostname:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# Check for duplicate
|
|
244
|
+
if hostname in existing_sessions and not merge:
|
|
245
|
+
sessions_skipped += 1
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Build description from DeviceType and Model
|
|
249
|
+
device_type = sess.get("DeviceType", "")
|
|
250
|
+
model = sess.get("Model", "")
|
|
251
|
+
vendor = sess.get("Vendor", "")
|
|
252
|
+
|
|
253
|
+
desc_parts = []
|
|
254
|
+
if device_type:
|
|
255
|
+
desc_parts.append(device_type)
|
|
256
|
+
if model:
|
|
257
|
+
desc_parts.append(model)
|
|
258
|
+
description = " - ".join(desc_parts) if desc_parts else ""
|
|
259
|
+
|
|
260
|
+
# Store extra metadata
|
|
261
|
+
extras = {}
|
|
262
|
+
if vendor:
|
|
263
|
+
extras["vendor"] = vendor
|
|
264
|
+
if device_type:
|
|
265
|
+
extras["device_type"] = device_type
|
|
266
|
+
if model:
|
|
267
|
+
extras["model"] = model
|
|
268
|
+
|
|
269
|
+
session = SavedSession(
|
|
270
|
+
name=sess.get("display_name", hostname),
|
|
271
|
+
description=description,
|
|
272
|
+
hostname=hostname,
|
|
273
|
+
port=int(sess.get("port", 22)),
|
|
274
|
+
credential_name=None, # Use agent auth by default
|
|
275
|
+
folder_id=folder_id,
|
|
276
|
+
extras=extras,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Check if updating existing
|
|
280
|
+
if hostname in existing_sessions and merge:
|
|
281
|
+
existing = existing_sessions[hostname]
|
|
282
|
+
session.id = existing.id
|
|
283
|
+
store.update_session(session)
|
|
284
|
+
else:
|
|
285
|
+
store.add_session(session)
|
|
286
|
+
existing_sessions[hostname] = session # Track for duplicates
|
|
287
|
+
|
|
288
|
+
sessions_imported += 1
|
|
289
|
+
|
|
290
|
+
return folders_created, sessions_imported, sessions_skipped
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class ExportDialog(QDialog):
|
|
294
|
+
"""Dialog for export options."""
|
|
295
|
+
|
|
296
|
+
def __init__(self, store: SessionStore, parent: QWidget = None):
|
|
297
|
+
super().__init__(parent)
|
|
298
|
+
self.store = store
|
|
299
|
+
|
|
300
|
+
self.setWindowTitle("Export Sessions")
|
|
301
|
+
self.setMinimumWidth(400)
|
|
302
|
+
|
|
303
|
+
layout = QVBoxLayout(self)
|
|
304
|
+
|
|
305
|
+
# Info
|
|
306
|
+
tree_data = store.get_tree()
|
|
307
|
+
count = len(tree_data["sessions"])
|
|
308
|
+
folder_count = len(tree_data["folders"])
|
|
309
|
+
|
|
310
|
+
info = QLabel(f"Export {count} sessions and {folder_count} folders to JSON file.")
|
|
311
|
+
layout.addWidget(info)
|
|
312
|
+
|
|
313
|
+
# Options
|
|
314
|
+
options_group = QGroupBox("Options")
|
|
315
|
+
options_layout = QVBoxLayout(options_group)
|
|
316
|
+
|
|
317
|
+
self._include_stats = QCheckBox("Include connection statistics")
|
|
318
|
+
self._include_stats.setToolTip("Export connect count and last connected timestamp")
|
|
319
|
+
options_layout.addWidget(self._include_stats)
|
|
320
|
+
|
|
321
|
+
layout.addWidget(options_group)
|
|
322
|
+
|
|
323
|
+
# Buttons
|
|
324
|
+
buttons = QDialogButtonBox(
|
|
325
|
+
QDialogButtonBox.StandardButton.Save |
|
|
326
|
+
QDialogButtonBox.StandardButton.Cancel
|
|
327
|
+
)
|
|
328
|
+
buttons.accepted.connect(self._on_save)
|
|
329
|
+
buttons.rejected.connect(self.reject)
|
|
330
|
+
layout.addWidget(buttons)
|
|
331
|
+
|
|
332
|
+
def _on_save(self) -> None:
|
|
333
|
+
"""Handle save button."""
|
|
334
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
335
|
+
self,
|
|
336
|
+
"Export Sessions",
|
|
337
|
+
"nterm_sessions.json",
|
|
338
|
+
"JSON Files (*.json)"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if path:
|
|
342
|
+
try:
|
|
343
|
+
count = export_sessions(
|
|
344
|
+
self.store,
|
|
345
|
+
Path(path),
|
|
346
|
+
include_stats=self._include_stats.isChecked()
|
|
347
|
+
)
|
|
348
|
+
QMessageBox.information(
|
|
349
|
+
self,
|
|
350
|
+
"Export Complete",
|
|
351
|
+
f"Exported {count} sessions to:\n{path}"
|
|
352
|
+
)
|
|
353
|
+
self.accept()
|
|
354
|
+
except Exception as e:
|
|
355
|
+
QMessageBox.critical(
|
|
356
|
+
self,
|
|
357
|
+
"Export Failed",
|
|
358
|
+
f"Failed to export sessions:\n{e}"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class ImportDialog(QDialog):
|
|
363
|
+
"""Dialog for import options and preview."""
|
|
364
|
+
|
|
365
|
+
def __init__(self, store: SessionStore, parent: QWidget = None):
|
|
366
|
+
super().__init__(parent)
|
|
367
|
+
self.store = store
|
|
368
|
+
self._import_path: Optional[Path] = None
|
|
369
|
+
self._import_data: Optional[dict] = None
|
|
370
|
+
|
|
371
|
+
self.setWindowTitle("Import Sessions")
|
|
372
|
+
self.setMinimumWidth(500)
|
|
373
|
+
self.setMinimumHeight(400)
|
|
374
|
+
|
|
375
|
+
layout = QVBoxLayout(self)
|
|
376
|
+
|
|
377
|
+
# File selection
|
|
378
|
+
file_row = QHBoxLayout()
|
|
379
|
+
self._file_label = QLabel("No file selected")
|
|
380
|
+
file_row.addWidget(self._file_label, 1)
|
|
381
|
+
|
|
382
|
+
browse_btn = QPushButton("Browse...")
|
|
383
|
+
browse_btn.clicked.connect(self._browse_file)
|
|
384
|
+
file_row.addWidget(browse_btn)
|
|
385
|
+
|
|
386
|
+
layout.addLayout(file_row)
|
|
387
|
+
|
|
388
|
+
# Preview tree
|
|
389
|
+
preview_group = QGroupBox("Preview")
|
|
390
|
+
preview_layout = QVBoxLayout(preview_group)
|
|
391
|
+
|
|
392
|
+
self._preview_tree = QTreeWidget()
|
|
393
|
+
self._preview_tree.setHeaderLabels(["Name", "Host", "Port"])
|
|
394
|
+
self._preview_tree.setRootIsDecorated(True)
|
|
395
|
+
preview_layout.addWidget(self._preview_tree)
|
|
396
|
+
|
|
397
|
+
layout.addWidget(preview_group)
|
|
398
|
+
|
|
399
|
+
# Options
|
|
400
|
+
options_group = QGroupBox("Import Options")
|
|
401
|
+
options_layout = QVBoxLayout(options_group)
|
|
402
|
+
|
|
403
|
+
self._merge_check = QCheckBox("Merge with existing (update duplicates)")
|
|
404
|
+
self._merge_check.setChecked(True)
|
|
405
|
+
self._merge_check.setToolTip(
|
|
406
|
+
"If checked, sessions with matching hostnames will be updated.\n"
|
|
407
|
+
"If unchecked, duplicates will be skipped."
|
|
408
|
+
)
|
|
409
|
+
options_layout.addWidget(self._merge_check)
|
|
410
|
+
|
|
411
|
+
layout.addWidget(options_group)
|
|
412
|
+
|
|
413
|
+
# Buttons
|
|
414
|
+
self._button_box = QDialogButtonBox(
|
|
415
|
+
QDialogButtonBox.StandardButton.Ok |
|
|
416
|
+
QDialogButtonBox.StandardButton.Cancel
|
|
417
|
+
)
|
|
418
|
+
self._button_box.accepted.connect(self._on_import)
|
|
419
|
+
self._button_box.rejected.connect(self.reject)
|
|
420
|
+
self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
|
|
421
|
+
self._button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
|
|
422
|
+
layout.addWidget(self._button_box)
|
|
423
|
+
|
|
424
|
+
def _browse_file(self) -> None:
|
|
425
|
+
"""Browse for import file."""
|
|
426
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
427
|
+
self,
|
|
428
|
+
"Import Sessions",
|
|
429
|
+
"",
|
|
430
|
+
"JSON Files (*.json);;All Files (*)"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if path:
|
|
434
|
+
self._load_preview(Path(path))
|
|
435
|
+
|
|
436
|
+
def _load_preview(self, path: Path) -> None:
|
|
437
|
+
"""Load and preview import file."""
|
|
438
|
+
try:
|
|
439
|
+
with open(path) as f:
|
|
440
|
+
data = json.load(f)
|
|
441
|
+
|
|
442
|
+
self._import_path = path
|
|
443
|
+
self._import_data = data
|
|
444
|
+
self._file_label.setText(path.name)
|
|
445
|
+
|
|
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
|
+
self._preview_tree.expandAll()
|
|
479
|
+
self._preview_tree.resizeColumnToContents(0)
|
|
480
|
+
|
|
481
|
+
# Enable import button
|
|
482
|
+
self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True)
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
QMessageBox.critical(
|
|
486
|
+
self,
|
|
487
|
+
"Load Failed",
|
|
488
|
+
f"Failed to load file:\n{e}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def _on_import(self) -> None:
|
|
492
|
+
"""Perform import."""
|
|
493
|
+
if not self._import_path:
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
imported, skipped = import_sessions(
|
|
498
|
+
self.store,
|
|
499
|
+
self._import_path,
|
|
500
|
+
merge=self._merge_check.isChecked()
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
msg = f"Imported {imported} sessions."
|
|
504
|
+
if skipped:
|
|
505
|
+
msg += f"\nSkipped {skipped} duplicates."
|
|
506
|
+
|
|
507
|
+
QMessageBox.information(self, "Import Complete", msg)
|
|
508
|
+
self.accept()
|
|
509
|
+
|
|
510
|
+
except Exception as e:
|
|
511
|
+
QMessageBox.critical(
|
|
512
|
+
self,
|
|
513
|
+
"Import Failed",
|
|
514
|
+
f"Failed to import sessions:\n{e}"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class ImportTerminalTelemetryDialog(QDialog):
|
|
519
|
+
"""Dialog for importing TerminalTelemetry sessions.yaml."""
|
|
520
|
+
|
|
521
|
+
def __init__(self, store: SessionStore, parent: QWidget = None):
|
|
522
|
+
super().__init__(parent)
|
|
523
|
+
self.store = store
|
|
524
|
+
self._import_path: Optional[Path] = None
|
|
525
|
+
self._import_data: Optional[list] = None
|
|
526
|
+
|
|
527
|
+
self.setWindowTitle("Import from TerminalTelemetry")
|
|
528
|
+
self.setMinimumWidth(500)
|
|
529
|
+
self.setMinimumHeight(400)
|
|
530
|
+
|
|
531
|
+
layout = QVBoxLayout(self)
|
|
532
|
+
|
|
533
|
+
# Info
|
|
534
|
+
info = QLabel(
|
|
535
|
+
"Import sessions from TerminalTelemetry sessions.yaml file.\n"
|
|
536
|
+
"Folders and sessions will be created automatically."
|
|
537
|
+
)
|
|
538
|
+
layout.addWidget(info)
|
|
539
|
+
|
|
540
|
+
# File selection
|
|
541
|
+
file_row = QHBoxLayout()
|
|
542
|
+
self._file_label = QLabel("No file selected")
|
|
543
|
+
file_row.addWidget(self._file_label, 1)
|
|
544
|
+
|
|
545
|
+
browse_btn = QPushButton("Browse...")
|
|
546
|
+
browse_btn.clicked.connect(self._browse_file)
|
|
547
|
+
file_row.addWidget(browse_btn)
|
|
548
|
+
|
|
549
|
+
layout.addLayout(file_row)
|
|
550
|
+
|
|
551
|
+
# Preview tree
|
|
552
|
+
preview_group = QGroupBox("Preview")
|
|
553
|
+
preview_layout = QVBoxLayout(preview_group)
|
|
554
|
+
|
|
555
|
+
self._preview_tree = QTreeWidget()
|
|
556
|
+
self._preview_tree.setHeaderLabels(["Name", "Host", "Description"])
|
|
557
|
+
self._preview_tree.setRootIsDecorated(True)
|
|
558
|
+
preview_layout.addWidget(self._preview_tree)
|
|
559
|
+
|
|
560
|
+
layout.addWidget(preview_group)
|
|
561
|
+
|
|
562
|
+
# Options
|
|
563
|
+
options_group = QGroupBox("Import Options")
|
|
564
|
+
options_layout = QVBoxLayout(options_group)
|
|
565
|
+
|
|
566
|
+
self._merge_check = QCheckBox("Merge with existing (update duplicates)")
|
|
567
|
+
self._merge_check.setChecked(True)
|
|
568
|
+
options_layout.addWidget(self._merge_check)
|
|
569
|
+
|
|
570
|
+
layout.addWidget(options_group)
|
|
571
|
+
|
|
572
|
+
# Buttons
|
|
573
|
+
self._button_box = QDialogButtonBox(
|
|
574
|
+
QDialogButtonBox.StandardButton.Ok |
|
|
575
|
+
QDialogButtonBox.StandardButton.Cancel
|
|
576
|
+
)
|
|
577
|
+
self._button_box.accepted.connect(self._on_import)
|
|
578
|
+
self._button_box.rejected.connect(self.reject)
|
|
579
|
+
self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
|
|
580
|
+
self._button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
|
|
581
|
+
layout.addWidget(self._button_box)
|
|
582
|
+
|
|
583
|
+
def _browse_file(self) -> None:
|
|
584
|
+
"""Browse for sessions.yaml file."""
|
|
585
|
+
# Default to common TerminalTelemetry location
|
|
586
|
+
default_path = Path.home() / ".terminaltelemetry" / "sessions.yaml"
|
|
587
|
+
start_dir = str(default_path.parent) if default_path.parent.exists() else ""
|
|
588
|
+
|
|
589
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
590
|
+
self,
|
|
591
|
+
"Select TerminalTelemetry sessions.yaml",
|
|
592
|
+
start_dir,
|
|
593
|
+
"YAML Files (*.yaml *.yml);;All Files (*)"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if path:
|
|
597
|
+
self._load_preview(Path(path))
|
|
598
|
+
|
|
599
|
+
def _load_preview(self, path: Path) -> None:
|
|
600
|
+
"""Load and preview the YAML file."""
|
|
601
|
+
try:
|
|
602
|
+
with open(path) as f:
|
|
603
|
+
data = yaml.safe_load(f)
|
|
604
|
+
|
|
605
|
+
if not isinstance(data, list):
|
|
606
|
+
raise ValueError("Invalid format: expected list of folders")
|
|
607
|
+
|
|
608
|
+
self._import_path = path
|
|
609
|
+
self._import_data = data
|
|
610
|
+
self._file_label.setText(path.name)
|
|
611
|
+
|
|
612
|
+
# Build preview tree
|
|
613
|
+
self._preview_tree.clear()
|
|
614
|
+
|
|
615
|
+
for folder_entry in data:
|
|
616
|
+
folder_name = folder_entry.get("folder_name", "Unknown")
|
|
617
|
+
sessions = folder_entry.get("sessions", [])
|
|
618
|
+
|
|
619
|
+
# Create folder item
|
|
620
|
+
folder_item = QTreeWidgetItem()
|
|
621
|
+
folder_item.setText(0, f"📁 {folder_name}")
|
|
622
|
+
folder_item.setText(1, "")
|
|
623
|
+
folder_item.setText(2, f"{len(sessions)} sessions")
|
|
624
|
+
self._preview_tree.addTopLevelItem(folder_item)
|
|
625
|
+
|
|
626
|
+
# Add sessions
|
|
627
|
+
for sess in sessions:
|
|
628
|
+
item = QTreeWidgetItem()
|
|
629
|
+
item.setText(0, sess.get("display_name", ""))
|
|
630
|
+
item.setText(1, sess.get("host", ""))
|
|
631
|
+
|
|
632
|
+
# Build description preview
|
|
633
|
+
device_type = sess.get("DeviceType", "")
|
|
634
|
+
model = sess.get("Model", "")
|
|
635
|
+
desc = f"{device_type} - {model}" if model else device_type
|
|
636
|
+
item.setText(2, desc)
|
|
637
|
+
|
|
638
|
+
folder_item.addChild(item)
|
|
639
|
+
|
|
640
|
+
self._preview_tree.expandAll()
|
|
641
|
+
for i in range(3):
|
|
642
|
+
self._preview_tree.resizeColumnToContents(i)
|
|
643
|
+
|
|
644
|
+
# Enable import button
|
|
645
|
+
self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True)
|
|
646
|
+
|
|
647
|
+
except Exception as e:
|
|
648
|
+
QMessageBox.critical(
|
|
649
|
+
self,
|
|
650
|
+
"Load Failed",
|
|
651
|
+
f"Failed to load file:\n{e}"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _on_import(self) -> None:
|
|
655
|
+
"""Perform import."""
|
|
656
|
+
if not self._import_path:
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
folders, imported, skipped = import_terminal_telemetry(
|
|
661
|
+
self.store,
|
|
662
|
+
self._import_path,
|
|
663
|
+
merge=self._merge_check.isChecked()
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
msg = f"Created {folders} folders.\nImported {imported} sessions."
|
|
667
|
+
if skipped:
|
|
668
|
+
msg += f"\nSkipped {skipped} duplicates."
|
|
669
|
+
|
|
670
|
+
QMessageBox.information(self, "Import Complete", msg)
|
|
671
|
+
self.accept()
|
|
672
|
+
|
|
673
|
+
except Exception as e:
|
|
674
|
+
QMessageBox.critical(
|
|
675
|
+
self,
|
|
676
|
+
"Import Failed",
|
|
677
|
+
f"Failed to import sessions:\n{e}"
|
|
678
|
+
)
|