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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: castletool
3
- Version: 0.3.2
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.2"
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.2
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.2"
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