@follenfang/fupload 0.0.9 → 0.0.10
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 +17 -4
- package/fupload/references/modus.md +77 -0
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/cli.py +70 -2
- package/fupload/scripts/fupload_cli/io.py +3 -1
- package/fupload/scripts/fupload_cli/modus.py +768 -0
- package/fupload/scripts/fupload_cli/modus_zip.py +175 -0
- package/fupload/scripts/fupload_cli/schema.py +141 -2
- package/fupload/scripts/fupload_cli/state_machine.py +346 -0
- package/npm/skill-manifest.json +33 -13
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Derive ModUs release metadata from an addon ZIP archive.
|
|
2
|
+
|
|
3
|
+
The Creator release form sends two related values for a package:
|
|
4
|
+
|
|
5
|
+
* ``tocVersion`` is the Interface value(s) found in addon ``.toc`` files.
|
|
6
|
+
* ``supportedGameVersionsReqs`` contains ``{gameVersion, server}`` objects.
|
|
7
|
+
|
|
8
|
+
This module intentionally uses an explicit interface table. A numeric
|
|
9
|
+
Interface value is not enough to safely infer a ModUs game choice when the
|
|
10
|
+
Creator adds a new client, so unknown values fail deterministically instead
|
|
11
|
+
of being silently classified.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import zipfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, BinaryIO, Dict, Iterable, List, Mapping, Sequence, Tuple, Union
|
|
22
|
+
|
|
23
|
+
from .errors import ValidationError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# These are the game-version labels loaded by Creator's
|
|
27
|
+
# ReleaseFormPageViewModel.LoadDefaultGameVersions. 12.1.0 is also included
|
|
28
|
+
# because it is the live ModUs response used by the integration fixture.
|
|
29
|
+
# Classic values are the Interface values used by the corresponding WoW
|
|
30
|
+
# clients; all entries remain explicit so a new client cannot be guessed.
|
|
31
|
+
INTERFACE_GAME_VERSION_MAP: Mapping[str, Mapping[str, str]] = {
|
|
32
|
+
"110000": {"gameVersion": "11.0.0", "server": "wow_retail"},
|
|
33
|
+
"110002": {"gameVersion": "11.0.2", "server": "wow_retail"},
|
|
34
|
+
"111000": {"gameVersion": "11.1.0", "server": "wow_retail"},
|
|
35
|
+
"111005": {"gameVersion": "11.1.5", "server": "wow_retail"},
|
|
36
|
+
"120100": {"gameVersion": "12.1.0", "server": "wow_retail"},
|
|
37
|
+
"11506": {"gameVersion": "Classic Era", "server": "wow_classic_era"},
|
|
38
|
+
"11507": {"gameVersion": "Classic Era", "server": "wow_classic_era"},
|
|
39
|
+
"11508": {"gameVersion": "Classic Era", "server": "wow_classic_era"},
|
|
40
|
+
"40401": {"gameVersion": "Cataclysm Classic", "server": "wow_classic_cata"},
|
|
41
|
+
"40402": {"gameVersion": "Cataclysm Classic", "server": "wow_classic_cata"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_INTERFACE_RE = re.compile(r"^\s*##\s*Interface\s*:\s*(.*?)\s*$", re.IGNORECASE)
|
|
45
|
+
_INTERFACE_VALUE_RE = re.compile(r"^\d+$")
|
|
46
|
+
_SOURCE = Union[str, os.PathLike[str], bytes, bytearray, memoryview, BinaryIO]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_source(source: _SOURCE) -> Tuple[bytes, str]:
|
|
50
|
+
"""Read a path, byte buffer, or seekable stream without leaking content."""
|
|
51
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
52
|
+
return bytes(source), "<bytes>"
|
|
53
|
+
if hasattr(source, "read"):
|
|
54
|
+
try:
|
|
55
|
+
data = source.read() # type: ignore[union-attr]
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise ValidationError("cannot read ZIP archive: %s" % exc, path="$.file") from exc
|
|
58
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
59
|
+
raise ValidationError("ZIP source must return bytes", path="$.file")
|
|
60
|
+
return bytes(data), "<stream>"
|
|
61
|
+
path = Path(source)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
raise ValidationError("file does not exist or is not a regular file", path="$.file")
|
|
64
|
+
try:
|
|
65
|
+
return path.read_bytes(), str(path)
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
raise ValidationError("cannot read ZIP archive: %s" % exc, path="$.file") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _decode_toc(raw: bytes, name: str) -> str:
|
|
71
|
+
# Creator-generated TOCs are UTF-8. A small number of legacy addon TOCs
|
|
72
|
+
# use Windows-1252, which is deterministic to support without guessing.
|
|
73
|
+
try:
|
|
74
|
+
return raw.decode("utf-8-sig")
|
|
75
|
+
except UnicodeDecodeError:
|
|
76
|
+
try:
|
|
77
|
+
return raw.decode("cp1252")
|
|
78
|
+
except UnicodeDecodeError as exc:
|
|
79
|
+
raise ValidationError("addon TOC is not valid UTF-8 or Windows-1252", path="$.file:%s" % name) from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _interface_values(text: str, name: str) -> List[str]:
|
|
83
|
+
values: List[str] = []
|
|
84
|
+
for line in text.splitlines():
|
|
85
|
+
match = _INTERFACE_RE.match(line)
|
|
86
|
+
if not match:
|
|
87
|
+
continue
|
|
88
|
+
value_text = match.group(1).strip()
|
|
89
|
+
if not value_text:
|
|
90
|
+
raise ValidationError("addon TOC Interface value is missing", path="$.file:%s" % name)
|
|
91
|
+
candidates = [item for item in re.split(r"[;,\s]+", value_text) if item]
|
|
92
|
+
if not candidates or any(not _INTERFACE_VALUE_RE.fullmatch(item) for item in candidates):
|
|
93
|
+
raise ValidationError("addon TOC Interface value must contain decimal codes", path="$.file:%s" % name)
|
|
94
|
+
values.extend(candidates)
|
|
95
|
+
if not values:
|
|
96
|
+
raise ValidationError("addon TOC has no Interface field", path="$.file:%s" % name)
|
|
97
|
+
unique = list(dict.fromkeys(values))
|
|
98
|
+
return sorted(unique, key=lambda item: (int(item), item))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _zip_entries(raw: bytes, source_name: str) -> Iterable[Tuple[str, bytes]]:
|
|
102
|
+
try:
|
|
103
|
+
archive = zipfile.ZipFile(io.BytesIO(raw))
|
|
104
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
105
|
+
raise ValidationError("file must be a valid ZIP archive", path="$.file") from exc
|
|
106
|
+
with archive:
|
|
107
|
+
seen: set[str] = set()
|
|
108
|
+
toc_infos = []
|
|
109
|
+
for info in archive.infolist():
|
|
110
|
+
name = info.filename
|
|
111
|
+
if info.is_dir() or name.endswith("/") or not name.lower().endswith(".toc"):
|
|
112
|
+
continue
|
|
113
|
+
normalized = name.replace("\\", "/").casefold()
|
|
114
|
+
if normalized in seen:
|
|
115
|
+
raise ValidationError("ZIP contains duplicate addon TOC paths", path="$.file:%s" % name)
|
|
116
|
+
seen.add(normalized)
|
|
117
|
+
toc_infos.append(info)
|
|
118
|
+
if not toc_infos:
|
|
119
|
+
raise ValidationError("ZIP contains no addon .toc file", path="$.file")
|
|
120
|
+
for info in sorted(toc_infos, key=lambda item: item.filename.casefold()):
|
|
121
|
+
try:
|
|
122
|
+
yield info.filename, archive.read(info)
|
|
123
|
+
except (KeyError, RuntimeError, OSError) as exc:
|
|
124
|
+
raise ValidationError("cannot read addon TOC from ZIP", path="$.file:%s" % info.filename) from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_modus_zip(source: _SOURCE) -> Dict[str, Any]:
|
|
128
|
+
"""Return ModUs metadata inferred from ``source``.
|
|
129
|
+
|
|
130
|
+
Every ``.toc`` in the archive must declare the same Interface set. This
|
|
131
|
+
avoids choosing an arbitrary addon when a multi-addon archive contains
|
|
132
|
+
incompatible game versions. Multiple Interface values in one TOC are
|
|
133
|
+
supported and become a deterministic comma-separated ``toc_version``.
|
|
134
|
+
|
|
135
|
+
Returned keys are JSON-ready and use the exact snake_case names accepted
|
|
136
|
+
by the Fupload ModUs schema. ``interface_values`` and ``toc_files`` are
|
|
137
|
+
diagnostic fields for callers and can be omitted from the wire request.
|
|
138
|
+
"""
|
|
139
|
+
raw, source_name = _read_source(source)
|
|
140
|
+
signatures: List[Tuple[str, Tuple[str, ...]]] = []
|
|
141
|
+
for name, toc_raw in _zip_entries(raw, source_name):
|
|
142
|
+
values = tuple(_interface_values(_decode_toc(toc_raw, name), name))
|
|
143
|
+
signatures.append((name, values))
|
|
144
|
+
expected = signatures[0][1]
|
|
145
|
+
mismatches = [name for name, values in signatures[1:] if values != expected]
|
|
146
|
+
if mismatches:
|
|
147
|
+
names = ", ".join([signatures[0][0], *mismatches])
|
|
148
|
+
raise ValidationError("addon TOC Interface values are ambiguous across files: %s" % names, path="$.file")
|
|
149
|
+
|
|
150
|
+
unknown = [value for value in expected if value not in INTERFACE_GAME_VERSION_MAP]
|
|
151
|
+
if unknown:
|
|
152
|
+
raise ValidationError(
|
|
153
|
+
"unsupported addon TOC Interface value(s): %s" % ", ".join(unknown),
|
|
154
|
+
path="$.file",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
games: List[Dict[str, str]] = []
|
|
158
|
+
for interface in expected:
|
|
159
|
+
candidate = dict(INTERFACE_GAME_VERSION_MAP[interface])
|
|
160
|
+
if candidate not in games:
|
|
161
|
+
games.append(candidate)
|
|
162
|
+
return {
|
|
163
|
+
"toc_version": ",".join(expected),
|
|
164
|
+
"supported_game_versions": games,
|
|
165
|
+
"interface_values": list(expected),
|
|
166
|
+
"toc_files": [name for name, _ in signatures],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Short alias for integration code that already calls metadata parsers by a
|
|
171
|
+
# generic name.
|
|
172
|
+
parse_zip_metadata = parse_modus_zip
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = ["INTERFACE_GAME_VERSION_MAP", "parse_modus_zip", "parse_zip_metadata"]
|
|
@@ -61,11 +61,16 @@ class Schema:
|
|
|
61
61
|
raise ValidationError("null is not allowed", path="$.%s" % name)
|
|
62
62
|
continue
|
|
63
63
|
expected = JSON_TYPES[spec.type]
|
|
64
|
+
# Older Fupload documents used the Creator's display-name
|
|
65
|
+
# license string. Keep that input valid while structured license
|
|
66
|
+
# content is preferred for full-field round trips.
|
|
67
|
+
if self.name.startswith("fupload.v1.modus") and name == "license" and isinstance(item, str):
|
|
68
|
+
continue
|
|
64
69
|
if spec.type in ("integer", "number") and isinstance(item, bool):
|
|
65
70
|
raise ValidationError("expected %s" % spec.type, path="$.%s" % name)
|
|
66
71
|
if not isinstance(item, expected):
|
|
67
72
|
raise ValidationError("expected %s" % spec.type, path="$.%s" % name)
|
|
68
|
-
if spec.nonempty and item in ("", []):
|
|
73
|
+
if spec.nonempty and item in ("", [], {}):
|
|
69
74
|
raise ValidationError("must not be empty", path="$.%s" % name)
|
|
70
75
|
if spec.max_length is not None and len(item) > spec.max_length:
|
|
71
76
|
raise ValidationError(
|
|
@@ -93,7 +98,24 @@ class Schema:
|
|
|
93
98
|
return checked
|
|
94
99
|
|
|
95
100
|
def _validate_conditionals(self, value: Dict[str, Any]) -> None:
|
|
96
|
-
|
|
101
|
+
if self.name in ("fupload.v1.modus.project.create", "fupload.v1.modus.project.edit"):
|
|
102
|
+
snapshot = value.get("project_state")
|
|
103
|
+
if snapshot is None:
|
|
104
|
+
raise ValidationError(
|
|
105
|
+
"completed project_state is required; submit choose_game, basic_info, then license",
|
|
106
|
+
path="$.project_state",
|
|
107
|
+
)
|
|
108
|
+
# Keep the persisted form contract in one place. Restoring the
|
|
109
|
+
# snapshot validates every completed prerequisite and its order.
|
|
110
|
+
from .state_machine import COMPLETE, ProjectStateMachine
|
|
111
|
+
|
|
112
|
+
machine = ProjectStateMachine.from_snapshot(snapshot)
|
|
113
|
+
if machine.state != COMPLETE:
|
|
114
|
+
raise ValidationError(
|
|
115
|
+
"project state must be complete before submission",
|
|
116
|
+
path="$.project_state.state",
|
|
117
|
+
)
|
|
118
|
+
for name in ("id", "project_id", "mod_id", "file_id", "content_id", "source_id", "module_id", "version_id", "game_version_id", "cloud_id"):
|
|
97
119
|
if name in value and isinstance(value[name], int) and value[name] <= 0:
|
|
98
120
|
raise ValidationError("must be greater than zero", path="$.%s" % name)
|
|
99
121
|
if value.get("public") is True and value.get("submit_for_review") is not True:
|
|
@@ -351,6 +373,74 @@ class Schema:
|
|
|
351
373
|
raise ValidationError("array must contain nonempty folder names", path="$.core_folders")
|
|
352
374
|
if "file" in value and not zipfile.is_zipfile(value["file"]):
|
|
353
375
|
raise ValidationError("file must be a valid ZIP archive", path="$.file")
|
|
376
|
+
if self.name.startswith("fupload.v1.modus"):
|
|
377
|
+
object_array(
|
|
378
|
+
"supported_game_versions",
|
|
379
|
+
{"gameVersion", "server", "game_version"},
|
|
380
|
+
("gameVersion", "server"),
|
|
381
|
+
)
|
|
382
|
+
# The Creator UI treats publishing targets as an independent
|
|
383
|
+
# multi-select state: at least one target is required, and ModUs
|
|
384
|
+
# and BigFoot may be selected together.
|
|
385
|
+
if "publish_platforms" in value:
|
|
386
|
+
platforms = value["publish_platforms"]
|
|
387
|
+
if not isinstance(platforms, list):
|
|
388
|
+
raise ValidationError("expected array", path="$.publish_platforms")
|
|
389
|
+
if not platforms:
|
|
390
|
+
raise ValidationError("must contain at least one platform", path="$.publish_platforms")
|
|
391
|
+
allowed_platforms = {"modus", "bigfoot"}
|
|
392
|
+
if any(not isinstance(item, str) or item not in allowed_platforms for item in platforms):
|
|
393
|
+
raise ValidationError("platform must be modus or bigfoot", path="$.publish_platforms")
|
|
394
|
+
if len(set(platforms)) != len(platforms):
|
|
395
|
+
raise ValidationError("platforms must not contain duplicates", path="$.publish_platforms")
|
|
396
|
+
# A license may be supplied as the desktop client's display name
|
|
397
|
+
# or as its underlying editable content object.
|
|
398
|
+
if "license" in value and isinstance(value["license"], dict):
|
|
399
|
+
license_value = value["license"]
|
|
400
|
+
allowed_license = {"type", "holder", "year", "content"}
|
|
401
|
+
unknown_license = sorted(set(license_value) - allowed_license)
|
|
402
|
+
if unknown_license:
|
|
403
|
+
raise ValidationError(
|
|
404
|
+
"unknown field(s): %s" % ", ".join(unknown_license),
|
|
405
|
+
path="$.license.%s" % unknown_license[0],
|
|
406
|
+
)
|
|
407
|
+
for name in allowed_license:
|
|
408
|
+
if name in license_value and license_value[name] is not None:
|
|
409
|
+
if not isinstance(license_value[name], str):
|
|
410
|
+
raise ValidationError("expected string or null", path="$.license.%s" % name)
|
|
411
|
+
if not license_value[name].strip():
|
|
412
|
+
raise ValidationError("must not be empty", path="$.license.%s" % name)
|
|
413
|
+
# Empty object is the explicit clear/omitted state used by
|
|
414
|
+
# presence-aware edit schemas; non-empty objects are checked
|
|
415
|
+
# field-by-field above.
|
|
416
|
+
if "required_tier_id" in value and value["required_tier_id"] is not None:
|
|
417
|
+
tier = value["required_tier_id"]
|
|
418
|
+
if isinstance(tier, bool) or not isinstance(tier, int) or tier <= 0:
|
|
419
|
+
raise ValidationError("must be a positive integer", path="$.required_tier_id")
|
|
420
|
+
for name in ("categories", "screenshot_base64s"):
|
|
421
|
+
scalar_array(name, (str, int), "array must contain nonempty values")
|
|
422
|
+
if name in value and any((isinstance(item, str) and not item.strip()) or (isinstance(item, bool)) for item in value[name]):
|
|
423
|
+
raise ValidationError("array must contain nonempty values", path="$.%s" % name)
|
|
424
|
+
if "categories" in value and len(value["categories"]) > 5:
|
|
425
|
+
raise ValidationError("must contain at most 5 items", path="$.categories")
|
|
426
|
+
if "image_ops" in value:
|
|
427
|
+
object_array("image_ops", {"op", "name", "base64"}, ("op", "name"))
|
|
428
|
+
if "images" not in value:
|
|
429
|
+
raise ValidationError("images is required when image_ops is supplied", path="$.images")
|
|
430
|
+
for index, operation in enumerate(value["image_ops"]):
|
|
431
|
+
if operation["op"] not in ("upload", "delete"):
|
|
432
|
+
raise ValidationError("must be upload or delete", path="$.image_ops[%d].op" % index)
|
|
433
|
+
if not isinstance(operation["name"], str) or not operation["name"].strip():
|
|
434
|
+
raise ValidationError("must not be empty", path="$.image_ops[%d].name" % index)
|
|
435
|
+
if operation["op"] == "upload" and (not isinstance(operation.get("base64"), str) or not operation["base64"].strip()):
|
|
436
|
+
raise ValidationError("base64 is required for upload", path="$.image_ops[%d].base64" % index)
|
|
437
|
+
if operation["op"] == "delete" and "base64" in operation:
|
|
438
|
+
raise ValidationError("base64 is not allowed for delete", path="$.image_ops[%d].base64" % index)
|
|
439
|
+
for name in ("version", "type"):
|
|
440
|
+
if name in value and isinstance(value[name], str) and not value[name].strip():
|
|
441
|
+
raise ValidationError("must not be empty", path="$.%s" % name)
|
|
442
|
+
if "file" in value and not zipfile.is_zipfile(value["file"]):
|
|
443
|
+
raise ValidationError("file must be a valid ZIP archive", path="$.file")
|
|
354
444
|
|
|
355
445
|
|
|
356
446
|
def f(type_name: str, **kwargs: Any) -> Field:
|
|
@@ -563,6 +653,55 @@ register("curseforge", "plugin", "upload", required({
|
|
|
563
653
|
"is_marked_for_manual_release": f("boolean"),
|
|
564
654
|
}, ("project_id", "file", "changelog", "release_type")))
|
|
565
655
|
|
|
656
|
+
# ModUs.Creator author project and release contracts. The provider translates
|
|
657
|
+
# snake_case input names to the desktop client's camelCase wire fields.
|
|
658
|
+
MODUS_PROJECT_COMMON = {
|
|
659
|
+
"name": f("string", nonempty=True, max_length=120),
|
|
660
|
+
"alt_name": f("string", max_length=120),
|
|
661
|
+
"summary": f("string", nonempty=True, max_length=500),
|
|
662
|
+
"categories": f("array", nonempty=True, max_items=5),
|
|
663
|
+
"license": f("object", nullable=True),
|
|
664
|
+
"repo_url": f("string", max_length=500),
|
|
665
|
+
"required_tier_id": f("integer", nullable=True, minimum=1),
|
|
666
|
+
"publish_platforms": f("array", nonempty=True),
|
|
667
|
+
"project_state": f("object", nonempty=True),
|
|
668
|
+
}
|
|
669
|
+
MODUS_PROJECT_CREATE = {
|
|
670
|
+
**MODUS_PROJECT_COMMON,
|
|
671
|
+
"logo_base64": f("string", nonempty=True),
|
|
672
|
+
"screenshot_base64s": f("array"),
|
|
673
|
+
}
|
|
674
|
+
MODUS_PROJECT_EDIT = {
|
|
675
|
+
**MODUS_PROJECT_COMMON,
|
|
676
|
+
"description": f("string", nullable=True, max_length=100000),
|
|
677
|
+
"required_dependencies": f("string", nullable=True, max_length=4000),
|
|
678
|
+
"images": f("integer", minimum=0),
|
|
679
|
+
"image_ops": f("array", nonempty=True),
|
|
680
|
+
}
|
|
681
|
+
MODUS_RELEASE = {
|
|
682
|
+
"project_id": f("integer", minimum=1),
|
|
683
|
+
"file_id": f("integer", minimum=1),
|
|
684
|
+
"version": f("string", nonempty=True, max_length=120),
|
|
685
|
+
"type": f("string", nonempty=True, max_length=40),
|
|
686
|
+
"supported_game_versions": f("array", nonempty=True),
|
|
687
|
+
"md5": f("string", max_length=64),
|
|
688
|
+
"zip_size": f("integer", minimum=0),
|
|
689
|
+
"unzip_size": f("integer", minimum=0),
|
|
690
|
+
"path": f("string", max_length=500),
|
|
691
|
+
"toc_version": f("string", max_length=80),
|
|
692
|
+
"changelog": f("string", nullable=True, max_length=10000),
|
|
693
|
+
"file": f("string", local_file=True),
|
|
694
|
+
"transaction_log": f("string", max_length=1000),
|
|
695
|
+
}
|
|
696
|
+
register("modus", "project", "create", required(MODUS_PROJECT_CREATE, ("project_state",)))
|
|
697
|
+
register("modus", "project", "edit", required(with_id(MODUS_PROJECT_EDIT, "project_id"), ("project_id", "project_state")))
|
|
698
|
+
register("modus", "project", "delete", required({"project_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "confirm")))
|
|
699
|
+
register("modus", "plugin", "create", required(MODUS_RELEASE, ("project_id", "file")))
|
|
700
|
+
register("modus", "plugin", "upload", required(MODUS_RELEASE, ("project_id", "file")))
|
|
701
|
+
register("modus", "plugin", "update", required(MODUS_RELEASE, ("project_id", "file_id")))
|
|
702
|
+
register("modus", "plugin", "edit", required(MODUS_RELEASE, ("project_id", "file_id")))
|
|
703
|
+
register("modus", "plugin", "delete", required({"project_id": f("integer", minimum=1), "file_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "file_id", "confirm")))
|
|
704
|
+
|
|
566
705
|
register("blackbox", "plugin", "edit", required({
|
|
567
706
|
"id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
|
|
568
707
|
"type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),
|