Coverage for graphica/gui/dialogs/data_import.py: 95.5%
467 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""データの取り込みのダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。"""
3import logging
4import re
5import pandas as pd
6from PySide6.QtWidgets import (
7 QCheckBox,
8 QComboBox,
9 QDialog,
10 QDialogButtonBox,
11 QFormLayout,
12 QLabel,
13 QLineEdit,
14 QListWidget,
15 QListWidgetItem,
16 QPushButton,
17 QSpinBox,
18 QTableWidget,
19 QTableWidgetItem,
20 QVBoxLayout,
21)
22from PySide6.QtCore import Qt
23from graphica.gui import notify
24from graphica.gui.theme import apply_form_spacing
26logger = logging.getLogger(__name__)
29def _mixes_text_and_other_values(column):
30 """文字だけの列はカテゴリとして描けるが、文字と数値・空欄が混ざると描けない。"""
31 if column.dtype != object: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true
32 return False
33 has_text = has_other = False
34 for value in column:
35 if isinstance(value, str):
36 has_text = True
37 else:
38 has_other = True
39 if has_text and has_other:
40 return True
41 return False
44class ColumnPreviewDialog(QDialog):
45 """読み込む前に内容を見せて X/Y の列を選ばせる(列の多いファイルで意図しない列が選ばれないように)。
47 Excel はシートとヘッダー行、CSV は文字コード・区切り文字・ヘッダー行・固定長を変えられ、変えるたびにファイルから読み直す。
48 """
50 # 表示名 -> read_csv の sep。「空白」は空白の連続を区切りとする正規表現なので python エンジンで読む
51 _DELIMITER_CHOICES = {
52 "カンマ (,)": ",",
53 "タブ": "\t",
54 "セミコロン (;)": ";",
55 "空白": r"\s+",
56 }
57 _DELIMITER_CUSTOM_LABEL = "その他..."
59 _ENCODING_CHOICES = {
60 "UTF-8": "utf-8",
61 "UTF-8 (BOM付き)": "utf-8-sig",
62 "Shift_JIS": "cp932",
63 "Latin-1": "latin-1",
64 "UTF-16": "utf-16",
65 }
66 _AUTO_LABEL = "自動判定"
68 def __init__(self, df, file_name, parent=None, file_path=None):
69 """df は先頭のシート・1行目をヘッダーとして読んだもの。file_path は設定を変えたときの読み直しに使う。"""
70 super().__init__(parent)
71 self.setWindowTitle(f"列の選択: {file_name}")
72 self.resize(600, 480)
74 self.file_path = file_path
75 self.current_df = df
76 from graphica.gui.workers import is_delimited_text_file, is_excel_file
77 self.is_excel = is_excel_file(file_path)
78 # 組み込みの CSV の読み込みを通ったファイルだけ(プラグインで読んだ形式や貼り付けは対象外)。.txt も CSV と同じ
79 self.is_csv = not self.is_excel and is_delimited_text_file(file_path)
80 # {列名: "数値" / "文字列" / "日付"}
81 self.type_overrides = {}
82 self._numeric_table_note = ""
84 self.sheet_names = []
85 if self.is_excel:
86 try:
87 from graphica.gui.workers import excel_engine_for
88 self.sheet_names = pd.ExcelFile(file_path, engine=excel_engine_for(file_path)).sheet_names
89 except Exception as e:
90 logger.warning("Excelのシート一覧取得に失敗しました: %s", e)
92 self._detected_encoding = None
93 self._detected_delimiter = None
94 if self.is_csv:
95 from graphica.gui.workers import detect_csv_encoding, detect_csv_delimiter
96 try:
97 self._detected_encoding = detect_csv_encoding(file_path)
98 except Exception as e:
99 logger.warning("CSVの文字コード自動判定に失敗しました: %s", e)
100 self._detected_encoding = 'utf-8-sig'
101 try:
102 self._detected_delimiter = detect_csv_delimiter(file_path, self._detected_encoding)
103 except Exception as e:
104 logger.warning("CSVの区切り文字自動判定に失敗しました: %s", e)
105 self._detected_delimiter = ','
107 layout = QVBoxLayout(self)
109 if self.is_excel:
110 excel_form = QFormLayout()
111 if self.sheet_names: 111 ↛ 117line 111 didn't jump to line 117 because the condition on line 111 was always true
112 self.sheet_combo = QComboBox()
113 self.sheet_combo.addItems(self.sheet_names)
114 self.sheet_combo.currentIndexChanged.connect(self._on_sheet_or_header_changed)
115 excel_form.addRow("シート", self.sheet_combo)
116 else:
117 self.sheet_combo = None
119 self.header_row_spinbox = QSpinBox()
120 self.header_row_spinbox.setRange(1, 100)
121 self.header_row_spinbox.setValue(1)
122 self.header_row_spinbox.setToolTip("列名として使う行を指定します(データの上に説明行がある場合など)")
123 self.header_row_spinbox.valueChanged.connect(self._on_sheet_or_header_changed)
124 excel_form.addRow("ヘッダー行", self.header_row_spinbox)
126 # usecols は "A,C:E" のような Excel の列表記をそのまま受け付ける
127 self.usecols_edit = QLineEdit()
128 self.usecols_edit.setPlaceholderText("例: A,C:E (空欄で全列)")
129 self.usecols_edit.setToolTip("読み込む列をExcelの列表記で指定します(空欄なら全列を読み込みます)")
130 self.usecols_edit.editingFinished.connect(self._on_sheet_or_header_changed)
131 excel_form.addRow("使用する列 (usecols)", self.usecols_edit)
133 self.nrows_spinbox = QSpinBox()
134 self.nrows_spinbox.setRange(0, 10_000_000)
135 self.nrows_spinbox.setValue(0)
136 self.nrows_spinbox.setSpecialValueText("全行")
137 self.nrows_spinbox.setToolTip("ヘッダー行より下で読み込む最大行数(0で全行)")
138 self.nrows_spinbox.valueChanged.connect(self._on_sheet_or_header_changed)
139 excel_form.addRow("読み込む最大行数", self.nrows_spinbox)
141 layout.addLayout(excel_form)
143 self.check_types_button = QPushButton("列の型を確認...")
144 self.check_types_button.clicked.connect(self._on_check_column_types)
145 layout.addWidget(self.check_types_button)
146 else:
147 self.sheet_combo = None
148 self.header_row_spinbox = None
149 self.usecols_edit = None
150 self.nrows_spinbox = None
152 if self.is_csv:
153 csv_form = QFormLayout()
155 self.encoding_combo = QComboBox()
156 self.encoding_combo.addItems([self._AUTO_LABEL] + list(self._ENCODING_CHOICES.keys()))
157 self.encoding_combo.setToolTip(
158 "文字化けする場合は手動で選択してください(自動判定は"
159 f"「{self._detected_encoding}」と判定しました)"
160 )
161 csv_form.addRow("文字コード", self.encoding_combo)
163 self.delimiter_combo = QComboBox()
164 self.delimiter_combo.addItems(
165 [self._AUTO_LABEL] + list(self._DELIMITER_CHOICES.keys()) + [self._DELIMITER_CUSTOM_LABEL]
166 )
167 self.delimiter_combo.setToolTip(
168 f"自動判定は「{self._describe_delimiter(self._detected_delimiter)}」と判定しました"
169 )
170 csv_form.addRow("区切り文字", self.delimiter_combo)
172 self.custom_delimiter_label = QLabel("区切り文字(直接入力)")
173 self.custom_delimiter_edit = QLineEdit()
174 self.custom_delimiter_edit.setPlaceholderText("区切り文字を入力(例: |)")
175 csv_form.addRow(self.custom_delimiter_label, self.custom_delimiter_edit)
176 self.custom_delimiter_label.setVisible(False)
177 self.custom_delimiter_edit.setVisible(False)
179 self.csv_header_row_spinbox = QSpinBox()
180 self.csv_header_row_spinbox.setRange(1, 100)
181 self.csv_header_row_spinbox.setValue(1)
182 self.csv_header_row_spinbox.setToolTip(
183 "列名として使う行を指定します(装置が出力する説明文などの前文がある場合は、"
184 "その行数分だけ後ろにずらしてください)"
185 )
186 csv_form.addRow("ヘッダー行", self.csv_header_row_spinbox)
188 self.fixed_width_checkbox = QCheckBox("固定長フォーマットとして読み込む")
189 self.fixed_width_checkbox.setToolTip("区切り文字を使わず、列の文字位置で区切られたデータの場合に選択します")
190 csv_form.addRow(self.fixed_width_checkbox)
192 self.fixed_width_label = QLabel("列幅 (固定長)")
193 self.fixed_width_edit = QLineEdit()
194 self.fixed_width_edit.setPlaceholderText("カンマ区切りで指定(空欄なら自動推測)")
195 csv_form.addRow(self.fixed_width_label, self.fixed_width_edit)
196 self.fixed_width_label.setVisible(False)
197 self.fixed_width_edit.setVisible(False)
199 self.csv_nrows_spinbox = QSpinBox()
200 self.csv_nrows_spinbox.setRange(0, 10_000_000)
201 self.csv_nrows_spinbox.setValue(0)
202 self.csv_nrows_spinbox.setSpecialValueText("全行")
203 self.csv_nrows_spinbox.setToolTip("ヘッダー行より下で読み込む最大行数(0で全行)")
204 csv_form.addRow("読み込む最大行数", self.csv_nrows_spinbox)
206 layout.addLayout(csv_form)
208 self.check_types_button = QPushButton("列の型を確認...")
209 self.check_types_button.clicked.connect(self._on_check_column_types)
210 layout.addWidget(self.check_types_button)
212 self.encoding_combo.currentIndexChanged.connect(self._reload_csv_preview)
213 self.delimiter_combo.currentIndexChanged.connect(self._on_delimiter_combo_changed)
214 self.custom_delimiter_edit.editingFinished.connect(self._reload_csv_preview)
215 self.csv_header_row_spinbox.valueChanged.connect(self._reload_csv_preview)
216 self.fixed_width_checkbox.toggled.connect(self._on_fixed_width_toggled)
217 self.fixed_width_edit.editingFinished.connect(self._reload_csv_preview)
218 self.csv_nrows_spinbox.valueChanged.connect(self._reload_csv_preview)
219 else:
220 self.encoding_combo = None
221 self.delimiter_combo = None
222 self.custom_delimiter_edit = None
223 self.csv_header_row_spinbox = None
224 self.fixed_width_checkbox = None
225 self.fixed_width_edit = None
226 self.csv_nrows_spinbox = None
228 self.info_label = QLabel()
229 layout.addWidget(self.info_label)
231 self.table = QTableWidget()
232 self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
233 layout.addWidget(self.table)
235 form = QFormLayout()
236 self.x_col_combo = QComboBox()
237 self.y_col_combo = QComboBox()
238 form.addRow("X軸の列", self.x_col_combo)
239 form.addRow("Y軸の列", self.y_col_combo)
240 layout.addLayout(form)
242 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
243 QDialogButtonBox.StandardButton.Cancel)
244 button_box.accepted.connect(self.accept)
245 button_box.rejected.connect(self.reject)
246 layout.addWidget(button_box)
248 apply_form_spacing(self)
250 if self.is_csv:
251 # 最初に読んだ df はカンマ区切りなので、区切りが違うと1列に潰れている。判定した設定で読み直す
252 self._reload_csv_preview()
253 else:
254 self._rebuild_preview_table()
256 @staticmethod
257 def _describe_delimiter(delimiter):
258 labels = {',': 'カンマ', '\t': 'タブ', ';': 'セミコロン', r'\s+': '空白'}
259 return labels.get(delimiter, repr(delimiter))
261 def _resolve_csv_encoding(self):
262 text = self.encoding_combo.currentText()
263 if text == self._AUTO_LABEL:
264 return self._detected_encoding or 'utf-8-sig'
265 return self._ENCODING_CHOICES.get(text, 'utf-8-sig')
267 def _resolve_csv_delimiter(self):
268 text = self.delimiter_combo.currentText()
269 if text == self._AUTO_LABEL:
270 return self._detected_delimiter or ','
271 if text == self._DELIMITER_CUSTOM_LABEL: 271 ↛ 273line 271 didn't jump to line 273 because the condition on line 271 was always true
272 return self.custom_delimiter_edit.text() or ','
273 return self._DELIMITER_CHOICES.get(text, ',')
275 def _csv_settings_are_automatic(self):
276 """区切り・ヘッダー行・固定長を利用者が指定していない(指定したらその通りに読む)。"""
277 return (self.delimiter_combo.currentText() == self._AUTO_LABEL
278 and self.csv_header_row_spinbox.value() == 1
279 and not self.fixed_width_checkbox.isChecked())
281 def _on_delimiter_combo_changed(self, _index=None):
282 is_custom = self.delimiter_combo.currentText() == self._DELIMITER_CUSTOM_LABEL
283 self.custom_delimiter_label.setVisible(is_custom)
284 self.custom_delimiter_edit.setVisible(is_custom)
285 self._reload_csv_preview()
287 def _on_fixed_width_toggled(self, checked):
288 self.delimiter_combo.setEnabled(not checked)
289 self.custom_delimiter_edit.setEnabled(not checked)
290 self.fixed_width_label.setVisible(checked)
291 self.fixed_width_edit.setVisible(checked)
292 self._reload_csv_preview()
294 def _reload_csv_preview(self, *_args):
295 """CSV の設定が変わったら読み直す。区切りが正規表現のことがあるので python エンジンで読む。"""
296 from graphica.gui.workers import has_numeric_column, read_numeric_table
298 encoding = self._resolve_csv_encoding()
299 header_row = self.csv_header_row_spinbox.value() - 1 # 画面は1始まり
300 nrows = self.csv_nrows_spinbox.value() or None
301 new_df, error = None, None
302 try:
303 if self.fixed_width_checkbox.isChecked():
304 widths_text = self.fixed_width_edit.text().strip()
305 read_kwargs = {'header': header_row, 'encoding': encoding, 'nrows': nrows}
306 if widths_text:
307 read_kwargs['widths'] = [int(w.strip()) for w in widths_text.split(',') if w.strip()]
308 new_df = pd.read_fwf(self.file_path, **read_kwargs)
309 else:
310 from graphica.gui.workers import pandas_separator
311 delimiter = self._resolve_csv_delimiter()
312 # 推測が「空白」なら、最初の読み込みと同じく空白の連続を1つの区切りとみなす
313 sep, _engine = pandas_separator(delimiter) if delimiter == ' ' else (delimiter, 'python')
314 new_df = pd.read_csv(
315 self.file_path, sep=sep, header=header_row,
316 encoding=encoding, engine='python', nrows=nrows
317 )
318 except Exception as e:
319 error = e
321 self._numeric_table_note = ""
322 # 設定を変えていないのに読めない・数値の列が無いときは、測定条件などの行に挟まれた数値の表を探す
323 if self._csv_settings_are_automatic() and (error is not None or not has_numeric_column(new_df)):
324 try:
325 found = read_numeric_table(self.file_path, encoding)
326 except (OSError, ValueError): # 文字コードの誤りと pandas の ParserError は ValueError
327 logger.exception("数値の表を探せませんでした")
328 found = None
329 if found is not None:
330 table_df, table = found
331 new_df, error = (table_df.head(nrows) if nrows else table_df), None
332 self._numeric_table_note = (
333 f"前後の説明の行を除き、{table.first_line + 1}〜{table.stop_line} 行目の数値の表を読み込みました。"
334 )
336 if error is not None:
337 logger.error("CSV のプレビューを読み込めませんでした", exc_info=error)
338 notify.warning(
339 self, "読み込みエラー",
340 f"指定した条件(文字コード/区切り文字/ヘッダー行/固定長)では読み込めませんでした:\n{error}"
341 )
342 return
343 self.current_df = new_df
344 self._apply_type_overrides()
345 self._rebuild_preview_table()
347 def _on_sheet_or_header_changed(self):
348 """シート・ヘッダー行・使う列・最大行数が変わったら読み直す。"""
349 sheet_name = self.sheet_combo.currentText() if self.sheet_combo else 0
350 header_row = self.header_row_spinbox.value() - 1 # 画面は1始まり
351 usecols = self.usecols_edit.text().strip() or None if self.usecols_edit else None
352 nrows = (self.nrows_spinbox.value() or None) if self.nrows_spinbox else None
353 try:
354 from graphica.gui.workers import excel_engine_for
355 new_df = pd.read_excel(
356 self.file_path, sheet_name=sheet_name, header=header_row,
357 usecols=usecols, nrows=nrows, engine=excel_engine_for(self.file_path)
358 )
359 except Exception as e:
360 logger.exception("Excel のプレビューを読み込めませんでした")
361 notify.warning(
362 self, "読み込みエラー",
363 f"指定した条件(シート/ヘッダー行/使用する列/最大行数)では読み込めませんでした:\n{e}"
364 )
365 return
366 self.current_df = new_df
367 self._apply_type_overrides()
368 self._rebuild_preview_table()
370 def _on_check_column_types(self):
371 dialog = ColumnTypeDialog(self.current_df, self)
372 if dialog.exec() == QDialog.DialogCode.Accepted: 372 ↛ exitline 372 didn't return from function '_on_check_column_types' because the condition on line 372 was always true
373 self.type_overrides = dialog.get_overrides()
374 self._apply_type_overrides()
375 self._rebuild_preview_table()
377 def _apply_type_overrides(self):
378 """列の型の上書きを current_df に当てる。読み直した後も、同じ名前の列があれば当て直す。"""
379 for col_name, override in self.type_overrides.items():
380 if col_name not in self.current_df.columns:
381 continue
382 try:
383 if override == "数値":
384 self.current_df[col_name] = pd.to_numeric(self.current_df[col_name], errors='coerce')
385 elif override == "文字列":
386 self.current_df[col_name] = self.current_df[col_name].astype(str)
387 elif override == "日付": 387 ↛ 379line 387 didn't jump to line 379 because the condition on line 387 was always true
388 self.current_df[col_name] = pd.to_datetime(self.current_df[col_name], errors='coerce')
389 except Exception as e:
390 logger.warning("列「%s」の型変換(%s)に失敗しました: %s", col_name, override, e)
392 def _rebuild_preview_table(self):
393 df = self.current_df
394 columns = [str(c) for c in df.columns]
396 self.info_label.setText(
397 f"{len(df)}行 × {len(columns)}列 が見つかりました。"
398 "プレビューを確認し、X軸・Y軸に使う列を選択してください。"
399 + (f"\n{self._numeric_table_note}" if self._numeric_table_note else "")
400 )
402 preview_row_count = min(len(df), 20)
403 self.table.clear()
404 self.table.setRowCount(preview_row_count)
405 self.table.setColumnCount(len(columns))
406 self.table.setHorizontalHeaderLabels(columns)
407 for r in range(preview_row_count):
408 for c in range(len(columns)):
409 value = df.iloc[r, c]
410 text = "" if pd.isna(value) else str(value)
411 self.table.setItem(r, c, QTableWidgetItem(text))
412 self.table.resizeColumnsToContents()
414 # できるだけ前の選択を残し、無ければ先頭の2列
415 prev_x = self.x_col_combo.currentText()
416 prev_y = self.y_col_combo.currentText()
417 self.x_col_combo.blockSignals(True)
418 self.y_col_combo.blockSignals(True)
419 self.x_col_combo.clear()
420 self.y_col_combo.clear()
421 self.x_col_combo.addItems(columns)
422 self.y_col_combo.addItems(columns)
423 if prev_x in columns:
424 self.x_col_combo.setCurrentText(prev_x)
425 elif len(columns) >= 1: 425 ↛ 427line 425 didn't jump to line 427 because the condition on line 425 was always true
426 self.x_col_combo.setCurrentIndex(0)
427 if prev_y in columns:
428 self.y_col_combo.setCurrentText(prev_y)
429 elif len(columns) >= 2:
430 self.y_col_combo.setCurrentIndex(1)
431 self.x_col_combo.blockSignals(False)
432 self.y_col_combo.blockSignals(False)
434 def accept(self):
435 # 文字と数値(や空欄)が混ざった Y の列は、描画の途中で matplotlib が例外を出す。その前に理由を伝える
436 _x_col, y_col = self.get_selected_columns()
437 if y_col in self.current_df.columns and _mixes_text_and_other_values(self.current_df[y_col]):
438 notify.warning(
439 self, "Y軸の列を確認してください",
440 f"Y軸の列「{y_col}」には、文字と数値(または空欄)が混ざっているため、このままでは描画できません。\n\n"
441 "「列の型を確認...」で数値に変換するか、ヘッダー行や別の列を選び直してください。"
442 )
443 return
444 super().accept()
446 def get_selected_columns(self):
447 """(X の列名, Y の列名)"""
448 return self.x_col_combo.currentText(), self.y_col_combo.currentText()
450 def get_dataframe(self):
451 """いま見せている DataFrame(シートなどを変えていればそれを反映したもの)。"""
452 return self.current_df
455class ColumnTypeDialog(QDialog):
456 """自動判定した列の型を見せ、「数値」「文字列」「日付」に直せるようにする(日付が数値として読まれることがある)。"""
458 OVERRIDE_CHOICES = ["自動 (変換しない)", "数値", "文字列", "日付"]
460 def __init__(self, df, parent=None):
461 super().__init__(parent)
462 self.setWindowTitle("列の型を確認")
463 self.resize(480, 400)
465 layout = QVBoxLayout(self)
466 info_label = QLabel(
467 "各列について、現在検出されている型を表示しています。"
468 "日付が数値として読み込まれている場合など、意図と異なる場合は"
469 "「上書き後の型」で変換方法を選択してください。"
470 )
471 info_label.setWordWrap(True)
472 layout.addWidget(info_label)
474 self.table = QTableWidget()
475 self.table.setColumnCount(3)
476 self.table.setHorizontalHeaderLabels(["列名", "検出された型", "上書き後の型"])
477 self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
478 self.table.setRowCount(len(df.columns))
480 self._override_combos = {}
481 for row, col_name in enumerate(df.columns):
482 self.table.setItem(row, 0, QTableWidgetItem(str(col_name)))
484 detected_item = QTableWidgetItem(str(df[col_name].dtype))
485 detected_item.setFlags(detected_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
486 self.table.setItem(row, 1, detected_item)
488 combo = QComboBox()
489 combo.addItems(self.OVERRIDE_CHOICES)
490 self.table.setCellWidget(row, 2, combo)
491 self._override_combos[str(col_name)] = combo
493 self.table.resizeColumnsToContents()
494 layout.addWidget(self.table)
496 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
497 QDialogButtonBox.StandardButton.Cancel)
498 button_box.accepted.connect(self.accept)
499 button_box.rejected.connect(self.reject)
500 layout.addWidget(button_box)
502 def get_overrides(self):
503 """「自動」以外にした列だけの {列名: "数値" / "文字列" / "日付"}。"""
504 overrides = {}
505 for col_name, combo in self._override_combos.items():
506 text = combo.currentText()
507 if text != "自動 (変換しない)":
508 overrides[col_name] = text
509 return overrides
512class ExcelMultiSheetDialog(QDialog):
513 """取り込むシートを選ぶ。複数選ぶとシートごとに別のデータセットになる。"""
515 def __init__(self, sheet_names, parent=None):
516 super().__init__(parent)
517 self.setWindowTitle("シートの選択")
518 self.resize(320, 380)
520 layout = QVBoxLayout(self)
521 info_label = QLabel(
522 "読み込むシートを選択してください(複数選択可)。\n"
523 "2つ以上選択すると、シートごとに別々のデータセットとして追加されます。"
524 )
525 info_label.setWordWrap(True)
526 layout.addWidget(info_label)
528 self.sheet_list = QListWidget()
529 self.sheet_list.setSelectionMode(QListWidget.SelectionMode.NoSelection)
530 for i, name in enumerate(sheet_names):
531 item = QListWidgetItem(str(name))
532 item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
533 item.setCheckState(Qt.CheckState.Checked if i == 0 else Qt.CheckState.Unchecked)
534 self.sheet_list.addItem(item)
535 layout.addWidget(self.sheet_list)
537 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
538 QDialogButtonBox.StandardButton.Cancel)
539 button_box.accepted.connect(self.accept)
540 button_box.rejected.connect(self.reject)
541 layout.addWidget(button_box)
543 def get_selected_sheets(self):
544 """チェックしたシート名(シートの順)。"""
545 result = []
546 for i in range(self.sheet_list.count()):
547 item = self.sheet_list.item(i)
548 if item.checkState() == Qt.CheckState.Checked:
549 result.append(item.text())
550 return result
553class FolderImportDialog(QDialog):
554 """フォルダ一括取り込みの対象の一覧と、ファイル名から条件を取り出す正規表現。読み込みはドラッグ&ドロップと同じ待ち行列。"""
556 def __init__(self, folder_path, file_names, parent=None):
557 super().__init__(parent)
558 self.setWindowTitle("フォルダから一括インポート")
559 self.resize(480, 340)
561 layout = QVBoxLayout(self)
562 folder_label = QLabel(f"フォルダ: {folder_path}")
563 folder_label.setWordWrap(True)
564 layout.addWidget(folder_label)
565 layout.addWidget(QLabel(f"対象ファイル: {len(file_names)}件"))
567 self.file_list = QListWidget()
568 self.file_list.addItems(file_names)
569 self.file_list.setMaximumHeight(130)
570 layout.addWidget(self.file_list)
572 layout.addWidget(QLabel(
573 "ファイル名から抽出する正規表現(任意、名前付きグループが新しい列になります):"
574 ))
575 self.regex_edit = QLineEdit()
576 self.regex_edit.setPlaceholderText(r"例: (?P<temp>\d+)C_(?P<run>\d+)\.csv")
577 layout.addWidget(self.regex_edit)
579 self.preview_label = QLabel("")
580 self.preview_label.setWordWrap(True)
581 self.preview_label.setStyleSheet("font-size: 9pt; color: gray;")
582 layout.addWidget(self.preview_label)
584 self._first_file_name = file_names[0] if file_names else ""
585 self.regex_edit.textChanged.connect(self._update_preview)
586 self._update_preview()
588 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
589 QDialogButtonBox.StandardButton.Cancel)
590 button_box.accepted.connect(self.accept)
591 button_box.rejected.connect(self.reject)
592 layout.addWidget(button_box)
594 apply_form_spacing(self)
596 def _update_preview(self):
597 """先頭のファイル名で取り出した結果をその場で見せる。"""
598 pattern = self.regex_edit.text().strip()
599 if not pattern:
600 self.preview_label.setText("(正規表現が未入力のため、列は追加されません)")
601 return
602 try:
603 match = re.search(pattern, self._first_file_name)
604 except re.error as e:
605 self.preview_label.setText(f"正規表現エラー: {e}")
606 return
607 if match is None:
608 self.preview_label.setText(f"「{self._first_file_name}」にマッチしません")
609 return
610 groups = match.groupdict()
611 if not groups: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 self.preview_label.setText("名前付きグループ (?P<name>...) がありません(列は追加されません)")
613 return
614 preview = ", ".join(f"{k}={v}" for k, v in groups.items())
615 self.preview_label.setText(f"「{self._first_file_name}」からの抽出例: {preview}")
617 def get_regex_pattern(self):
618 """正規表現。空なら None。"""
619 pattern = self.regex_edit.text().strip()
620 return pattern if pattern else None
623class NewDatasetDialog(QDialog):
624 """名前・列名・行数だけで空のデータセットを作る(作った後はデータエディタで打ち込む)。"""
626 def __init__(self, parent=None):
627 super().__init__(parent)
628 from graphica.core.i18n import tr
630 self.setWindowTitle(tr("新規データセットを作成"))
631 self.resize(360, 180)
633 layout = QFormLayout(self)
635 self.name_edit = QLineEdit(tr("新規データセット"))
636 layout.addRow(tr("データセット名"), self.name_edit)
638 self.columns_edit = QLineEdit("X, Y")
639 self.columns_edit.setToolTip(tr("カンマ区切りで列名を入力してください(例: X, Y, 誤差)"))
640 layout.addRow(tr("列名 (カンマ区切り)"), self.columns_edit)
642 self.rows_spinbox = QSpinBox()
643 self.rows_spinbox.setRange(0, 10000)
644 self.rows_spinbox.setValue(5)
645 layout.addRow(tr("初期の空行数"), self.rows_spinbox)
647 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
648 QDialogButtonBox.StandardButton.Cancel)
649 button_box.accepted.connect(self._on_accept)
650 button_box.rejected.connect(self.reject)
651 layout.addRow(button_box)
653 apply_form_spacing(self)
655 def _on_accept(self):
656 from graphica.core.i18n import tr
657 if not self.get_dataset_name():
658 notify.warning(self, tr("新規データセットを作成"), tr("データセット名を入力してください。"))
659 return
660 if not self.get_column_names():
661 notify.warning(self, tr("新規データセットを作成"), tr("列名を1つ以上入力してください。"))
662 return
663 self.accept()
665 def get_dataset_name(self):
666 return self.name_edit.text().strip()
668 def get_column_names(self):
669 """カンマ区切りを列名のリストにする(重複と空は除く)。"""
670 raw = self.columns_edit.text()
671 names = [c.strip() for c in raw.split(',') if c.strip()]
672 unique_names = []
673 for name in names:
674 if name not in unique_names:
675 unique_names.append(name)
676 return unique_names
678 def get_row_count(self):
679 return self.rows_spinbox.value()