ntermqt 0.1.8__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/manager/io.py CHANGED
@@ -3,11 +3,14 @@ Session import/export functionality.
3
3
 
4
4
  Supports JSON format for portability.
5
5
  Also supports importing from TerminalTelemetry YAML format.
6
+ Also supports simple CSV import for quick session lists.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
10
+ import csv
9
11
  import json
10
12
  import yaml
13
+ from io import StringIO
11
14
  from pathlib import Path
12
15
  from datetime import datetime
13
16
  from typing import Optional
@@ -17,9 +20,10 @@ from PyQt6.QtWidgets import (
17
20
  QWidget, QFileDialog, QMessageBox, QDialog,
18
21
  QVBoxLayout, QHBoxLayout, QLabel, QCheckBox,
19
22
  QPushButton, QDialogButtonBox, QTreeWidget,
20
- QTreeWidgetItem, QGroupBox
23
+ QTreeWidgetItem, QGroupBox, QComboBox, QTextEdit
21
24
  )
22
25
  from PyQt6.QtCore import Qt
26
+ from PyQt6.QtGui import QFont
23
27
 
24
28
  from .models import SessionStore, SavedSession, SessionFolder
25
29
 
@@ -189,6 +193,133 @@ def import_sessions(
189
193
  return imported, skipped
190
194
 
191
195
 
196
+ def import_sessions_csv(
197
+ store: SessionStore,
198
+ path: Path,
199
+ merge: bool = True,
200
+ folder_name: Optional[str] = None
201
+ ) -> tuple[int, int, int]:
202
+ """
203
+ Import sessions from CSV file.
204
+
205
+ Supports flexible column names:
206
+ - name/display_name/hostname → session name
207
+ - hostname/host/ip/address → connection hostname
208
+ - port → port (default 22)
209
+ - description/desc → description
210
+ - folder/folder_name/group → folder assignment
211
+
212
+ Args:
213
+ store: Session store instance
214
+ path: Input CSV file path
215
+ merge: If True, merge with existing. If False, skip duplicates.
216
+ folder_name: Override folder for all imported sessions (optional)
217
+
218
+ Returns:
219
+ Tuple of (folders_created, sessions_imported, sessions_skipped)
220
+ """
221
+ with open(path, newline='', encoding='utf-8-sig') as f:
222
+ # Sniff dialect and read
223
+ sample = f.read(4096)
224
+ f.seek(0)
225
+
226
+ try:
227
+ dialect = csv.Sniffer().sniff(sample)
228
+ except csv.Error:
229
+ dialect = csv.excel # Default to standard CSV
230
+
231
+ reader = csv.DictReader(f, dialect=dialect)
232
+
233
+ # Normalize header names (lowercase, strip whitespace)
234
+ if reader.fieldnames:
235
+ reader.fieldnames = [h.lower().strip() for h in reader.fieldnames]
236
+
237
+ rows = list(reader)
238
+
239
+ if not rows:
240
+ return 0, 0, 0
241
+
242
+ # Column name mappings (first match wins)
243
+ name_cols = ['name', 'display_name', 'session_name', 'device_name', 'device']
244
+ host_cols = ['hostname', 'host', 'ip', 'ip_address', 'address', 'mgmt_ip']
245
+ port_cols = ['port', 'ssh_port']
246
+ desc_cols = ['description', 'desc', 'notes', 'comment']
247
+ folder_cols = ['folder', 'folder_name', 'group', 'site', 'location']
248
+
249
+ def find_col(row: dict, candidates: list[str]) -> Optional[str]:
250
+ """Find first matching column value."""
251
+ for col in candidates:
252
+ if col in row and row[col]:
253
+ return row[col].strip()
254
+ return None
255
+
256
+ # Track folders and sessions
257
+ folders_created = 0
258
+ sessions_imported = 0
259
+ sessions_skipped = 0
260
+
261
+ existing_sessions = {s.hostname: s for s in store.list_all_sessions()}
262
+ folder_cache: dict[str, int] = {} # folder_name -> folder_id
263
+
264
+ for row in rows:
265
+ # Extract fields with fallbacks
266
+ hostname = find_col(row, host_cols)
267
+ if not hostname:
268
+ continue # Skip rows without hostname
269
+
270
+ name = find_col(row, name_cols) or hostname
271
+ port_str = find_col(row, port_cols)
272
+ port = int(port_str) if port_str and port_str.isdigit() else 22
273
+ description = find_col(row, desc_cols) or ""
274
+
275
+ # Determine folder
276
+ row_folder = folder_name or find_col(row, folder_cols)
277
+ folder_id = None
278
+
279
+ if row_folder:
280
+ if row_folder in folder_cache:
281
+ folder_id = folder_cache[row_folder]
282
+ else:
283
+ # Check if folder exists
284
+ existing_folders = store.list_folders(None)
285
+ existing = next((f for f in existing_folders if f.name == row_folder), None)
286
+
287
+ if existing:
288
+ folder_id = existing.id
289
+ else:
290
+ folder_id = store.add_folder(row_folder)
291
+ folders_created += 1
292
+
293
+ folder_cache[row_folder] = folder_id
294
+
295
+ # Check for duplicate
296
+ if hostname in existing_sessions and not merge:
297
+ sessions_skipped += 1
298
+ continue
299
+
300
+ session = SavedSession(
301
+ name=name,
302
+ description=description,
303
+ hostname=hostname,
304
+ port=port,
305
+ credential_name=None,
306
+ folder_id=folder_id,
307
+ )
308
+
309
+ # Update or insert
310
+ if hostname in existing_sessions and merge:
311
+ existing = existing_sessions[hostname]
312
+ session.id = existing.id
313
+ store.update_session(session)
314
+ else:
315
+ store.add_session(session)
316
+ existing_sessions[hostname] = session
317
+
318
+ sessions_imported += 1
319
+
320
+ return folders_created, sessions_imported, sessions_skipped
321
+
322
+
192
323
  def import_terminal_telemetry(
193
324
  store: SessionStore,
194
325
  path: Path,
@@ -359,6 +490,49 @@ class ExportDialog(QDialog):
359
490
  )
360
491
 
361
492
 
493
+ # =============================================================================
494
+ # Format Help Text
495
+ # =============================================================================
496
+
497
+ CSV_HELP_TEXT = """\
498
+ <b>CSV Format</b><br><br>
499
+ Simple comma-separated format for quick imports from spreadsheets or other tools.<br><br>
500
+
501
+ <b>Supported Columns:</b>
502
+ <table cellspacing="4">
503
+ <tr><td><code>name</code></td><td>Session display name (falls back to hostname)</td></tr>
504
+ <tr><td><code>hostname</code></td><td><b>Required.</b> IP address or DNS name</td></tr>
505
+ <tr><td><code>port</code></td><td>SSH port (default: 22)</td></tr>
506
+ <tr><td><code>description</code></td><td>Optional notes</td></tr>
507
+ <tr><td><code>folder</code></td><td>Folder name (created if missing)</td></tr>
508
+ </table>
509
+ <br>
510
+ <b>Example:</b><br>
511
+ <code>name,hostname,port,folder<br>
512
+ core-rtr-01,10.0.0.1,22,Core<br>
513
+ core-rtr-02,10.0.0.2,22,Core<br>
514
+ edge-sw-01,10.1.0.1,22,Edge</code><br><br>
515
+
516
+ <i>Column names are flexible: "host", "ip", "address" also work for hostname.</i>
517
+ """
518
+
519
+ JSON_HELP_TEXT = """\
520
+ <b>JSON Format</b><br><br>
521
+ Native nterm export format. Preserves folders, hierarchy, and all session metadata.<br><br>
522
+
523
+ <b>Structure:</b><br>
524
+ <code>{<br>
525
+ &nbsp;&nbsp;"version": 1,<br>
526
+ &nbsp;&nbsp;"folders": [{"id": 1, "name": "Site A", ...}],<br>
527
+ &nbsp;&nbsp;"sessions": [<br>
528
+ &nbsp;&nbsp;&nbsp;&nbsp;{"name": "router-01", "hostname": "10.0.0.1", "port": 22, "folder_id": 1}<br>
529
+ &nbsp;&nbsp;]<br>
530
+ }</code><br><br>
531
+
532
+ <b>Tip:</b> Use <i>Export Sessions</i> to create a template, then edit and re-import.
533
+ """
534
+
535
+
362
536
  class ImportDialog(QDialog):
363
537
  """Dialog for import options and preview."""
364
538
 
@@ -366,14 +540,45 @@ class ImportDialog(QDialog):
366
540
  super().__init__(parent)
367
541
  self.store = store
368
542
  self._import_path: Optional[Path] = None
369
- self._import_data: Optional[dict] = None
543
+ self._import_data = None # Can be dict (JSON) or list of rows (CSV)
544
+ self._import_format: str = "json"
370
545
 
371
546
  self.setWindowTitle("Import Sessions")
372
- self.setMinimumWidth(500)
373
- self.setMinimumHeight(400)
547
+ self.setMinimumWidth(600)
548
+ self.setMinimumHeight(500)
374
549
 
375
550
  layout = QVBoxLayout(self)
376
551
 
552
+ # Format selection row
553
+ format_row = QHBoxLayout()
554
+ format_row.addWidget(QLabel("Format:"))
555
+
556
+ self._format_combo = QComboBox()
557
+ self._format_combo.addItem("JSON (nterm native)", "json")
558
+ self._format_combo.addItem("CSV (spreadsheet)", "csv")
559
+ self._format_combo.currentIndexChanged.connect(self._on_format_changed)
560
+ self._format_combo.setMinimumWidth(180)
561
+ format_row.addWidget(self._format_combo)
562
+
563
+ format_row.addStretch()
564
+
565
+ # Help toggle
566
+ self._help_btn = QPushButton("? Help")
567
+ self._help_btn.setCheckable(True)
568
+ self._help_btn.setMaximumWidth(80)
569
+ self._help_btn.toggled.connect(self._toggle_help)
570
+ format_row.addWidget(self._help_btn)
571
+
572
+ layout.addLayout(format_row)
573
+
574
+ # Help panel (hidden by default)
575
+ self._help_panel = QTextEdit()
576
+ self._help_panel.setReadOnly(True)
577
+ self._help_panel.setMaximumHeight(180)
578
+ self._help_panel.setHtml(JSON_HELP_TEXT)
579
+ self._help_panel.hide()
580
+ layout.addWidget(self._help_panel)
581
+
377
582
  # File selection
378
583
  file_row = QHBoxLayout()
379
584
  self._file_label = QLabel("No file selected")
@@ -392,6 +597,7 @@ class ImportDialog(QDialog):
392
597
  self._preview_tree = QTreeWidget()
393
598
  self._preview_tree.setHeaderLabels(["Name", "Host", "Port"])
394
599
  self._preview_tree.setRootIsDecorated(True)
600
+ self._preview_tree.setAlternatingRowColors(True)
395
601
  preview_layout.addWidget(self._preview_tree)
396
602
 
397
603
  layout.addWidget(preview_group)
@@ -421,13 +627,41 @@ class ImportDialog(QDialog):
421
627
  self._button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
422
628
  layout.addWidget(self._button_box)
423
629
 
630
+ def _on_format_changed(self, index: int) -> None:
631
+ """Handle format selection change."""
632
+ self._import_format = self._format_combo.currentData()
633
+
634
+ # Update help text
635
+ if self._import_format == "csv":
636
+ self._help_panel.setHtml(CSV_HELP_TEXT)
637
+ else:
638
+ self._help_panel.setHtml(JSON_HELP_TEXT)
639
+
640
+ # Clear preview if format changed after file loaded
641
+ if self._import_path:
642
+ self._preview_tree.clear()
643
+ self._import_path = None
644
+ self._import_data = None
645
+ self._file_label.setText("No file selected")
646
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
647
+
648
+ def _toggle_help(self, show: bool) -> None:
649
+ """Show/hide help panel."""
650
+ self._help_panel.setVisible(show)
651
+ self._help_btn.setText("▼ Help" if show else "? Help")
652
+
424
653
  def _browse_file(self) -> None:
425
654
  """Browse for import file."""
655
+ if self._import_format == "csv":
656
+ filter_str = "CSV Files (*.csv);;All Files (*)"
657
+ else:
658
+ filter_str = "JSON Files (*.json);;All Files (*)"
659
+
426
660
  path, _ = QFileDialog.getOpenFileName(
427
661
  self,
428
662
  "Import Sessions",
429
663
  "",
430
- "JSON Files (*.json);;All Files (*)"
664
+ filter_str
431
665
  )
432
666
 
433
667
  if path:
@@ -436,47 +670,19 @@ class ImportDialog(QDialog):
436
670
  def _load_preview(self, path: Path) -> None:
437
671
  """Load and preview import file."""
438
672
  try:
439
- with open(path) as f:
440
- data = json.load(f)
673
+ self._preview_tree.clear()
674
+
675
+ if self._import_format == "csv":
676
+ self._load_csv_preview(path)
677
+ else:
678
+ self._load_json_preview(path)
441
679
 
442
680
  self._import_path = path
443
- self._import_data = data
444
681
  self._file_label.setText(path.name)
445
682
 
446
- # Build preview tree
447
- self._preview_tree.clear()
448
-
449
- # Create folder items
450
- folder_items: dict[int, QTreeWidgetItem] = {}
451
- for folder_data in data.get("folders", []):
452
- item = QTreeWidgetItem()
453
- item.setText(0, f"📁 {folder_data['name']}")
454
- folder_items[folder_data["id"]] = item
455
-
456
- # Parent folders
457
- for folder_data in data.get("folders", []):
458
- item = folder_items[folder_data["id"]]
459
- parent_id = folder_data.get("parent_id")
460
- if parent_id and parent_id in folder_items:
461
- folder_items[parent_id].addChild(item)
462
- else:
463
- self._preview_tree.addTopLevelItem(item)
464
-
465
- # Add sessions
466
- for session_data in data.get("sessions", []):
467
- item = QTreeWidgetItem()
468
- item.setText(0, session_data.get("name", ""))
469
- item.setText(1, session_data.get("hostname", ""))
470
- item.setText(2, str(session_data.get("port", 22)))
471
-
472
- folder_id = session_data.get("folder_id")
473
- if folder_id and folder_id in folder_items:
474
- folder_items[folder_id].addChild(item)
475
- else:
476
- self._preview_tree.addTopLevelItem(item)
477
-
478
683
  self._preview_tree.expandAll()
479
- self._preview_tree.resizeColumnToContents(0)
684
+ for i in range(3):
685
+ self._preview_tree.resizeColumnToContents(i)
480
686
 
481
687
  # Enable import button
482
688
  self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True)
@@ -488,19 +694,126 @@ class ImportDialog(QDialog):
488
694
  f"Failed to load file:\n{e}"
489
695
  )
490
696
 
697
+ def _load_csv_preview(self, path: Path) -> None:
698
+ """Load CSV file and populate preview."""
699
+ with open(path, newline='', encoding='utf-8-sig') as f:
700
+ sample = f.read(4096)
701
+ f.seek(0)
702
+
703
+ try:
704
+ dialect = csv.Sniffer().sniff(sample)
705
+ except csv.Error:
706
+ dialect = csv.excel
707
+
708
+ reader = csv.DictReader(f, dialect=dialect)
709
+ if reader.fieldnames:
710
+ reader.fieldnames = [h.lower().strip() for h in reader.fieldnames]
711
+
712
+ rows = list(reader)
713
+
714
+ self._import_data = rows
715
+
716
+ # Column mappings
717
+ name_cols = ['name', 'display_name', 'session_name', 'device_name', 'device']
718
+ host_cols = ['hostname', 'host', 'ip', 'ip_address', 'address', 'mgmt_ip']
719
+ port_cols = ['port', 'ssh_port']
720
+ folder_cols = ['folder', 'folder_name', 'group', 'site', 'location']
721
+
722
+ def find_col(row: dict, candidates: list[str]) -> Optional[str]:
723
+ for col in candidates:
724
+ if col in row and row[col]:
725
+ return row[col].strip()
726
+ return None
727
+
728
+ # Group by folder for preview
729
+ folder_items: dict[str, QTreeWidgetItem] = {}
730
+ root_sessions: list[QTreeWidgetItem] = []
731
+
732
+ for row in rows:
733
+ hostname = find_col(row, host_cols)
734
+ if not hostname:
735
+ continue
736
+
737
+ name = find_col(row, name_cols) or hostname
738
+ port = find_col(row, port_cols) or "22"
739
+ folder = find_col(row, folder_cols)
740
+
741
+ item = QTreeWidgetItem()
742
+ item.setText(0, name)
743
+ item.setText(1, hostname)
744
+ item.setText(2, port)
745
+
746
+ if folder:
747
+ if folder not in folder_items:
748
+ folder_item = QTreeWidgetItem()
749
+ folder_item.setText(0, f"📁 {folder}")
750
+ self._preview_tree.addTopLevelItem(folder_item)
751
+ folder_items[folder] = folder_item
752
+ folder_items[folder].addChild(item)
753
+ else:
754
+ root_sessions.append(item)
755
+
756
+ # Add ungrouped sessions at root
757
+ for item in root_sessions:
758
+ self._preview_tree.addTopLevelItem(item)
759
+
760
+ def _load_json_preview(self, path: Path) -> None:
761
+ """Load JSON file and populate preview."""
762
+ with open(path) as f:
763
+ data = json.load(f)
764
+
765
+ self._import_data = data
766
+
767
+ # Create folder items
768
+ folder_items: dict[int, QTreeWidgetItem] = {}
769
+ for folder_data in data.get("folders", []):
770
+ item = QTreeWidgetItem()
771
+ item.setText(0, f"📁 {folder_data['name']}")
772
+ folder_items[folder_data["id"]] = item
773
+
774
+ # Parent folders
775
+ for folder_data in data.get("folders", []):
776
+ item = folder_items[folder_data["id"]]
777
+ parent_id = folder_data.get("parent_id")
778
+ if parent_id and parent_id in folder_items:
779
+ folder_items[parent_id].addChild(item)
780
+ else:
781
+ self._preview_tree.addTopLevelItem(item)
782
+
783
+ # Add sessions
784
+ for session_data in data.get("sessions", []):
785
+ item = QTreeWidgetItem()
786
+ item.setText(0, session_data.get("name", ""))
787
+ item.setText(1, session_data.get("hostname", ""))
788
+ item.setText(2, str(session_data.get("port", 22)))
789
+
790
+ folder_id = session_data.get("folder_id")
791
+ if folder_id and folder_id in folder_items:
792
+ folder_items[folder_id].addChild(item)
793
+ else:
794
+ self._preview_tree.addTopLevelItem(item)
795
+
491
796
  def _on_import(self) -> None:
492
797
  """Perform import."""
493
798
  if not self._import_path:
494
799
  return
495
800
 
496
801
  try:
497
- imported, skipped = import_sessions(
498
- self.store,
499
- self._import_path,
500
- merge=self._merge_check.isChecked()
501
- )
802
+ if self._import_format == "csv":
803
+ folders, imported, skipped = import_sessions_csv(
804
+ self.store,
805
+ self._import_path,
806
+ merge=self._merge_check.isChecked()
807
+ )
808
+ msg = f"Created {folders} folders.\nImported {imported} sessions."
809
+ else:
810
+ imported, skipped = import_sessions(
811
+ self.store,
812
+ self._import_path,
813
+ merge=self._merge_check.isChecked()
814
+ )
815
+ msg = f"Imported {imported} sessions."
502
816
 
503
- msg = f"Imported {imported} sessions."
504
817
  if skipped:
505
818
  msg += f"\nSkipped {skipped} duplicates."
506
819