@agent-webui/ai-desk-harness-gimp 1.0.29-beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/manifest.json +22 -0
- package/package.json +11 -0
- package/python/agent-harness/GIMP.md +301 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
- package/python/agent-harness/cli_anything/gimp/README.md +202 -0
- package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
- package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
- package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
- package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
- package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
- package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
- package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
- package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
- package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
- package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
- package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
- package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
- package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
- package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
- package/python/agent-harness/setup.py +54 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""GIMP CLI - Media file analysis module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Dict, Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def probe_image(path: str) -> Dict[str, Any]:
|
|
10
|
+
"""Analyze an image file and return metadata."""
|
|
11
|
+
if not os.path.exists(path):
|
|
12
|
+
raise FileNotFoundError(f"Image file not found: {path}")
|
|
13
|
+
|
|
14
|
+
from PIL import Image
|
|
15
|
+
|
|
16
|
+
info = {
|
|
17
|
+
"path": os.path.abspath(path),
|
|
18
|
+
"filename": os.path.basename(path),
|
|
19
|
+
"file_size": os.path.getsize(path),
|
|
20
|
+
"file_size_human": _human_size(os.path.getsize(path)),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with Image.open(path) as img:
|
|
25
|
+
info["width"] = img.width
|
|
26
|
+
info["height"] = img.height
|
|
27
|
+
info["mode"] = img.mode
|
|
28
|
+
info["format"] = img.format
|
|
29
|
+
info["format_description"] = img.format_description if hasattr(img, 'format_description') else img.format
|
|
30
|
+
info["megapixels"] = f"{img.width * img.height / 1_000_000:.2f}"
|
|
31
|
+
|
|
32
|
+
# DPI info
|
|
33
|
+
dpi = img.info.get("dpi")
|
|
34
|
+
if dpi:
|
|
35
|
+
info["dpi"] = {"x": round(dpi[0]), "y": round(dpi[1])}
|
|
36
|
+
|
|
37
|
+
# Animation info (GIF, APNG)
|
|
38
|
+
info["is_animated"] = getattr(img, "is_animated", False)
|
|
39
|
+
if info["is_animated"]:
|
|
40
|
+
info["n_frames"] = getattr(img, "n_frames", 1)
|
|
41
|
+
|
|
42
|
+
# Color palette
|
|
43
|
+
if img.mode == "P":
|
|
44
|
+
palette = img.getpalette()
|
|
45
|
+
info["palette_colors"] = len(palette) // 3 if palette else 0
|
|
46
|
+
|
|
47
|
+
# EXIF data (basic)
|
|
48
|
+
exif = img.getexif()
|
|
49
|
+
if exif:
|
|
50
|
+
exif_data = {}
|
|
51
|
+
tag_names = {
|
|
52
|
+
271: "Make", 272: "Model", 274: "Orientation",
|
|
53
|
+
305: "Software", 306: "DateTime",
|
|
54
|
+
36867: "DateTimeOriginal", 37378: "ApertureValue",
|
|
55
|
+
33434: "ExposureTime", 34855: "ISOSpeedRatings",
|
|
56
|
+
}
|
|
57
|
+
for tag_id, name in tag_names.items():
|
|
58
|
+
if tag_id in exif:
|
|
59
|
+
exif_data[name] = str(exif[tag_id])
|
|
60
|
+
if exif_data:
|
|
61
|
+
info["exif"] = exif_data
|
|
62
|
+
|
|
63
|
+
# Image bands/channels
|
|
64
|
+
info["channels"] = len(img.getbands())
|
|
65
|
+
info["bands"] = list(img.getbands())
|
|
66
|
+
|
|
67
|
+
# Bits per pixel estimation
|
|
68
|
+
bits_per_channel = {"1": 1, "L": 8, "P": 8, "RGB": 8, "RGBA": 8,
|
|
69
|
+
"CMYK": 8, "I": 32, "F": 32, "LA": 8}
|
|
70
|
+
bpc = bits_per_channel.get(img.mode, 8)
|
|
71
|
+
info["bits_per_pixel"] = bpc * info["channels"]
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
info["error"] = str(e)
|
|
75
|
+
|
|
76
|
+
return info
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_media_in_project(project: Dict[str, Any]) -> list:
|
|
80
|
+
"""List all media files referenced in the project."""
|
|
81
|
+
media = []
|
|
82
|
+
for i, layer in enumerate(project.get("layers", [])):
|
|
83
|
+
source = layer.get("source")
|
|
84
|
+
if source:
|
|
85
|
+
exists = os.path.exists(source)
|
|
86
|
+
media.append({
|
|
87
|
+
"layer_index": i,
|
|
88
|
+
"layer_name": layer.get("name", f"Layer {i}"),
|
|
89
|
+
"source": source,
|
|
90
|
+
"exists": exists,
|
|
91
|
+
})
|
|
92
|
+
return media
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_media(project: Dict[str, Any]) -> Dict[str, Any]:
|
|
96
|
+
"""Check that all referenced media files exist."""
|
|
97
|
+
media = list_media_in_project(project)
|
|
98
|
+
missing = [m for m in media if not m["exists"]]
|
|
99
|
+
return {
|
|
100
|
+
"total": len(media),
|
|
101
|
+
"found": len(media) - len(missing),
|
|
102
|
+
"missing": len(missing),
|
|
103
|
+
"missing_files": [m["source"] for m in missing],
|
|
104
|
+
"status": "ok" if not missing else "missing_files",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_image_histogram(path: str) -> Dict[str, Any]:
|
|
109
|
+
"""Get histogram data for an image."""
|
|
110
|
+
if not os.path.exists(path):
|
|
111
|
+
raise FileNotFoundError(f"Image file not found: {path}")
|
|
112
|
+
|
|
113
|
+
from PIL import Image
|
|
114
|
+
|
|
115
|
+
with Image.open(path) as img:
|
|
116
|
+
if img.mode not in ("RGB", "RGBA", "L"):
|
|
117
|
+
img = img.convert("RGB")
|
|
118
|
+
|
|
119
|
+
hist = img.histogram()
|
|
120
|
+
|
|
121
|
+
if img.mode in ("RGB", "RGBA"):
|
|
122
|
+
r_hist = hist[0:256]
|
|
123
|
+
g_hist = hist[256:512]
|
|
124
|
+
b_hist = hist[512:768]
|
|
125
|
+
return {
|
|
126
|
+
"mode": img.mode,
|
|
127
|
+
"channels": {
|
|
128
|
+
"red": {"min": _first_nonzero(r_hist), "max": _last_nonzero(r_hist),
|
|
129
|
+
"mean": _hist_mean(r_hist)},
|
|
130
|
+
"green": {"min": _first_nonzero(g_hist), "max": _last_nonzero(g_hist),
|
|
131
|
+
"mean": _hist_mean(g_hist)},
|
|
132
|
+
"blue": {"min": _first_nonzero(b_hist), "max": _last_nonzero(b_hist),
|
|
133
|
+
"mean": _hist_mean(b_hist)},
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
else:
|
|
137
|
+
return {
|
|
138
|
+
"mode": img.mode,
|
|
139
|
+
"channels": {
|
|
140
|
+
"luminance": {"min": _first_nonzero(hist), "max": _last_nonzero(hist),
|
|
141
|
+
"mean": _hist_mean(hist)},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _human_size(nbytes: int) -> str:
|
|
147
|
+
"""Convert byte count to human-readable string."""
|
|
148
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
149
|
+
if nbytes < 1024:
|
|
150
|
+
return f"{nbytes:.1f} {unit}"
|
|
151
|
+
nbytes /= 1024
|
|
152
|
+
return f"{nbytes:.1f} TB"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _first_nonzero(hist: list) -> int:
|
|
156
|
+
for i, v in enumerate(hist):
|
|
157
|
+
if v > 0:
|
|
158
|
+
return i
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _last_nonzero(hist: list) -> int:
|
|
163
|
+
for i in range(len(hist) - 1, -1, -1):
|
|
164
|
+
if hist[i] > 0:
|
|
165
|
+
return i
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _hist_mean(hist: list) -> float:
|
|
170
|
+
total = sum(hist)
|
|
171
|
+
if total == 0:
|
|
172
|
+
return 0.0
|
|
173
|
+
weighted = sum(i * v for i, v in enumerate(hist))
|
|
174
|
+
return round(weighted / total, 1)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""GIMP CLI - Core project management module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import copy
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional, Dict, Any, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Default canvas profiles
|
|
11
|
+
PROFILES = {
|
|
12
|
+
"hd1080p": {"width": 1920, "height": 1080, "dpi": 72},
|
|
13
|
+
"hd720p": {"width": 1280, "height": 720, "dpi": 72},
|
|
14
|
+
"4k": {"width": 3840, "height": 2160, "dpi": 72},
|
|
15
|
+
"square1080": {"width": 1080, "height": 1080, "dpi": 72},
|
|
16
|
+
"a4_300dpi": {"width": 2480, "height": 3508, "dpi": 300},
|
|
17
|
+
"a4_150dpi": {"width": 1240, "height": 1754, "dpi": 150},
|
|
18
|
+
"letter_300dpi": {"width": 2550, "height": 3300, "dpi": 300},
|
|
19
|
+
"web_banner": {"width": 1200, "height": 628, "dpi": 72},
|
|
20
|
+
"instagram_post": {"width": 1080, "height": 1080, "dpi": 72},
|
|
21
|
+
"instagram_story": {"width": 1080, "height": 1920, "dpi": 72},
|
|
22
|
+
"twitter_header": {"width": 1500, "height": 500, "dpi": 72},
|
|
23
|
+
"youtube_thumb": {"width": 1280, "height": 720, "dpi": 72},
|
|
24
|
+
"icon_256": {"width": 256, "height": 256, "dpi": 72},
|
|
25
|
+
"icon_512": {"width": 512, "height": 512, "dpi": 72},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
PROJECT_VERSION = "1.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_project(
|
|
32
|
+
width: int = 1920,
|
|
33
|
+
height: int = 1080,
|
|
34
|
+
color_mode: str = "RGB",
|
|
35
|
+
background: str = "#ffffff",
|
|
36
|
+
dpi: int = 72,
|
|
37
|
+
name: str = "untitled",
|
|
38
|
+
profile: Optional[str] = None,
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
"""Create a new GIMP CLI project."""
|
|
41
|
+
if profile and profile in PROFILES:
|
|
42
|
+
p = PROFILES[profile]
|
|
43
|
+
width = p["width"]
|
|
44
|
+
height = p["height"]
|
|
45
|
+
dpi = p["dpi"]
|
|
46
|
+
|
|
47
|
+
if color_mode not in ("RGB", "RGBA", "L", "LA"):
|
|
48
|
+
raise ValueError(f"Invalid color mode: {color_mode}. Use RGB, RGBA, L, or LA.")
|
|
49
|
+
if width < 1 or height < 1:
|
|
50
|
+
raise ValueError(f"Canvas dimensions must be positive: {width}x{height}")
|
|
51
|
+
if dpi < 1:
|
|
52
|
+
raise ValueError(f"DPI must be positive: {dpi}")
|
|
53
|
+
|
|
54
|
+
project = {
|
|
55
|
+
"version": PROJECT_VERSION,
|
|
56
|
+
"name": name,
|
|
57
|
+
"canvas": {
|
|
58
|
+
"width": width,
|
|
59
|
+
"height": height,
|
|
60
|
+
"color_mode": color_mode,
|
|
61
|
+
"background": background,
|
|
62
|
+
"dpi": dpi,
|
|
63
|
+
},
|
|
64
|
+
"layers": [],
|
|
65
|
+
"selection": None,
|
|
66
|
+
"guides": [],
|
|
67
|
+
"metadata": {
|
|
68
|
+
"created": datetime.now().isoformat(),
|
|
69
|
+
"modified": datetime.now().isoformat(),
|
|
70
|
+
"software": "gimp-cli 1.0",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
return project
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def open_project(path: str) -> Dict[str, Any]:
|
|
77
|
+
"""Open a .gimp-cli.json project file."""
|
|
78
|
+
if not os.path.exists(path):
|
|
79
|
+
raise FileNotFoundError(f"Project file not found: {path}")
|
|
80
|
+
with open(path, "r") as f:
|
|
81
|
+
project = json.load(f)
|
|
82
|
+
if "version" not in project or "canvas" not in project:
|
|
83
|
+
raise ValueError(f"Invalid project file: {path}")
|
|
84
|
+
return project
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def save_project(project: Dict[str, Any], path: str) -> str:
|
|
88
|
+
"""Save project to a .gimp-cli.json file."""
|
|
89
|
+
project["metadata"]["modified"] = datetime.now().isoformat()
|
|
90
|
+
with open(path, "w") as f:
|
|
91
|
+
json.dump(project, f, indent=2, default=str)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_project_info(project: Dict[str, Any]) -> Dict[str, Any]:
|
|
96
|
+
"""Get summary information about the project."""
|
|
97
|
+
canvas = project["canvas"]
|
|
98
|
+
layers = project.get("layers", [])
|
|
99
|
+
return {
|
|
100
|
+
"name": project.get("name", "untitled"),
|
|
101
|
+
"version": project.get("version", "unknown"),
|
|
102
|
+
"canvas": {
|
|
103
|
+
"width": canvas["width"],
|
|
104
|
+
"height": canvas["height"],
|
|
105
|
+
"color_mode": canvas.get("color_mode", "RGB"),
|
|
106
|
+
"background": canvas.get("background", "#ffffff"),
|
|
107
|
+
"dpi": canvas.get("dpi", 72),
|
|
108
|
+
},
|
|
109
|
+
"layer_count": len(layers),
|
|
110
|
+
"layers": [
|
|
111
|
+
{
|
|
112
|
+
"id": l.get("id", i),
|
|
113
|
+
"name": l.get("name", f"Layer {i}"),
|
|
114
|
+
"type": l.get("type", "image"),
|
|
115
|
+
"visible": l.get("visible", True),
|
|
116
|
+
"opacity": l.get("opacity", 1.0),
|
|
117
|
+
"blend_mode": l.get("blend_mode", "normal"),
|
|
118
|
+
"filter_count": len(l.get("filters", [])),
|
|
119
|
+
}
|
|
120
|
+
for i, l in enumerate(layers)
|
|
121
|
+
],
|
|
122
|
+
"metadata": project.get("metadata", {}),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def list_profiles() -> List[Dict[str, Any]]:
|
|
127
|
+
"""List all available canvas profiles."""
|
|
128
|
+
result = []
|
|
129
|
+
for name, p in PROFILES.items():
|
|
130
|
+
result.append({"name": name, "width": p["width"], "height": p["height"], "dpi": p["dpi"]})
|
|
131
|
+
return result
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""GIMP CLI - Session management with undo/redo."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import copy
|
|
6
|
+
from typing import Dict, Any, Optional, List
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Session:
|
|
11
|
+
"""Manages project state with undo/redo history."""
|
|
12
|
+
|
|
13
|
+
MAX_UNDO = 50
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.project: Optional[Dict[str, Any]] = None
|
|
17
|
+
self.project_path: Optional[str] = None
|
|
18
|
+
self._undo_stack: List[Dict[str, Any]] = []
|
|
19
|
+
self._redo_stack: List[Dict[str, Any]] = []
|
|
20
|
+
self._modified: bool = False
|
|
21
|
+
|
|
22
|
+
def has_project(self) -> bool:
|
|
23
|
+
return self.project is not None
|
|
24
|
+
|
|
25
|
+
def get_project(self) -> Dict[str, Any]:
|
|
26
|
+
if self.project is None:
|
|
27
|
+
raise RuntimeError("No project loaded. Use 'project new' or 'project open' first.")
|
|
28
|
+
return self.project
|
|
29
|
+
|
|
30
|
+
def set_project(self, project: Dict[str, Any], path: Optional[str] = None) -> None:
|
|
31
|
+
self.project = project
|
|
32
|
+
self.project_path = path
|
|
33
|
+
self._undo_stack.clear()
|
|
34
|
+
self._redo_stack.clear()
|
|
35
|
+
self._modified = False
|
|
36
|
+
|
|
37
|
+
def snapshot(self, description: str = "") -> None:
|
|
38
|
+
"""Save current state to undo stack before a mutation."""
|
|
39
|
+
if self.project is None:
|
|
40
|
+
return
|
|
41
|
+
state = {
|
|
42
|
+
"project": copy.deepcopy(self.project),
|
|
43
|
+
"description": description,
|
|
44
|
+
"timestamp": datetime.now().isoformat(),
|
|
45
|
+
}
|
|
46
|
+
self._undo_stack.append(state)
|
|
47
|
+
if len(self._undo_stack) > self.MAX_UNDO:
|
|
48
|
+
self._undo_stack.pop(0)
|
|
49
|
+
self._redo_stack.clear()
|
|
50
|
+
self._modified = True
|
|
51
|
+
|
|
52
|
+
def undo(self) -> Optional[str]:
|
|
53
|
+
"""Undo the last operation. Returns description of undone action."""
|
|
54
|
+
if not self._undo_stack:
|
|
55
|
+
raise RuntimeError("Nothing to undo.")
|
|
56
|
+
if self.project is None:
|
|
57
|
+
raise RuntimeError("No project loaded.")
|
|
58
|
+
|
|
59
|
+
# Save current state to redo stack
|
|
60
|
+
self._redo_stack.append({
|
|
61
|
+
"project": copy.deepcopy(self.project),
|
|
62
|
+
"description": "redo point",
|
|
63
|
+
"timestamp": datetime.now().isoformat(),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Restore previous state
|
|
67
|
+
state = self._undo_stack.pop()
|
|
68
|
+
self.project = state["project"]
|
|
69
|
+
self._modified = True
|
|
70
|
+
return state.get("description", "")
|
|
71
|
+
|
|
72
|
+
def redo(self) -> Optional[str]:
|
|
73
|
+
"""Redo the last undone operation."""
|
|
74
|
+
if not self._redo_stack:
|
|
75
|
+
raise RuntimeError("Nothing to redo.")
|
|
76
|
+
if self.project is None:
|
|
77
|
+
raise RuntimeError("No project loaded.")
|
|
78
|
+
|
|
79
|
+
# Save current state to undo stack
|
|
80
|
+
self._undo_stack.append({
|
|
81
|
+
"project": copy.deepcopy(self.project),
|
|
82
|
+
"description": "undo point",
|
|
83
|
+
"timestamp": datetime.now().isoformat(),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# Restore redo state
|
|
87
|
+
state = self._redo_stack.pop()
|
|
88
|
+
self.project = state["project"]
|
|
89
|
+
self._modified = True
|
|
90
|
+
return state.get("description", "")
|
|
91
|
+
|
|
92
|
+
def status(self) -> Dict[str, Any]:
|
|
93
|
+
"""Get session status."""
|
|
94
|
+
return {
|
|
95
|
+
"has_project": self.project is not None,
|
|
96
|
+
"project_path": self.project_path,
|
|
97
|
+
"modified": self._modified,
|
|
98
|
+
"undo_count": len(self._undo_stack),
|
|
99
|
+
"redo_count": len(self._redo_stack),
|
|
100
|
+
"project_name": self.project.get("name", "untitled") if self.project else None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def save_session(self, path: Optional[str] = None) -> str:
|
|
104
|
+
"""Save the session state (project + undo history) to disk."""
|
|
105
|
+
if self.project is None:
|
|
106
|
+
raise RuntimeError("No project to save.")
|
|
107
|
+
|
|
108
|
+
save_path = path or self.project_path
|
|
109
|
+
if not save_path:
|
|
110
|
+
raise ValueError("No save path specified.")
|
|
111
|
+
|
|
112
|
+
# Save project
|
|
113
|
+
self.project["metadata"]["modified"] = datetime.now().isoformat()
|
|
114
|
+
with open(save_path, "w") as f:
|
|
115
|
+
json.dump(self.project, f, indent=2, default=str)
|
|
116
|
+
|
|
117
|
+
self.project_path = save_path
|
|
118
|
+
self._modified = False
|
|
119
|
+
return save_path
|
|
120
|
+
|
|
121
|
+
def list_history(self) -> List[Dict[str, str]]:
|
|
122
|
+
"""List undo history."""
|
|
123
|
+
result = []
|
|
124
|
+
for i, state in enumerate(reversed(self._undo_stack)):
|
|
125
|
+
result.append({
|
|
126
|
+
"index": i,
|
|
127
|
+
"description": state.get("description", ""),
|
|
128
|
+
"timestamp": state.get("timestamp", ""),
|
|
129
|
+
})
|
|
130
|
+
return result
|