castletool 0.3.2__tar.gz → 0.3.4__tar.gz
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.
- {castletool-0.3.2 → castletool-0.3.4}/PKG-INFO +1 -1
- {castletool-0.3.2 → castletool-0.3.4}/castletool/castletool.py +230 -1
- {castletool-0.3.2 → castletool-0.3.4}/castletool.egg-info/PKG-INFO +1 -1
- {castletool-0.3.2 → castletool-0.3.4}/pyproject.toml +1 -1
- {castletool-0.3.2 → castletool-0.3.4}/LICENSE +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/README.md +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/castletool/__init__.py +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/castletool.egg-info/SOURCES.txt +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/castletool.egg-info/dependency_links.txt +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/castletool.egg-info/entry_points.txt +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/castletool.egg-info/requires.txt +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/castletool.egg-info/top_level.txt +0 -0
- {castletool-0.3.2 → castletool-0.3.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: castletool
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -16,12 +16,13 @@ import re
|
|
|
16
16
|
import shutil
|
|
17
17
|
import subprocess
|
|
18
18
|
import sys
|
|
19
|
+
import urllib.error
|
|
19
20
|
import urllib.request
|
|
20
21
|
import uuid
|
|
21
22
|
import xml.etree.ElementTree as ET
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
|
|
24
|
-
CURRENT_VERSION = "0.3.
|
|
25
|
+
CURRENT_VERSION = "0.3.4"
|
|
25
26
|
PYPI_URL = "https://pypi.org/pypi/castletool/json"
|
|
26
27
|
|
|
27
28
|
# ── optional deps ────────────────────────────────────────────────────────────
|
|
@@ -1084,6 +1085,230 @@ def do_add_midi(bp_path: Path, actor: dict):
|
|
|
1084
1085
|
ps("MIDI added.")
|
|
1085
1086
|
|
|
1086
1087
|
|
|
1088
|
+
CASTLE_API_URL = "https://api.castle.xyz/graphql"
|
|
1089
|
+
|
|
1090
|
+
def _env_flag(name: str) -> bool:
|
|
1091
|
+
"""True if an env var is set to a truthy value (unset/'0'/'false'/'' = off)."""
|
|
1092
|
+
return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no")
|
|
1093
|
+
|
|
1094
|
+
def find_castle_token() -> str | None:
|
|
1095
|
+
"""Look for a Castle CLI login token in the usual config locations."""
|
|
1096
|
+
candidates = []
|
|
1097
|
+
for var in ("CASTLE_CLI_HOME", "CASTLE_HOME"):
|
|
1098
|
+
val = os.environ.get(var)
|
|
1099
|
+
if val:
|
|
1100
|
+
candidates.append(Path(val))
|
|
1101
|
+
candidates.append(Path.home() / ".castle")
|
|
1102
|
+
for d in candidates:
|
|
1103
|
+
cfg = d / "config.json"
|
|
1104
|
+
if cfg.exists():
|
|
1105
|
+
try:
|
|
1106
|
+
token = json.loads(cfg.read_text(encoding="utf-8")).get("token")
|
|
1107
|
+
if token:
|
|
1108
|
+
return token
|
|
1109
|
+
except (json.JSONDecodeError, OSError):
|
|
1110
|
+
continue
|
|
1111
|
+
return None
|
|
1112
|
+
|
|
1113
|
+
def _castle_graphql(token: str, query: str, variables: dict) -> dict:
|
|
1114
|
+
payload = json.dumps({"query": query, "variables": variables}).encode("utf-8")
|
|
1115
|
+
req = urllib.request.Request(
|
|
1116
|
+
CASTLE_API_URL, data=payload, method="POST",
|
|
1117
|
+
headers={
|
|
1118
|
+
"X-OS": "cli",
|
|
1119
|
+
"X-CLI-API-Version": "4",
|
|
1120
|
+
"X-Scene-Creator-Version": "latest",
|
|
1121
|
+
"X-Auth-Token": token,
|
|
1122
|
+
"Content-Type": "application/json",
|
|
1123
|
+
"Accept": "application/json",
|
|
1124
|
+
},
|
|
1125
|
+
)
|
|
1126
|
+
try:
|
|
1127
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
1128
|
+
return json.loads(resp.read())
|
|
1129
|
+
except urllib.error.HTTPError as e:
|
|
1130
|
+
return json.loads(e.read())
|
|
1131
|
+
|
|
1132
|
+
def _multipart_post(url, fields: dict, file_field_name, file_bytes, file_name, content_type):
|
|
1133
|
+
boundary = uuid.uuid4().hex
|
|
1134
|
+
parts = []
|
|
1135
|
+
for key, val in fields.items():
|
|
1136
|
+
parts.append(
|
|
1137
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{val}\r\n'.encode())
|
|
1138
|
+
parts.append(
|
|
1139
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field_name}"; '
|
|
1140
|
+
f'filename="{file_name}"\r\nContent-Type: {content_type}\r\n\r\n'.encode())
|
|
1141
|
+
parts.append(file_bytes)
|
|
1142
|
+
parts.append(f'\r\n--{boundary}--\r\n'.encode())
|
|
1143
|
+
req = urllib.request.Request(url, data=b"".join(parts), method="POST")
|
|
1144
|
+
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
|
1145
|
+
try:
|
|
1146
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
1147
|
+
return resp.status, resp.read()
|
|
1148
|
+
except urllib.error.HTTPError as e:
|
|
1149
|
+
return e.code, e.read()
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def do_upload_html(deck: Path, card: Path):
|
|
1153
|
+
while True:
|
|
1154
|
+
raw = ask_path("Enter the file path for your HTML file")
|
|
1155
|
+
html_path = resolve_path(raw)
|
|
1156
|
+
if html_path.exists():
|
|
1157
|
+
break
|
|
1158
|
+
pe(f"File not found: {html_path}")
|
|
1159
|
+
|
|
1160
|
+
with open(deck / "deck.json", "r", encoding="utf-8") as f:
|
|
1161
|
+
deck_meta = json.load(f)
|
|
1162
|
+
deck_id = deck_meta.get("deckId")
|
|
1163
|
+
if not deck_id:
|
|
1164
|
+
pe("This deck hasn't been uploaded yet. Use Upload Deck first, then try again.")
|
|
1165
|
+
return
|
|
1166
|
+
card_id = card.name
|
|
1167
|
+
|
|
1168
|
+
token = find_castle_token()
|
|
1169
|
+
if not token:
|
|
1170
|
+
pe("Castle token not found. Run 'castle login' first.")
|
|
1171
|
+
return
|
|
1172
|
+
|
|
1173
|
+
p()
|
|
1174
|
+
pb("Reading target deck metadata...")
|
|
1175
|
+
try:
|
|
1176
|
+
deck_resp = _castle_graphql(token, """
|
|
1177
|
+
query($deckId: ID!) {
|
|
1178
|
+
deck(deckId: $deckId) {
|
|
1179
|
+
title visibility variables
|
|
1180
|
+
initialCard { cardId title sceneDataUrl }
|
|
1181
|
+
cards { cardId title sceneDataUrl }
|
|
1182
|
+
}
|
|
1183
|
+
}""", {"deckId": deck_id})
|
|
1184
|
+
except Exception as e:
|
|
1185
|
+
pe(f"Failed to reach Castle: {e}")
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
deck_data = (deck_resp.get("data") or {}).get("deck")
|
|
1189
|
+
if not deck_data:
|
|
1190
|
+
pe("Castle didn't return this deck. Check that it's still yours and still exists.")
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
cards = deck_data.get("cards") or [deck_data.get("initialCard")]
|
|
1194
|
+
card_json = next((c for c in cards if c and c.get("cardId") == card_id), None)
|
|
1195
|
+
if not card_json:
|
|
1196
|
+
pe(f"Card {card_id} is not inside deck {deck_id}.")
|
|
1197
|
+
return
|
|
1198
|
+
|
|
1199
|
+
scene_url = card_json.get("sceneDataUrl")
|
|
1200
|
+
card_title = card_json.get("title") or "Card"
|
|
1201
|
+
is_initial = (deck_data.get("initialCard") or {}).get("cardId") == card_id
|
|
1202
|
+
deck_title = deck_data.get("title") or "Untitled"
|
|
1203
|
+
deck_visibility = deck_data.get("visibility") or "unlisted"
|
|
1204
|
+
deck_variables = deck_data.get("variables") or []
|
|
1205
|
+
|
|
1206
|
+
if scene_url:
|
|
1207
|
+
pb("Downloading existing scene file...")
|
|
1208
|
+
try:
|
|
1209
|
+
with urllib.request.urlopen(scene_url, timeout=15) as resp:
|
|
1210
|
+
scene = json.loads(resp.read())
|
|
1211
|
+
except Exception as e:
|
|
1212
|
+
pe(f"Failed to download scene data: {e}")
|
|
1213
|
+
return
|
|
1214
|
+
else:
|
|
1215
|
+
pb("No scene data url found. Creating blank snapshot...")
|
|
1216
|
+
scene = {"snapshot": {}}
|
|
1217
|
+
|
|
1218
|
+
pb("Injecting HTML file into experimentalWeb bundle...")
|
|
1219
|
+
scene["experimentalWeb"] = {"enabled": True, "bundle": html_path.read_text(encoding="utf-8")}
|
|
1220
|
+
scene_bytes = json.dumps(scene).encode("utf-8")
|
|
1221
|
+
|
|
1222
|
+
pb("Requesting signed upload parameters...")
|
|
1223
|
+
try:
|
|
1224
|
+
upload_resp = _castle_graphql(token, """
|
|
1225
|
+
mutation($cardIds: [ID!]!) {
|
|
1226
|
+
createSceneDataUploadConfig(cardIds: $cardIds) {
|
|
1227
|
+
uploadId postUrl postFields
|
|
1228
|
+
}
|
|
1229
|
+
}""", {"cardIds": [card_id]})
|
|
1230
|
+
except Exception as e:
|
|
1231
|
+
pe(f"Failed to reach Castle: {e}")
|
|
1232
|
+
return
|
|
1233
|
+
|
|
1234
|
+
configs = (upload_resp.get("data") or {}).get("createSceneDataUploadConfig") or []
|
|
1235
|
+
if not configs:
|
|
1236
|
+
pe("Castle did not return an upload configuration.")
|
|
1237
|
+
return
|
|
1238
|
+
upload_config = configs[0]
|
|
1239
|
+
post_fields = upload_config.get("postFields") or {}
|
|
1240
|
+
|
|
1241
|
+
# If the presigned POST policy declares a max size, check up front so we
|
|
1242
|
+
# get a clear error instead of an opaque HTTP failure later.
|
|
1243
|
+
policy_b64 = post_fields.get("policy")
|
|
1244
|
+
if policy_b64:
|
|
1245
|
+
try:
|
|
1246
|
+
policy = json.loads(base64.b64decode(policy_b64))
|
|
1247
|
+
max_bytes = next(
|
|
1248
|
+
(c[2] for c in policy.get("conditions", [])
|
|
1249
|
+
if isinstance(c, list) and c and c[0] == "content-length-range"),
|
|
1250
|
+
None)
|
|
1251
|
+
if max_bytes is not None and len(scene_bytes) > max_bytes:
|
|
1252
|
+
pe("Your file is too large!")
|
|
1253
|
+
return
|
|
1254
|
+
except Exception:
|
|
1255
|
+
pass
|
|
1256
|
+
|
|
1257
|
+
upload_id = upload_config.get("uploadId")
|
|
1258
|
+
post_url = upload_config.get("postUrl")
|
|
1259
|
+
|
|
1260
|
+
pb("Uploading patched scene payload...")
|
|
1261
|
+
fields = {"Content-Type": "application/json", **post_fields}
|
|
1262
|
+
try:
|
|
1263
|
+
status, body = _multipart_post(post_url, fields, "file", scene_bytes,
|
|
1264
|
+
"scene.json", "application/json")
|
|
1265
|
+
except Exception as e:
|
|
1266
|
+
pe(f"Scene data upload failed: {e}")
|
|
1267
|
+
return
|
|
1268
|
+
if status >= 300:
|
|
1269
|
+
pe(f"Scene data upload failed with HTTP {status}")
|
|
1270
|
+
return
|
|
1271
|
+
|
|
1272
|
+
pb("Attaching upload reference to deck...")
|
|
1273
|
+
deck_input = {
|
|
1274
|
+
"deckId": deck_id,
|
|
1275
|
+
"title": deck_title,
|
|
1276
|
+
"visibility": deck_visibility,
|
|
1277
|
+
"contentFlags": {
|
|
1278
|
+
"bloodOrGuns": False, "profanity": False, "sexuallySuggestive": False,
|
|
1279
|
+
"horrorOrFear": False, "alcoholOrDrugs": False, "loudSounds": False,
|
|
1280
|
+
"flashingLights": False,
|
|
1281
|
+
},
|
|
1282
|
+
}
|
|
1283
|
+
card_input = {"cardId": card_id, "title": card_title, "blocks": [], "uploadId": upload_id}
|
|
1284
|
+
if is_initial:
|
|
1285
|
+
deck_input["initialCardId"] = card_id
|
|
1286
|
+
deck_input["variables"] = deck_variables
|
|
1287
|
+
card_input["makeInitialCard"] = True
|
|
1288
|
+
|
|
1289
|
+
try:
|
|
1290
|
+
update_resp = _castle_graphql(token, """
|
|
1291
|
+
mutation($deck: DeckInput!, $card: CardInput!) {
|
|
1292
|
+
updateCardAndDeckV2(deck: $deck, card: $card) {
|
|
1293
|
+
deck { deckId }
|
|
1294
|
+
card { cardId }
|
|
1295
|
+
}
|
|
1296
|
+
}""", {"deck": deck_input, "card": card_input})
|
|
1297
|
+
except Exception as e:
|
|
1298
|
+
pe(f"Failed to reach Castle: {e}")
|
|
1299
|
+
return
|
|
1300
|
+
|
|
1301
|
+
final_deck_id = (((update_resp.get("data") or {})
|
|
1302
|
+
.get("updateCardAndDeckV2") or {})
|
|
1303
|
+
.get("deck") or {}).get("deckId")
|
|
1304
|
+
if not final_deck_id:
|
|
1305
|
+
pe("Castle rejected the deck update.")
|
|
1306
|
+
return
|
|
1307
|
+
|
|
1308
|
+
ps("HTML uploaded!")
|
|
1309
|
+
pb(f"Live at: https://castle.xyz/d/{deck_id}")
|
|
1310
|
+
|
|
1311
|
+
|
|
1087
1312
|
def do_upload_deck(deck: Path):
|
|
1088
1313
|
castle_argv = ["castle", "save-deck", str(deck)]
|
|
1089
1314
|
if os.name == "nt":
|
|
@@ -1179,6 +1404,8 @@ def main():
|
|
|
1179
1404
|
options.append("Add image")
|
|
1180
1405
|
if HAS_MIDO:
|
|
1181
1406
|
options.append("Add MIDI")
|
|
1407
|
+
if _env_flag("CASTLETOOL_HTML"):
|
|
1408
|
+
options.append("Upload HTML")
|
|
1182
1409
|
options.append("Upload Deck")
|
|
1183
1410
|
options.append("Exit Tool")
|
|
1184
1411
|
|
|
@@ -1188,6 +1415,8 @@ def main():
|
|
|
1188
1415
|
do_add_image(bp_path, actor)
|
|
1189
1416
|
elif action == "Add MIDI":
|
|
1190
1417
|
do_add_midi(bp_path, actor)
|
|
1418
|
+
elif action == "Upload HTML":
|
|
1419
|
+
do_upload_html(deck, card)
|
|
1191
1420
|
elif action == "Upload Deck":
|
|
1192
1421
|
do_upload_deck(deck)
|
|
1193
1422
|
elif action == "Exit Tool":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: castletool
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "castletool"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Your Name", email = "your.email@example.com" }
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|