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

1"""データの取り込みのダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。""" 

2 

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 

25 

26logger = logging.getLogger(__name__) 

27 

28 

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 

42 

43 

44class ColumnPreviewDialog(QDialog): 

45 """読み込む前に内容を見せて X/Y の列を選ばせる(列の多いファイルで意図しない列が選ばれないように)。 

46 

47 Excel はシートとヘッダー行、CSV は文字コード・区切り文字・ヘッダー行・固定長を変えられ、変えるたびにファイルから読み直す。 

48 """ 

49 

50 # 表示名 -> read_csv の sep。「空白」は空白の連続を区切りとする正規表現なので python エンジンで読む 

51 _DELIMITER_CHOICES = { 

52 "カンマ (,)": ",", 

53 "タブ": "\t", 

54 "セミコロン (;)": ";", 

55 "空白": r"\s+", 

56 } 

57 _DELIMITER_CUSTOM_LABEL = "その他..." 

58 

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 = "自動判定" 

67 

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) 

73 

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 = "" 

83 

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) 

91 

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 = ',' 

106 

107 layout = QVBoxLayout(self) 

108 

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 

118 

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) 

125 

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) 

132 

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) 

140 

141 layout.addLayout(excel_form) 

142 

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 

151 

152 if self.is_csv: 

153 csv_form = QFormLayout() 

154 

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) 

162 

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) 

171 

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) 

178 

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) 

187 

188 self.fixed_width_checkbox = QCheckBox("固定長フォーマットとして読み込む") 

189 self.fixed_width_checkbox.setToolTip("区切り文字を使わず、列の文字位置で区切られたデータの場合に選択します") 

190 csv_form.addRow(self.fixed_width_checkbox) 

191 

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) 

198 

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) 

205 

206 layout.addLayout(csv_form) 

207 

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) 

211 

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 

227 

228 self.info_label = QLabel() 

229 layout.addWidget(self.info_label) 

230 

231 self.table = QTableWidget() 

232 self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) 

233 layout.addWidget(self.table) 

234 

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) 

241 

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) 

247 

248 apply_form_spacing(self) 

249 

250 if self.is_csv: 

251 # 最初に読んだ df はカンマ区切りなので、区切りが違うと1列に潰れている。判定した設定で読み直す 

252 self._reload_csv_preview() 

253 else: 

254 self._rebuild_preview_table() 

255 

256 @staticmethod 

257 def _describe_delimiter(delimiter): 

258 labels = {',': 'カンマ', '\t': 'タブ', ';': 'セミコロン', r'\s+': '空白'} 

259 return labels.get(delimiter, repr(delimiter)) 

260 

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') 

266 

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, ',') 

274 

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()) 

280 

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() 

286 

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() 

293 

294 def _reload_csv_preview(self, *_args): 

295 """CSV の設定が変わったら読み直す。区切りが正規表現のことがあるので python エンジンで読む。""" 

296 from graphica.gui.workers import has_numeric_column, read_numeric_table 

297 

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 

320 

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 ) 

335 

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() 

346 

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() 

369 

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() 

376 

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) 

391 

392 def _rebuild_preview_table(self): 

393 df = self.current_df 

394 columns = [str(c) for c in df.columns] 

395 

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 ) 

401 

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() 

413 

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) 

433 

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() 

445 

446 def get_selected_columns(self): 

447 """(X の列名, Y の列名)""" 

448 return self.x_col_combo.currentText(), self.y_col_combo.currentText() 

449 

450 def get_dataframe(self): 

451 """いま見せている DataFrame(シートなどを変えていればそれを反映したもの)。""" 

452 return self.current_df 

453 

454 

455class ColumnTypeDialog(QDialog): 

456 """自動判定した列の型を見せ、「数値」「文字列」「日付」に直せるようにする(日付が数値として読まれることがある)。""" 

457 

458 OVERRIDE_CHOICES = ["自動 (変換しない)", "数値", "文字列", "日付"] 

459 

460 def __init__(self, df, parent=None): 

461 super().__init__(parent) 

462 self.setWindowTitle("列の型を確認") 

463 self.resize(480, 400) 

464 

465 layout = QVBoxLayout(self) 

466 info_label = QLabel( 

467 "各列について、現在検出されている型を表示しています。" 

468 "日付が数値として読み込まれている場合など、意図と異なる場合は" 

469 "「上書き後の型」で変換方法を選択してください。" 

470 ) 

471 info_label.setWordWrap(True) 

472 layout.addWidget(info_label) 

473 

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)) 

479 

480 self._override_combos = {} 

481 for row, col_name in enumerate(df.columns): 

482 self.table.setItem(row, 0, QTableWidgetItem(str(col_name))) 

483 

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) 

487 

488 combo = QComboBox() 

489 combo.addItems(self.OVERRIDE_CHOICES) 

490 self.table.setCellWidget(row, 2, combo) 

491 self._override_combos[str(col_name)] = combo 

492 

493 self.table.resizeColumnsToContents() 

494 layout.addWidget(self.table) 

495 

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) 

501 

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 

510 

511 

512class ExcelMultiSheetDialog(QDialog): 

513 """取り込むシートを選ぶ。複数選ぶとシートごとに別のデータセットになる。""" 

514 

515 def __init__(self, sheet_names, parent=None): 

516 super().__init__(parent) 

517 self.setWindowTitle("シートの選択") 

518 self.resize(320, 380) 

519 

520 layout = QVBoxLayout(self) 

521 info_label = QLabel( 

522 "読み込むシートを選択してください(複数選択可)。\n" 

523 "2つ以上選択すると、シートごとに別々のデータセットとして追加されます。" 

524 ) 

525 info_label.setWordWrap(True) 

526 layout.addWidget(info_label) 

527 

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) 

536 

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) 

542 

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 

551 

552 

553class FolderImportDialog(QDialog): 

554 """フォルダ一括取り込みの対象の一覧と、ファイル名から条件を取り出す正規表現。読み込みはドラッグ&ドロップと同じ待ち行列。""" 

555 

556 def __init__(self, folder_path, file_names, parent=None): 

557 super().__init__(parent) 

558 self.setWindowTitle("フォルダから一括インポート") 

559 self.resize(480, 340) 

560 

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)}件")) 

566 

567 self.file_list = QListWidget() 

568 self.file_list.addItems(file_names) 

569 self.file_list.setMaximumHeight(130) 

570 layout.addWidget(self.file_list) 

571 

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) 

578 

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) 

583 

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() 

587 

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) 

593 

594 apply_form_spacing(self) 

595 

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}") 

616 

617 def get_regex_pattern(self): 

618 """正規表現。空なら None。""" 

619 pattern = self.regex_edit.text().strip() 

620 return pattern if pattern else None 

621 

622 

623class NewDatasetDialog(QDialog): 

624 """名前・列名・行数だけで空のデータセットを作る(作った後はデータエディタで打ち込む)。""" 

625 

626 def __init__(self, parent=None): 

627 super().__init__(parent) 

628 from graphica.core.i18n import tr 

629 

630 self.setWindowTitle(tr("新規データセットを作成")) 

631 self.resize(360, 180) 

632 

633 layout = QFormLayout(self) 

634 

635 self.name_edit = QLineEdit(tr("新規データセット")) 

636 layout.addRow(tr("データセット名"), self.name_edit) 

637 

638 self.columns_edit = QLineEdit("X, Y") 

639 self.columns_edit.setToolTip(tr("カンマ区切りで列名を入力してください(例: X, Y, 誤差)")) 

640 layout.addRow(tr("列名 (カンマ区切り)"), self.columns_edit) 

641 

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) 

646 

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) 

652 

653 apply_form_spacing(self) 

654 

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() 

664 

665 def get_dataset_name(self): 

666 return self.name_edit.text().strip() 

667 

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 

677 

678 def get_row_count(self): 

679 return self.rows_spinbox.value()