@follenfang/fupload 0.0.12 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
  name: fupload
3
3
  description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, Heybox Workshop, and ModUs.Creator, including local Creator login reuse and plugin ZIP publishing. Use only when the user explicitly invokes `$fupload`, explicitly asks to use the Fupload Skill, or loads this Skill by path. Do not trigger from ordinary mentions of publishing, NewBeeBox, DD, CurseForge, Heybox, ModUs, plugins, configurations, or WA.
4
4
  metadata:
5
- version: "0.0.12"
5
+ version: "0.0.15"
6
6
  ---
7
7
 
8
8
  # Fupload
@@ -35,31 +35,42 @@ The complete project model includes `project_id`, `name`, `alt_name`, `summary`,
35
35
 
36
36
  ### Project field contract
37
37
 
38
- | CLI/state field | Creator wire/readback | JSON type | Required/default | Source and state dependency | Successful readback |
39
- | --- | --- | --- | --- | --- | --- |
40
- | `schema` | none | string | required by every write document | versioned CLI envelope | not sent |
41
- | `project_id` | `projectId` | positive integer | edit/delete required; create assigned by server | outer write document, not form state | `projectId` |
42
- | `game` | none confirmed | object or non-empty string | required; no default | `choose_game`; object needs a stable ID/key | preserved in state snapshot |
43
- | `name` | `name` | non-empty string | required; no default | `basic_info` | `name` |
44
- | `alt_name` | `altName` | string/null | optional; omitted preserves value | `basic_info`; edit clear uses `<null>` | `altName` |
45
- | `summary` | `summary` | non-empty string | required; no default | `basic_info` | `summary` |
46
- | `categories` | comma-delimited `categories` | 1-5 positive integer IDs | required; no default | service `categories` enum; `basic_info`; ID 998 requires BigFoot only | comma-delimited `categories` |
47
- | `publish_platforms` | derived `synchronizationType` | non-empty unique enum array | required; no default | `basic_info`; values `modus`, `bigfoot`, or both | integer `1`, `2`, or `3` |
48
- | `synchronization_type` | `synchronizationType` | integer | derived; caller value is replaced | derived from platform toggles in `basic_info` | `synchronizationType` |
49
- | `required_tier_id` | `requiredTierId` | nullable positive integer input | omitted means no tier | service `subscription-tiers` enum; current result is `[]`; BigFoot requires no tier | omitted from the wire for no tier |
50
- | `repo_url` | `repoUrl` | string/null | optional; omitted preserves value | `basic_info`; create empty is omitted; edit clear uses `<null>` | `repoUrl` |
51
- | `logo_base64` | `screenshotBase64sReqs.screenshotBase64s` | base64 string | optional; default empty string | create-only `basic_info`; sent as `logo.webp` | server-managed `logo` path |
52
- | `screenshot_base64s` | same create logo payload | string array | compatibility alias; default empty array | create-only `basic_info`; first value is used when `logo_base64` is absent | server-managed `logo` path |
53
- | `license.type` | `license` JSON string `type` | non-empty string | required; no default | separate `license` step | parsed `license` |
54
- | `license.holder/year/content` | fields inside `license` JSON string | non-empty strings | optional except custom content | separate `license` step; custom requires content | parsed `license` |
55
- | `description` | `description` | string/null | edit-only; omitted preserves value | edit `basic_info`; clear uses `<null>` | `description` |
56
- | `required_dependencies` | `requiredDependencies` | string/null | edit-only; omitted preserves value | edit `basic_info`; clear uses `<null>`; dependency query is dynamic | `requiredDependencies` |
57
- | `images` | `images` | non-negative integer | edit-only; required with `image_ops` | edit `basic_info` | `images` count |
58
- | `image_ops` | `imagesOps` | operation array | edit-only; no default | edit `basic_info`; upload requires base64, delete forbids it | `images` count |
59
- | `cf_url` | `cfUrl` | string/null | read-only | known detail field may be preserved in a snapshot but is never sent | `cfUrl` |
60
- | `logo` | `logo` | string/object/null | read-only | server-managed detail field; create uses `logo_base64` instead | `logo` |
61
- | `status` | `status` | integer/null | read-only | server-managed detail field | `status` |
62
- | `project_state` | none | object | required and must be `complete` | wraps `choose_game -> basic_info -> license`; unknown nested fields are rejected | local resumable snapshot |
38
+ The table below is the A2 field inventory. `C` means create, `U` update/edit,
39
+ and `D` delete. The current CLI's Creator endpoints are `C POST
40
+ /game/data/author/project/release`, `U POST /game/data/author/project/update`,
41
+ `D POST /game/data/author/project/delete/project/{projectId}`, and readback is
42
+ `GET /game/data/author/project/detail/{projectId}` unless stated otherwise.
43
+
44
+ | CLI/state field | Creator wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
45
+ | --- | --- | --- | --- | --- | --- | --- | --- |
46
+ | `schema` | not sent | string | required for each CLI write | schema registry | must match action | local validation only | not applicable |
47
+ | `project_id` | `projectId` | positive integer | U/D required; C server-assigned | project list/detail | outer document, not `project_state` | U/D | `projectId` |
48
+ | `game` | not sent by current CLI | object or non-empty string | required; no default | Creator game selector | first `choose_game` state; stable key/ID required | local state only | `project_state.game`; server version binding is separate |
49
+ | `name` | `name` | non-empty string, max 120 | required; no default | free text | `basic_info` | C/U | `name` |
50
+ | `alt_name` | `altName` | string/null, max 120 | optional; omitted preserves | free text | U clear becomes literal `<null>` | C/U | `altName` |
51
+ | `summary` | `summary` | non-empty string, max 500 | required; no default | free text | `basic_info` | C/U | `summary` |
52
+ | `categories` | comma-delimited `categories` | array of 1-5 positive IDs | required; no default | `GET /plugin/list/Categories` | ID `998` forces BigFoot-only | C/U | comma-delimited `categories` / category detail |
53
+ | `publish_platforms` | derives `synchronizationType` | unique array | required; no default | fixed `modus`, `bigfoot` | at least one; `[modus]=>1`, `[bigfoot]=>2`, both `=>3` | C/U through derived field | `synchronizationType` |
54
+ | `synchronization_type` | `synchronizationType` | integer `1..3` | derived; caller value replaced | derived from `publish_platforms` | must equal platform selection | C/U | `synchronizationType` |
55
+ | `required_tier_id` | `requiredTierId` | positive integer/null | default none: C omits; U null sends `<null>` | `GET /user/author/subscription/tiers` | BigFoot branch requires none | C/U | `requiredTierId`; absence is no-tier |
56
+ | `repo_url` | `repoUrl` | string/null, max 500 | optional | free URL text | C empty omitted; U clear sends `<null>` | C/U | `repoUrl` |
57
+ | `logo_base64` | `screenshotBase64sReqs.screenshotBase64s` | non-empty base64 string | C optional; default `""` | local image | C only; companion name fixed `logo.webp` | C | server-managed `logo` path |
58
+ | `screenshot_base64s` | same create logo member | base64 string array | compatibility alias; default `[]` | local images | current CLI consumes only first item when `logo_base64` absent | C | server-managed `logo` path |
59
+ | `license.type` | JSON-string `license.type` | non-empty string | required | Creator license selector | final `license` state | C/U | parse JSON-string `license` |
60
+ | `license.holder` | JSON-string `license.holder` | non-empty string | optional | free text | license step | C/U | parse JSON-string `license.holder` |
61
+ | `license.year` | JSON-string `license.year` | non-empty string | optional | free text | license step | C/U | parse JSON-string `license.year` |
62
+ | `license.content` | JSON-string `license.content` | non-empty string | custom license requires content | free text | license step/custom branch | C/U | parse JSON-string `license.content` |
63
+ | `description` | `description` | string/null, max 100000 | U only; omitted preserves | rich-text editor | clear sends `<null>` | U | `description` |
64
+ | `required_dependencies` | `requiredDependencies` | string/null, max 4000 | U only; omitted preserves | `POST /game/data/author/project/dependency/query` | clear sends `<null>` | U | `requiredDependencies` |
65
+ | `images` | `images` | non-negative integer | U required with `image_ops` | current detail count | count must agree with requested operations | U | `images` |
66
+ | `image_ops[].op` | `imagesOps[].op` | enum string | U operation required | fixed `upload`, `delete`, `rename` | upload/delete use `name`; rename uses `from/to` | U | `images`, logo/image paths |
67
+ | `image_ops[].name` | `imagesOps[].name` | non-empty string | upload/delete required | current object name | forbidden for rename | U | resulting image path/count |
68
+ | `image_ops[].base64` | `imagesOps[].base64` | base64 string | upload required; delete forbidden | local image | upload only | U | resulting image path/count |
69
+ | `image_ops[].from/to` | `imagesOps[].from/to` | non-empty strings | rename required | current/target name | rename only | U | resulting image paths |
70
+ | `cf_url` | `cfUrl` | string/null | read-only | server | never sent | none | `cfUrl` |
71
+ | `logo` | `logo` | string/object/null | read-only | server | C uses base64 input instead | none | `logo` |
72
+ | `status` | `status` | integer/null | read-only | server status enum | delete/publication state | none | `status` |
73
+ | `project_state` | not sent | object | required; state=`complete` | CLI state machine | exact order `choose_game -> basic_info -> license`; unknown fields rejected | local validation only | persisted CLI snapshot |
63
74
 
64
75
  Create-only, edit-only, and read-only fields may be preserved in a resumable
65
76
  snapshot, but `_project_wire` sends only fields supported by the selected API
@@ -82,8 +93,9 @@ named `logo.webp`; `screenshot_base64s` remains the compatibility alias.
82
93
  Edit sends only changed Creator fields plus `projectId`. `description`, cleared
83
94
  `repo_url`, cleared `alt_name`, null `required_tier_id`, and cleared
84
95
  `required_dependencies` use Creator's literal `<null>` marker. `image_ops` maps
85
- to `imagesOps` entries with `op` (`upload` or `delete`), `name`, and upload-only
86
- `base64`; `images` is required with image operations. `cf_url`, returned `logo`,
96
+ to `imagesOps` entries with `op` (`upload`, `delete`, or `rename`), `name` and
97
+ upload-only `base64`, or rename-only `from`/`to`; `images` is required with
98
+ image operations. `cf_url`, returned `logo`,
87
99
  and `status` are read-only/server-managed. `publish_platforms` and `game` are
88
100
  local form state, not project update keys.
89
101
  `project_state` is a resumable `choose_game` -> `basic_info` -> `license` ->
@@ -101,6 +113,35 @@ Deletion requires `confirm: "DELETE"`.
101
113
  `project_id`, `file_id`, `version`, `type`, `supported_game_versions`, `md5`,
102
114
  `zip_size`, `unzip_size`, `path`, `toc_version`, `changelog`, and `file`.
103
115
  `transaction_log` optionally selects the redacted transaction record path.
116
+
117
+ ### Creator release/version field contract
118
+
119
+ This is the A4 inventory. Metadata create uses `POST
120
+ /game/data/author/project/upload`; metadata update/edit uses `POST
121
+ /game/data/author/project/file/update`; delete uses `POST
122
+ /game/data/author/project/delete`; detail is `GET
123
+ /game/data/author/project/file/detail/{fileId}` and history/list is `POST
124
+ /game/data/author/project/file/list`. File-ID allocation and the binary object
125
+ PUT are separate stages.
126
+
127
+ | CLI field | Creator wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
128
+ | --- | --- | --- | --- | --- | --- | --- | --- |
129
+ | `schema` | not sent | string | required | schema registry | must match release action | local validation | not applicable |
130
+ | `project_id` | `projectId` | positive integer | all release writes required | project detail | parent project must exist | metadata create/update/delete; signature path | `projectId` in list/detail |
131
+ | `file_id` | `fileId` | positive integer | update/edit/delete required; upload allocates | `GET /game/data/author/project/fileId/{projectId}` | allocated ID is used by signature; create metadata omits it | update/delete/signature | `id`/`fileId` in release detail |
132
+ | `version` | `version` | non-empty string, max 120 | create/upload required after ZIP preflight | ZIP `.toc` plus caller label | caller value preserved | metadata create/update | `version` |
133
+ | `type` | `type` | non-empty string, max 40 | create/upload required | Creator release-type choices/API | must be a server-supported value | metadata create/update | `type` |
134
+ | `supported_game_versions` | `supportedGameVersionsReqs[]` | non-empty object array | required; ZIP-derived | installed `.toc` `Interface` values and game config | each entry requires string `gameVersion` and `server`; explicit caller value must match ZIP | metadata create/update | `supportedGameVersionsReqs`/version compatibility rows |
135
+ | `md5` | `md5` | hex string, max 64 | derived from exact ZIP bytes | local ZIP preflight | must describe bytes later PUT | metadata create/update | `md5` |
136
+ | `zip_size` | `zipSize` | non-negative integer | derived | local ZIP stat | must describe exact ZIP | metadata create/update | `zipSize` |
137
+ | `unzip_size` | `unzipSize` | non-negative integer | derived | ZIP central directory | bounded by ZIP preflight | metadata create/update | `unzipSize` |
138
+ | `path` | `path` | string, max 500 | metadata optional | Creator/object response | local path is never binary upload | metadata create/update | `path` |
139
+ | `toc_version` | `tocVersion` | string, max 80 | derived | every ZIP `.toc` `Interface` | incompatible/missing/mixed values fail before write | metadata create/update | `tocVersion` |
140
+ | `changelog` | `changelog` | string/null, max 10000 | optional | free text | omitted/empty behavior is action-specific | metadata create/update | `changelog` |
141
+ | `file` | not in JSON; raw bytes | local path | create/upload required | local filesystem | valid ZIP, max 200 MiB; bytes PUT only after metadata/signature | signed URL from `GET .../upload/signature/{projectId}/{fileId}`, then HTTP `PUT` | object upload status plus release detail hashes/sizes |
142
+ | `transaction_log` | not sent | local path string | optional | caller | redacted durable stage record | local record only | transaction JSON |
143
+ | derived signature URL | not returned to caller | URL | required for PUT | signature endpoint | query/userinfo redacted and never persisted | object-store `PUT application/zip` | HTTP status only; then release detail |
144
+
104
145
  Successful records include the completed `file_id`, metadata, signature, and
105
146
  binary upload stages that ran; failed records include `failed_stage`, a redacted
106
147
  error, and whether the ZIP was retained. The transaction record is established
@@ -134,26 +175,55 @@ The configuration surface uses:
134
175
  * `POST /system/user/share/create`, `PUT /system/user/share/update`,
135
176
  `DELETE /system/user/share/delete/{id}`
136
177
 
137
- Share write fields are `share_id`, `addons_id`, `account_name`, `backup_id`,
138
- `content`, `content_text`, `image_url`, `is_paid`, `is_public`, `price`,
139
- `share_type`, `tags`, `title`, `exclude_wtf`, `role_name`,
140
- `required_tier_id`, `sub_type`, optional low-level `platform` /
141
- `synchronization_type`, and
142
- `server_type`. `exclude_wtf` is a binary state: when it is `1`, account and
143
- role are empty; when it is `0`, an account is required and a selected role is
144
- optional. `sub_type=0` is the normal subscription mode; `sub_type=1` is the
145
- season mode. Both modes allow the "none" tier represented by null or omitted
146
- `required_tier_id`; Fupload omits `requiredTierId` from the wire in that branch.
147
- A selected tier must be a positive ID returned by the service. The official
148
- configuration form's submit object omits `platform` and `synchronizationType`.
149
- Fupload's low-level API surface nevertheless exposes the backend fields: list
150
- defaults `platform=0`, create defaults `platform=1` and
151
- `synchronizationType=1`, and explicit low-level values are passed through.
152
- `backup_id`, content, cover, title, and tags are required for create;
153
- update is presence-aware and uses `share_id`. `addons_id` must be non-empty,
154
- the trimmed title must contain at least 6 characters, tags contain 1 to 3
155
- comma-delimited IDs, and plain `content_text` must contain more than 20
156
- characters.
178
+ ### Configuration list/filter contract
179
+
180
+ | CLI field | Wire/header name | JSON type | Required/default | Enum/source | State/linkage rule | Request endpoint | Readback location |
181
+ | --- | --- | --- | --- | --- | --- | --- | --- |
182
+ | `page_num` | `pageNum` | positive integer | default `1` | caller | list only | `POST /system/user/share/list` | pagination response |
183
+ | `page_size` | `pageSize` | positive integer | default `20` | caller | list only | same | pagination response |
184
+ | `server_type` / `server` | body `server`; header `X-Server-Type` | integer | local selected Build, fallback `0` | fixed Build table below | body and header must be identical | list; header on all config calls | list/detail records by Build |
185
+ | `mine` | `mine` | boolean | default `false` | fixed boolean | list only | list | returned rows |
186
+ | `share_type` | `shareType` | non-negative integer | default `0` | service/UI | list filter | list | returned rows |
187
+ | `platform` | `platform` | integer | list default `0` | service; write choices `1`,`3` | not Build | list | `platform` |
188
+ | `keyword` | `keyword` | string | optional | caller | list filter | list | returned rows |
189
+ | `status` | `status` | integer | optional | service status values | list filter | list | `status` |
190
+ | `tags` | `tags` | string | optional | `config-tags` static rows | list filter | list | tags on rows/detail |
191
+ | `order_by` | `orderBy` | service value | optional | service/UI | list sort | list | result order |
192
+ | `is_public` | `isPublic` | integer `0/1` | optional | fixed boolean | list filter | list | `isPublic` |
193
+ | `is_paid` | `isPaid` | integer `0/1` | optional | fixed boolean | list filter | list | `isPaid` |
194
+
195
+ ### Configuration write field contract
196
+
197
+ This is the A6 inventory. Create is `POST /system/user/share/create`, update/edit
198
+ is `PUT /system/user/share/update`, delete is `DELETE
199
+ /system/user/share/delete/{id}`, and readback is `POST
200
+ /system/user/share/detail` with `{"shareIds":[id]}`. Every call carries the
201
+ selected Build in `X-Server-Type`.
202
+
203
+ | CLI field | Wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
204
+ | --- | --- | --- | --- | --- | --- | --- | --- |
205
+ | `schema` | not sent | string | required | schema registry | must match action | local validation | not applicable |
206
+ | `share_id` | `id` / path ID | opaque decimal string | U/D required; C server-assigned | create response/detail | preserve as string | U/D | detail `id` |
207
+ | `addons_id` | `addonsId` | string, max 10000 | C required | selected backup `knownAddons.projectids` | must correspond to backup selection | C/U | `addonsId` |
208
+ | `backup_id` | `backupId` | positive integer | C required | `GET /system/user/backup/list` | account/role choices derive from this backup | C/U | `backupId` |
209
+ | `account_name` | `accountName` | string/null, max 120 | conditional | selected backup `wtfAccounts` | required and non-empty when `exclude_wtf=0`; empty when `1` | C/U | `accountName` |
210
+ | `role_name` | `roleName` | string/null, max 120 | optional in schema; UI requires selection when chosen account has roles | backup account roles | empty when `exclude_wtf=1` | C/U | `roleName` |
211
+ | `exclude_wtf` | `excludeWtf` | integer `0/1` | C required; provider normalizes missing U to `0` | fixed boolean | `1` forbids account/role; `0` requires account | C/U | `excludeWtf` |
212
+ | `content` | `content` | non-empty HTML string | C required | rich-text editor | editor image keys are uploaded before submit | C/U | `content` hash/length, not raw evidence |
213
+ | `content_text` | `contentText` | non-empty string | C required; trimmed length >20 | derived editor plain text | must correspond to `content` | C/U | `contentText` hash/length |
214
+ | `image_url` | `imageUrl` | non-empty comma-delimited key string, max 1000 | C required; 1-10 images in 1.2.16 UI | `modus media upload` results | trim/filter keys; first is cover; delete/reorder updates CSV, no blob-delete API observed | C/U | `imageUrl` |
215
+ | `is_paid` | `isPaid` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `isPaid` |
216
+ | `price` | `price` | number `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `price` |
217
+ | `is_public` | `isPublic` | integer `0/1` | CLI optional; 1.2.16 form defaults public | fixed boolean | visibility/soft-delete state | C/U | `isPublic` |
218
+ | `share_type` | `shareType` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `shareType` |
219
+ | `tags` | comma-delimited `tags` | string of 1-3 IDs | C required | `modus options config-tags` | no empty/duplicate positions | C/U | `tags` and/or `modusShareTags` |
220
+ | `title` | `title` | string, max 120 | C required; trimmed length >=6 | free text | none | C/U | `title` |
221
+ | `required_tier_id` | `requiredTierId` | positive integer/null | default none: omitted | normal subscription or season option | selected tier disables BigFoot platform; ID from active options | C/U | `requiredTierId`; absence means none |
222
+ | `sub_type` | `subType` | integer `0/1` | helper default `0` | fixed normal=`0`, season=`1` | determines tier option source | C/U | `subType` |
223
+ | `platform` | `platform` | integer `1/3` | provider C/U default `1` | ModUs=`1`, ModUs+BigFoot=`3` | at least ModUs; tier selection forces `1` | C/U | `platform` |
224
+ | `synchronization_type` | `synchronizationType` | integer `1/3` | C defaults from platform; U omitted unless explicit | derived from platform | C should equal platform; 1.2.16 config U form omits it | C/U | `synchronizationType` if returned |
225
+ | `server_type` | header `X-Server-Type` | integer `0..4` | local selected Build or fallback `0` | fixed Build table | not `platform`; applies to target namespace | C/U/D/detail | record returned under same Build |
226
+ | `confirm` | not sent | literal `"DELETE"` | D required | fixed CLI guard | destructive action only | local guard then D | detail `status=4,isPublic=0`; absent from active list |
157
227
 
158
228
  Backup rename uses `backup_id`, `backup_name`, and `server_type`; backup
159
229
  deletion uses `backup_id`, `confirm`, and `server_type`.
@@ -168,23 +238,117 @@ String articles use:
168
238
  * `POST /system/user/import/version/publish`
169
239
  * `DELETE /system/user/import/version/delete?versionId={id}`
170
240
 
171
- String fields are `import_id`, `code_text`, `content`, `addons_id`,
172
- `content_text`, `file_path`, `image_url`, `is_paid`, `is_public`, `price`,
173
- `share_type`, `support_addon`, `tags`, `title`, `version`, `required_tier_id`,
174
- `sub_type`, and `server_type`. The client requires a supported addon,
175
- non-empty rendered/plain article content, cover, title, tags, and version.
176
- `sub_type` and `required_tier_id` follow the same normal/season state machine.
177
- The current account uses the no-tier branch, so `requiredTierId` is absent from
178
- the wire. The official string form's submit object omits `platform` and
179
- `synchronizationType`; Fupload's low-level API surface defaults list
180
- `platform=0` and create/update `platform=1` / `synchronizationType=1`, while
181
- preserving explicit values.
182
- The trimmed title must contain at least 6 characters, tags contain 1 to 3
183
- comma-delimited IDs, plain `content_text` must contain more than 20 characters,
184
- and `file_path` is the client's fixed empty string. Version history is separate
185
- from article metadata:
186
- `version-publish` takes `import_id`, `version`, `code_text`, and optional
187
- `changelog`; `version-delete` takes `version_id` and `confirm: "DELETE"`.
241
+ ### WA/string list/filter contract
242
+
243
+ | CLI field | Wire/header name | JSON type | Required/default | Enum/source | State/linkage rule | Request endpoint | Readback location |
244
+ | --- | --- | --- | --- | --- | --- | --- | --- |
245
+ | `page_num` | `pageNum` | positive integer | default `1` | caller | list only | `POST /system/user/import/list` | pagination response |
246
+ | `page_size` | `pageSize` | positive integer | default `10` | caller | list only | same | pagination response |
247
+ | `server_type` / `server` | body `server`; header `X-Server-Type` | integer | local Build, fallback `0` | fixed Build table | body/header must match | list; header on every WA call | list/detail records by Build |
248
+ | `mine` | `mine` | boolean | default `false` | fixed boolean | list only | list | rows |
249
+ | `status` | `status` | integer | default `1` | service status enum | list only | list | `status` |
250
+ | `platform` | `platform` | integer | list default `0` | service; write `1/3` | not Build | list | `platform` |
251
+ | `keyword` | `keyword` | string | optional | caller | list filter | list | rows |
252
+ | `support_addon` | `supportAddon` | string | optional | `wa-support-addons` static rows | list filter | list | `supportAddon` |
253
+ | `tags` | `tags` | string | optional | `wa-tags` static rows | list filter | list | tags on rows/detail |
254
+ | `is_paid` | `isPaid` | integer `0/1` | optional | fixed boolean | list filter | list | `isPaid` |
255
+ | `order_by` | `orderBy` | service value | optional | service/UI | list sort | list | result order |
256
+
257
+ ### WA/string write field contract
258
+
259
+ This is the A8 inventory. Create is `POST /system/user/import/create`,
260
+ update/edit is `POST /system/user/import/update`, delete is `DELETE
261
+ /system/user/import/delete/{id}`, and readback is `POST
262
+ /system/user/import/detail` with `{"importIds":[id]}`.
263
+
264
+ | CLI field | Wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
265
+ | --- | --- | --- | --- | --- | --- | --- | --- |
266
+ | `schema` | not sent | string | required | schema registry | must match action | local validation | not applicable |
267
+ | `import_id` | `id` / path ID | opaque decimal string | U/D required; C server-assigned | create response/detail | preserve as string | U/D | detail `id` |
268
+ | `support_addon` | `supportAddon` | non-empty string, max 120 | C required | `modus options wa-support-addons` | chosen name must resolve to an addon row | C/U | `supportAddon` |
269
+ | `addons_id` | `addonsId` | non-empty string, max 10000 | C required | ID on selected support-addon row | must match `support_addon` | C/U | `addonsId` |
270
+ | `code_text` | `codeText` | non-empty string | C required; version publish required | WA/string input | create version and later version payload must be deliberate | C/U/version publish | current top-level `codeText` is not echoed; after the next publish, the previous current value is read from `versionList[].codeText` |
271
+ | `version` | `version` | non-empty string, max 120 | C required; U form preserves current | free text/version list | new immutable versions use version-publish | C/U/version publish | `version`, `versionList[].version` |
272
+ | `content` | `content` | non-empty HTML string | C required | rich-text editor | editor images uploaded before submit | C/U | `content` hash/length |
273
+ | `content_text` | `contentText` | non-empty string | C required; trimmed length >20 | derived plain text | must correspond to HTML content | C/U | `contentText` hash/length |
274
+ | `file_path` | `filePath` | literal empty string | optional in CLI; UI fixed `""` | fixed client behavior | only `""` accepted | C/U | `filePath` |
275
+ | `image_url` | `imageUrl` | non-empty comma-delimited key string, max 1000 | C required; 1-10 images in 1.2.16 UI | `modus media upload` results | first key is cover; delete/reorder rewrites CSV, no blob-delete API observed | C/U | `imageUrl` |
276
+ | `tags` | comma-delimited `tags` | string of 1-3 IDs | C required | `modus options wa-tags` | no empty positions | C/U | `tags` |
277
+ | `title` | `title` | string, max 120 | C required; trimmed length >=6 | free text | none | C/U | `title` |
278
+ | `is_paid` | `isPaid` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `isPaid` |
279
+ | `price` | `price` | number `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `price` |
280
+ | `is_public` | `isPublic` | integer `0/1` | CLI optional; 1.2.16 form fixes `1` | fixed boolean | soft-delete later forces private | C/U | `isPublic` |
281
+ | `share_type` | `shareType` | integer `0` | CLI optional; 1.2.16 form fixes `0` | installed client fixed value | nonzero values fail before mutation | C/U | `shareType` |
282
+ | `required_tier_id` | `requiredTierId` | positive integer/null | default none: omitted | normal/season tier options | selected tier disables BigFoot | C/U | `requiredTierId`; absence means none |
283
+ | `sub_type` | `subType` | integer `0/1` | helper default `0` | normal=`0`, season=`1` | selects tier option source | C/U | `subType` |
284
+ | `platform` | `platform` | integer `1/3` | C/U default `1` | ModUs=`1`, ModUs+BigFoot=`3` | at least ModUs; tier selection forces `1` | C/U | `platform` |
285
+ | `synchronization_type` | `synchronizationType` | integer `1/3` | C/U defaults from platform | derived | 1.2.16 sends it on both C and U | C/U | `synchronizationType` |
286
+ | `server_type` | header `X-Server-Type` | integer `0..4` | local Build or fallback `0` | fixed Build table | target namespace, not platform | C/U/D/detail/version | record under same Build |
287
+ | `confirm` | not sent | literal `"DELETE"` | D required | fixed CLI guard | destructive action only | local guard then D | `status=4,isPublic=0`; absent active list |
288
+
289
+ Version history is separate from article metadata:
290
+
291
+ | CLI field | Wire name | JSON type | Required/default | Enum/source | State/linkage rule | Write endpoint | Detail/readback location |
292
+ | --- | --- | --- | --- | --- | --- | --- | --- |
293
+ | `import_id` | `importId` | opaque decimal string | publish required | article detail | parent must exist | `POST /system/user/import/version/publish` | `versionList[].importId`/parent detail |
294
+ | `version` | `version` | non-empty string, max 120 | publish required | caller | expected immutable version label | same | `versionList[].version` |
295
+ | `code_text` | `codeText` | non-empty string | publish required | caller | content hash/length must match evidence | same | current top-level value is not echoed; publish the next version, then read the previous value from `versionList[].codeText` |
296
+ | `changelog` | `changelog` | string, max 10000 | optional; empty omitted | caller | version-specific | same | `versionList[].changelog` |
297
+ | `version_id` | query `versionId` | opaque identifier | delete required | `versionList[].versionId`/`id` | target exact version | `DELETE /system/user/import/version/delete?versionId={id}` | version absent from refreshed list |
298
+ | `confirm` | not sent | literal `"DELETE"` | version delete required | fixed CLI guard | destructive action only | local guard | refreshed `versionList` |
299
+
300
+ ## Image upload and delete contract
301
+
302
+ There are three different image surfaces; they must not be collapsed into one
303
+ generic "image URL" test.
304
+
305
+ | Surface/CLI field | Official 1.2.16 wire | Input/preprocessing | Required headers | Response/reference | Delete behavior | Current CLI status/readback |
306
+ | --- | --- | --- | --- | --- | --- | --- |
307
+ | Creator logo: `logo_base64` / `screenshot_base64s` | C project JSON `screenshotBase64sReqs:{name:"logo.webp",screenshotBase64s:<base64>}` | prepared WebP base64 | Creator JSON auth | project `logo` path | U `imagesOps` upload/delete `logo.webp` | live create, replace, delete, restore and detail readback passed |
308
+ | Creator screenshots | U project JSON `imagesOps` | prepared WebP base64 for upload; names for delete/rename | Creator JSON auth | project `images` count | upload/delete use `name`; rename uses `from`/`to` | two-image upload, rename, single delete and final delete all passed with detail counts |
309
+ | Config cover/gallery: `image_url` | `POST /game/data/file/upload/file/image`, multipart field exactly `file`; one request per image | original local file; up to 10 comma-delimited references | raw `Authorization`, optional `X-Device-Id`; no `X-Server-Type` | key priority `cosStoreKey`, `cosStoreUrl`, `key`, then pathname of `downloadUrl/url/fileUrl`; CSV first key is cover | remove/reorder key in CSV and submit config U; no object-delete endpoint observed | two distinct local images uploaded with byte hashes; returned keys passed config C/U detail readback |
310
+ | WA cover/gallery: `image_url` | same endpoint and multipart `file`; one request per image | original local file; up to 10 comma-delimited references | same; no `X-Server-Type` | same key priority and CSV ordering | remove/reorder key in CSV and submit WA U; no object-delete endpoint observed | two distinct local images uploaded with byte hashes; returned keys passed WA C/U detail readback |
311
+
312
+ The 1.2.16 upload helper is `FormData.append("file", File)` and sets
313
+ `Content-Type: multipart/form-data`; the browser adapter supplies the boundary,
314
+ filename, and part MIME. The CLI multipart helper supplies a generated boundary,
315
+ uses the local basename, derives MIME through `mimetypes.guess_type`, and fixes
316
+ `.webp` to `image/webp`. A key-only response is accepted and its display URL is
317
+ synthesized. The CLI does not reproduce the UI's optional client-side image
318
+ compression; the final implementation's original-file upload was accepted by
319
+ the live service and the returned object keys were used in real C/U requests.
320
+
321
+ ## Build and linked-state regression matrix
322
+
323
+ This is the A10 branch inventory. Positive rows were sent/read back where the
324
+ installed clients expose a writable branch; negative rows fail locally before
325
+ remote mutation.
326
+
327
+ | Branch | Positive cases | Negative case | Current implementation/gate |
328
+ | --- | --- | --- | --- |
329
+ | Build | `0 retail`, `1 classic_era`, `2 classic`, `3 classic_titan`, `4 anniversary`; for each, backup/config/WA list sends identical body `server` and `X-Server-Type` | `<0`, `>4`, non-integer | `_build` and schema reject outside `0..4`; all five live list triplets passed |
330
+ | Creator publish platform | ModUs-only `1`, BigFoot-only `2`, both `3` | empty, unknown, duplicate | state machine covers at-least-one/enum/duplicate |
331
+ | Creator category | 1-5 IDs from live categories | empty, >5, unknown/non-ID; `998` with ModUs/both | local count/linkage plus write preflight membership against `plugin/list/Categories`; real unknown positive ID fails before project mutation |
332
+ | Config/WA platform | omitted defaults ModUs `1`; explicit `1`; explicit ModUs+BigFoot `3` | `0`, `2`, other; mismatched platform/synchronization pairs | schema choices cover numeric invalids; write normalization requires the pair to be present together and equal; it is distinct from Build |
333
+ | Tier mode | normal `subType=0`, season `1`; each allows none by omitting `requiredTierId`; selected tier is positive dynamic ID | zero/negative/non-ID; ID absent from current normal/season options | write preflight reads the selected tier option set and rejects unknown IDs before mutation; the tested account exposes no tier, so real positive writes cover the no-tier branch |
334
+ | Tier + platform | no-tier allows platform `1/3`; selected tier forces platform `1` in UI | selected tier with platform/synchronization `3` | schema rejects tier+BigFoot; current account's live options are empty so real writes use the confirmed no-tier branch |
335
+ | Config WTF | `exclude_wtf=1` with empty account/role; `0` with an account and, when available, one of its roles | `1` with account/role; `0` without account; backup/account/role absent from the selected Build hierarchy; missing role when the account has roles | write preflight reads the selected Build backup hierarchy and rejects every invalid linkage before mutation |
336
+ | Tags | 1, 2, and 3 dynamic IDs | 0, 4, empty/duplicate CSV position, ID absent from the current config/WA tag options | schema covers count/shape/duplicates; write preflight binds every ID to the selected Build's current config/WA options before mutation |
337
+ | Paid/price | installed 1.2.16 forms submit fixed free `is_paid=0,price=0,share_type=0` | paid value, positive/negative price, or nonzero share type | schema/write preflight enforce the installed UI's fixed free branch; real field cycles cover exactly those values and do not claim an unavailable paid branch |
338
+ | Visibility | public `1`, private `0` where UI exposes it | other integer | schema enum covered; WA 1.2.16 form fixes public |
339
+ | Config/WA image CSV | 1 and 10 keys; first key is cover | empty C, >10, empty CSV entries, upload business code >=400 | schema enforces 1-10 non-empty references; media error and live C/U readback covered |
340
+ | Creator image ops | upload, delete, rename with matching required members | missing/extra member, upload without base64, delete with base64 | local validation plus live upload/rename/delete/readback covered |
341
+
342
+ ### Acceptance coverage crosswalk
343
+
344
+ | Acceptance item | Matrix/evidence required here | Documentation status |
345
+ | --- | --- | --- |
346
+ | A2/A3 | Creator project table plus per-field C/U/readback/restore and logo/screenshot/image operations | `analyze/modus-creator/iteration4-live-regression.json`: passed, 140 steps; SHA-256 `7C7BE87959024A1431BC154B13830178815FFF32D23D55E4EAAED75B96B56E4B`; 126 positive exits `0`, 14 expected negative/not-found exits `2`, and unknown positive category membership fails before project mutation; CDN bytes matched local image SHA-256 after create/replace/delete/restore, then final project deletion made the restored logo return 404 |
347
+ | A4 | allocation, metadata, two distinct ZIP PUTs, detail, type/version/path edit/restore and cleanup | same evidence; ZIP SHA-256 and changed MD5/size recorded |
348
+ | A5-A9 | five Builds, config/WA per-field cycles, media upload, version rollover/readback/delete and cleanup | `analyze/modus/live-main-crud-builds-20260826.json`: passed, 209 steps/40 field checks; SHA-256 `298CD33D8E9B72E5AE7B63B6A82627622493CB2DC7476AFE61434E395312BB78`; 191 positive exits `0` and 18 negative exits `2`/`validation_error`; four media uploads carry nonempty byte/SHA-256 evidence for two different binaries, while account/role evidence stores presence only |
349
+ | A10 | linked-state table above, dynamic options and invalid combinations | live option selection plus schema/negative regression passed |
350
+ | A11 | commands, redacted input/response, exit status and content/image/ZIP digest evidence | both evidence records retain hashes instead of credentials or raw content |
351
+ | A19 | complete field names, wire names, types, defaults, sources, dependencies, endpoints and readback locations | matrices in this document |
188
352
 
189
353
  Both main-client modules support explicit `confirm: "DELETE"` on destructive
190
354
  commands. List operations send only server fields (`pageNum`, `pageSize`,
@@ -1,3 +1,3 @@
1
1
  """Fupload Python CLI."""
2
2
 
3
- __version__ = "0.0.12"
3
+ __version__ = "0.0.15"
@@ -248,7 +248,13 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
248
248
  )
249
249
  groups = root.add_subparsers(dest="resource_command", required=True)
250
250
  session = groups.add_parser("session", help="Local authentication diagnostics").add_subparsers(dest="action_command", required=True)
251
- _read_leaf(session, "doctor", "Check the local ModUs.Creator token store, DPAPI decryption, and authenticated API readiness without exposing credentials.", platform="modus", resource="session", action="doctor")
251
+ doctor = _read_leaf(session, "doctor", "Check one local ModUs token store, DPAPI decryption, and authenticated API readiness without exposing credentials.", platform="modus", resource="session", action="doctor")
252
+ doctor.add_argument(
253
+ "--client",
254
+ choices=("creator", "main"),
255
+ default="creator",
256
+ help="Authenticated client session to diagnose (technical default: creator).",
257
+ )
252
258
  _read_leaf(groups, "builds", "List the fixed ModUs WoW Build ids, codes, names, and the locally selected current Build.", platform="modus", resource="builds", action="list")
253
259
 
254
260
  account = groups.add_parser("account", help="ModUs author account and statistics").add_subparsers(dest="action_command", required=True)
@@ -303,6 +309,9 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
303
309
  leaf = _read_leaf(plugin, "get", "Read one ModUs plugin release detail.", platform="modus", resource="plugin", action="get"); leaf.add_argument("--project-id", type=_positive, required=True); leaf.add_argument("--file-id", type=_positive, required=True); _modus_server_flag(leaf)
304
310
  leaf = _read_leaf(plugin, "versions", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="versions"); leaf.add_argument("--project-id", type=_positive, required=True); _modus_server_flag(leaf); _page_flags(leaf)
305
311
 
312
+ media = groups.add_parser("media", help="ModUs main-client image uploads").add_subparsers(dest="action_command", required=True)
313
+ _write_leaf(media, "modus", "media", "upload", "Upload one local image and return its reusable ModUs object key and URL.")
314
+
306
315
  config = groups.add_parser("config", help="ModUs main-client configuration shares and backups").add_subparsers(dest="action_command", required=True)
307
316
  for action, text in (("create", "Create a configuration share from an existing ModUs cloud backup."), ("update", "Update a configuration share."), ("edit", "Edit a configuration share."), ("delete", "Delete one explicitly confirmed configuration share."), ("backup-edit", "Rename an existing ModUs cloud backup."), ("backup-delete", "Delete one explicitly confirmed ModUs cloud backup.")):
308
317
  _write_leaf(config, "modus", "config", action, text)
@@ -378,7 +387,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
378
387
  if args.dry_run:
379
388
  write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
380
389
  return 0
381
- provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(main_session=resource in ("config", "wa")))))
390
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(main_session=resource in ("media", "config", "wa")))))
382
391
  try:
383
392
  if platform == "dd":
384
393
  data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
@@ -391,7 +400,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
391
400
  write_output(platform, operation, data)
392
401
  return 0
393
402
  modus_doctor = platform == "modus" and resource == "session" and action == "doctor"
394
- provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(authenticate=not modus_doctor, main_session=resource in ("addon", "plugin", "config", "wa", "builds")))))
403
+ modus_main_session = resource in ("addon", "plugin", "config", "wa", "builds") or (
404
+ modus_doctor and getattr(args, "client", "creator") == "main"
405
+ )
406
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(authenticate=not modus_doctor, main_session=modus_main_session))))
395
407
  try:
396
408
  if platform == "dd":
397
409
  data = provider.execute_read(resource, action, args, getattr(args, "session", None))
@@ -282,6 +282,31 @@ def _load_live_state() -> Optional[Dict[str, Any]]:
282
282
  return value
283
283
 
284
284
 
285
+ def _remove_session_state(path: Path, session_id: str, timeout: float = 5) -> bool:
286
+ deadline = time.monotonic() + timeout
287
+ while True:
288
+ try:
289
+ current = _read_json(path)
290
+ except FuploadError as exc:
291
+ if isinstance(exc.__cause__, FileNotFoundError):
292
+ return True
293
+ if not isinstance(exc.__cause__, OSError):
294
+ return False
295
+ else:
296
+ if current.get("session_id") != session_id:
297
+ return False
298
+ try:
299
+ path.unlink()
300
+ return True
301
+ except FileNotFoundError:
302
+ return True
303
+ except OSError:
304
+ pass
305
+ if time.monotonic() >= deadline:
306
+ return False
307
+ time.sleep(0.05)
308
+
309
+
285
310
  def doctor() -> Dict[str, Any]:
286
311
  dd_dir, signature = _dd_module().discover_dd_info()
287
312
  processes = running_dd_processes()
@@ -471,15 +496,23 @@ def status(session_id: Optional[str] = None) -> Dict[str, Any]:
471
496
 
472
497
  def stop(session_id: str) -> Dict[str, Any]:
473
498
  data = _send({"session_id": session_id, "command": "stop"}, timeout=30)
474
- deadline = time.time() + 30
475
- while time.time() < deadline and _load_live_state():
499
+ deadline = time.monotonic() + 30
500
+ while True:
501
+ try:
502
+ state = _load_live_state()
503
+ except FuploadError as exc:
504
+ if exc.kind != "session_error" or not isinstance(exc.__cause__, OSError):
505
+ raise
506
+ else:
507
+ if not state:
508
+ break
509
+ if time.monotonic() >= deadline:
510
+ raise FuploadError(
511
+ "DD task session acknowledged stop but did not finish cleanup",
512
+ kind="session_stop_failed",
513
+ stage="session",
514
+ )
476
515
  time.sleep(0.05)
477
- if _load_live_state():
478
- raise FuploadError(
479
- "DD task session acknowledged stop but did not finish cleanup",
480
- kind="session_stop_failed",
481
- stage="session",
482
- )
483
516
  result = dict(data or {})
484
517
  result["cleanup_complete"] = True
485
518
  return result
@@ -615,12 +648,7 @@ def _serve(startup_id: str) -> int:
615
648
  listener.close()
616
649
  if sidecar is not None:
617
650
  sidecar.__exit__(None, None, None)
618
- try:
619
- current = _read_json(state_path)
620
- if current.get("session_id") == session_id:
621
- state_path.unlink()
622
- except (FuploadError, OSError):
623
- pass
651
+ _remove_session_state(state_path, session_id)
624
652
 
625
653
 
626
654
  def main(argv: Optional[List[str]] = None) -> int:
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
6
+ import binascii
5
7
  import hashlib
6
8
  import json
7
9
  import sys
@@ -22,8 +24,10 @@ _SENSITIVE_KEYS = {
22
24
  }
23
25
  _RAW_CONTENT_KEYS = {
24
26
  "content", "wa_str", "t_wa_str", "raw_wtf", "wtf_zip", "download_url",
25
- "import_string",
27
+ "import_string", "content_text", "contenttext", "code_text", "codetext",
28
+ "description", "changelog", "license_content", "licensecontent",
26
29
  }
30
+ _BASE64_CONTENT_KEYS = {"base64", "logo_base64", "screenshot_base64s"}
27
31
 
28
32
 
29
33
  class _DuplicateKey(ValueError):
@@ -102,6 +106,18 @@ def sanitize_output(value: Any) -> Any:
102
106
  "bytes": len(text),
103
107
  "sha256": hashlib.sha256(text).hexdigest(),
104
108
  }
109
+ elif normalized in _BASE64_CONTENT_KEYS and isinstance(item, str):
110
+ try:
111
+ content = base64.b64decode(item, validate=True)
112
+ encoding = "base64"
113
+ except (binascii.Error, ValueError):
114
+ content = item.encode("utf-8")
115
+ encoding = "utf-8"
116
+ result[str(key) + "_summary"] = {
117
+ "bytes": len(content),
118
+ "sha256": hashlib.sha256(content).hexdigest(),
119
+ "source_encoding": encoding,
120
+ }
105
121
  else:
106
122
  result[key] = sanitize_output(item)
107
123
  return result
@@ -25,11 +25,12 @@ 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
28
+ from .transport import json_request, multipart_request
29
29
 
30
30
 
31
31
  API_BASE = "https://app.modus.cool/api/"
32
32
  STATIC_API_BASE = "https://cdn.modus.cool/modus/client_static_api/"
33
+ RESOURCE_BASE = "https://cdn.modus.cool/"
33
34
  TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
34
35
  TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
35
36
  MODUS_APPDATA = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) / "modus"
@@ -110,6 +111,14 @@ def _category_ids(value: Any) -> Any:
110
111
  return output
111
112
 
112
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
+
113
122
  def _sync_type(value: Any) -> Any:
114
123
  if value is None:
115
124
  return 0
@@ -134,13 +143,22 @@ def _image_ops(value: Any) -> list[Dict[str, str]]:
134
143
  path = "image_ops[%d]" % index
135
144
  if not isinstance(item, Mapping):
136
145
  raise ValidationError("image operation must be an object", path=path)
137
- unknown = sorted(set(item) - {"op", "name", "base64"})
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)
138
151
  if unknown:
139
152
  raise ValidationError("unknown image operation field(s): %s" % ", ".join(unknown), path=path + "." + unknown[0])
140
- op = item.get("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
141
161
  name = item.get("name")
142
- if op not in ("upload", "delete"):
143
- raise ValidationError("image operation must be upload or delete", path=path + ".op")
144
162
  if not isinstance(name, str) or not name.strip():
145
163
  raise ValidationError("image operation name must be non-empty", path=path + ".name")
146
164
  operation = {"op": op, "name": name.strip()}
@@ -294,7 +312,7 @@ def _project_wire(value: Mapping[str, Any], *, create: bool = False) -> Dict[str
294
312
  """Project project create/update fields to Creator's request shape."""
295
313
  result: Dict[str, Any] = {}
296
314
  for key in ("name", "alt_name", "summary", "repo_url"):
297
- if key in value and value[key] is not None:
315
+ if key in value and (value[key] is not None or not create):
298
316
  wire_name = _WIRE_NAMES.get(key, key)
299
317
  if create:
300
318
  if key == "repo_url" and not str(value[key]).strip():
@@ -363,13 +381,56 @@ def _business_code(payload: Mapping[str, Any]) -> Optional[int]:
363
381
  return None
364
382
 
365
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
+
366
416
  def _safe(value: Any) -> Any:
367
417
  """Recursively redact credentials and presigned URLs in API results."""
368
418
  if isinstance(value, Mapping):
369
419
  result = {}
370
420
  for key, item in value.items():
371
421
  normalized = str(key).replace("-", "_").lower()
372
- result[str(key)] = "[REDACTED]" if normalized in _SECRET_KEYS else _safe(item)
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)
373
434
  return result
374
435
  if isinstance(value, list):
375
436
  return [_safe(item) for item in value]
@@ -599,7 +660,7 @@ class ModUs:
599
660
  except Exception:
600
661
  detail = "HTTP %d" % exc.code
601
662
  raise FuploadError(redact(str(detail)), endpoint=url, http_status=exc.code, kind="platform_error", stage=stage) from exc
602
- except (OSError, urllib.error.URLError) as exc:
663
+ except (OSError, urllib.error.URLError, http.client.IncompleteRead) as exc:
603
664
  raise FuploadError("ModUs request failed: %s" % exc, endpoint=url, verification_required=method != "GET", stage=stage) from exc
604
665
  if status < 200 or status >= 300:
605
666
  raise FuploadError("ModUs returned HTTP %d" % status, endpoint=url, http_status=status, stage=stage)
@@ -621,6 +682,32 @@ class ModUs:
621
682
  return payload
622
683
 
623
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
624
711
  selected = self.token_path
625
712
  if not selected.is_file() and selected.with_name("token.json").is_file():
626
713
  selected = selected.with_name("token.json")
@@ -771,6 +858,143 @@ class ModUs:
771
858
  raise ValidationError("unsupported ModUs options operation")
772
859
  return _safe(_unwrap(self._request("GET", routes[action])))
773
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
+
774
998
  # Main ModUs client: configuration shares and string articles.
775
999
  def cloud_backups(self, *, server_type: Optional[int] = None) -> Any:
776
1000
  return _safe(_unwrap(self._request("GET", "system/user/backup/list", headers={"X-Server-Type": str(self._build(server_type))})))
@@ -785,6 +1009,40 @@ class ModUs:
785
1009
  def cloud_backup_delete(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
786
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))})))
787
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
+
788
1046
  @staticmethod
789
1047
  def _share_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
790
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"}
@@ -793,7 +1051,7 @@ class ModUs:
793
1051
  for key, value in doc.items():
794
1052
  wire = aliases.get(key, key)
795
1053
  if wire in allowed and value is not None:
796
- result[wire] = value
1054
+ result[wire] = str(value) if wire == "addonsId" else value
797
1055
  result.setdefault("platform", 1)
798
1056
  exclude = result.get("excludeWtf", 0)
799
1057
  result["excludeWtf"] = 1 if str(exclude).strip().lower() in {"1", "true"} else 0
@@ -815,7 +1073,9 @@ class ModUs:
815
1073
  wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
816
1074
  for key in ("keyword", "status", "tags", "order_by", "is_public", "is_paid"):
817
1075
  if key in source and source[key] is not None:
818
- wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] = source[key]
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
+ )
819
1079
  return _safe(_unwrap(self._request("POST", "system/user/share/list", wire, headers={"X-Server-Type": str(selected_build)})))
820
1080
 
821
1081
  def share_detail(self, share_ids: Any, *, server_type: Optional[int] = None) -> Any:
@@ -823,9 +1083,7 @@ class ModUs:
823
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))})))
824
1084
 
825
1085
  def share_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
826
- wire = self._share_wire(doc)
827
- wire.setdefault("synchronizationType", 3 if wire["platform"] == 3 else 1)
828
- return _safe(_unwrap(self._request("POST", "system/user/share/create", wire, headers={"X-Server-Type": str(self._build(server_type))})))
1086
+ return _safe(_unwrap(self._request("POST", "system/user/share/create", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
829
1087
 
830
1088
  def share_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
831
1089
  return _safe(_unwrap(self._request("PUT", "system/user/share/update", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
@@ -838,6 +1096,8 @@ class ModUs:
838
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"}
839
1097
  allowed = {"id", "codeText", "content", "addonsId", "contentText", "filePath", "imageUrl", "isPaid", "isPublic", "price", "shareType", "supportAddon", "tags", "title", "version", "requiredTierId", "subType", "platform", "synchronizationType"}
840
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"])
841
1101
  result.setdefault("platform", 1)
842
1102
  result.setdefault("synchronizationType", 3 if result["platform"] == 3 else 1)
843
1103
  return result
@@ -858,7 +1118,9 @@ class ModUs:
858
1118
  wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
859
1119
  for key in ("keyword", "support_addon", "tags", "is_paid", "order_by"):
860
1120
  if key in source and source[key] is not None:
861
- wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] = source[key]
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
+ )
862
1124
  return _safe(_unwrap(self._request("POST", "system/user/import/list", wire, headers={"X-Server-Type": str(selected_build)})))
863
1125
 
864
1126
  def import_detail(self, import_ids: Any, *, server_type: Optional[int] = None) -> Any:
@@ -1113,6 +1375,9 @@ class ModUs:
1113
1375
  raise ValidationError("unsupported ModUs read operation")
1114
1376
 
1115
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)
1116
1381
  if resource == "config" and action == "create": return self.share_create(doc, server_type=doc.get("server_type"))
1117
1382
  if resource == "config" and action in ("update", "edit"): return self.share_update(doc, server_type=doc.get("server_type"))
1118
1383
  if resource == "config" and action == "delete": return self.share_delete(doc["share_id"], server_type=doc.get("server_type"))
@@ -114,6 +114,37 @@ class Schema:
114
114
  tags = [item.strip() for item in str(value["tags"]).split(",")]
115
115
  if any(not item for item in tags) or not 1 <= len(tags) <= 3:
116
116
  raise ValidationError("must contain 1 to 3 comma-delimited tag IDs", path="$.tags")
117
+ if len(set(tags)) != len(tags):
118
+ raise ValidationError("must not contain duplicate tag IDs", path="$.tags")
119
+ if "image_url" in value:
120
+ images = [item.strip() for item in str(value["image_url"]).split(",")]
121
+ if any(not item for item in images) or not 1 <= len(images) <= 10:
122
+ raise ValidationError("must contain 1 to 10 comma-delimited image references", path="$.image_url")
123
+ if value.get("required_tier_id") is not None and (
124
+ value.get("platform") == 3 or value.get("synchronization_type") == 3
125
+ ):
126
+ raise ValidationError(
127
+ "required_tier_id cannot be combined with BigFoot synchronization",
128
+ path="$.required_tier_id",
129
+ )
130
+ if value.get("is_paid", 0) != 0:
131
+ raise ValidationError("current ModUs form requires is_paid=0", path="$.is_paid")
132
+ if value.get("price", 0) != 0:
133
+ raise ValidationError("current ModUs form requires price=0", path="$.price")
134
+ if value.get("share_type", 0) != 0:
135
+ raise ValidationError("current ModUs form requires share_type=0", path="$.share_type")
136
+ platform = value.get("platform")
137
+ synchronization = value.get("synchronization_type")
138
+ if (platform is None) != (synchronization is None):
139
+ raise ValidationError(
140
+ "platform and synchronization_type must be provided together",
141
+ path="$.synchronization_type" if synchronization is None else "$.platform",
142
+ )
143
+ if platform is not None and synchronization is not None and platform != synchronization:
144
+ raise ValidationError(
145
+ "platform and synchronization_type must select the same ModUs/BigFoot targets",
146
+ path="$.synchronization_type",
147
+ )
117
148
  if self.name in ("fupload.v1.modus.project.create", "fupload.v1.modus.project.edit"):
118
149
  snapshot = value.get("project_state")
119
150
  if snapshot is None:
@@ -440,14 +471,28 @@ class Schema:
440
471
  if "categories" in value and len(value["categories"]) > 5:
441
472
  raise ValidationError("must contain at most 5 items", path="$.categories")
442
473
  if "image_ops" in value:
443
- object_array("image_ops", {"op", "name", "base64"}, ("op", "name"))
474
+ if not isinstance(value["image_ops"], list) or not value["image_ops"]:
475
+ raise ValidationError("expected nonempty array", path="$.image_ops")
444
476
  if "images" not in value:
445
477
  raise ValidationError("images is required when image_ops is supplied", path="$.images")
446
478
  for index, operation in enumerate(value["image_ops"]):
447
- if operation["op"] not in ("upload", "delete"):
448
- raise ValidationError("must be upload or delete", path="$.image_ops[%d].op" % index)
449
- if not isinstance(operation["name"], str) or not operation["name"].strip():
450
- raise ValidationError("must not be empty", path="$.image_ops[%d].name" % index)
479
+ path = "$.image_ops[%d]" % index
480
+ if not isinstance(operation, dict):
481
+ raise ValidationError("expected object", path=path)
482
+ op = operation.get("op")
483
+ if op not in ("upload", "delete", "rename"):
484
+ raise ValidationError("must be upload, delete, or rename", path=path + ".op")
485
+ allowed = {"op", "from", "to"} if op == "rename" else {"op", "name", "base64"}
486
+ unknown = sorted(set(operation) - allowed)
487
+ if unknown:
488
+ raise ValidationError("unknown field", path=path + "." + unknown[0])
489
+ if op == "rename":
490
+ for name in ("from", "to"):
491
+ if not isinstance(operation.get(name), str) or not operation[name].strip():
492
+ raise ValidationError("must not be empty", path=path + "." + name)
493
+ continue
494
+ if not isinstance(operation.get("name"), str) or not operation["name"].strip():
495
+ raise ValidationError("must not be empty", path=path + ".name")
451
496
  if operation["op"] == "upload" and (not isinstance(operation.get("base64"), str) or not operation["base64"].strip()):
452
497
  raise ValidationError("base64 is required for upload", path="$.image_ops[%d].base64" % index)
453
498
  if operation["op"] == "delete" and "base64" in operation:
@@ -455,7 +500,7 @@ class Schema:
455
500
  for name in ("version", "type"):
456
501
  if name in value and isinstance(value[name], str) and not value[name].strip():
457
502
  raise ValidationError("must not be empty", path="$.%s" % name)
458
- if "file" in value and not zipfile.is_zipfile(value["file"]):
503
+ if self.name.startswith("fupload.v1.modus.plugin") and "file" in value and not zipfile.is_zipfile(value["file"]):
459
504
  raise ValidationError("file must be a valid ZIP archive", path="$.file")
460
505
 
461
506
 
@@ -711,7 +756,7 @@ MODUS_RELEASE = {
711
756
  }
712
757
  MODUS_BUILD_ID = f("integer", minimum=0, maximum=4)
713
758
  MODUS_SHARE = {
714
- "share_id": f("identifier", nonempty=True), "addons_id": f("string", nonempty=True, max_length=10000),
759
+ "share_id": f("identifier", nonempty=True), "addons_id": f("string", max_length=10000),
715
760
  "account_name": f("string", nullable=True, max_length=120), "backup_id": f("integer", minimum=1),
716
761
  "content": f("string", nonempty=True), "content_text": f("string", nonempty=True),
717
762
  "image_url": f("string", nonempty=True, max_length=1000), "is_paid": f("integer", choices=(0, 1)),
@@ -742,6 +787,7 @@ register("modus", "plugin", "upload", required(MODUS_RELEASE, ("project_id", "fi
742
787
  register("modus", "plugin", "update", required(MODUS_RELEASE, ("project_id", "file_id")))
743
788
  register("modus", "plugin", "edit", required(MODUS_RELEASE, ("project_id", "file_id")))
744
789
  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")))
790
+ register("modus", "media", "upload", required({"file": f("string", local_file=True)}, ("file",)))
745
791
  register("modus", "config", "create", required(MODUS_SHARE, ("addons_id", "backup_id", "content", "content_text", "image_url", "tags", "title", "exclude_wtf")))
746
792
  register("modus", "config", "update", required(MODUS_SHARE, ("share_id",)))
747
793
  register("modus", "config", "edit", required(MODUS_SHARE, ("share_id",)))
@@ -14,6 +14,17 @@ from .errors import FuploadError
14
14
  from .trust import official_opener, require_official_url
15
15
 
16
16
 
17
+ _IMAGE_CONTENT_TYPES = {
18
+ ".bmp": "image/bmp",
19
+ ".gif": "image/gif",
20
+ ".jpeg": "image/jpeg",
21
+ ".jpg": "image/jpeg",
22
+ ".png": "image/png",
23
+ ".svg": "image/svg+xml",
24
+ ".webp": "image/webp",
25
+ }
26
+
27
+
17
28
  def _http_error_details(raw: bytes, fallback: str) -> tuple[str, Any]:
18
29
  """Extract a server error only from a conforming UTF-8 JSON object."""
19
30
  try:
@@ -88,7 +99,8 @@ def multipart_request(
88
99
  str(value).encode("utf-8"), b"\r\n",
89
100
  ])
90
101
  filename = os.path.basename(file_path)
91
- content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
102
+ suffix = os.path.splitext(filename)[1].lower()
103
+ content_type = _IMAGE_CONTENT_TYPES.get(suffix) or mimetypes.guess_type(filename)[0] or "application/octet-stream"
92
104
  chunks.extend([
93
105
  ("--%s\r\n" % boundary).encode(),
94
106
  ('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (file_field, filename)).encode("utf-8"),
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema": "fupload.npm-skill-manifest.v1",
3
3
  "package_name": "@follenfang/fupload",
4
- "package_version": "0.0.12",
5
- "skill_version": "0.0.12",
6
- "tree_sha256": "f1e559aef665537c4b2b24f15dab01b9e4140378bd3a70a00331d56358d2071a",
4
+ "package_version": "0.0.15",
5
+ "skill_version": "0.0.15",
6
+ "tree_sha256": "cfccc3347d2013188a37b120b94282c839ad81eb4fa92f8617e0cfa12dfbbaef",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
@@ -92,8 +92,8 @@
92
92
  },
93
93
  {
94
94
  "path": "references/modus.md",
95
- "bytes": 13492,
96
- "sha256": "919b8b097fd1ec14d38583e84f4d33a5320d24af03703dc1c06a072dbab33e68"
95
+ "bytes": 37142,
96
+ "sha256": "84e1524bc3404de3c3bc0582d85651b884c616ff009b1d01839fb84e348917b3"
97
97
  },
98
98
  {
99
99
  "path": "references/newbee-official-cli.md",
@@ -113,7 +113,7 @@
113
113
  {
114
114
  "path": "scripts/fupload_cli/__init__.py",
115
115
  "bytes": 50,
116
- "sha256": "cad1ed5cd2ed3020e1fc98c2036d22b47e1138772e9829f5dd61c39cc3bd810f"
116
+ "sha256": "9159fa55aa8ef07864abf206b64aabd44ea78c2e987ead8b51ec9384b5d1cebf"
117
117
  },
118
118
  {
119
119
  "path": "scripts/fupload_cli/blackbox_web.py",
@@ -127,8 +127,8 @@
127
127
  },
128
128
  {
129
129
  "path": "scripts/fupload_cli/cli.py",
130
- "bytes": 36811,
131
- "sha256": "bdd159c7d86c009685417e7b4a25194d98a49547e036606b920ff44090b1619e"
130
+ "bytes": 37420,
131
+ "sha256": "3a052ec53c29ca95eafc9ba845d448ef45e4bd0231bf41ec1906e55281aa1650"
132
132
  },
133
133
  {
134
134
  "path": "scripts/fupload_cli/curseforge.py",
@@ -137,8 +137,8 @@
137
137
  },
138
138
  {
139
139
  "path": "scripts/fupload_cli/dd_broker.py",
140
- "bytes": 25750,
141
- "sha256": "48ace5b13687c0d8364c62ee9407ed6ead16946df62ce34d04de1aa83ca44026"
140
+ "bytes": 26638,
141
+ "sha256": "a23fb486dd1ca9d0a1d2f2ca642690535a28e05051ce828ce066cdbc67b37b37"
142
142
  },
143
143
  {
144
144
  "path": "scripts/fupload_cli/dd_sidecar.py",
@@ -157,8 +157,8 @@
157
157
  },
158
158
  {
159
159
  "path": "scripts/fupload_cli/io.py",
160
- "bytes": 4515,
161
- "sha256": "dafcfcaa493def7bc42719f357416208a3cabbb163cf651fb18307b501b46e4b"
160
+ "bytes": 5319,
161
+ "sha256": "57af3921d016815ed5747788881f86c8a6ce04a939e9f6f6571bd6a3f7383a4d"
162
162
  },
163
163
  {
164
164
  "path": "scripts/fupload_cli/modus_zip.py",
@@ -167,8 +167,8 @@
167
167
  },
168
168
  {
169
169
  "path": "scripts/fupload_cli/modus.py",
170
- "bytes": 60792,
171
- "sha256": "9cf3d8127e2b7583f1760a74f88512b81ab272ea116bf9ca185c7531eba960d2"
170
+ "bytes": 74134,
171
+ "sha256": "9841ce7188083b62c1c217d89436b3c3ddc3ecf726719d3c44c0b83027172fac"
172
172
  },
173
173
  {
174
174
  "path": "scripts/fupload_cli/newbee_auth.py",
@@ -182,8 +182,8 @@
182
182
  },
183
183
  {
184
184
  "path": "scripts/fupload_cli/schema.py",
185
- "bytes": 51541,
186
- "sha256": "aca30dc7997f47645b3da154fa3656fefa1179733e1d61e7ff0de326734f2575"
185
+ "bytes": 54561,
186
+ "sha256": "17d57a9c9c53e556faf9e415020d47f84965283680c66f3f1c33beef6b83d104"
187
187
  },
188
188
  {
189
189
  "path": "scripts/fupload_cli/state_machine.py",
@@ -192,8 +192,8 @@
192
192
  },
193
193
  {
194
194
  "path": "scripts/fupload_cli/transport.py",
195
- "bytes": 4895,
196
- "sha256": "0489493c1250c50d460c408394824eba7fc0f6efea9a3c1ec06f3ad67e6b89a7"
195
+ "bytes": 5195,
196
+ "sha256": "37eb742c1cf75184d14f4f8ae85117222cad3d0748ab4c211452e78b5da0c279"
197
197
  },
198
198
  {
199
199
  "path": "scripts/fupload_cli/trust.py",
@@ -208,7 +208,7 @@
208
208
  {
209
209
  "path": "SKILL.md",
210
210
  "bytes": 25720,
211
- "sha256": "fe14cba8f02167dce36034ce8b73784793c92ed3de37dda1ab982a5a8a9cc8de"
211
+ "sha256": "fae17edc0a59ea74dc70a5744b5aef46a057c23a9056e6d6b1e60990fa54782f"
212
212
  }
213
213
  ]
214
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.12",
3
+ "version": "0.0.15",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {