269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""
|
|
Import CSV / JSON into a database table.
|
|
|
|
Flow:
|
|
1. User picks a file (.csv or .json)
|
|
2. A preview of the first N rows is shown
|
|
3. User confirms → rows are inserted via the driver's insert_row()
|
|
"""
|
|
import csv
|
|
import json
|
|
import os
|
|
from PyQt6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
|
QLabel, QLineEdit, QPushButton, QComboBox,
|
|
QTableWidget, QTableWidgetItem, QHeaderView,
|
|
QDialogButtonBox, QFileDialog, QProgressBar,
|
|
QMessageBox, QCheckBox,
|
|
)
|
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
|
|
|
_PREVIEW_ROWS = 50
|
|
|
|
|
|
# ── Background import worker ──────────────────────────────────────────────────
|
|
|
|
class _ImportWorker(QThread):
|
|
progress = pyqtSignal(int) # rows inserted so far
|
|
finished = pyqtSignal(int) # total rows inserted
|
|
error = pyqtSignal(str)
|
|
|
|
def __init__(self, driver, database: str, table: str,
|
|
rows: list, parent=None):
|
|
super().__init__(parent)
|
|
self._driver = driver
|
|
self._database = database
|
|
self._table = table
|
|
self._rows = rows # list of dicts {col: value}
|
|
|
|
def run(self):
|
|
inserted = 0
|
|
try:
|
|
for row in self._rows:
|
|
self._driver.insert_row(self._database, self._table, row)
|
|
inserted += 1
|
|
if inserted % 50 == 0:
|
|
self.progress.emit(inserted)
|
|
self.finished.emit(inserted)
|
|
except Exception as e:
|
|
self.error.emit(f"Row {inserted + 1}: {e}")
|
|
|
|
|
|
# ── Dialog ────────────────────────────────────────────────────────────────────
|
|
|
|
class ImportDialog(QDialog):
|
|
"""Select a CSV or JSON file and import its contents into a table."""
|
|
|
|
def __init__(self, driver, database: str, table: str, parent=None):
|
|
super().__init__(parent)
|
|
self._driver = driver
|
|
self._database = database
|
|
self._table = table
|
|
self._rows: list = [] # parsed rows ready for import
|
|
self._worker = None
|
|
|
|
self.setWindowTitle(f"Import into {table}")
|
|
self.setModal(True)
|
|
self.setMinimumSize(640, 480)
|
|
self._build_ui()
|
|
|
|
# ── UI ────────────────────────────────────────────────────────────────────
|
|
|
|
def _build_ui(self):
|
|
root = QVBoxLayout(self)
|
|
|
|
# File picker row
|
|
file_row = QHBoxLayout()
|
|
self._path_edit = QLineEdit()
|
|
self._path_edit.setReadOnly(True)
|
|
self._path_edit.setPlaceholderText("Select a .csv or .json file…")
|
|
browse_btn = QPushButton("Browse…")
|
|
browse_btn.setFixedWidth(80)
|
|
browse_btn.clicked.connect(self._browse)
|
|
file_row.addWidget(self._path_edit)
|
|
file_row.addWidget(browse_btn)
|
|
root.addLayout(file_row)
|
|
|
|
# CSV options (hidden until a CSV is selected)
|
|
self._csv_opts = QHBoxLayout()
|
|
self._csv_opts_widget = self._build_csv_opts()
|
|
root.addWidget(self._csv_opts_widget)
|
|
self._csv_opts_widget.setVisible(False)
|
|
|
|
# Skip-header checkbox
|
|
self._header_cb = QCheckBox("First row is a header (CSV only)")
|
|
self._header_cb.setChecked(True)
|
|
self._header_cb.toggled.connect(self._reload_preview)
|
|
root.addWidget(self._header_cb)
|
|
|
|
# Preview table
|
|
root.addWidget(QLabel("Preview (first 50 rows):"))
|
|
self._preview = QTableWidget(0, 0)
|
|
self._preview.setAlternatingRowColors(True)
|
|
self._preview.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
self._preview.horizontalHeader().setSectionResizeMode(
|
|
QHeaderView.ResizeMode.ResizeToContents
|
|
)
|
|
self._preview.verticalHeader().setDefaultSectionSize(22)
|
|
self._preview.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
|
root.addWidget(self._preview, 1)
|
|
|
|
# Status / progress
|
|
self._status_lbl = QLabel("")
|
|
root.addWidget(self._status_lbl)
|
|
self._progress = QProgressBar()
|
|
self._progress.setVisible(False)
|
|
root.addWidget(self._progress)
|
|
|
|
# Buttons
|
|
self._btns = QDialogButtonBox(
|
|
QDialogButtonBox.StandardButton.Ok |
|
|
QDialogButtonBox.StandardButton.Cancel
|
|
)
|
|
self._ok_btn = self._btns.button(QDialogButtonBox.StandardButton.Ok)
|
|
self._ok_btn.setText("Import")
|
|
self._ok_btn.setEnabled(False)
|
|
self._btns.accepted.connect(self._start_import)
|
|
self._btns.rejected.connect(self.reject)
|
|
root.addWidget(self._btns)
|
|
|
|
def _build_csv_opts(self) -> QHBoxLayout:
|
|
from PyQt6.QtWidgets import QWidget
|
|
w = QWidget()
|
|
lay = QHBoxLayout(w)
|
|
lay.setContentsMargins(0, 0, 0, 0)
|
|
lay.addWidget(QLabel("Delimiter:"))
|
|
self._delim_combo = QComboBox()
|
|
self._delim_combo.addItems([", (comma)", "; (semicolon)",
|
|
"\\t (tab)", "| (pipe)"])
|
|
self._delim_combo.setFixedWidth(140)
|
|
self._delim_combo.currentIndexChanged.connect(self._reload_preview)
|
|
lay.addWidget(self._delim_combo)
|
|
lay.addStretch()
|
|
return w
|
|
|
|
# ── File loading ──────────────────────────────────────────────────────────
|
|
|
|
def _browse(self):
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, "Open file", "",
|
|
"CSV / JSON files (*.csv *.json);;All files (*)"
|
|
)
|
|
if not path:
|
|
return
|
|
self._path_edit.setText(path)
|
|
ext = os.path.splitext(path)[1].lower()
|
|
self._csv_opts_widget.setVisible(ext == ".csv")
|
|
self._header_cb.setVisible(ext == ".csv")
|
|
self._reload_preview()
|
|
|
|
def _delimiter(self) -> str:
|
|
mapping = {0: ",", 1: ";", 2: "\t", 3: "|"}
|
|
return mapping.get(self._delim_combo.currentIndex(), ",")
|
|
|
|
def _reload_preview(self):
|
|
path = self._path_edit.text()
|
|
if not path:
|
|
return
|
|
ext = os.path.splitext(path)[1].lower()
|
|
try:
|
|
if ext == ".csv":
|
|
self._rows = self._parse_csv(path)
|
|
elif ext == ".json":
|
|
self._rows = self._parse_json(path)
|
|
else:
|
|
self._status_lbl.setText("Unsupported file type.")
|
|
return
|
|
except Exception as e:
|
|
self._status_lbl.setText(f"Parse error: {e}")
|
|
self._rows = []
|
|
self._ok_btn.setEnabled(False)
|
|
return
|
|
|
|
self._populate_preview(self._rows[:_PREVIEW_ROWS])
|
|
self._status_lbl.setText(
|
|
f"{len(self._rows)} row(s) ready to import into "
|
|
f"'{self._database}'.'{self._table}'"
|
|
)
|
|
self._ok_btn.setEnabled(bool(self._rows))
|
|
|
|
def _parse_csv(self, path: str) -> list:
|
|
rows = []
|
|
with open(path, newline="", encoding="utf-8-sig") as f:
|
|
reader = csv.reader(f, delimiter=self._delimiter())
|
|
all_rows = list(reader)
|
|
if not all_rows:
|
|
return []
|
|
if self._header_cb.isChecked():
|
|
headers = all_rows[0]
|
|
data_rows = all_rows[1:]
|
|
else:
|
|
headers = [f"col{i+1}" for i in range(len(all_rows[0]))]
|
|
data_rows = all_rows
|
|
for row in data_rows:
|
|
# Pad short rows, truncate long ones
|
|
padded = (row + [""] * len(headers))[: len(headers)]
|
|
rows.append(dict(zip(headers, padded)))
|
|
return rows
|
|
|
|
def _parse_json(self, path: str) -> list:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, list):
|
|
return [r for r in data if isinstance(r, dict)]
|
|
if isinstance(data, dict):
|
|
# Support {rows: [...]} or {data: [...]} wrappers
|
|
for key in ("rows", "data", "records", "items"):
|
|
if isinstance(data.get(key), list):
|
|
return data[key]
|
|
raise ValueError("JSON must be an array of objects or {rows: [...]}")
|
|
|
|
# ── Preview ───────────────────────────────────────────────────────────────
|
|
|
|
def _populate_preview(self, rows: list):
|
|
if not rows:
|
|
self._preview.setRowCount(0)
|
|
self._preview.setColumnCount(0)
|
|
return
|
|
headers = list(rows[0].keys())
|
|
self._preview.setColumnCount(len(headers))
|
|
self._preview.setHorizontalHeaderLabels(headers)
|
|
self._preview.setRowCount(len(rows))
|
|
for r, row in enumerate(rows):
|
|
for c, key in enumerate(headers):
|
|
val = row.get(key, "")
|
|
self._preview.setItem(
|
|
r, c, QTableWidgetItem("" if val is None else str(val))
|
|
)
|
|
|
|
# ── Import ────────────────────────────────────────────────────────────────
|
|
|
|
def _start_import(self):
|
|
if not self._rows:
|
|
return
|
|
self._ok_btn.setEnabled(False)
|
|
self._progress.setMaximum(len(self._rows))
|
|
self._progress.setValue(0)
|
|
self._progress.setVisible(True)
|
|
|
|
self._worker = _ImportWorker(
|
|
self._driver, self._database, self._table, self._rows, parent=self
|
|
)
|
|
self._worker.progress.connect(self._progress.setValue)
|
|
self._worker.finished.connect(self._on_done)
|
|
self._worker.error.connect(self._on_error)
|
|
self._worker.start()
|
|
|
|
def _on_done(self, count: int):
|
|
self._progress.setValue(count)
|
|
QMessageBox.information(
|
|
self, "Import Complete",
|
|
f"Successfully imported {count} row(s) into '{self._table}'."
|
|
)
|
|
self.accept()
|
|
|
|
def _on_error(self, msg: str):
|
|
self._progress.setVisible(False)
|
|
self._ok_btn.setEnabled(True)
|
|
QMessageBox.critical(self, "Import Error", f"Import failed:\n{msg}")
|