Coverage for graphica/gui/project_files.py: 94.0%

253 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""プロジェクトのファイル: 保存・読み込み・未保存の変更の確認・オートセーブと世代・最近使ったファイル。""" 

2import logging 

3import os 

4import time 

5from PySide6.QtWidgets import QDialog, QMessageBox 

6from datetime import datetime 

7from graphica.core.i18n import tr 

8from graphica.core.app_paths import get_app_data_dir 

9from graphica.gui import app_settings, notify 

10from graphica.gui import dialog_dirs 

11from graphica.gui.builders import canvas_area 

12from graphica.gui.dialogs import AutosaveHistoryDialog 

13from graphica.models.project import LEGACY_PICKLE_EXTENSION, PROJECT_FILE_EXTENSION, PROJECT_FILE_EXTENSIONS 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18AUTOSAVE_GENERATIONS = 3 # 最新の autosave.gra を含む 

19 

20 

21MAX_RECENT_FILES = 10 

22 

23 

24# 未保存の変更の確認を出すか。テストはモーダルなダイアログで止まるので tests/conftest.py が "0" にする 

25UNSAVED_CHANGES_PROMPT_ENV = "GRAPHICA_CONFIRM_UNSAVED_CHANGES" 

26 

27 

28def _unsaved_changes_prompt_enabled(): 

29 return os.environ.get(UNSAVED_CHANGES_PROMPT_ENV, "1") != "0" 

30 

31 

32def check_autosave_recovery(app): 

33 """前回が正常に終わらず、オートセーブが残っていれば、復元するか尋ねる(起動時に1回)。 

34 

35 無ければ、拡張子を .gra に変える前の版が残した autosave.graphica を探す。 

36 """ 

37 if app._had_clean_exit: 

38 return 

39 

40 autosave_path = app._autosave_filename 

41 if not os.path.exists(autosave_path): 

42 legacy_path = os.path.splitext(app._autosave_filename)[0] + '.graphica' 

43 if os.path.exists(legacy_path): 

44 autosave_path = legacy_path 

45 else: 

46 return 

47 

48 reply = notify.question( 

49 app, "オートセーブからの復元", 

50 "前回はプロジェクトが正常に終了しなかったようです。\n" 

51 "自動保存されていたデータを復元しますか?", 

52 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 

53 QMessageBox.StandardButton.Yes 

54 ) 

55 if reply == QMessageBox.StandardButton.Yes: 

56 app._load_project_from_path(autosave_path, add_to_recent=False) 

57 

58 

59def on_show_autosave_history(app): 

60 """オートセーブの世代を新しい順に見せ、選んだものを読み込む(復元確認と同じく、最近使ったファイルに載せず、上書き保存の対象にもしない)。""" 

61 base, ext = os.path.splitext(app._autosave_filename) 

62 candidates = [(app._autosave_filename, "現在(最新)")] 

63 for gen in range(1, AUTOSAVE_GENERATIONS): 

64 candidates.append((f"{base}.{gen}{ext}", f"{gen}世代前")) 

65 

66 generations = [] 

67 for path, label in candidates: 

68 if not os.path.exists(path): 

69 continue 

70 try: 

71 mtime_text = datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d %H:%M:%S") 

72 except OSError: 

73 mtime_text = "(更新日時不明)" 

74 generations.append((path, label, mtime_text)) 

75 

76 if not generations: 

77 notify.information(app, "自動バックアップ履歴", "自動バックアップファイルが見つかりませんでした。") 

78 return 

79 

80 dialog = AutosaveHistoryDialog(generations, parent=app) 

81 if dialog.exec() != QDialog.DialogCode.Accepted: 

82 return 

83 selected_path = dialog.get_selected_path() 

84 if not selected_path: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 return 

86 

87 reply = notify.question( 

88 app, "自動バックアップ履歴", 

89 "選択した世代の内容で復元します。現在の未保存の変更は失われます。よろしいですか?", 

90 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 

91 QMessageBox.StandardButton.No 

92 ) 

93 if reply != QMessageBox.StandardButton.Yes: 

94 return 

95 

96 app._load_project_from_path(selected_path, add_to_recent=False) 

97 

98 

99def update_autosave_path(app): 

100 """autosave_dir(空なら get_app_data_dir())から保存先を決め、フォルダを作る。 

101 

102 カレントディレクトリは使わない(macOS の .app では書き込めない場所になる)。 

103 """ 

104 autosave_dir = app_settings.AUTOSAVE_DIR.read(app.settings) 

105 if autosave_dir: 

106 try: 

107 os.makedirs(autosave_dir, exist_ok=True) 

108 except OSError as e: 

109 logger.warning("オートセーブ保存先フォルダの作成に失敗しました: %s", e) 

110 autosave_dir = "" 

111 if not autosave_dir: 

112 autosave_dir = get_app_data_dir() 

113 app._autosave_filename = os.path.join(autosave_dir, app._autosave_base_filename) 

114 

115 

116def rotate_autosave_generations(app): 

117 """autosave.gra を autosave.1.gra, autosave.2.gra, ... へ押し出す。""" 

118 base, ext = os.path.splitext(app._autosave_filename) 

119 

120 oldest = f"{base}.{AUTOSAVE_GENERATIONS - 1}{ext}" 

121 if os.path.exists(oldest): 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 os.remove(oldest) 

123 

124 for gen in range(AUTOSAVE_GENERATIONS - 2, 0, -1): 

125 src = f"{base}.{gen}{ext}" 

126 dst = f"{base}.{gen + 1}{ext}" 

127 if os.path.exists(src): 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 os.replace(src, dst) 

129 

130 if os.path.exists(app._autosave_filename): 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 os.replace(app._autosave_filename, f"{base}.1{ext}") 

132 

133 

134def sync_project_from_ui(app): 

135 """UI にしか無い状態(フォルダ構造、サブプロットの行数と列数)を、保存や比較の前に ProjectModel へ移す。""" 

136 app.project.dataset_group_tree = app._capture_dataset_group_tree() 

137 app.project.layout_rows = app.subplot_rows_spinbox.value() 

138 app.project.layout_cols = app.subplot_cols_spinbox.value() 

139 

140 

141def remember_saved_content(app): 

142 """いまの内容を保存済みとみなす(保存・読み込みの成功直後に呼ぶ)。""" 

143 app._sync_project_from_ui() 

144 app._saved_content_fingerprint = app.project.content_fingerprint() 

145 

146 

147def document_title(app): 

148 if app._current_project_path: 

149 return os.path.basename(app._current_project_path) 

150 if app._restored_unsaved: 

151 return tr("無題のプロジェクト(復元)") 

152 return tr("無題のプロジェクト") 

153 

154 

155def has_unsaved_changes(app): 

156 """データセットが無く、ファイルとも対応していない新しいタブは False。""" 

157 if not app.project.datasets and not app._current_project_path: 

158 return False 

159 if app._saved_content_fingerprint is None: 

160 return True 

161 app._sync_project_from_ui() 

162 return app.project.content_fingerprint() != app._saved_content_fingerprint 

163 

164 

165def confirm_unsaved_changes(app, action_text): 

166 """未保存の変更があれば「保存 / 保存しない / キャンセル」を尋ねる。続けてよければ True。""" 

167 if not _unsaved_changes_prompt_enabled() or not app.has_unsaved_changes(): 

168 return True 

169 name = app.document_title() 

170 box = QMessageBox(app) 

171 box.setIcon(QMessageBox.Icon.Warning) 

172 box.setWindowTitle("保存されていない変更") 

173 box.setText(f"「{name}」には保存されていない変更があります。") 

174 box.setInformativeText(f"{action_text}前に保存しますか?") 

175 save_button = box.addButton("保存", QMessageBox.ButtonRole.AcceptRole) 

176 discard_button = box.addButton("保存しない", QMessageBox.ButtonRole.DestructiveRole) 

177 cancel_button = box.addButton("キャンセル", QMessageBox.ButtonRole.RejectRole) 

178 box.setDefaultButton(save_button) 

179 box.setEscapeButton(cancel_button) 

180 box.exec() 

181 clicked = box.clickedButton() 

182 if clicked is discard_button: 

183 return True 

184 if clicked is save_button: 

185 app.manual_save() 

186 return not app.has_unsaved_changes() 

187 return False 

188 

189 

190def auto_save(app): 

191 try: 

192 app._sync_project_from_ui() 

193 app._rotate_autosave_generations() 

194 app.project.save_project(app._autosave_filename) 

195 # ステータスバーの「自動保存 N 分前」の起点 

196 app._last_autosave_time = time.monotonic() 

197 canvas_area.refresh_status_bar(app) 

198 app.statusBar().showMessage("オートセーブ完了", 3000) 

199 except Exception as e: 

200 logger.exception("オートセーブに失敗しました") 

201 app.statusBar().showMessage(f"オートセーブ失敗: {e}", 3000) 

202 

203 

204def manual_save(app): 

205 """保存先が分かっていればそこへ上書きし、無ければ「名前を付けて保存」にする。""" 

206 if not app._current_project_path: 

207 app.manual_save_as() 

208 return 

209 app._save_project_to_path(app._current_project_path) 

210 

211 

212def manual_save_as(app): 

213 # 保存したことがあればその場所と名前、まだならデータの隣、どちらも無ければ前回のプロジェクトのフォルダ 

214 file_name = os.path.basename(app._current_project_path) if app._current_project_path else '' 

215 filepath, _ = dialog_dirs.get_save_file_name( 

216 app, dialog_dirs.PROJECT, "名前を付けて保存", file_name, f"Graphica Project (*{PROJECT_FILE_EXTENSION})", 

217 preferred_dir=dialog_dirs.output_dir(app), 

218 ) 

219 if filepath: 

220 # 拡張子を付けないファイルダイアログがある 

221 if os.path.splitext(filepath)[1].lower() not in PROJECT_FILE_EXTENSIONS: 

222 filepath += PROJECT_FILE_EXTENSION 

223 app._save_project_to_path(filepath) 

224 

225 

226def save_project_to_path(app, filepath): 

227 try: 

228 app._sync_project_from_ui() 

229 app.project.save_project(filepath) 

230 app._current_project_path = filepath 

231 app._restored_unsaved = False 

232 app._saved_content_fingerprint = app.project.content_fingerprint() 

233 app.statusBar().showMessage(f"保存しました: {filepath}", 3000) 

234 app._add_recent_file(filepath) 

235 app.project_state_changed.emit() 

236 except Exception as e: 

237 logger.exception("プロジェクトの保存に失敗しました: %s", filepath) 

238 notify.critical(app, "エラー", f"保存に失敗しました:\n{e}") 

239 

240 

241def project_file_filter(): 

242 return "Graphica Project (" + " ".join(f"*{ext}" for ext in PROJECT_FILE_EXTENSIONS) + ")" 

243 

244 

245def manual_load(app): 

246 if not app.confirm_unsaved_changes("別のプロジェクトを開く"): 

247 return 

248 filepath, _ = dialog_dirs.get_open_file_name(app, dialog_dirs.PROJECT, "プロジェクトを開く", project_file_filter()) 

249 if filepath: 

250 app._load_project_from_path(filepath) 

251 

252 

253def load_project_from_path(app, filepath, add_to_recent=True): 

254 """add_to_recent=False はオートセーブからの復元。最近使ったファイルに載せず、上書き保存の対象にもしない。""" 

255 try: 

256 app.project.load_project(filepath) 

257 # 中身はもう入れ替わっている。この先で失敗したとき前のファイルが保存先に 

258 # 残っていると、上書き保存でそのファイルを別の内容で壊してしまう。 

259 app._current_project_path = None 

260 app._saved_content_fingerprint = None 

261 app._restored_unsaved = False 

262 

263 app._rebuild_dataset_tree_widget() 

264 

265 app._block_all_signals(True) 

266 app.subplot_rows_spinbox.setValue(app.project.layout_rows) 

267 app.subplot_cols_spinbox.setValue(app.project.layout_cols) 

268 is_free_layout = getattr(app.project, 'layout_mode', 'grid') == 'free' 

269 app.free_layout_checkbox.setChecked(is_free_layout) 

270 app.subplot_rows_spinbox.setEnabled(not is_free_layout) 

271 app.subplot_cols_spinbox.setEnabled(not is_free_layout) 

272 app.add_free_subplot_button.setEnabled(is_free_layout) 

273 app.remove_free_subplot_button.setEnabled(is_free_layout) 

274 app.layout_edit_action.setEnabled(is_free_layout) 

275 if not is_free_layout and app.layout_edit_action.isChecked(): 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 app.layout_edit_action.setChecked(False) 

277 app._toggle_layout_edit_mode(False) 

278 app.panel_labels_action.setChecked(app.project.panel_labels_enabled) 

279 app.share_x_checkbox.setChecked(getattr(app.project, 'share_x_axis', False)) 

280 app.share_y_checkbox.setChecked(getattr(app.project, 'share_y_axis', False)) 

281 app.share_x_checkbox.setEnabled(not is_free_layout) 

282 app.share_y_checkbox.setEnabled(not is_free_layout) 

283 app._block_all_signals(False) 

284 

285 if app.project.all_plot_settings: 285 ↛ 292line 285 didn't jump to line 292 because the condition on line 285 was always true

286 app._apply_settings_to_ui_controls( 

287 app.project.all_plot_settings[app.project.active_axis_index] 

288 ) 

289 

290 # 前の文書へのコマンドは datasets をリストごと差し戻すので、残すと 

291 # Undo 1回で読み込んだ内容が前の文書に置き換わる。 

292 app.undo_stack.clear() 

293 # マウスで変えた表示範囲は前の文書のもの 

294 app.view_navigation.forget_all_views() 

295 app._forget_default_style_template() 

296 

297 app.property_panel.update_ui_state() 

298 app._update_plot() 

299 

300 app.statusBar().showMessage("プロジェクトを読み込みました", 3000) 

301 if add_to_recent: 

302 app._add_recent_file(filepath) 

303 app._current_project_path = filepath 

304 app._remember_saved_content() 

305 else: 

306 app._restored_unsaved = True 

307 app.project_state_changed.emit() 

308 except Exception as e: 

309 logger.exception("プロジェクトの読み込みに失敗しました: %s", filepath) 

310 notify.critical(app, "エラー", f"読み込みに失敗しました:\n{e}") 

311 

312 

313def get_recent_files(app): 

314 return app_settings.as_list_keeping_empty_string(app_settings.RECENT_FILES.read(app.settings)) 

315 

316 

317def add_recent_file(app, file_path): 

318 file_path = os.path.abspath(file_path) 

319 files = app._get_recent_files() 

320 if file_path in files: 

321 files.remove(file_path) 

322 files.insert(0, file_path) 

323 files = files[:MAX_RECENT_FILES] 

324 app_settings.RECENT_FILES.write(app.settings, files) 

325 app._update_recent_files_menu() 

326 

327 

328def update_recent_files_menu(app): 

329 try: 

330 app.recent_files_menu.clear() 

331 files = app._get_recent_files() 

332 

333 if not files: 

334 empty_action = app.recent_files_menu.addAction(tr("(履歴なし)")) 

335 empty_action.setEnabled(False) 

336 return 

337 

338 for file_path in files: 

339 action = app.recent_files_menu.addAction(file_path) 

340 action.triggered.connect(lambda checked=False, p=file_path: app._on_open_recent_file(p)) 

341 

342 app.recent_files_menu.addSeparator() 

343 clear_action = app.recent_files_menu.addAction(tr("履歴をクリア")) 

344 clear_action.triggered.connect(app._on_clear_recent_files) 

345 except RuntimeError: 

346 # まれにメニューの C++ 側が破棄済みのことがある(PySide6 の回収、原因は未特定)。表示が古いだけなので落とさない 

347 logger.warning("recent_files_menuの更新に失敗しました(既に破棄されている可能性があります)。", exc_info=True) 

348 

349 

350def on_open_recent_file(app, file_path): 

351 if not os.path.exists(file_path): 

352 notify.warning(app, "エラー", f"ファイルが見つかりません:\n{file_path}") 

353 files = app._get_recent_files() 

354 if file_path in files: 354 ↛ 358line 354 didn't jump to line 358 because the condition on line 354 was always true

355 files.remove(file_path) 

356 app_settings.RECENT_FILES.write(app.settings, files) 

357 app._update_recent_files_menu() 

358 return 

359 

360 # .pkl は開けないが、データとして読ませずに「対応していない」と知らせる 

361 if file_path.lower().endswith(PROJECT_FILE_EXTENSIONS + (LEGACY_PICKLE_EXTENSION,)): 

362 if not app.confirm_unsaved_changes("別のプロジェクトを開く"): 

363 return 

364 app._load_project_from_path(file_path) 

365 else: 

366 app.load_data(file_path) 

367 

368 

369def on_clear_recent_files(app): 

370 app_settings.RECENT_FILES.write(app.settings, []) 

371 app._update_recent_files_menu()