@follenfang/fupload 0.0.11 → 0.0.15
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/fupload/SKILL.md +1 -1
- package/fupload/references/modus.md +275 -30
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/cli.py +59 -11
- package/fupload/scripts/fupload_cli/dd_broker.py +42 -14
- package/fupload/scripts/fupload_cli/io.py +17 -1
- package/fupload/scripts/fupload_cli/modus.py +571 -18
- package/fupload/scripts/fupload_cli/schema.py +105 -7
- package/fupload/scripts/fupload_cli/transport.py +13 -1
- package/npm/skill-manifest.json +19 -19
- package/package.json +1 -1
|
@@ -25,12 +25,23 @@ from typing import Any, Dict, Mapping, Optional
|
|
|
25
25
|
from .errors import FuploadError, ValidationError, redact
|
|
26
26
|
from .state_machine import COMPLETE, ProjectStateMachine
|
|
27
27
|
from .modus_zip import parse_modus_zip
|
|
28
|
+
from .transport import json_request, multipart_request
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
API_BASE = "https://app.modus.cool/api/"
|
|
32
|
+
STATIC_API_BASE = "https://cdn.modus.cool/modus/client_static_api/"
|
|
33
|
+
RESOURCE_BASE = "https://cdn.modus.cool/"
|
|
31
34
|
TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
|
|
32
35
|
TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
|
|
36
|
+
MODUS_APPDATA = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) / "modus"
|
|
33
37
|
MAX_PACKAGE_BYTES = 200 * 1024 * 1024
|
|
38
|
+
MODUS_BUILDS = (
|
|
39
|
+
{"id": 0, "code": "retail", "name": "至暗之夜", "label": "正式服-至暗之夜"},
|
|
40
|
+
{"id": 1, "code": "classic_era", "name": "经典旧世", "label": "怀旧服-经典旧世"},
|
|
41
|
+
{"id": 2, "code": "classic", "name": "熊猫人之谜", "label": "怀旧服-熊猫人之谜"},
|
|
42
|
+
{"id": 3, "code": "classic_titan", "name": "泰坦重铸", "label": "时光服-泰坦重铸"},
|
|
43
|
+
{"id": 4, "code": "anniversary", "name": "燃烧的远征", "label": "周年服-燃烧的远征"},
|
|
44
|
+
)
|
|
34
45
|
_SECRET_KEYS = {"token", "access_token", "authorization", "cookie", "signedurl", "signed_url", "upload_url"}
|
|
35
46
|
_WIRE_NAMES = {
|
|
36
47
|
"project_id": "projectId", "file_id": "fileId", "alt_name": "altName",
|
|
@@ -100,6 +111,14 @@ def _category_ids(value: Any) -> Any:
|
|
|
100
111
|
return output
|
|
101
112
|
|
|
102
113
|
|
|
114
|
+
def _tag_filter(value: Any) -> list[str]:
|
|
115
|
+
values = value if isinstance(value, (list, tuple, set)) else str(value).split(",")
|
|
116
|
+
result = [str(item).strip() for item in values if str(item).strip()]
|
|
117
|
+
if not result:
|
|
118
|
+
raise ValidationError("tag filter must contain at least one ID", path="tags")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
103
122
|
def _sync_type(value: Any) -> Any:
|
|
104
123
|
if value is None:
|
|
105
124
|
return 0
|
|
@@ -124,13 +143,22 @@ def _image_ops(value: Any) -> list[Dict[str, str]]:
|
|
|
124
143
|
path = "image_ops[%d]" % index
|
|
125
144
|
if not isinstance(item, Mapping):
|
|
126
145
|
raise ValidationError("image operation must be an object", path=path)
|
|
127
|
-
|
|
146
|
+
op = item.get("op")
|
|
147
|
+
if op not in ("upload", "delete", "rename"):
|
|
148
|
+
raise ValidationError("image operation must be upload, delete, or rename", path=path + ".op")
|
|
149
|
+
allowed = {"op", "from", "to"} if op == "rename" else {"op", "name", "base64"}
|
|
150
|
+
unknown = sorted(set(item) - allowed)
|
|
128
151
|
if unknown:
|
|
129
152
|
raise ValidationError("unknown image operation field(s): %s" % ", ".join(unknown), path=path + "." + unknown[0])
|
|
130
|
-
op
|
|
153
|
+
if op == "rename":
|
|
154
|
+
source, target = item.get("from"), item.get("to")
|
|
155
|
+
if not isinstance(source, str) or not source.strip():
|
|
156
|
+
raise ValidationError("rename image operation requires from", path=path + ".from")
|
|
157
|
+
if not isinstance(target, str) or not target.strip():
|
|
158
|
+
raise ValidationError("rename image operation requires to", path=path + ".to")
|
|
159
|
+
result.append({"op": op, "from": source.strip(), "to": target.strip()})
|
|
160
|
+
continue
|
|
131
161
|
name = item.get("name")
|
|
132
|
-
if op not in ("upload", "delete"):
|
|
133
|
-
raise ValidationError("image operation must be upload or delete", path=path + ".op")
|
|
134
162
|
if not isinstance(name, str) or not name.strip():
|
|
135
163
|
raise ValidationError("image operation name must be non-empty", path=path + ".name")
|
|
136
164
|
operation = {"op": op, "name": name.strip()}
|
|
@@ -158,6 +186,16 @@ def _positive_id(value: Any, *, field: str) -> int:
|
|
|
158
186
|
return result
|
|
159
187
|
|
|
160
188
|
|
|
189
|
+
def _resource_id(value: Any, *, field: str) -> str:
|
|
190
|
+
"""ModUs share/import IDs are opaque positive decimal strings."""
|
|
191
|
+
if isinstance(value, bool) or value is None:
|
|
192
|
+
raise ValidationError("%s must be a non-empty identifier" % field, path=field)
|
|
193
|
+
text = str(value).strip()
|
|
194
|
+
if not text or (text.isdigit() and int(text) <= 0):
|
|
195
|
+
raise ValidationError("%s must be a non-empty identifier" % field, path=field)
|
|
196
|
+
return text
|
|
197
|
+
|
|
198
|
+
|
|
161
199
|
def _supported_game_versions(value: Any) -> list[Dict[str, str]]:
|
|
162
200
|
"""Map CLI aliases to Creator's anonymous {gameVersion, server} objects.
|
|
163
201
|
|
|
@@ -274,7 +312,7 @@ def _project_wire(value: Mapping[str, Any], *, create: bool = False) -> Dict[str
|
|
|
274
312
|
"""Project project create/update fields to Creator's request shape."""
|
|
275
313
|
result: Dict[str, Any] = {}
|
|
276
314
|
for key in ("name", "alt_name", "summary", "repo_url"):
|
|
277
|
-
if key in value and value[key] is not None:
|
|
315
|
+
if key in value and (value[key] is not None or not create):
|
|
278
316
|
wire_name = _WIRE_NAMES.get(key, key)
|
|
279
317
|
if create:
|
|
280
318
|
if key == "repo_url" and not str(value[key]).strip():
|
|
@@ -343,13 +381,56 @@ def _business_code(payload: Mapping[str, Any]) -> Optional[int]:
|
|
|
343
381
|
return None
|
|
344
382
|
|
|
345
383
|
|
|
384
|
+
def _image_upload_record(payload: Any) -> Dict[str, str]:
|
|
385
|
+
"""Extract the reusable key and display URL used by the main client."""
|
|
386
|
+
data = _unwrap(payload)
|
|
387
|
+
if not isinstance(data, Mapping):
|
|
388
|
+
raise FuploadError(
|
|
389
|
+
"ModUs image upload returned no media record",
|
|
390
|
+
kind="platform_data_error",
|
|
391
|
+
stage="media_upload",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
download_url = data.get("downloadUrl") or data.get("url") or data.get("fileUrl")
|
|
395
|
+
reference = data.get("cosStoreKey") or data.get("cosStoreUrl") or data.get("key")
|
|
396
|
+
if not reference and isinstance(download_url, str) and download_url.strip():
|
|
397
|
+
parsed = urllib.parse.urlsplit(download_url.strip())
|
|
398
|
+
reference = urllib.parse.unquote(parsed.path.lstrip("/"))
|
|
399
|
+
if not isinstance(reference, str) or not reference.strip():
|
|
400
|
+
raise FuploadError(
|
|
401
|
+
"ModUs image upload omitted its object key",
|
|
402
|
+
kind="platform_data_error",
|
|
403
|
+
stage="media_upload",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
reference = reference.strip()
|
|
407
|
+
if isinstance(download_url, str) and download_url.strip():
|
|
408
|
+
display_url = download_url.strip()
|
|
409
|
+
elif reference.startswith(("https://", "http://")):
|
|
410
|
+
display_url = reference
|
|
411
|
+
else:
|
|
412
|
+
display_url = urllib.parse.urljoin(RESOURCE_BASE, reference.lstrip("/"))
|
|
413
|
+
return {"key": reference, "url": display_url, "reference": reference}
|
|
414
|
+
|
|
415
|
+
|
|
346
416
|
def _safe(value: Any) -> Any:
|
|
347
417
|
"""Recursively redact credentials and presigned URLs in API results."""
|
|
348
418
|
if isinstance(value, Mapping):
|
|
349
419
|
result = {}
|
|
350
420
|
for key, item in value.items():
|
|
351
421
|
normalized = str(key).replace("-", "_").lower()
|
|
352
|
-
|
|
422
|
+
if normalized in _SECRET_KEYS:
|
|
423
|
+
result[str(key)] = "[REDACTED]"
|
|
424
|
+
elif normalized in {
|
|
425
|
+
"content", "contenttext", "codetext", "changelog",
|
|
426
|
+
"description", "licensecontent",
|
|
427
|
+
}:
|
|
428
|
+
# write_output() hashes these fields before serializing JSON.
|
|
429
|
+
# Preserve the original here so the digest represents the
|
|
430
|
+
# service value instead of a generic redaction placeholder.
|
|
431
|
+
result[str(key)] = item
|
|
432
|
+
else:
|
|
433
|
+
result[str(key)] = _safe(item)
|
|
353
434
|
return result
|
|
354
435
|
if isinstance(value, list):
|
|
355
436
|
return [_safe(item) for item in value]
|
|
@@ -407,6 +488,94 @@ def load_token(path: Optional[Path] = None) -> str:
|
|
|
407
488
|
return token
|
|
408
489
|
|
|
409
490
|
|
|
491
|
+
def load_main_session(root: Optional[Path] = None) -> Dict[str, str]:
|
|
492
|
+
"""Recover the main ModUs renderer session from Chromium local storage.
|
|
493
|
+
|
|
494
|
+
Chromium LevelDB values are length-prefixed and may be locked by the
|
|
495
|
+
running client. We therefore scan shared-read bytes for JSON fragments,
|
|
496
|
+
accepting only the known persisted token/device keys and never returning
|
|
497
|
+
unrelated storage values.
|
|
498
|
+
"""
|
|
499
|
+
base = root or MODUS_APPDATA
|
|
500
|
+
leveldb = base / "Local Storage" / "leveldb"
|
|
501
|
+
if not leveldb.is_dir():
|
|
502
|
+
raise FuploadError("ModUs main-client local storage was not found", kind="authentication_error")
|
|
503
|
+
token = ""
|
|
504
|
+
device = ""
|
|
505
|
+
for path in sorted(leveldb.iterdir()):
|
|
506
|
+
if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
with path.open("rb", buffering=0) as handle:
|
|
510
|
+
raw = handle.read()
|
|
511
|
+
except OSError:
|
|
512
|
+
continue
|
|
513
|
+
text = raw.decode("utf-8", errors="ignore")
|
|
514
|
+
# Pinia persistence is JSON, but the LevelDB record can contain a
|
|
515
|
+
# prefix/suffix. Try JSON objects first, then bounded key/value pairs.
|
|
516
|
+
candidates = [text]
|
|
517
|
+
# Persisted Pinia user JSON contains nested wallet objects, so a
|
|
518
|
+
# shallow-brace regex is insufficient. Extract only scalar credential
|
|
519
|
+
# fields from the record instead of attempting to parse the whole log.
|
|
520
|
+
import re
|
|
521
|
+
token_match = re.search(r'"(?:token|accessToken|access_token)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"', text)
|
|
522
|
+
device_match = re.search(r'"(?:deviceId|device_id)"\s*:\s*"([^"\\]*)"', text)
|
|
523
|
+
if token_match and not token:
|
|
524
|
+
token = bytes(token_match.group(1), "utf-8").decode("unicode_escape")
|
|
525
|
+
if device_match and not device:
|
|
526
|
+
device = device_match.group(1)
|
|
527
|
+
for candidate in candidates:
|
|
528
|
+
try:
|
|
529
|
+
parsed = json.loads(candidate)
|
|
530
|
+
except (TypeError, ValueError):
|
|
531
|
+
continue
|
|
532
|
+
stack = [parsed]
|
|
533
|
+
while stack:
|
|
534
|
+
item = stack.pop()
|
|
535
|
+
if isinstance(item, Mapping):
|
|
536
|
+
for key, value in item.items():
|
|
537
|
+
normalized = str(key).lower()
|
|
538
|
+
if isinstance(value, str) and value.strip():
|
|
539
|
+
if normalized in {"token", "accesstoken", "access_token"} and not token:
|
|
540
|
+
token = value.strip()
|
|
541
|
+
elif normalized in {"deviceid", "device_id"} and not device:
|
|
542
|
+
device = value.strip()
|
|
543
|
+
elif isinstance(value, (Mapping, list)):
|
|
544
|
+
stack.append(value)
|
|
545
|
+
elif isinstance(item, list):
|
|
546
|
+
stack.extend(item)
|
|
547
|
+
if token and device:
|
|
548
|
+
break
|
|
549
|
+
if not token:
|
|
550
|
+
raise FuploadError("ModUs main-client login token was not found", kind="authentication_error")
|
|
551
|
+
return {"token": token, **({"device_id": device} if device else {})}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def load_current_build(root: Optional[Path] = None) -> Optional[int]:
|
|
555
|
+
"""Read the persisted main-client currentGameWow id when available."""
|
|
556
|
+
base = root or MODUS_APPDATA
|
|
557
|
+
leveldb = base / "Local Storage" / "leveldb"
|
|
558
|
+
if not leveldb.is_dir():
|
|
559
|
+
return None
|
|
560
|
+
import re
|
|
561
|
+
for path in sorted(leveldb.iterdir()):
|
|
562
|
+
if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
|
|
563
|
+
continue
|
|
564
|
+
try:
|
|
565
|
+
with path.open("rb", buffering=0) as handle:
|
|
566
|
+
text = handle.read().decode("utf-8", errors="ignore")
|
|
567
|
+
except OSError:
|
|
568
|
+
continue
|
|
569
|
+
# Pinia persistence may be split across records; accept only the scalar id.
|
|
570
|
+
for pattern in (r'currentGameWow[^{}]{0,120}?"id"\s*:\s*(\d+)', r'"currentGameWowId"\s*:\s*(\d+)'):
|
|
571
|
+
match = re.search(pattern, text)
|
|
572
|
+
if match:
|
|
573
|
+
value = int(match.group(1))
|
|
574
|
+
if 0 <= value <= 4:
|
|
575
|
+
return value
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
|
|
410
579
|
def _unwrap(payload: Any) -> Any:
|
|
411
580
|
if isinstance(payload, Mapping) and "data" in payload:
|
|
412
581
|
return payload["data"]
|
|
@@ -423,12 +592,28 @@ class ModUs:
|
|
|
423
592
|
base_url: str = API_BASE,
|
|
424
593
|
timeout: int = 60,
|
|
425
594
|
token_path: Optional[Path] = None,
|
|
595
|
+
device_id: Optional[str] = None,
|
|
596
|
+
main_session: bool = False,
|
|
426
597
|
authenticate: bool = True,
|
|
427
598
|
) -> None:
|
|
428
599
|
self.base_url = base_url.rstrip("/") + "/"
|
|
429
600
|
self.timeout = timeout
|
|
430
601
|
self.token_path = token_path or TOKEN_PATH
|
|
431
|
-
self.
|
|
602
|
+
self.device_id = device_id
|
|
603
|
+
self.main_session = main_session
|
|
604
|
+
self.current_build = load_current_build() if main_session else None
|
|
605
|
+
self.authorization_scheme = ""
|
|
606
|
+
if token is not None:
|
|
607
|
+
self.token = token
|
|
608
|
+
elif authenticate:
|
|
609
|
+
if main_session:
|
|
610
|
+
session = load_main_session()
|
|
611
|
+
self.token, self.device_id = session["token"], session.get("device_id")
|
|
612
|
+
self.authorization_scheme = ""
|
|
613
|
+
else:
|
|
614
|
+
self.token = load_token(self.token_path)
|
|
615
|
+
else:
|
|
616
|
+
self.token = ""
|
|
432
617
|
|
|
433
618
|
def _url(self, path: str) -> str:
|
|
434
619
|
return urllib.parse.urljoin(self.base_url, path.lstrip("/"))
|
|
@@ -454,7 +639,10 @@ class ModUs:
|
|
|
454
639
|
def _request(self, method: str, path: str, body: Any = None, *, headers: Optional[Mapping[str, str]] = None) -> Any:
|
|
455
640
|
url = self._url(path)
|
|
456
641
|
stage = self._request_stage(method, path)
|
|
457
|
-
|
|
642
|
+
authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
|
|
643
|
+
request_headers = {"Accept": "application/json", "Authorization": authorization}
|
|
644
|
+
if self.device_id:
|
|
645
|
+
request_headers["X-Device-Id"] = self.device_id
|
|
458
646
|
if headers:
|
|
459
647
|
request_headers.update(headers)
|
|
460
648
|
data = None
|
|
@@ -472,7 +660,7 @@ class ModUs:
|
|
|
472
660
|
except Exception:
|
|
473
661
|
detail = "HTTP %d" % exc.code
|
|
474
662
|
raise FuploadError(redact(str(detail)), endpoint=url, http_status=exc.code, kind="platform_error", stage=stage) from exc
|
|
475
|
-
except (OSError, urllib.error.URLError) as exc:
|
|
663
|
+
except (OSError, urllib.error.URLError, http.client.IncompleteRead) as exc:
|
|
476
664
|
raise FuploadError("ModUs request failed: %s" % exc, endpoint=url, verification_required=method != "GET", stage=stage) from exc
|
|
477
665
|
if status < 200 or status >= 300:
|
|
478
666
|
raise FuploadError("ModUs returned HTTP %d" % status, endpoint=url, http_status=status, stage=stage)
|
|
@@ -494,6 +682,32 @@ class ModUs:
|
|
|
494
682
|
return payload
|
|
495
683
|
|
|
496
684
|
def doctor(self) -> Dict[str, Any]:
|
|
685
|
+
if self.main_session:
|
|
686
|
+
result = {
|
|
687
|
+
"token_present": False,
|
|
688
|
+
"token_decrypted": False,
|
|
689
|
+
"token_nonempty": False,
|
|
690
|
+
"api_ready": False,
|
|
691
|
+
}
|
|
692
|
+
try:
|
|
693
|
+
session = load_main_session()
|
|
694
|
+
token = str(session.get("token") or "").strip()
|
|
695
|
+
result["token_present"] = bool(token)
|
|
696
|
+
result["token_decrypted"] = bool(token)
|
|
697
|
+
result["token_nonempty"] = bool(token)
|
|
698
|
+
if not token:
|
|
699
|
+
return result
|
|
700
|
+
previous_token, previous_device = self.token, self.device_id
|
|
701
|
+
self.token = token
|
|
702
|
+
self.device_id = session.get("device_id")
|
|
703
|
+
try:
|
|
704
|
+
self.user_info()
|
|
705
|
+
result["api_ready"] = True
|
|
706
|
+
finally:
|
|
707
|
+
self.token, self.device_id = previous_token, previous_device
|
|
708
|
+
except (FuploadError, OSError, UnicodeError, ValueError, TypeError):
|
|
709
|
+
pass
|
|
710
|
+
return result
|
|
497
711
|
selected = self.token_path
|
|
498
712
|
if not selected.is_file() and selected.with_name("token.json").is_file():
|
|
499
713
|
selected = selected.with_name("token.json")
|
|
@@ -576,31 +790,57 @@ class ModUs:
|
|
|
576
790
|
def user_info(self) -> Any:
|
|
577
791
|
return _safe(_unwrap(self._request("GET", "system/user/getInfo")))
|
|
578
792
|
|
|
793
|
+
def builds(self) -> Any:
|
|
794
|
+
"""Return the fixed WoW Build choices used by the ModUs main client."""
|
|
795
|
+
current = self.current_build
|
|
796
|
+
return {"current": current, "builds": [dict(item) for item in MODUS_BUILDS]}
|
|
797
|
+
|
|
798
|
+
def _build(self, value: Optional[int]) -> int:
|
|
799
|
+
selected = self.current_build if value is None else value
|
|
800
|
+
if selected is None:
|
|
801
|
+
# The desktop wrapper uses getCurrentWow?.id || 0.
|
|
802
|
+
selected = 0
|
|
803
|
+
selected = int(selected)
|
|
804
|
+
if selected < 0 or selected > 4:
|
|
805
|
+
raise ValidationError("server_type must be a known ModUs Build id", path="server_type")
|
|
806
|
+
return selected
|
|
807
|
+
|
|
579
808
|
def active_subscription_count(self) -> Any:
|
|
580
809
|
return _safe(_unwrap(self._request("GET", "user/author/subscription/active/count")))
|
|
581
810
|
|
|
582
811
|
def project_statistics(self) -> Any:
|
|
583
812
|
return _safe(_unwrap(self._request("GET", "game/data/author/project/statistics")))
|
|
584
813
|
|
|
585
|
-
def addon_info(self, directories: Any, *, server_type: int =
|
|
814
|
+
def addon_info(self, directories: Any, *, server_type: Optional[int] = None) -> Any:
|
|
586
815
|
values = directories if isinstance(directories, (list, tuple)) else [directories]
|
|
587
816
|
body = {"pluginList": [str(value) for value in values]}
|
|
588
|
-
return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(
|
|
817
|
+
return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
589
818
|
|
|
590
|
-
def addon_project_info(self, project_ids: Any, *, server_type: int =
|
|
819
|
+
def addon_project_info(self, project_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
591
820
|
values = project_ids if isinstance(project_ids, (list, tuple)) else [project_ids]
|
|
592
821
|
body = {"projectIds": [int(value) for value in values]}
|
|
593
|
-
return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(
|
|
822
|
+
return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
594
823
|
|
|
595
|
-
def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: int =
|
|
824
|
+
def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: Optional[int] = None) -> Any:
|
|
596
825
|
body = {"projectIds": [int(project_id)], "pageNum": int(page_num), "pageSize": int(page_size)}
|
|
597
|
-
return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(
|
|
826
|
+
return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
598
827
|
|
|
599
828
|
def project_dependencies(self, query: Any) -> Any:
|
|
600
829
|
body = _dependency_query_wire(query)
|
|
601
830
|
return _safe(_unwrap(self._request("POST", "game/data/author/project/dependency/query", body)))
|
|
602
831
|
|
|
603
832
|
def options(self, action: str, *, keys: Optional[Any] = None) -> Any:
|
|
833
|
+
static_files = {
|
|
834
|
+
"config-tags": "share_tags.json",
|
|
835
|
+
"wa-tags": "imports_tags.json",
|
|
836
|
+
"wa-support-addons": "imports_support_addons.json",
|
|
837
|
+
}
|
|
838
|
+
if action in static_files:
|
|
839
|
+
url = urllib.parse.urljoin(STATIC_API_BASE, static_files[action])
|
|
840
|
+
payload = json_request(url)
|
|
841
|
+
if isinstance(payload, Mapping) and "rows" in payload:
|
|
842
|
+
payload = payload["rows"]
|
|
843
|
+
return _safe(_unwrap(payload))
|
|
604
844
|
routes = {
|
|
605
845
|
# These paths are the routes used by ModUs.Creator's ApiService.
|
|
606
846
|
"categories": "plugin/list/Categories",
|
|
@@ -618,6 +858,298 @@ class ModUs:
|
|
|
618
858
|
raise ValidationError("unsupported ModUs options operation")
|
|
619
859
|
return _safe(_unwrap(self._request("GET", routes[action])))
|
|
620
860
|
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _option_rows(value: Any) -> list[Mapping[str, Any]]:
|
|
863
|
+
if isinstance(value, Mapping):
|
|
864
|
+
value = value.get("rows", value.get("data", []))
|
|
865
|
+
return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
866
|
+
|
|
867
|
+
@classmethod
|
|
868
|
+
def _nested_option_rows(cls, value: Any) -> list[Mapping[str, Any]]:
|
|
869
|
+
rows: list[Mapping[str, Any]] = []
|
|
870
|
+
pending = list(cls._option_rows(value))
|
|
871
|
+
while pending:
|
|
872
|
+
item = pending.pop(0)
|
|
873
|
+
rows.append(item)
|
|
874
|
+
pending.extend(cls._option_rows(item.get("children", [])))
|
|
875
|
+
return rows
|
|
876
|
+
|
|
877
|
+
@staticmethod
|
|
878
|
+
def _csv_values(value: Any) -> list[str]:
|
|
879
|
+
return [item.strip() for item in str(value or "").split(",") if item.strip()]
|
|
880
|
+
|
|
881
|
+
@staticmethod
|
|
882
|
+
def _account_rows(backup: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
883
|
+
value = backup.get("wtfAccounts") or []
|
|
884
|
+
if isinstance(value, str):
|
|
885
|
+
try:
|
|
886
|
+
value = json.loads(value)
|
|
887
|
+
except (TypeError, ValueError):
|
|
888
|
+
value = []
|
|
889
|
+
return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
890
|
+
|
|
891
|
+
@staticmethod
|
|
892
|
+
def _backup_addons_id(backup: Mapping[str, Any]) -> Optional[str]:
|
|
893
|
+
value = backup.get("knownAddons")
|
|
894
|
+
if not isinstance(value, str) or not value.strip():
|
|
895
|
+
return None
|
|
896
|
+
try:
|
|
897
|
+
parsed = json.loads(value)
|
|
898
|
+
except (TypeError, ValueError):
|
|
899
|
+
return None
|
|
900
|
+
project_ids = parsed.get("projectids") if isinstance(parsed, Mapping) else None
|
|
901
|
+
return None if project_ids is None else str(project_ids)
|
|
902
|
+
|
|
903
|
+
def _validate_project_write(self, action: str, doc: Mapping[str, Any]) -> None:
|
|
904
|
+
if action not in {"create", "release", "update", "edit"}:
|
|
905
|
+
return
|
|
906
|
+
document = _project_document(doc)
|
|
907
|
+
allowed_categories = {
|
|
908
|
+
int(item["id"])
|
|
909
|
+
for item in self._nested_option_rows(self.options("categories"))
|
|
910
|
+
if item.get("id") is not None
|
|
911
|
+
}
|
|
912
|
+
unknown = [item for item in document.get("categories", []) if item not in allowed_categories]
|
|
913
|
+
if unknown:
|
|
914
|
+
raise ValidationError(
|
|
915
|
+
"category is not present in current Creator options",
|
|
916
|
+
path="$.project_state.basic_info.categories",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
def _validate_main_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> None:
|
|
920
|
+
if resource not in {"config", "wa"} or action not in {"create", "update", "edit"}:
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
if "tags" in doc:
|
|
924
|
+
option_name = "config-tags" if resource == "config" else "wa-tags"
|
|
925
|
+
allowed_tags = {str(item.get("id")) for item in self._option_rows(self.options(option_name)) if item.get("id") is not None}
|
|
926
|
+
unknown = [item for item in self._csv_values(doc.get("tags")) if item not in allowed_tags]
|
|
927
|
+
if unknown:
|
|
928
|
+
raise ValidationError("tag is not present in current %s options" % option_name, path="$.tags")
|
|
929
|
+
|
|
930
|
+
tier_id = doc.get("required_tier_id")
|
|
931
|
+
if tier_id is not None:
|
|
932
|
+
tiers = self._option_rows(self.options("subscription-tiers"))
|
|
933
|
+
allowed_tiers = {
|
|
934
|
+
str(item.get("id"))
|
|
935
|
+
for item in tiers
|
|
936
|
+
if item.get("id") is not None and item.get("isEnabled", item.get("is_enabled", 1)) not in (0, False)
|
|
937
|
+
}
|
|
938
|
+
if str(tier_id) not in allowed_tiers:
|
|
939
|
+
raise ValidationError("required_tier_id is not present in current tier options", path="$.required_tier_id")
|
|
940
|
+
|
|
941
|
+
if resource == "wa" and ("support_addon" in doc or "addons_id" in doc):
|
|
942
|
+
selected_doc = dict(doc)
|
|
943
|
+
if action in {"update", "edit"} and (
|
|
944
|
+
"support_addon" not in selected_doc or "addons_id" not in selected_doc
|
|
945
|
+
):
|
|
946
|
+
current = self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
|
|
947
|
+
current = current[0] if isinstance(current, list) and current else current
|
|
948
|
+
if isinstance(current, Mapping):
|
|
949
|
+
selected_doc.setdefault("support_addon", current.get("supportAddon"))
|
|
950
|
+
selected_doc.setdefault("addons_id", current.get("addonsId"))
|
|
951
|
+
support_rows = self._option_rows(self.options("wa-support-addons"))
|
|
952
|
+
selected = next((item for item in support_rows if str(item.get("name")) == str(selected_doc.get("support_addon"))), None)
|
|
953
|
+
if selected is None or str(selected.get("id")) != str(selected_doc.get("addons_id")):
|
|
954
|
+
raise ValidationError("support_addon and addons_id must match current options", path="$.support_addon")
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
if resource != "config":
|
|
958
|
+
return
|
|
959
|
+
linkage_fields = {"backup_id", "addons_id", "account_name", "role_name", "exclude_wtf"}
|
|
960
|
+
if not linkage_fields.intersection(doc):
|
|
961
|
+
return
|
|
962
|
+
merged = dict(doc)
|
|
963
|
+
if action in {"update", "edit"} and "backup_id" not in merged:
|
|
964
|
+
current = self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
|
|
965
|
+
current = current[0] if isinstance(current, list) and current else current
|
|
966
|
+
if isinstance(current, Mapping):
|
|
967
|
+
aliases = {
|
|
968
|
+
"backupId": "backup_id", "addonsId": "addons_id", "accountName": "account_name",
|
|
969
|
+
"roleName": "role_name", "excludeWtf": "exclude_wtf",
|
|
970
|
+
}
|
|
971
|
+
for wire, local in aliases.items():
|
|
972
|
+
if local not in merged and wire in current:
|
|
973
|
+
merged[local] = current[wire]
|
|
974
|
+
backup_id = merged.get("backup_id")
|
|
975
|
+
backups = self._option_rows(self.cloud_backups(server_type=merged.get("server_type")))
|
|
976
|
+
backup = next((item for item in backups if str(item.get("id")) == str(backup_id)), None)
|
|
977
|
+
if backup is None:
|
|
978
|
+
raise ValidationError("backup_id is not present in the selected Build", path="$.backup_id")
|
|
979
|
+
expected_addons = self._backup_addons_id(backup)
|
|
980
|
+
if expected_addons is not None and "addons_id" in merged and str(merged.get("addons_id")) != expected_addons:
|
|
981
|
+
raise ValidationError("addons_id does not match selected backup knownAddons.projectids", path="$.addons_id")
|
|
982
|
+
if int(merged.get("exclude_wtf") or 0) == 1:
|
|
983
|
+
return
|
|
984
|
+
account_name = str(merged.get("account_name") or "")
|
|
985
|
+
account = next((item for item in self._account_rows(backup) if account_name in {
|
|
986
|
+
str(item.get("id") or ""), str(item.get("accountId") or ""),
|
|
987
|
+
str(item.get("name") or ""), str(item.get("characterName") or ""),
|
|
988
|
+
}), None)
|
|
989
|
+
if account is None:
|
|
990
|
+
raise ValidationError("account_name is not present in selected backup", path="$.account_name")
|
|
991
|
+
roles = [str(item) for item in account.get("roles") or [] if str(item)]
|
|
992
|
+
role_name = str(merged.get("role_name") or "")
|
|
993
|
+
if roles and not role_name:
|
|
994
|
+
raise ValidationError("role_name is required for selected account", path="$.role_name")
|
|
995
|
+
if role_name and role_name not in roles:
|
|
996
|
+
raise ValidationError("role_name is not present in selected account", path="$.role_name")
|
|
997
|
+
|
|
998
|
+
# Main ModUs client: configuration shares and string articles.
|
|
999
|
+
def cloud_backups(self, *, server_type: Optional[int] = None) -> Any:
|
|
1000
|
+
return _safe(_unwrap(self._request("GET", "system/user/backup/list", headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1001
|
+
|
|
1002
|
+
def cloud_backup_detail(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
|
|
1003
|
+
return _safe(_unwrap(self._request("GET", "system/user/backup/detail/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1004
|
+
|
|
1005
|
+
def cloud_backup_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
1006
|
+
body = {"id": _positive_id(doc["backup_id"], field="backup_id"), "backupName": doc["backup_name"]}
|
|
1007
|
+
return _safe(_unwrap(self._request("POST", "system/user/backup/update", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1008
|
+
|
|
1009
|
+
def cloud_backup_delete(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
|
|
1010
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/backup/delete/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1011
|
+
|
|
1012
|
+
def image_upload(self, file_path: str) -> Dict[str, Any]:
|
|
1013
|
+
"""Upload an image through the exact ModUs main-client multipart API."""
|
|
1014
|
+
path = Path(file_path)
|
|
1015
|
+
if not path.is_file():
|
|
1016
|
+
raise ValidationError("image file does not exist", path="$.file")
|
|
1017
|
+
authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
|
|
1018
|
+
headers = {"Accept": "application/json", "Authorization": authorization}
|
|
1019
|
+
if self.device_id:
|
|
1020
|
+
headers["X-Device-Id"] = self.device_id
|
|
1021
|
+
payload = multipart_request(
|
|
1022
|
+
self._url("game/data/file/upload/file/image"),
|
|
1023
|
+
str(path),
|
|
1024
|
+
file_field="file",
|
|
1025
|
+
headers=headers,
|
|
1026
|
+
timeout=max(self.timeout, 600),
|
|
1027
|
+
)
|
|
1028
|
+
if isinstance(payload, Mapping):
|
|
1029
|
+
business_code = _business_code(payload)
|
|
1030
|
+
if payload.get("success") is False or (business_code is not None and business_code != 200):
|
|
1031
|
+
raise FuploadError(
|
|
1032
|
+
str(payload.get("msg") or payload.get("message") or "ModUs image upload was rejected"),
|
|
1033
|
+
endpoint=self._url("game/data/file/upload/file/image"),
|
|
1034
|
+
business_code=business_code,
|
|
1035
|
+
kind="platform_error",
|
|
1036
|
+
stage="media_upload",
|
|
1037
|
+
)
|
|
1038
|
+
record = _image_upload_record(payload)
|
|
1039
|
+
content = path.read_bytes()
|
|
1040
|
+
return {
|
|
1041
|
+
**record,
|
|
1042
|
+
"bytes": len(content),
|
|
1043
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
@staticmethod
|
|
1047
|
+
def _share_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
|
|
1048
|
+
aliases = {"share_id": "id", "addons_id": "addonsId", "backup_id": "backupId", "account_name": "accountName", "content_text": "contentText", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "exclude_wtf": "excludeWtf", "role_name": "roleName", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
|
|
1049
|
+
allowed = {"id", "addonsId", "backupId", "accountName", "content", "contentText", "imageUrl", "isPaid", "isPublic", "price", "shareType", "tags", "title", "excludeWtf", "roleName", "requiredTierId", "subType", "platform", "synchronizationType"}
|
|
1050
|
+
result = {}
|
|
1051
|
+
for key, value in doc.items():
|
|
1052
|
+
wire = aliases.get(key, key)
|
|
1053
|
+
if wire in allowed and value is not None:
|
|
1054
|
+
result[wire] = str(value) if wire == "addonsId" else value
|
|
1055
|
+
result.setdefault("platform", 1)
|
|
1056
|
+
exclude = result.get("excludeWtf", 0)
|
|
1057
|
+
result["excludeWtf"] = 1 if str(exclude).strip().lower() in {"1", "true"} else 0
|
|
1058
|
+
return result
|
|
1059
|
+
|
|
1060
|
+
def share_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
|
|
1061
|
+
source = body or {}
|
|
1062
|
+
selected_build = self._build(source.get("server", server_type))
|
|
1063
|
+
mine = source.get("mine")
|
|
1064
|
+
share_type = source.get("share_type")
|
|
1065
|
+
wire = {
|
|
1066
|
+
"pageNum": int(source.get("page_num") or 1),
|
|
1067
|
+
"pageSize": int(source.get("page_size") or 20),
|
|
1068
|
+
"server": selected_build,
|
|
1069
|
+
"mine": False if mine is None else bool(mine),
|
|
1070
|
+
"shareType": 0 if share_type is None else share_type,
|
|
1071
|
+
}
|
|
1072
|
+
platform = source.get("platform")
|
|
1073
|
+
wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
|
|
1074
|
+
for key in ("keyword", "status", "tags", "order_by", "is_public", "is_paid"):
|
|
1075
|
+
if key in source and source[key] is not None:
|
|
1076
|
+
wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] = (
|
|
1077
|
+
_tag_filter(source[key]) if key == "tags" else source[key]
|
|
1078
|
+
)
|
|
1079
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/list", wire, headers={"X-Server-Type": str(selected_build)})))
|
|
1080
|
+
|
|
1081
|
+
def share_detail(self, share_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
1082
|
+
ids = share_ids if isinstance(share_ids, list) else [share_ids]
|
|
1083
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/detail", {"shareIds": [_resource_id(x, field="share_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1084
|
+
|
|
1085
|
+
def share_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
1086
|
+
return _safe(_unwrap(self._request("POST", "system/user/share/create", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1087
|
+
|
|
1088
|
+
def share_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
1089
|
+
return _safe(_unwrap(self._request("PUT", "system/user/share/update", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1090
|
+
|
|
1091
|
+
def share_delete(self, share_id: Any, *, server_type: Optional[int] = None) -> Any:
|
|
1092
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/share/delete/%s" % urllib.parse.quote(_resource_id(share_id, field="share_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1093
|
+
|
|
1094
|
+
@staticmethod
|
|
1095
|
+
def _import_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
|
|
1096
|
+
aliases = {"import_id": "id", "code_text": "codeText", "addons_id": "addonsId", "content_text": "contentText", "file_path": "filePath", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "support_addon": "supportAddon", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
|
|
1097
|
+
allowed = {"id", "codeText", "content", "addonsId", "contentText", "filePath", "imageUrl", "isPaid", "isPublic", "price", "shareType", "supportAddon", "tags", "title", "version", "requiredTierId", "subType", "platform", "synchronizationType"}
|
|
1098
|
+
result = {aliases.get(key, key): value for key, value in doc.items() if aliases.get(key, key) in allowed and value is not None}
|
|
1099
|
+
if "addonsId" in result:
|
|
1100
|
+
result["addonsId"] = str(result["addonsId"])
|
|
1101
|
+
result.setdefault("platform", 1)
|
|
1102
|
+
result.setdefault("synchronizationType", 3 if result["platform"] == 3 else 1)
|
|
1103
|
+
return result
|
|
1104
|
+
|
|
1105
|
+
def import_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
|
|
1106
|
+
source = body or {}
|
|
1107
|
+
selected_build = self._build(source.get("server", server_type))
|
|
1108
|
+
mine = source.get("mine")
|
|
1109
|
+
status = source.get("status")
|
|
1110
|
+
wire = {
|
|
1111
|
+
"pageNum": int(source.get("page_num") or 1),
|
|
1112
|
+
"pageSize": int(source.get("page_size") or 10),
|
|
1113
|
+
"server": selected_build,
|
|
1114
|
+
"mine": False if mine is None else bool(mine),
|
|
1115
|
+
"status": 1 if status is None else status,
|
|
1116
|
+
}
|
|
1117
|
+
platform = source.get("platform")
|
|
1118
|
+
wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
|
|
1119
|
+
for key in ("keyword", "support_addon", "tags", "is_paid", "order_by"):
|
|
1120
|
+
if key in source and source[key] is not None:
|
|
1121
|
+
wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] = (
|
|
1122
|
+
_tag_filter(source[key]) if key == "tags" else source[key]
|
|
1123
|
+
)
|
|
1124
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/list", wire, headers={"X-Server-Type": str(selected_build)})))
|
|
1125
|
+
|
|
1126
|
+
def import_detail(self, import_ids: Any, *, server_type: Optional[int] = None) -> Any:
|
|
1127
|
+
ids = import_ids if isinstance(import_ids, list) else [import_ids]
|
|
1128
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/detail", {"importIds": [_resource_id(x, field="import_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1129
|
+
|
|
1130
|
+
def import_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
1131
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/create", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1132
|
+
|
|
1133
|
+
def import_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
1134
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/update", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1135
|
+
|
|
1136
|
+
def import_delete(self, import_id: Any, *, server_type: Optional[int] = None) -> Any:
|
|
1137
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/import/delete/%s" % urllib.parse.quote(_resource_id(import_id, field="import_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1138
|
+
|
|
1139
|
+
def import_version_publish(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
|
|
1140
|
+
body = {
|
|
1141
|
+
"importId": _resource_id(doc["import_id"], field="import_id"),
|
|
1142
|
+
"version": str(doc["version"]).strip(),
|
|
1143
|
+
"codeText": str(doc["code_text"]).strip(),
|
|
1144
|
+
}
|
|
1145
|
+
changelog = str(doc.get("changelog") or "").strip()
|
|
1146
|
+
if changelog:
|
|
1147
|
+
body["changelog"] = changelog
|
|
1148
|
+
return _safe(_unwrap(self._request("POST", "system/user/import/version/publish", body, headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1149
|
+
|
|
1150
|
+
def import_version_delete(self, version_id: Any, *, server_type: Optional[int] = None) -> Any:
|
|
1151
|
+
return _safe(_unwrap(self._request("DELETE", "system/user/import/version/delete?versionId=%s" % urllib.parse.quote(str(version_id), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
|
|
1152
|
+
|
|
621
1153
|
def release_signature(self, project_id: int, file_id: int) -> str:
|
|
622
1154
|
result = _unwrap(self._request("GET", "game/data/author/project/file/upload/signature/%s/%s" % (_positive_id(project_id, field="project_id"), _positive_id(file_id, field="file_id"))))
|
|
623
1155
|
url = result.get("signedUrl") if isinstance(result, Mapping) else result
|
|
@@ -819,15 +1351,23 @@ class ModUs:
|
|
|
819
1351
|
|
|
820
1352
|
def execute_read(self, resource: str, action: str, args: Any = None) -> Any:
|
|
821
1353
|
if resource == "session" and action == "doctor": return self.doctor()
|
|
1354
|
+
if resource == "options" and action == "builds": return self.builds()
|
|
1355
|
+
if resource == "builds" and action == "list": return self.builds()
|
|
822
1356
|
doc = vars(args) if args is not None and hasattr(args, "__dict__") else (args or {})
|
|
823
1357
|
if resource == "account" and action == "info": return self.user_info()
|
|
824
1358
|
if resource == "account" and action == "subscription-count": return self.active_subscription_count()
|
|
825
1359
|
if resource == "account" and action == "statistics": return self.project_statistics()
|
|
826
|
-
if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type"
|
|
827
|
-
if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type"
|
|
828
|
-
if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type"
|
|
1360
|
+
if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type"))
|
|
1361
|
+
if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type"))
|
|
1362
|
+
if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type"))
|
|
829
1363
|
if resource == "project" and action == "dependencies": return self.project_dependencies(doc.get("query") or doc.get("project_ids") or doc.get("project_id"))
|
|
830
1364
|
if resource == "options": return self.options(action, keys=doc.get("keys"))
|
|
1365
|
+
if resource == "config" and action == "backups": return self.cloud_backups(server_type=doc.get("server_type"))
|
|
1366
|
+
if resource == "config" and action == "backup-get": return self.cloud_backup_detail(doc["backup_id"], server_type=doc.get("server_type"))
|
|
1367
|
+
if resource == "config" and action == "list": return self.share_list(doc, server_type=doc.get("server_type"))
|
|
1368
|
+
if resource == "config" and action == "get": return self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
|
|
1369
|
+
if resource == "wa" and action == "list": return self.import_list(doc, server_type=doc.get("server_type"))
|
|
1370
|
+
if resource == "wa" and action == "get": return self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
|
|
831
1371
|
if resource == "project" and action == "list": return self.project_list(**doc)
|
|
832
1372
|
if resource == "project" and action in ("get", "detail"): return self.project_detail(doc["project_id"])
|
|
833
1373
|
if resource in ("plugin", "release") and action in ("list", "versions"): return self.release_list(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 50))
|
|
@@ -835,6 +1375,19 @@ class ModUs:
|
|
|
835
1375
|
raise ValidationError("unsupported ModUs read operation")
|
|
836
1376
|
|
|
837
1377
|
def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
|
|
1378
|
+
if resource == "media" and action == "upload": return self.image_upload(str(doc["file"]))
|
|
1379
|
+
if resource == "project": self._validate_project_write(action, doc)
|
|
1380
|
+
self._validate_main_write(resource, action, doc)
|
|
1381
|
+
if resource == "config" and action == "create": return self.share_create(doc, server_type=doc.get("server_type"))
|
|
1382
|
+
if resource == "config" and action in ("update", "edit"): return self.share_update(doc, server_type=doc.get("server_type"))
|
|
1383
|
+
if resource == "config" and action == "delete": return self.share_delete(doc["share_id"], server_type=doc.get("server_type"))
|
|
1384
|
+
if resource == "config" and action == "backup-edit": return self.cloud_backup_update(doc, server_type=doc.get("server_type"))
|
|
1385
|
+
if resource == "config" and action == "backup-delete": return self.cloud_backup_delete(doc["backup_id"], server_type=doc.get("server_type"))
|
|
1386
|
+
if resource == "wa" and action == "create": return self.import_create(doc, server_type=doc.get("server_type"))
|
|
1387
|
+
if resource == "wa" and action in ("update", "edit"): return self.import_update(doc, server_type=doc.get("server_type"))
|
|
1388
|
+
if resource == "wa" and action == "delete": return self.import_delete(doc["import_id"], server_type=doc.get("server_type"))
|
|
1389
|
+
if resource == "wa" and action == "version-publish": return self.import_version_publish(doc, server_type=doc.get("server_type"))
|
|
1390
|
+
if resource == "wa" and action == "version-delete": return self.import_version_delete(doc["version_id"], server_type=doc.get("server_type"))
|
|
838
1391
|
if resource == "project" and action in ("create", "release"): return self.project_create(doc)
|
|
839
1392
|
if resource == "project" and action in ("update", "edit"): return self.project_update(doc)
|
|
840
1393
|
if resource == "project" and action == "delete": return self.project_delete(int(doc["project_id"]))
|