Coverage for graphica/models/project.py: 98.7%
119 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
1import hashlib
2import json
3import os
4import logging
5import uuid
7from PySide6.QtCore import QObject, Signal
9from graphica.core.dataset import Dataset
10from graphica.core.json_utils import GraphicaJSONEncoder
12logger = logging.getLogger(__name__)
14# 保存は .gra。.graphica(以前の拡張子)も中身は同じ JSON なので読み書きできる
15PROJECT_FILE_EXTENSION = '.gra'
16PROJECT_FILE_EXTENSIONS = ('.gra', '.graphica')
17LEGACY_PICKLE_EXTENSION = '.pkl'
20# format_version が無いファイルは 0。構造を変えたら上げて、前の版からの変換を _MIGRATIONS に足す
21CURRENT_FORMAT_VERSION = 1
24def _migrate_v0_to_v1(data):
25 """0 から 1 は format_version を足しただけで、構造は同じ。"""
26 return data
29# from_version -> (from_version + 1) の形にする関数
30_MIGRATIONS = {
31 0: _migrate_v0_to_v1,
32}
35def _migrate_project_data(data):
36 """CURRENT_FORMAT_VERSION まで順に変換する。新しい版のファイルはエラーにする(黙って半分だけ読まない)。"""
37 version = data.get('format_version', 0)
38 if version > CURRENT_FORMAT_VERSION:
39 raise ValueError(
40 f"このプロジェクトファイルはバージョン{version}で保存されていますが、"
41 f"このアプリケーションが対応しているのはバージョン{CURRENT_FORMAT_VERSION}までです。"
42 "アプリケーションを最新版に更新してください。"
43 )
44 while version < CURRENT_FORMAT_VERSION:
45 migrate = _MIGRATIONS.get(version)
46 if migrate is None: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true
47 raise ValueError(f"バージョン{version}からの移行手順が見つかりません。")
48 data = migrate(data)
49 version += 1
50 return data
53class ProjectModel(QObject):
54 """1つの文書(データセット、軸ごとの設定、レイアウト)。
56 既存の変更箇所は PlotterApp._update_plot() を直接呼ぶ。changed は、そのメソッドを知らない新しい経路が
57 再描画を頼むためのもので、今は誰も発行しない。
58 """
60 changed = Signal()
62 def __init__(self):
63 super().__init__()
64 # 最後に保存・読み込みしたパス(オートセーブでも変わる。上書き保存先は PlotterApp._current_project_path)
65 self.current_filepath = ""
67 self.datasets = []
68 # {'name', 'children': [...]} の入れ子。子はフォルダか {'dataset': Dataset}。name='' のルートは表示しない
69 self.dataset_group_tree = {'name': '', 'children': []}
70 self.all_plot_settings = [] # 軸ごとの設定(core/axis_settings.py)
71 self.active_axis_index = 0
73 self.layout_rows = 1
74 self.layout_cols = 1
75 # 'grid' か 'free'。'free' では all_plot_settings の数がサブプロットの数で、各設定の
76 # 'free_rect' に (left, bottom, width, height) を 0〜1 で持つ
77 self.layout_mode = 'grid'
79 # (a)(b)(c)… は描くときに並びから決めるので、文字は保存しない
80 self.panel_labels_enabled = False
82 # 同じ行(sharex)・同じ列(sharey)のサブプロットの軸を束ね、内側の目盛りラベルを隠す。グリッドのときだけ
83 self.share_x_axis = False
84 self.share_y_axis = False
86 def notify_changed(self):
87 self.changed.emit()
89 def save_project(self, filepath):
90 """JSON で保存する(開いてもコードが実行されない)。拡張子は .gra か .graphica。"""
91 ext = os.path.splitext(filepath)[1].lower()
92 if ext not in PROJECT_FILE_EXTENSIONS:
93 raise ValueError(f"サポートされていない拡張子です: {ext}")
94 self._save_project_json(filepath)
95 self.current_filepath = filepath
97 def load_project(self, filepath):
98 if not os.path.exists(filepath):
99 raise FileNotFoundError(f"ファイルが見つかりません: {filepath}")
101 ext = os.path.splitext(filepath)[1].lower()
102 if ext == LEGACY_PICKLE_EXTENSION:
103 raise ValueError("旧形式(.pkl)のプロジェクトには対応していません。")
104 if ext not in PROJECT_FILE_EXTENSIONS:
105 raise ValueError(f"サポートされていない拡張子です: {ext}")
106 self._load_project_json(filepath)
107 self.current_filepath = filepath
109 @staticmethod
110 def _tree_to_json(node):
111 """Dataset の参照を dataset_id の文字列にする(JSON にするため)。"""
112 if 'dataset' in node:
113 return {'dataset_id': node['dataset'].dataset_id}
114 return {
115 'name': node.get('name', ''),
116 'children': [ProjectModel._tree_to_json(child) for child in node.get('children', [])],
117 }
119 @staticmethod
120 def _tree_from_json(node, dataset_map):
121 """_tree_to_json() の逆。見つからない ID は警告して除く(壊れたファイルでも読めるように)。
123 dataset_map は ID -> まだ並びに置いていないデータセットの列。同じ ID の系列があれば(複製が ID まで写していた
124 ころのファイル)、出てきた順に1本ずつ割り当てる。
125 """
126 if 'dataset_id' in node:
127 candidates = dataset_map.get(node['dataset_id'])
128 ds = candidates.pop(0) if candidates else None
129 if ds is None:
130 logger.warning(
131 "dataset_group_tree内に存在しないdataset_idがあるため、"
132 "このリーフをスキップします: %s", node['dataset_id']
133 )
134 return None
135 return {'dataset': ds}
137 children = []
138 for child in node.get('children', []):
139 converted = ProjectModel._tree_from_json(child, dataset_map)
140 if converted is not None:
141 children.append(converted)
142 return {'name': node.get('name', ''), 'children': children}
144 def _make_dataset_ids_unique(self):
145 """同じ ID の2本目以降に新しい ID を振る(並びを決めてから。統計値ラベルなどの参照は1本目を指したまま)。"""
146 seen = set()
147 for ds in self.datasets:
148 if ds.dataset_id in seen:
149 logger.warning("同じ dataset_id のデータセットがあったため、新しい ID を振りました: %s", ds.name)
150 ds.dataset_id = uuid.uuid4().hex
151 seen.add(ds.dataset_id)
153 def content_fingerprint(self):
154 """保存すると書き出される内容のハッシュ(未保存の変更の判定)。
156 Undo の clean 状態では判定しない(軸の設定・列の計算・フォルダ操作は Undo の対象外)。
157 呼ぶ前に UI にしか無い状態を反映しておくこと(PlotterApp._sync_project_from_ui)。
158 """
159 payload = self._json_payload()
160 # 編集対象のサブプロットを切り替えただけで保存の確認が出ないように
161 payload.pop('active_axis_index', None)
162 text = json.dumps(payload, cls=GraphicaJSONEncoder, sort_keys=True, ensure_ascii=False)
163 return hashlib.sha256(text.encode('utf-8')).hexdigest()
165 def _json_payload(self):
166 """.graphica に書き出す辞書(保存と content_fingerprint で共有)。"""
167 return {
168 'format_version': CURRENT_FORMAT_VERSION,
169 'datasets': [ds.to_dict() for ds in self.datasets],
170 'dataset_group_tree': self._tree_to_json(self.dataset_group_tree),
171 'all_plot_settings': self.all_plot_settings,
172 'active_axis_index': self.active_axis_index,
173 'layout_rows': self.layout_rows,
174 'layout_cols': self.layout_cols,
175 'layout_mode': self.layout_mode,
176 'panel_labels_enabled': self.panel_labels_enabled,
177 'share_x_axis': self.share_x_axis,
178 'share_y_axis': self.share_y_axis,
179 }
181 def _save_project_json(self, filepath):
182 data = self._json_payload()
183 # 名前に日本語が多いので \uXXXX にせず読める形で残す
184 with open(filepath, 'w', encoding='utf-8') as f:
185 json.dump(data, f, cls=GraphicaJSONEncoder, indent=2, ensure_ascii=False)
187 def _load_project_json(self, filepath):
188 with open(filepath, 'r', encoding='utf-8') as f:
189 data = json.load(f)
191 data = _migrate_project_data(data)
193 self.datasets = [Dataset.from_dict(d) for d in data.get('datasets', [])]
194 dataset_map = {}
195 for ds in self.datasets:
196 dataset_map.setdefault(ds.dataset_id, []).append(ds)
198 tree_data = data.get('dataset_group_tree')
199 if tree_data:
200 self.dataset_group_tree = self._tree_from_json(tree_data, dataset_map)
201 else:
202 # キーが無ければ、.pkl と同じく全部をルートに置く
203 self.dataset_group_tree = {
204 'name': '', 'children': [{'dataset': ds} for ds in self.datasets]
205 }
206 self._make_dataset_ids_unique()
208 self.all_plot_settings = data.get('all_plot_settings', [])
209 self.active_axis_index = data.get('active_axis_index', 0)
210 self.layout_rows = data.get('layout_rows', 1)
211 self.layout_cols = data.get('layout_cols', 1)
212 self.layout_mode = data.get('layout_mode', 'grid')
213 self.panel_labels_enabled = data.get('panel_labels_enabled', False)
214 self.share_x_axis = data.get('share_x_axis', False)
215 self.share_y_axis = data.get('share_y_axis', False)