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
« 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
15logger = logging.getLogger(__name__)
18AUTOSAVE_GENERATIONS = 3 # 最新の autosave.gra を含む
21MAX_RECENT_FILES = 10
24# 未保存の変更の確認を出すか。テストはモーダルなダイアログで止まるので tests/conftest.py が "0" にする
25UNSAVED_CHANGES_PROMPT_ENV = "GRAPHICA_CONFIRM_UNSAVED_CHANGES"
28def _unsaved_changes_prompt_enabled():
29 return os.environ.get(UNSAVED_CHANGES_PROMPT_ENV, "1") != "0"
32def check_autosave_recovery(app):
33 """前回が正常に終わらず、オートセーブが残っていれば、復元するか尋ねる(起動時に1回)。
35 無ければ、拡張子を .gra に変える前の版が残した autosave.graphica を探す。
36 """
37 if app._had_clean_exit:
38 return
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
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)
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}世代前"))
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))
76 if not generations:
77 notify.information(app, "自動バックアップ履歴", "自動バックアップファイルが見つかりませんでした。")
78 return
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
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
96 app._load_project_from_path(selected_path, add_to_recent=False)
99def update_autosave_path(app):
100 """autosave_dir(空なら get_app_data_dir())から保存先を決め、フォルダを作る。
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)
116def rotate_autosave_generations(app):
117 """autosave.gra を autosave.1.gra, autosave.2.gra, ... へ押し出す。"""
118 base, ext = os.path.splitext(app._autosave_filename)
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)
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)
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}")
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()
141def remember_saved_content(app):
142 """いまの内容を保存済みとみなす(保存・読み込みの成功直後に呼ぶ)。"""
143 app._sync_project_from_ui()
144 app._saved_content_fingerprint = app.project.content_fingerprint()
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("無題のプロジェクト")
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
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
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)
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)
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)
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}")
241def project_file_filter():
242 return "Graphica Project (" + " ".join(f"*{ext}" for ext in PROJECT_FILE_EXTENSIONS) + ")"
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)
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
263 app._rebuild_dataset_tree_widget()
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)
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 )
290 # 前の文書へのコマンドは datasets をリストごと差し戻すので、残すと
291 # Undo 1回で読み込んだ内容が前の文書に置き換わる。
292 app.undo_stack.clear()
293 # マウスで変えた表示範囲は前の文書のもの
294 app.view_navigation.forget_all_views()
295 app._forget_default_style_template()
297 app.property_panel.update_ui_state()
298 app._update_plot()
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}")
313def get_recent_files(app):
314 return app_settings.as_list_keeping_empty_string(app_settings.RECENT_FILES.read(app.settings))
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()
328def update_recent_files_menu(app):
329 try:
330 app.recent_files_menu.clear()
331 files = app._get_recent_files()
333 if not files:
334 empty_action = app.recent_files_menu.addAction(tr("(履歴なし)"))
335 empty_action.setEnabled(False)
336 return
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))
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)
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
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)
369def on_clear_recent_files(app):
370 app_settings.RECENT_FILES.write(app.settings, [])
371 app._update_recent_files_menu()