@overcrow/mcp 0.0.0 → 0.1.0
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/LICENSE +21 -0
- package/README.md +91 -1
- package/dist/audit/index.js +103 -0
- package/dist/audit/lexer.js +417 -0
- package/dist/audit/project.js +200 -0
- package/dist/audit/rules.js +661 -0
- package/dist/bootstrap/download.js +229 -0
- package/dist/bootstrap/install.js +280 -0
- package/dist/bootstrap/pin.js +85 -0
- package/dist/bootstrap/pin.json +119 -0
- package/dist/bootstrap/zip.js +258 -0
- package/dist/cli.js +200 -0
- package/dist/confine.js +244 -0
- package/dist/content/docs/en/cli.md +353 -0
- package/dist/content/docs/en/dev-channel.md +130 -0
- package/dist/content/docs/en/elements.md +469 -0
- package/dist/content/docs/en/forms.md +328 -0
- package/dist/content/docs/en/guide.md +253 -0
- package/dist/content/docs/en/index.md +85 -0
- package/dist/content/docs/en/limits.md +241 -0
- package/dist/content/docs/en/logic.md +446 -0
- package/dist/content/docs/en/manifest.md +381 -0
- package/dist/content/docs/en/mcp.md +165 -0
- package/dist/content/docs/en/package.md +131 -0
- package/dist/content/docs/en/publishing.md +162 -0
- package/dist/content/docs/en/reference.md +55 -0
- package/dist/content/docs/en/result-shapes.md +332 -0
- package/dist/content/docs/en/sdk.md +295 -0
- package/dist/content/docs/en/security.md +115 -0
- package/dist/content/docs/en/service-reference.md +344 -0
- package/dist/content/docs/en/services.md +517 -0
- package/dist/content/docs/en/style-properties.md +270 -0
- package/dist/content/docs/en/style.md +266 -0
- package/dist/content/docs/en/testing.md +400 -0
- package/dist/content/docs/en/view.md +338 -0
- package/dist/content/docs/en/widgets.md +76 -0
- package/dist/content/docs/fr/cli.md +367 -0
- package/dist/content/docs/fr/dev-channel.md +140 -0
- package/dist/content/docs/fr/elements.md +475 -0
- package/dist/content/docs/fr/forms.md +341 -0
- package/dist/content/docs/fr/guide.md +264 -0
- package/dist/content/docs/fr/index.md +90 -0
- package/dist/content/docs/fr/limits.md +244 -0
- package/dist/content/docs/fr/logic.md +461 -0
- package/dist/content/docs/fr/manifest.md +392 -0
- package/dist/content/docs/fr/mcp.md +173 -0
- package/dist/content/docs/fr/package.md +139 -0
- package/dist/content/docs/fr/publishing.md +172 -0
- package/dist/content/docs/fr/reference.md +59 -0
- package/dist/content/docs/fr/result-shapes.md +333 -0
- package/dist/content/docs/fr/sdk.md +301 -0
- package/dist/content/docs/fr/security.md +129 -0
- package/dist/content/docs/fr/service-reference.md +346 -0
- package/dist/content/docs/fr/services.md +540 -0
- package/dist/content/docs/fr/style-properties.md +272 -0
- package/dist/content/docs/fr/style.md +281 -0
- package/dist/content/docs/fr/testing.md +417 -0
- package/dist/content/docs/fr/view.md +352 -0
- package/dist/content/docs/fr/widgets.md +81 -0
- package/dist/content/examples/clock/LICENSE +21 -0
- package/dist/content/examples/clock/README.md +75 -0
- package/dist/content/examples/clock/listing.json +19 -0
- package/dist/content/examples/clock/locales/en.json +4 -0
- package/dist/content/examples/clock/locales/fr.json +4 -0
- package/dist/content/examples/clock/logic.ts +85 -0
- package/dist/content/examples/clock/manifest.json +44 -0
- package/dist/content/examples/clock/package.json +16 -0
- package/dist/content/examples/clock/style.ocss +26 -0
- package/dist/content/examples/clock/tests/hidden.scenario.json +68 -0
- package/dist/content/examples/clock/tests/load-logic.mjs +22 -0
- package/dist/content/examples/clock/tests/logic.test.mjs +89 -0
- package/dist/content/examples/clock/tests/options.scenario.json +108 -0
- package/dist/content/examples/clock/tests/preview.scenario.json +25 -0
- package/dist/content/examples/clock/tests/render.scenario.json +94 -0
- package/dist/content/examples/clock/tests/summer-time.scenario.json +74 -0
- package/dist/content/examples/clock/tests/ticks.scenario.json +120 -0
- package/dist/content/examples/clock/tsconfig.json +16 -0
- package/dist/content/examples/clock/view.ocml +8 -0
- package/dist/content/examples/countdown/LICENSE +20 -0
- package/dist/content/examples/countdown/listing.json +10 -0
- package/dist/content/examples/countdown/locales/en.json +15 -0
- package/dist/content/examples/countdown/locales/fr.json +15 -0
- package/dist/content/examples/countdown/logic.ts +179 -0
- package/dist/content/examples/countdown/manifest.json +34 -0
- package/dist/content/examples/countdown/package.json +15 -0
- package/dist/content/examples/countdown/style.ocss +94 -0
- package/dist/content/examples/countdown/tsconfig.json +16 -0
- package/dist/content/examples/countdown/view.ocml +42 -0
- package/dist/content/examples/fps/LICENSE +21 -0
- package/dist/content/examples/fps/README.md +64 -0
- package/dist/content/examples/fps/listing.json +19 -0
- package/dist/content/examples/fps/locales/en.json +11 -0
- package/dist/content/examples/fps/locales/fr.json +11 -0
- package/dist/content/examples/fps/logic.ts +64 -0
- package/dist/content/examples/fps/manifest.json +30 -0
- package/dist/content/examples/fps/package.json +16 -0
- package/dist/content/examples/fps/style.ocss +31 -0
- package/dist/content/examples/fps/tests/label.scenario.json +63 -0
- package/dist/content/examples/fps/tests/load-logic.mjs +22 -0
- package/dist/content/examples/fps/tests/logic.test.mjs +60 -0
- package/dist/content/examples/fps/tests/preview.scenario.json +29 -0
- package/dist/content/examples/fps/tests/refused.scenario.json +20 -0
- package/dist/content/examples/fps/tests/render.scenario.json +96 -0
- package/dist/content/examples/fps/tests/stale.scenario.json +91 -0
- package/dist/content/examples/fps/tests/unavailable.scenario.json +111 -0
- package/dist/content/examples/fps/tsconfig.json +16 -0
- package/dist/content/examples/fps/view.ocml +11 -0
- package/dist/content/examples/media/LICENSE +21 -0
- package/dist/content/examples/media/README.md +147 -0
- package/dist/content/examples/media/listing.json +19 -0
- package/dist/content/examples/media/locales/en.json +11 -0
- package/dist/content/examples/media/locales/fr.json +11 -0
- package/dist/content/examples/media/logic.ts +191 -0
- package/dist/content/examples/media/manifest.json +35 -0
- package/dist/content/examples/media/package.json +16 -0
- package/dist/content/examples/media/style.ocss +148 -0
- package/dist/content/examples/media/tests/covers.scenario.json +144 -0
- package/dist/content/examples/media/tests/gesture.scenario.json +173 -0
- package/dist/content/examples/media/tests/load-logic.mjs +22 -0
- package/dist/content/examples/media/tests/logic.test.mjs +176 -0
- package/dist/content/examples/media/tests/marquee.scenario.json +127 -0
- package/dist/content/examples/media/tests/preview.scenario.json +52 -0
- package/dist/content/examples/media/tests/read-only.scenario.json +53 -0
- package/dist/content/examples/media/tests/refused.scenario.json +22 -0
- package/dist/content/examples/media/tests/refused.test.mjs +31 -0
- package/dist/content/examples/media/tests/render.scenario.json +136 -0
- package/dist/content/examples/media/tests/states.scenario.json +257 -0
- package/dist/content/examples/media/tests/unavailable.scenario.json +120 -0
- package/dist/content/examples/media/tsconfig.json +16 -0
- package/dist/content/examples/media/view.ocml +41 -0
- package/dist/content/examples/notes/LICENSE +21 -0
- package/dist/content/examples/notes/README.md +219 -0
- package/dist/content/examples/notes/listing.json +19 -0
- package/dist/content/examples/notes/locales/en.json +25 -0
- package/dist/content/examples/notes/locales/fr.json +25 -0
- package/dist/content/examples/notes/logic.ts +799 -0
- package/dist/content/examples/notes/manifest.json +40 -0
- package/dist/content/examples/notes/package.json +16 -0
- package/dist/content/examples/notes/style.ocss +469 -0
- package/dist/content/examples/notes/tests/checks.scenario.json +237 -0
- package/dist/content/examples/notes/tests/create.scenario.json +260 -0
- package/dist/content/examples/notes/tests/delete.scenario.json +342 -0
- package/dist/content/examples/notes/tests/drafts.scenario.json +714 -0
- package/dist/content/examples/notes/tests/editor.scenario.json +232 -0
- package/dist/content/examples/notes/tests/frame.scenario.json +93 -0
- package/dist/content/examples/notes/tests/interactive.scenario.json +344 -0
- package/dist/content/examples/notes/tests/limits.scenario.json +1351 -0
- package/dist/content/examples/notes/tests/load-logic.mjs +22 -0
- package/dist/content/examples/notes/tests/logic.test.mjs +514 -0
- package/dist/content/examples/notes/tests/preview.scenario.json +99 -0
- package/dist/content/examples/notes/tests/read-only.scenario.json +105 -0
- package/dist/content/examples/notes/tests/refused.scenario.json +33 -0
- package/dist/content/examples/notes/tests/save.scenario.json +378 -0
- package/dist/content/examples/notes/tests/states.scenario.json +640 -0
- package/dist/content/examples/notes/tests/typing.scenario.json +562 -0
- package/dist/content/examples/notes/tests/unavailable.scenario.json +246 -0
- package/dist/content/examples/notes/tsconfig.json +16 -0
- package/dist/content/examples/notes/view.ocml +154 -0
- package/dist/content/examples/performance/LICENSE +21 -0
- package/dist/content/examples/performance/README.md +134 -0
- package/dist/content/examples/performance/listing.json +19 -0
- package/dist/content/examples/performance/locales/en.json +20 -0
- package/dist/content/examples/performance/locales/fr.json +20 -0
- package/dist/content/examples/performance/logic.ts +210 -0
- package/dist/content/examples/performance/manifest.json +83 -0
- package/dist/content/examples/performance/package.json +16 -0
- package/dist/content/examples/performance/style.ocss +75 -0
- package/dist/content/examples/performance/tests/fahrenheit.scenario.json +110 -0
- package/dist/content/examples/performance/tests/horizontal.scenario.json +96 -0
- package/dist/content/examples/performance/tests/load-logic.mjs +22 -0
- package/dist/content/examples/performance/tests/logic.test.mjs +226 -0
- package/dist/content/examples/performance/tests/menu.scenario.json +170 -0
- package/dist/content/examples/performance/tests/no-fps-source.scenario.json +47 -0
- package/dist/content/examples/performance/tests/preview.scenario.json +45 -0
- package/dist/content/examples/performance/tests/refused.scenario.json +20 -0
- package/dist/content/examples/performance/tests/refused.test.mjs +19 -0
- package/dist/content/examples/performance/tests/region.scenario.json +89 -0
- package/dist/content/examples/performance/tests/render.scenario.json +112 -0
- package/dist/content/examples/performance/tests/sources.scenario.json +151 -0
- package/dist/content/examples/performance/tests/stale.scenario.json +111 -0
- package/dist/content/examples/performance/tests/telemetry-only.scenario.json +44 -0
- package/dist/content/examples/performance/tests/thresholds.scenario.json +147 -0
- package/dist/content/examples/performance/tests/waiting.scenario.json +105 -0
- package/dist/content/examples/performance/tsconfig.json +16 -0
- package/dist/content/examples/performance/view.ocml +18 -0
- package/dist/content/examples/playervox-journal/LICENSE +21 -0
- package/dist/content/examples/playervox-journal/README.md +150 -0
- package/dist/content/examples/playervox-journal/listing.json +19 -0
- package/dist/content/examples/playervox-journal/locales/en.json +25 -0
- package/dist/content/examples/playervox-journal/locales/fr.json +25 -0
- package/dist/content/examples/playervox-journal/logic.ts +556 -0
- package/dist/content/examples/playervox-journal/manifest.json +19 -0
- package/dist/content/examples/playervox-journal/package.json +16 -0
- package/dist/content/examples/playervox-journal/style.ocss +142 -0
- package/dist/content/examples/playervox-journal/tests/changes.scenario.json +482 -0
- package/dist/content/examples/playervox-journal/tests/delete.scenario.json +432 -0
- package/dist/content/examples/playervox-journal/tests/frame.scenario.json +169 -0
- package/dist/content/examples/playervox-journal/tests/load-logic.mjs +22 -0
- package/dist/content/examples/playervox-journal/tests/logic.test.mjs +349 -0
- package/dist/content/examples/playervox-journal/tests/paging.scenario.json +428 -0
- package/dist/content/examples/playervox-journal/tests/preview.scenario.json +184 -0
- package/dist/content/examples/playervox-journal/tests/read-only.scenario.json +157 -0
- package/dist/content/examples/playervox-journal/tests/refused.scenario.json +39 -0
- package/dist/content/examples/playervox-journal/tests/scale.scenario.json +286 -0
- package/dist/content/examples/playervox-journal/tests/signed-out.scenario.json +134 -0
- package/dist/content/examples/playervox-journal/tests/states.scenario.json +741 -0
- package/dist/content/examples/playervox-journal/tests/unavailable.scenario.json +183 -0
- package/dist/content/examples/playervox-journal/tsconfig.json +16 -0
- package/dist/content/examples/playervox-journal/view.ocml +52 -0
- package/dist/content/examples/playervox-rating/LICENSE +21 -0
- package/dist/content/examples/playervox-rating/README.md +166 -0
- package/dist/content/examples/playervox-rating/listing.json +19 -0
- package/dist/content/examples/playervox-rating/locales/en.json +35 -0
- package/dist/content/examples/playervox-rating/locales/fr.json +35 -0
- package/dist/content/examples/playervox-rating/logic.ts +581 -0
- package/dist/content/examples/playervox-rating/manifest.json +19 -0
- package/dist/content/examples/playervox-rating/package.json +16 -0
- package/dist/content/examples/playervox-rating/style.ocss +333 -0
- package/dist/content/examples/playervox-rating/tests/account.scenario.json +125 -0
- package/dist/content/examples/playervox-rating/tests/bounds.scenario.json +73 -0
- package/dist/content/examples/playervox-rating/tests/draft.scenario.json +199 -0
- package/dist/content/examples/playervox-rating/tests/form.scenario.json +142 -0
- package/dist/content/examples/playervox-rating/tests/load-logic.mjs +22 -0
- package/dist/content/examples/playervox-rating/tests/logic.test.mjs +346 -0
- package/dist/content/examples/playervox-rating/tests/offline.scenario.json +142 -0
- package/dist/content/examples/playervox-rating/tests/preview.scenario.json +44 -0
- package/dist/content/examples/playervox-rating/tests/publish-keyboard.scenario.json +221 -0
- package/dist/content/examples/playervox-rating/tests/publish-pointer.scenario.json +314 -0
- package/dist/content/examples/playervox-rating/tests/read-only.scenario.json +70 -0
- package/dist/content/examples/playervox-rating/tests/refused.scenario.json +45 -0
- package/dist/content/examples/playervox-rating/tests/states.scenario.json +362 -0
- package/dist/content/examples/playervox-rating/tsconfig.json +16 -0
- package/dist/content/examples/playervox-rating/view.ocml +62 -0
- package/dist/content/examples/playervox-reviews/LICENSE +21 -0
- package/dist/content/examples/playervox-reviews/README.md +194 -0
- package/dist/content/examples/playervox-reviews/listing.json +19 -0
- package/dist/content/examples/playervox-reviews/locales/en.json +32 -0
- package/dist/content/examples/playervox-reviews/locales/fr.json +32 -0
- package/dist/content/examples/playervox-reviews/logic.ts +822 -0
- package/dist/content/examples/playervox-reviews/manifest.json +30 -0
- package/dist/content/examples/playervox-reviews/package.json +16 -0
- package/dist/content/examples/playervox-reviews/style.ocss +260 -0
- package/dist/content/examples/playervox-reviews/tests/account.scenario.json +249 -0
- package/dist/content/examples/playervox-reviews/tests/bounds.scenario.json +336 -0
- package/dist/content/examples/playervox-reviews/tests/changes.scenario.json +515 -0
- package/dist/content/examples/playervox-reviews/tests/filter.scenario.json +400 -0
- package/dist/content/examples/playervox-reviews/tests/frame.scenario.json +185 -0
- package/dist/content/examples/playervox-reviews/tests/keyboard.scenario.json +188 -0
- package/dist/content/examples/playervox-reviews/tests/load-logic.mjs +22 -0
- package/dist/content/examples/playervox-reviews/tests/logic.test.mjs +559 -0
- package/dist/content/examples/playervox-reviews/tests/offline-start.scenario.json +152 -0
- package/dist/content/examples/playervox-reviews/tests/offline.scenario.json +311 -0
- package/dist/content/examples/playervox-reviews/tests/paging.scenario.json +497 -0
- package/dist/content/examples/playervox-reviews/tests/preview.scenario.json +286 -0
- package/dist/content/examples/playervox-reviews/tests/reading.scenario.json +591 -0
- package/dist/content/examples/playervox-reviews/tests/refused.scenario.json +54 -0
- package/dist/content/examples/playervox-reviews/tests/scale.scenario.json +303 -0
- package/dist/content/examples/playervox-reviews/tests/states.scenario.json +700 -0
- package/dist/content/examples/playervox-reviews/tests/unavailable.scenario.json +192 -0
- package/dist/content/examples/playervox-reviews/tsconfig.json +16 -0
- package/dist/content/examples/playervox-reviews/view.ocml +110 -0
- package/dist/content/examples/playervox-score/LICENSE +21 -0
- package/dist/content/examples/playervox-score/README.md +131 -0
- package/dist/content/examples/playervox-score/listing.json +19 -0
- package/dist/content/examples/playervox-score/locales/en.json +23 -0
- package/dist/content/examples/playervox-score/locales/fr.json +23 -0
- package/dist/content/examples/playervox-score/logic.ts +430 -0
- package/dist/content/examples/playervox-score/manifest.json +47 -0
- package/dist/content/examples/playervox-score/package.json +16 -0
- package/dist/content/examples/playervox-score/style.ocss +247 -0
- package/dist/content/examples/playervox-score/tests/grades.scenario.json +279 -0
- package/dist/content/examples/playervox-score/tests/load-logic.mjs +22 -0
- package/dist/content/examples/playervox-score/tests/logic.test.mjs +229 -0
- package/dist/content/examples/playervox-score/tests/options.scenario.json +302 -0
- package/dist/content/examples/playervox-score/tests/preview.scenario.json +68 -0
- package/dist/content/examples/playervox-score/tests/pulse.scenario.json +157 -0
- package/dist/content/examples/playervox-score/tests/refused.scenario.json +21 -0
- package/dist/content/examples/playervox-score/tests/signed-out.scenario.json +46 -0
- package/dist/content/examples/playervox-score/tests/states.scenario.json +450 -0
- package/dist/content/examples/playervox-score/tests/unavailable.scenario.json +84 -0
- package/dist/content/examples/playervox-score/tsconfig.json +16 -0
- package/dist/content/examples/playervox-score/view.ocml +51 -0
- package/dist/content/examples/session/LICENSE +21 -0
- package/dist/content/examples/session/README.md +57 -0
- package/dist/content/examples/session/listing.json +19 -0
- package/dist/content/examples/session/locales/en.json +3 -0
- package/dist/content/examples/session/locales/fr.json +3 -0
- package/dist/content/examples/session/logic.ts +26 -0
- package/dist/content/examples/session/manifest.json +19 -0
- package/dist/content/examples/session/package.json +16 -0
- package/dist/content/examples/session/style.ocss +16 -0
- package/dist/content/examples/session/tests/hidden.scenario.json +45 -0
- package/dist/content/examples/session/tests/load-logic.mjs +22 -0
- package/dist/content/examples/session/tests/logic.test.mjs +37 -0
- package/dist/content/examples/session/tests/preview.scenario.json +28 -0
- package/dist/content/examples/session/tests/refused.scenario.json +20 -0
- package/dist/content/examples/session/tests/refused.test.mjs +16 -0
- package/dist/content/examples/session/tests/render.scenario.json +95 -0
- package/dist/content/examples/session/tests/ticks.scenario.json +139 -0
- package/dist/content/examples/session/tests/unknown.scenario.json +37 -0
- package/dist/content/examples/session/tsconfig.json +16 -0
- package/dist/content/examples/session/view.ocml +11 -0
- package/dist/content/examples/stopwatch/LICENSE +21 -0
- package/dist/content/examples/stopwatch/README.md +99 -0
- package/dist/content/examples/stopwatch/listing.json +19 -0
- package/dist/content/examples/stopwatch/locales/en.json +8 -0
- package/dist/content/examples/stopwatch/locales/fr.json +8 -0
- package/dist/content/examples/stopwatch/logic.ts +93 -0
- package/dist/content/examples/stopwatch/manifest.json +19 -0
- package/dist/content/examples/stopwatch/package.json +16 -0
- package/dist/content/examples/stopwatch/style.ocss +54 -0
- package/dist/content/examples/stopwatch/tests/errors.scenario.json +121 -0
- package/dist/content/examples/stopwatch/tests/gesture.scenario.json +154 -0
- package/dist/content/examples/stopwatch/tests/hidden.scenario.json +57 -0
- package/dist/content/examples/stopwatch/tests/hundredths.scenario.json +173 -0
- package/dist/content/examples/stopwatch/tests/load-logic.mjs +22 -0
- package/dist/content/examples/stopwatch/tests/logic.test.mjs +99 -0
- package/dist/content/examples/stopwatch/tests/preview.scenario.json +36 -0
- package/dist/content/examples/stopwatch/tests/read-only.scenario.json +42 -0
- package/dist/content/examples/stopwatch/tests/read-only.test.mjs +18 -0
- package/dist/content/examples/stopwatch/tests/refused.scenario.json +22 -0
- package/dist/content/examples/stopwatch/tests/refused.test.mjs +18 -0
- package/dist/content/examples/stopwatch/tests/render.scenario.json +105 -0
- package/dist/content/examples/stopwatch/tests/states.scenario.json +246 -0
- package/dist/content/examples/stopwatch/tests/unknown.scenario.json +40 -0
- package/dist/content/examples/stopwatch/tsconfig.json +16 -0
- package/dist/content/examples/stopwatch/view.ocml +29 -0
- package/dist/content/examples/twitch-chat/LICENSE +21 -0
- package/dist/content/examples/twitch-chat/README.md +283 -0
- package/dist/content/examples/twitch-chat/listing.json +19 -0
- package/dist/content/examples/twitch-chat/locales/en.json +52 -0
- package/dist/content/examples/twitch-chat/locales/fr.json +52 -0
- package/dist/content/examples/twitch-chat/logic.ts +887 -0
- package/dist/content/examples/twitch-chat/manifest.json +33 -0
- package/dist/content/examples/twitch-chat/package.json +16 -0
- package/dist/content/examples/twitch-chat/style.ocss +544 -0
- package/dist/content/examples/twitch-chat/tests/account.scenario.json +325 -0
- package/dist/content/examples/twitch-chat/tests/burst.scenario.json +1866 -0
- package/dist/content/examples/twitch-chat/tests/channel-refused.scenario.json +372 -0
- package/dist/content/examples/twitch-chat/tests/channel.scenario.json +480 -0
- package/dist/content/examples/twitch-chat/tests/draft.scenario.json +279 -0
- package/dist/content/examples/twitch-chat/tests/fade.scenario.json +269 -0
- package/dist/content/examples/twitch-chat/tests/failures.scenario.json +420 -0
- package/dist/content/examples/twitch-chat/tests/favorites.scenario.json +376 -0
- package/dist/content/examples/twitch-chat/tests/frame.scenario.json +152 -0
- package/dist/content/examples/twitch-chat/tests/lifetime.scenario.json +186 -0
- package/dist/content/examples/twitch-chat/tests/load-logic.mjs +22 -0
- package/dist/content/examples/twitch-chat/tests/logic.test.mjs +1053 -0
- package/dist/content/examples/twitch-chat/tests/passive.scenario.json +536 -0
- package/dist/content/examples/twitch-chat/tests/preview.scenario.json +147 -0
- package/dist/content/examples/twitch-chat/tests/read-only.scenario.json +202 -0
- package/dist/content/examples/twitch-chat/tests/read-only.test.mjs +63 -0
- package/dist/content/examples/twitch-chat/tests/refused.scenario.json +65 -0
- package/dist/content/examples/twitch-chat/tests/refused.test.mjs +43 -0
- package/dist/content/examples/twitch-chat/tests/render.scenario.json +347 -0
- package/dist/content/examples/twitch-chat/tests/reply.scenario.json +384 -0
- package/dist/content/examples/twitch-chat/tests/send-refused.scenario.json +383 -0
- package/dist/content/examples/twitch-chat/tests/send.scenario.json +300 -0
- package/dist/content/examples/twitch-chat/tests/states.scenario.json +455 -0
- package/dist/content/examples/twitch-chat/tests/unavailable.scenario.json +256 -0
- package/dist/content/examples/twitch-chat/tests/unread.scenario.json +997 -0
- package/dist/content/examples/twitch-chat/tsconfig.json +16 -0
- package/dist/content/examples/twitch-chat/view.ocml +141 -0
- package/dist/content/examples/warframe-market/LICENSE +21 -0
- package/dist/content/examples/warframe-market/README.md +176 -0
- package/dist/content/examples/warframe-market/listing.json +19 -0
- package/dist/content/examples/warframe-market/locales/en.json +44 -0
- package/dist/content/examples/warframe-market/locales/fr.json +44 -0
- package/dist/content/examples/warframe-market/logic.ts +1265 -0
- package/dist/content/examples/warframe-market/manifest.json +92 -0
- package/dist/content/examples/warframe-market/package.json +16 -0
- package/dist/content/examples/warframe-market/style.ocss +422 -0
- package/dist/content/examples/warframe-market/tests/bounds.scenario.json +60 -0
- package/dist/content/examples/warframe-market/tests/cached.scenario.json +70 -0
- package/dist/content/examples/warframe-market/tests/detail.scenario.json +251 -0
- package/dist/content/examples/warframe-market/tests/errors.scenario.json +105 -0
- package/dist/content/examples/warframe-market/tests/fixtures.json +295 -0
- package/dist/content/examples/warframe-market/tests/load-logic.mjs +22 -0
- package/dist/content/examples/warframe-market/tests/logic.test.mjs +505 -0
- package/dist/content/examples/warframe-market/tests/no-clipboard.scenario.json +104 -0
- package/dist/content/examples/warframe-market/tests/passive.scenario.json +87 -0
- package/dist/content/examples/warframe-market/tests/preview.scenario.json +146 -0
- package/dist/content/examples/warframe-market/tests/refresh.scenario.json +56 -0
- package/dist/content/examples/warframe-market/tests/refused.scenario.json +74 -0
- package/dist/content/examples/warframe-market/tests/render.scenario.json +210 -0
- package/dist/content/examples/warframe-market/tests/search.scenario.json +189 -0
- package/dist/content/examples/warframe-market/tests/unavailable.scenario.json +54 -0
- package/dist/content/examples/warframe-market/tsconfig.json +16 -0
- package/dist/content/examples/warframe-market/view.ocml +116 -0
- package/dist/content/examples/weather/LICENSE +20 -0
- package/dist/content/examples/weather/listing.json +10 -0
- package/dist/content/examples/weather/locales/en.json +9 -0
- package/dist/content/examples/weather/locales/fr.json +9 -0
- package/dist/content/examples/weather/logic.ts +62 -0
- package/dist/content/examples/weather/manifest.json +51 -0
- package/dist/content/examples/weather/package.json +15 -0
- package/dist/content/examples/weather/style.ocss +29 -0
- package/dist/content/examples/weather/tsconfig.json +16 -0
- package/dist/content/examples/weather/view.ocml +12 -0
- package/dist/content/index.json +3601 -0
- package/dist/content/reference/errors.json +1712 -0
- package/dist/content/reference/limits.json +1154 -0
- package/dist/content/reference/manifest.md +223 -0
- package/dist/content/reference/permissions.json +413 -0
- package/dist/content/templates/blank/locales/en.json +3 -0
- package/dist/content/templates/blank/locales/fr.json +3 -0
- package/dist/content/templates/blank/logic.ts +7 -0
- package/dist/content/templates/blank/manifest.json +16 -0
- package/dist/content/templates/blank/style.ocss +10 -0
- package/dist/content/templates/blank/tests/example.scenario.json +10 -0
- package/dist/content/templates/blank/view.ocml +6 -0
- package/dist/content/templates/chart/locales/en.json +3 -0
- package/dist/content/templates/chart/locales/fr.json +3 -0
- package/dist/content/templates/chart/logic.ts +27 -0
- package/dist/content/templates/chart/manifest.json +16 -0
- package/dist/content/templates/chart/style.ocss +27 -0
- package/dist/content/templates/chart/tests/example.scenario.json +10 -0
- package/dist/content/templates/chart/view.ocml +10 -0
- package/dist/content/templates/counter/locales/en.json +4 -0
- package/dist/content/templates/counter/locales/fr.json +4 -0
- package/dist/content/templates/counter/logic.ts +20 -0
- package/dist/content/templates/counter/manifest.json +16 -0
- package/dist/content/templates/counter/style.ocss +26 -0
- package/dist/content/templates/counter/tests/example.scenario.json +11 -0
- package/dist/content/templates/counter/view.ocml +13 -0
- package/dist/content/templates/list/locales/en.json +8 -0
- package/dist/content/templates/list/locales/fr.json +8 -0
- package/dist/content/templates/list/logic.ts +35 -0
- package/dist/content/templates/list/manifest.json +16 -0
- package/dist/content/templates/list/style.ocss +42 -0
- package/dist/content/templates/list/tests/example.scenario.json +10 -0
- package/dist/content/templates/list/view.ocml +17 -0
- package/dist/content/templates/shared/LICENSE +20 -0
- package/dist/content/templates/shared/package.json +14 -0
- package/dist/content/templates/shared/tsconfig.json +16 -0
- package/dist/content.js +68 -0
- package/dist/env.js +156 -0
- package/dist/fingerprint.js +32 -0
- package/dist/ids.js +27 -0
- package/dist/index.js +82 -0
- package/dist/knowledge.js +222 -0
- package/dist/npm.js +149 -0
- package/dist/prompts.js +47 -0
- package/dist/resources.js +82 -0
- package/dist/run.js +120 -0
- package/dist/server.js +43 -0
- package/dist/session.js +205 -0
- package/dist/sources.js +218 -0
- package/dist/text.js +80 -0
- package/dist/texts.js +44 -0
- package/dist/tools/audit.js +105 -0
- package/dist/tools/build.js +453 -0
- package/dist/tools/common.js +122 -0
- package/dist/tools/docs.js +196 -0
- package/dist/tools/preview.js +87 -0
- package/dist/tools/project.js +178 -0
- package/dist/tools/setup.js +146 -0
- package/dist/tools/submission.js +369 -0
- package/dist/version.js +3 -0
- package/npm-shrinkwrap.json +402 -0
- package/package.json +50 -4
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
// Warframe Market: searches the public PC item catalog of warframe.market,
|
|
2
|
+
// shows every online offer of one item, sellers and buyers in two tabs, and
|
|
3
|
+
// copies a trade whisper on an explicit click. The catalog (about 3,900
|
|
4
|
+
// items, 1.6 MB of JSON) is read once per catalog version, kept compact in
|
|
5
|
+
// host storage and searched in memory. An item's orders (up to about 1 MB
|
|
6
|
+
// of JSON, a few hundred online among thousands) are read in slices, one
|
|
7
|
+
// VM turn each, so that no turn nears the host's budget under the VM's CPU
|
|
8
|
+
// ceiling. Both routes declare `maxResponseBytes` beyond the default 1 MiB.
|
|
9
|
+
import {
|
|
10
|
+
clipboard,
|
|
11
|
+
formatNumber,
|
|
12
|
+
host,
|
|
13
|
+
http,
|
|
14
|
+
initState,
|
|
15
|
+
onHost,
|
|
16
|
+
storage,
|
|
17
|
+
t,
|
|
18
|
+
timers,
|
|
19
|
+
type JsonValue,
|
|
20
|
+
type Timer,
|
|
21
|
+
} from "@overcrow/sdk";
|
|
22
|
+
|
|
23
|
+
const API = "https://api.warframe.market";
|
|
24
|
+
|
|
25
|
+
/** The provider allows three requests per second: one starts every 400 ms at most. */
|
|
26
|
+
export const REQUEST_SPACING_MS = 400;
|
|
27
|
+
export const MAX_ITEMS = 8192;
|
|
28
|
+
export const MAX_RESULTS = 12;
|
|
29
|
+
export const MAX_QUERY_CHARS = 64;
|
|
30
|
+
const MAX_NAME_CHARS = 96;
|
|
31
|
+
const MAX_SLUG_BYTES = 96;
|
|
32
|
+
const MAX_VERSION_CHARS = 128;
|
|
33
|
+
/** Rows of an orders answer beyond which it is refused (about 10 MB of JSON). */
|
|
34
|
+
export const MAX_ORDERS_IN_ANSWER = 20_000;
|
|
35
|
+
/** Offers kept and shown per tab; the rest is counted (`+N more`). */
|
|
36
|
+
export const MAX_OFFERS_SHOWN = 150;
|
|
37
|
+
/** Orders parsed in one VM turn: 2 to 3 ms of CPU. */
|
|
38
|
+
export const ORDER_SLICE_ROWS = 300;
|
|
39
|
+
/**
|
|
40
|
+
* A slice of orders larger than this is refused rather than parsed: a
|
|
41
|
+
* provider format without the row boundary the reader expects fails closed
|
|
42
|
+
* instead of parsing a whole answer in one turn.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_ORDER_SLICE_BYTES = 512 * 1024;
|
|
45
|
+
const MAX_PLATINUM = 900_000;
|
|
46
|
+
/** A catalog chunk stays below `MAX_STORAGE_VALUE_BYTES` (64 KiB) as JSON. */
|
|
47
|
+
export const CHUNK_BYTES = 60_000;
|
|
48
|
+
/** At most this many chunks: 180 KB of the 256 KiB quota. */
|
|
49
|
+
export const MAX_CHUNKS = 3;
|
|
50
|
+
/** The search text is saved this long after the last keystroke. */
|
|
51
|
+
const SAVE_DELAY_MS = 1000;
|
|
52
|
+
/**
|
|
53
|
+
* Catalog rows handled in one VM turn. A turn must stay far below the
|
|
54
|
+
* host's turn budget even under the VM's CPU ceiling (a quarter of one
|
|
55
|
+
* core), so the 1.6 MB catalog is read, checked and stored in slices, one
|
|
56
|
+
* host timer apart.
|
|
57
|
+
*/
|
|
58
|
+
export const SLICE_ITEMS = 250;
|
|
59
|
+
/** Stored lines read back in one turn: splitting and checking a line costs a fraction of parsing an item. */
|
|
60
|
+
export const DECODE_SLICE_LINES = 800;
|
|
61
|
+
const SLICE_PAUSE_MS = 100;
|
|
62
|
+
|
|
63
|
+
export interface Item {
|
|
64
|
+
readonly slug: string;
|
|
65
|
+
readonly name: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Side = "sell" | "buy";
|
|
69
|
+
/** Only players in game or online on the site are shown. */
|
|
70
|
+
export type Presence = "ingame" | "online";
|
|
71
|
+
|
|
72
|
+
export interface Order {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly side: Side;
|
|
75
|
+
readonly platinum: number;
|
|
76
|
+
readonly trader: string;
|
|
77
|
+
readonly presence: Presence;
|
|
78
|
+
readonly perTrade: number;
|
|
79
|
+
readonly rank?: number;
|
|
80
|
+
readonly charges?: number;
|
|
81
|
+
readonly amberStars?: number;
|
|
82
|
+
readonly cyanStars?: number;
|
|
83
|
+
readonly subtype?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Detail {
|
|
87
|
+
readonly slug: string;
|
|
88
|
+
readonly name: string;
|
|
89
|
+
/** The best `MAX_OFFERS_SHOWN` of each side, by price. */
|
|
90
|
+
readonly sells: readonly Order[];
|
|
91
|
+
readonly buys: readonly Order[];
|
|
92
|
+
/** Online offers of each side, those not kept included. */
|
|
93
|
+
readonly sellTotal: number;
|
|
94
|
+
readonly buyTotal: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `wrapper.menu` row `auto-refresh`: the shown offers reload on their own. */
|
|
98
|
+
export type AutoRefresh = "off" | "every-30s" | "every-1m";
|
|
99
|
+
|
|
100
|
+
export type Phase = "loading" | "ready" | "unavailable";
|
|
101
|
+
export type Failure = "catalog_unavailable" | "storage_unavailable" | "orders_unavailable";
|
|
102
|
+
export type CopyState = "pending" | "copied" | "failed";
|
|
103
|
+
|
|
104
|
+
export interface CopyFeedback {
|
|
105
|
+
readonly side: Side;
|
|
106
|
+
readonly id: string;
|
|
107
|
+
readonly state: CopyState;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
declare module "@overcrow/sdk" {
|
|
111
|
+
interface WidgetState {
|
|
112
|
+
phase: Phase;
|
|
113
|
+
count: number;
|
|
114
|
+
error: Failure | null;
|
|
115
|
+
query: string;
|
|
116
|
+
results: readonly Item[];
|
|
117
|
+
detail: Detail | null;
|
|
118
|
+
loadingOrders: boolean;
|
|
119
|
+
/** The shown offers are reloading; they stay visible meanwhile. */
|
|
120
|
+
refreshing: boolean;
|
|
121
|
+
tab: Side;
|
|
122
|
+
copy: CopyFeedback | null;
|
|
123
|
+
interactive: boolean;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const state = initState({
|
|
128
|
+
phase: "loading" as Phase,
|
|
129
|
+
count: 0,
|
|
130
|
+
error: null as Failure | null,
|
|
131
|
+
query: "",
|
|
132
|
+
results: [] as readonly Item[],
|
|
133
|
+
detail: null as Detail | null,
|
|
134
|
+
loadingOrders: false,
|
|
135
|
+
refreshing: false,
|
|
136
|
+
tab: "sell" as Side,
|
|
137
|
+
copy: null as CopyFeedback | null,
|
|
138
|
+
interactive: host.mode === "interactive",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** The searchable catalog; outside `state`, so it never reaches the view. */
|
|
142
|
+
let items: Item[] = [];
|
|
143
|
+
let lowered: string[] = [];
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Validation of what the provider and the storage return.
|
|
147
|
+
|
|
148
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
153
|
+
|
|
154
|
+
function hasControl(text: string): boolean {
|
|
155
|
+
return CONTROL.test(text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function safeSlug(value: unknown): value is string {
|
|
159
|
+
return (
|
|
160
|
+
typeof value === "string"
|
|
161
|
+
&& value.length >= 1
|
|
162
|
+
&& value.length <= MAX_SLUG_BYTES
|
|
163
|
+
&& /^[a-z0-9_-]+$/.test(value)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A display name: trimmed, without control characters, cut to 96 characters. */
|
|
168
|
+
export function sanitizeName(value: unknown): string | null {
|
|
169
|
+
if (typeof value !== "string") {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const trimmed = value.trim();
|
|
173
|
+
if (!trimmed || hasControl(trimmed)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
// UTF-16 units bound the characters: most names skip the split.
|
|
177
|
+
if (trimmed.length <= MAX_NAME_CHARS) {
|
|
178
|
+
return trimmed;
|
|
179
|
+
}
|
|
180
|
+
const characters = [...trimmed];
|
|
181
|
+
return characters.length <= MAX_NAME_CHARS
|
|
182
|
+
? trimmed
|
|
183
|
+
: `${characters.slice(0, MAX_NAME_CHARS - 1).join("")}…`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The checked items of a catalog, fed row by row; a later row replaces an earlier one with its slug. */
|
|
187
|
+
export class CatalogBuilder {
|
|
188
|
+
private readonly bySlug = new Map<string, Item>();
|
|
189
|
+
private rows = 0;
|
|
190
|
+
|
|
191
|
+
add(slug: unknown, name: unknown): void {
|
|
192
|
+
this.rows += 1;
|
|
193
|
+
if (this.rows > MAX_ITEMS) {
|
|
194
|
+
throw new Error("invalid catalog");
|
|
195
|
+
}
|
|
196
|
+
const clean = sanitizeName(name);
|
|
197
|
+
if (clean && safeSlug(slug)) {
|
|
198
|
+
this.bySlug.set(slug, { slug, name: clean });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
finish(): Item[] {
|
|
203
|
+
if (this.bySlug.size === 0) {
|
|
204
|
+
throw new Error("invalid catalog");
|
|
205
|
+
}
|
|
206
|
+
return [...this.bySlug.values()];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function addRow(builder: CatalogBuilder, row: unknown): void {
|
|
211
|
+
const record = isObject(row) ? row : {};
|
|
212
|
+
const i18n = isObject(record.i18n) ? record.i18n : {};
|
|
213
|
+
const en = isObject(i18n.en) ? i18n.en : {};
|
|
214
|
+
builder.add(record.slug, en.name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The index of the quote that closes the JSON string opening at `start`. */
|
|
218
|
+
function stringEnd(text: string, start: number): number {
|
|
219
|
+
let from = start + 1;
|
|
220
|
+
for (;;) {
|
|
221
|
+
const quote = text.indexOf('"', from);
|
|
222
|
+
if (quote < 0) {
|
|
223
|
+
throw new Error("invalid catalog");
|
|
224
|
+
}
|
|
225
|
+
let slashes = 0;
|
|
226
|
+
while (text.charCodeAt(quote - 1 - slashes) === 0x5c) {
|
|
227
|
+
slashes += 1;
|
|
228
|
+
}
|
|
229
|
+
if (slashes % 2 === 0) {
|
|
230
|
+
return quote;
|
|
231
|
+
}
|
|
232
|
+
from = quote + 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const STRUCTURE = /[{}[\]"]/g;
|
|
237
|
+
const MAX_DEPTH = 64;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reads the text of `GET /v2/items`, `{ …, "data": [ {…}, … ], … }`, a
|
|
241
|
+
* slice at a time: it tracks the nesting and the strings, parses each item
|
|
242
|
+
* of `data` alone with `JSON.parse` and feeds its slug and English name to
|
|
243
|
+
* a builder. Unbalanced or misplaced brackets, a second `data`, a non-object
|
|
244
|
+
* item or text after the root fail the catalog.
|
|
245
|
+
*/
|
|
246
|
+
export class CatalogScanner {
|
|
247
|
+
private position = 0;
|
|
248
|
+
private readonly open: string[] = [];
|
|
249
|
+
private inData = false;
|
|
250
|
+
private sawData = false;
|
|
251
|
+
private closed = false;
|
|
252
|
+
private itemStart = -1;
|
|
253
|
+
private key = "";
|
|
254
|
+
private keyEnd = -1;
|
|
255
|
+
|
|
256
|
+
constructor(private readonly text: string, private readonly builder: CatalogBuilder) {}
|
|
257
|
+
|
|
258
|
+
/** Reads up to `SLICE_ITEMS` items; `true` once the whole text is read. */
|
|
259
|
+
step(): boolean {
|
|
260
|
+
const text = this.text;
|
|
261
|
+
const invalid = (): never => {
|
|
262
|
+
throw new Error("invalid catalog");
|
|
263
|
+
};
|
|
264
|
+
let found = 0;
|
|
265
|
+
while (found < SLICE_ITEMS) {
|
|
266
|
+
STRUCTURE.lastIndex = this.position;
|
|
267
|
+
const match = STRUCTURE.exec(text);
|
|
268
|
+
if (!match) {
|
|
269
|
+
if (!this.closed || !this.sawData || text.slice(this.position).trim()) {
|
|
270
|
+
invalid();
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
const at = match.index;
|
|
275
|
+
const token = match[0];
|
|
276
|
+
if (this.closed) {
|
|
277
|
+
invalid();
|
|
278
|
+
}
|
|
279
|
+
if (token === '"') {
|
|
280
|
+
const end = stringEnd(text, at);
|
|
281
|
+
if (this.open.length === 0) {
|
|
282
|
+
invalid();
|
|
283
|
+
}
|
|
284
|
+
if (this.open.length === 1) {
|
|
285
|
+
this.key = end - at <= 8 ? text.slice(at + 1, end) : "";
|
|
286
|
+
this.keyEnd = end;
|
|
287
|
+
}
|
|
288
|
+
this.position = end + 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (token === "{" || token === "[") {
|
|
292
|
+
const depth = this.open.length;
|
|
293
|
+
if (depth === 0 && (token !== "{" || text.slice(this.position, at).trim())) {
|
|
294
|
+
invalid();
|
|
295
|
+
}
|
|
296
|
+
if (depth >= MAX_DEPTH) {
|
|
297
|
+
invalid();
|
|
298
|
+
}
|
|
299
|
+
if (this.inData && depth === 2) {
|
|
300
|
+
if (token !== "{") {
|
|
301
|
+
invalid();
|
|
302
|
+
}
|
|
303
|
+
this.itemStart = at;
|
|
304
|
+
}
|
|
305
|
+
if (depth === 1 && token === "[" && this.key === "data"
|
|
306
|
+
&& /^\s*:\s*$/.test(text.slice(this.keyEnd + 1, at))) {
|
|
307
|
+
if (this.sawData) {
|
|
308
|
+
invalid();
|
|
309
|
+
}
|
|
310
|
+
this.inData = true;
|
|
311
|
+
this.sawData = true;
|
|
312
|
+
}
|
|
313
|
+
this.open.push(token);
|
|
314
|
+
} else {
|
|
315
|
+
const opened = this.open.pop();
|
|
316
|
+
if (opened !== (token === "}" ? "{" : "[")) {
|
|
317
|
+
invalid();
|
|
318
|
+
}
|
|
319
|
+
const depth = this.open.length;
|
|
320
|
+
if (this.inData && depth === 2 && token === "}") {
|
|
321
|
+
addRow(this.builder, JSON.parse(text.slice(this.itemStart, at + 1)));
|
|
322
|
+
found += 1;
|
|
323
|
+
} else if (this.inData && depth === 1) {
|
|
324
|
+
this.inData = false;
|
|
325
|
+
}
|
|
326
|
+
this.closed = depth === 0;
|
|
327
|
+
}
|
|
328
|
+
this.position = at + 1;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function pause(): Promise<void> {
|
|
335
|
+
return new Promise((resolve) => {
|
|
336
|
+
timers.after(SLICE_PAUSE_MS, () => resolve());
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Runs `step` once per VM turn, one host timer apart, until it returns `true`. */
|
|
341
|
+
async function sliced(step: () => boolean): Promise<void> {
|
|
342
|
+
while (!step()) {
|
|
343
|
+
await pause();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The whole catalog of an answer, at once (tests and small answers). */
|
|
348
|
+
export function parseCatalogText(text: string): Item[] {
|
|
349
|
+
const builder = new CatalogBuilder();
|
|
350
|
+
const scanner = new CatalogScanner(text, builder);
|
|
351
|
+
while (!scanner.step()) {
|
|
352
|
+
// one slice after another
|
|
353
|
+
}
|
|
354
|
+
return builder.finish();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `GET /v2/items` as an object: `{ data: [{ slug, i18n: { en: { name } } }] }`. */
|
|
358
|
+
export function parseCatalog(payload: unknown): Item[] {
|
|
359
|
+
return parseCatalogText(JSON.stringify(payload) ?? "");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** `GET /v2/versions`: the catalog's version, `data.collections.items`. */
|
|
363
|
+
export function parseVersion(payload: unknown): string {
|
|
364
|
+
const data = isObject(payload) ? payload.data : undefined;
|
|
365
|
+
const collections = isObject(data) ? data.collections : undefined;
|
|
366
|
+
const value = isObject(collections) ? collections.items : undefined;
|
|
367
|
+
return typeof value === "string" && value.length <= MAX_VERSION_CHARS ? value : "";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function presence(status: unknown): Presence | null {
|
|
371
|
+
return status === "ingame" || status === "online" ? status : null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function sanitizeTrader(value: unknown): string | null {
|
|
375
|
+
if (typeof value !== "string") {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
const output = [...value]
|
|
379
|
+
.filter((character) => !hasControl(character) && character !== "/" && character !== "\\")
|
|
380
|
+
.slice(0, 32)
|
|
381
|
+
.join("")
|
|
382
|
+
.trim();
|
|
383
|
+
return output || null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A visible PC order of a player in game or online, checked field by field. */
|
|
387
|
+
export function parseOrder(row: unknown): Order | null {
|
|
388
|
+
if (!isObject(row) || row.visible !== true || (row.type !== "sell" && row.type !== "buy")) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const side: Side = row.type;
|
|
392
|
+
const user = isObject(row.user) ? row.user : null;
|
|
393
|
+
const status = presence(user?.status);
|
|
394
|
+
if (!user || user.platform !== "pc" || !status) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
const id = row.id;
|
|
398
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9_-]{1,96}$/.test(id)) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
const platinum = row.platinum;
|
|
402
|
+
if (typeof platinum !== "number" || !Number.isInteger(platinum) || platinum < 1 || platinum > MAX_PLATINUM) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
const trader = sanitizeTrader(user.ingameName);
|
|
406
|
+
if (!trader) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const perTrade = row.perTrade ?? 1;
|
|
410
|
+
if (typeof perTrade !== "number" || !Number.isInteger(perTrade) || perTrade < 1 || perTrade > 6) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const order: {
|
|
414
|
+
-readonly [K in keyof Order]: Order[K];
|
|
415
|
+
} = { id, side, platinum, trader, presence: status, perTrade };
|
|
416
|
+
for (const key of ["rank", "charges", "amberStars", "cyanStars"] as const) {
|
|
417
|
+
const value = row[key];
|
|
418
|
+
if (value === undefined || value === null) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 100) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
order[key] = value;
|
|
425
|
+
}
|
|
426
|
+
if (row.subtype !== undefined && row.subtype !== null) {
|
|
427
|
+
if (typeof row.subtype !== "string" || !/^[a-z0-9_-]{1,64}$/.test(row.subtype)) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
order.subtype = row.subtype;
|
|
431
|
+
}
|
|
432
|
+
return order;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The online offers of an answer, sorted: sells by rising price, buys by falling price. */
|
|
436
|
+
export class OrderCollector {
|
|
437
|
+
private readonly sells: Order[] = [];
|
|
438
|
+
private readonly buys: Order[] = [];
|
|
439
|
+
private rows = 0;
|
|
440
|
+
|
|
441
|
+
add(row: unknown): void {
|
|
442
|
+
this.rows += 1;
|
|
443
|
+
if (this.rows > MAX_ORDERS_IN_ANSWER) {
|
|
444
|
+
throw new Error("invalid orders");
|
|
445
|
+
}
|
|
446
|
+
const order = parseOrder(row);
|
|
447
|
+
if (order) {
|
|
448
|
+
(order.side === "sell" ? this.sells : this.buys).push(order);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
finish(slug: string, name: string): Detail {
|
|
453
|
+
this.sells.sort((a, b) => a.platinum / a.perTrade - b.platinum / b.perTrade);
|
|
454
|
+
this.buys.sort((a, b) => b.platinum / b.perTrade - a.platinum / a.perTrade);
|
|
455
|
+
return {
|
|
456
|
+
slug,
|
|
457
|
+
name,
|
|
458
|
+
sells: this.sells.slice(0, MAX_OFFERS_SHOWN),
|
|
459
|
+
buys: this.buys.slice(0, MAX_OFFERS_SHOWN),
|
|
460
|
+
sellTotal: this.sells.length,
|
|
461
|
+
buyTotal: this.buys.length,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* `GET /v2/orders/item/{slug}`, `{"apiVersion":…,"data":[{"id":…},…],…}`,
|
|
468
|
+
* read `ORDER_SLICE_ROWS` rows per step. Rows are cut where `},{"id":"`
|
|
469
|
+
* starts the next one (an unescaped quote never occurs inside a JSON
|
|
470
|
+
* string), and each slice is parsed by the engine's own `JSON.parse`: a
|
|
471
|
+
* wrong cut fails that parse, never silently. A slice beyond
|
|
472
|
+
* `MAX_ORDER_SLICE_BYTES` is refused.
|
|
473
|
+
*/
|
|
474
|
+
export class OrderReader {
|
|
475
|
+
private position: number;
|
|
476
|
+
private readonly end: number;
|
|
477
|
+
|
|
478
|
+
constructor(
|
|
479
|
+
private readonly text: string,
|
|
480
|
+
private readonly collector: OrderCollector,
|
|
481
|
+
) {
|
|
482
|
+
const start = text.indexOf('"data":[');
|
|
483
|
+
this.end = text.lastIndexOf("]");
|
|
484
|
+
if (start < 0 || start > 256 || this.end < start || text.length - this.end > 4096) {
|
|
485
|
+
throw new Error("invalid orders");
|
|
486
|
+
}
|
|
487
|
+
// The envelope around the rows, without them, is a JSON object.
|
|
488
|
+
const envelope: unknown = JSON.parse(`${text.slice(0, start)}"data":[]${text.slice(this.end + 1)}`);
|
|
489
|
+
if (!isObject(envelope) || !Array.isArray(envelope.data)) {
|
|
490
|
+
throw new Error("invalid orders");
|
|
491
|
+
}
|
|
492
|
+
this.position = start + '"data":['.length;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Reads one slice; `true` once every row is read. */
|
|
496
|
+
step(): boolean {
|
|
497
|
+
const text = this.text;
|
|
498
|
+
let cut = this.position;
|
|
499
|
+
let count = 0;
|
|
500
|
+
while (count < ORDER_SLICE_ROWS) {
|
|
501
|
+
const next = text.indexOf('},{"id":"', cut);
|
|
502
|
+
if (next < 0 || next >= this.end) {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
cut = next + 1;
|
|
506
|
+
count += 1;
|
|
507
|
+
}
|
|
508
|
+
const last = count < ORDER_SLICE_ROWS;
|
|
509
|
+
const close = last ? this.end : cut;
|
|
510
|
+
if (close - this.position > MAX_ORDER_SLICE_BYTES) {
|
|
511
|
+
throw new Error("invalid orders");
|
|
512
|
+
}
|
|
513
|
+
const rows: unknown = JSON.parse(`[${text.slice(this.position, close)}]`);
|
|
514
|
+
if (!Array.isArray(rows)) {
|
|
515
|
+
throw new Error("invalid orders");
|
|
516
|
+
}
|
|
517
|
+
for (const row of rows) {
|
|
518
|
+
this.collector.add(row);
|
|
519
|
+
}
|
|
520
|
+
this.position = close + 1;
|
|
521
|
+
return last;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** A whole orders answer at once (tests and small answers). */
|
|
526
|
+
export function parseOrdersText(text: string, slug = "", name = ""): Detail {
|
|
527
|
+
const collector = new OrderCollector();
|
|
528
|
+
const reader = new OrderReader(text, collector);
|
|
529
|
+
while (!reader.step()) {
|
|
530
|
+
// one slice after another
|
|
531
|
+
}
|
|
532
|
+
return collector.finish(slug, name);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// The compact stored catalog: one line per item, `name` when the slug
|
|
537
|
+
// derives from it, else `name<TAB>slug`, in chunks of at most 60 KB.
|
|
538
|
+
|
|
539
|
+
export function derivedSlug(name: string): string {
|
|
540
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const ASCII = /^[\u0000-\u007f]*$/;
|
|
544
|
+
const ESCAPED = /["\\\t]/;
|
|
545
|
+
|
|
546
|
+
/** Bytes of `line` in compact JSON, with the separator that follows it. */
|
|
547
|
+
function storedBytes(line: string): number {
|
|
548
|
+
let bytes = line.length + 2;
|
|
549
|
+
if (!ASCII.test(line)) {
|
|
550
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
551
|
+
const code = line.charCodeAt(index);
|
|
552
|
+
// A surrogate pair is 4 bytes for 2 units; other units 2 or 3 bytes for 1.
|
|
553
|
+
bytes += code < 0x80 ? 0 : code < 0x800 ? 1 : code >= 0xd800 && code <= 0xdbff ? 1 : 2;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (ESCAPED.test(line)) {
|
|
557
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
558
|
+
const code = line.charCodeAt(index);
|
|
559
|
+
if (code === 0x22 || code === 0x5c || code === 0x09) {
|
|
560
|
+
bytes += 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return bytes;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Encodes a catalog for storage, a slice at a time: one line per item,
|
|
569
|
+
* `name` when the slug derives from it, else `name<TAB>slug`, in chunks of
|
|
570
|
+
* at most `CHUNK_BYTES`.
|
|
571
|
+
*/
|
|
572
|
+
export class CatalogEncoder {
|
|
573
|
+
private index = 0;
|
|
574
|
+
private lines: string[] = [];
|
|
575
|
+
private size = 2;
|
|
576
|
+
private readonly done: string[] = [];
|
|
577
|
+
|
|
578
|
+
constructor(private readonly catalog: readonly Item[]) {}
|
|
579
|
+
|
|
580
|
+
step(): boolean {
|
|
581
|
+
const end = Math.min(this.catalog.length, this.index + SLICE_ITEMS);
|
|
582
|
+
for (; this.index < end; this.index += 1) {
|
|
583
|
+
const item = this.catalog[this.index];
|
|
584
|
+
if (!item) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const line = derivedSlug(item.name) === item.slug ? item.name : `${item.name}\t${item.slug}`;
|
|
588
|
+
const cost = storedBytes(line);
|
|
589
|
+
if (this.lines.length > 0 && this.size + cost > CHUNK_BYTES) {
|
|
590
|
+
this.done.push(this.lines.join("\n"));
|
|
591
|
+
this.lines = [];
|
|
592
|
+
this.size = 2;
|
|
593
|
+
}
|
|
594
|
+
this.lines.push(line);
|
|
595
|
+
this.size += cost;
|
|
596
|
+
}
|
|
597
|
+
if (this.index < this.catalog.length) {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
if (this.lines.length > 0) {
|
|
601
|
+
this.done.push(this.lines.join("\n"));
|
|
602
|
+
this.lines = [];
|
|
603
|
+
}
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The chunks, or `null` when they would not fit `MAX_CHUNKS`. */
|
|
608
|
+
chunks(): string[] | null {
|
|
609
|
+
return this.done.length <= MAX_CHUNKS ? this.done : null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Reads stored chunks back, a slice of lines at a time, through the same checks as remote data. */
|
|
614
|
+
export class CatalogDecoder {
|
|
615
|
+
private readonly lines: string[] = [];
|
|
616
|
+
private index = 0;
|
|
617
|
+
|
|
618
|
+
constructor(chunks: readonly unknown[], private readonly builder: CatalogBuilder) {
|
|
619
|
+
for (const chunk of chunks) {
|
|
620
|
+
if (typeof chunk !== "string" || chunk.length > CHUNK_BYTES) {
|
|
621
|
+
throw new Error("invalid stored catalog");
|
|
622
|
+
}
|
|
623
|
+
for (const line of chunk.split("\n")) {
|
|
624
|
+
this.lines.push(line);
|
|
625
|
+
}
|
|
626
|
+
if (this.lines.length > MAX_ITEMS) {
|
|
627
|
+
throw new Error("invalid stored catalog");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
step(): boolean {
|
|
633
|
+
const end = Math.min(this.lines.length, this.index + DECODE_SLICE_LINES);
|
|
634
|
+
for (; this.index < end; this.index += 1) {
|
|
635
|
+
const [name, slug, extra] = (this.lines[this.index] ?? "").split("\t");
|
|
636
|
+
if (extra !== undefined || name === undefined) {
|
|
637
|
+
throw new Error("invalid stored catalog");
|
|
638
|
+
}
|
|
639
|
+
this.builder.add(slug ?? derivedSlug(name), name);
|
|
640
|
+
}
|
|
641
|
+
return this.index >= this.lines.length;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Chunks of the catalog at once (tests), or `null` beyond `MAX_CHUNKS`. */
|
|
646
|
+
export function encodeCatalog(catalog: readonly Item[]): string[] | null {
|
|
647
|
+
const encoder = new CatalogEncoder(catalog);
|
|
648
|
+
while (!encoder.step()) {
|
|
649
|
+
// one slice after another
|
|
650
|
+
}
|
|
651
|
+
return encoder.chunks();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Stored chunks back to items at once (tests). */
|
|
655
|
+
export function decodeCatalog(chunks: readonly unknown[]): Item[] {
|
|
656
|
+
const builder = new CatalogBuilder();
|
|
657
|
+
const decoder = new CatalogDecoder(chunks, builder);
|
|
658
|
+
while (!decoder.step()) {
|
|
659
|
+
// one slice after another
|
|
660
|
+
}
|
|
661
|
+
return builder.finish();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// Search.
|
|
666
|
+
|
|
667
|
+
/** The search text, or `""` when it is too long or holds control characters. */
|
|
668
|
+
export function normalizeQuery(value: unknown): string {
|
|
669
|
+
if (typeof value !== "string") {
|
|
670
|
+
return "";
|
|
671
|
+
}
|
|
672
|
+
const trimmed = value.trim();
|
|
673
|
+
if ([...trimmed].length > MAX_QUERY_CHARS || hasControl(trimmed)) {
|
|
674
|
+
return "";
|
|
675
|
+
}
|
|
676
|
+
return trimmed;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** At most `MAX_RESULTS` items whose name or slug holds the query, in catalog order. */
|
|
680
|
+
export function searchItems(catalog: readonly Item[], names: readonly string[], query: string): Item[] {
|
|
681
|
+
const needle = normalizeQuery(query).toLowerCase();
|
|
682
|
+
if (!needle) {
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
const hits: Item[] = [];
|
|
686
|
+
for (let index = 0; index < catalog.length; index += 1) {
|
|
687
|
+
const item = catalog[index];
|
|
688
|
+
if (item && ((names[index] ?? "").includes(needle) || item.slug.includes(needle))) {
|
|
689
|
+
hits.push(item);
|
|
690
|
+
if (hits.length === MAX_RESULTS) {
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return hits;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function useCatalog(catalog: Item[]): void {
|
|
699
|
+
items = catalog;
|
|
700
|
+
lowered = catalog.map((item) => item.name.toLowerCase());
|
|
701
|
+
state.count = catalog.length;
|
|
702
|
+
state.phase = "ready";
|
|
703
|
+
state.results = searchItems(items, lowered, state.query);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// Requests, paced for the provider.
|
|
708
|
+
|
|
709
|
+
let nextStart = 0;
|
|
710
|
+
|
|
711
|
+
function wait(ms: number): Promise<void> {
|
|
712
|
+
return new Promise((resolve) => {
|
|
713
|
+
timers.after(ms, () => resolve());
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Milliseconds before the next request may start, reserving that start. */
|
|
718
|
+
function reserveStart(): number {
|
|
719
|
+
const now = Date.now();
|
|
720
|
+
const start = Math.max(now, nextStart);
|
|
721
|
+
nextStart = start + REQUEST_SPACING_MS;
|
|
722
|
+
return start - now;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The catalog and the orders reserve their large byte bounds (3 and 2 MiB)
|
|
727
|
+
* from the widget's network budget: they run one after the other.
|
|
728
|
+
*/
|
|
729
|
+
let lane: Promise<unknown> = Promise.resolve();
|
|
730
|
+
|
|
731
|
+
export function inLane<T>(task: () => Promise<T>): Promise<T> {
|
|
732
|
+
const run = lane.then(task, task);
|
|
733
|
+
lane = run.catch(() => undefined);
|
|
734
|
+
return run;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function getText(path: string): Promise<string> {
|
|
738
|
+
return inLane(async () => {
|
|
739
|
+
const delay = reserveStart();
|
|
740
|
+
if (delay > 0) {
|
|
741
|
+
await wait(delay);
|
|
742
|
+
}
|
|
743
|
+
const response = await http.fetch(`${API}${path}`, { as: "text" });
|
|
744
|
+
if (response.status !== 200) {
|
|
745
|
+
throw new Error("request failed");
|
|
746
|
+
}
|
|
747
|
+
return response.body;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function getJson(path: string): Promise<JsonValue> {
|
|
752
|
+
const delay = reserveStart();
|
|
753
|
+
if (delay > 0) {
|
|
754
|
+
await wait(delay);
|
|
755
|
+
}
|
|
756
|
+
const response = await http.fetch(`${API}${path}`, { as: "json" });
|
|
757
|
+
if (response.status !== 200) {
|
|
758
|
+
throw new Error("request failed");
|
|
759
|
+
}
|
|
760
|
+
return response.body;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ---------------------------------------------------------------------------
|
|
764
|
+
// Storage.
|
|
765
|
+
|
|
766
|
+
const STATE_KEY = "state";
|
|
767
|
+
const CATALOG_KEY = "catalog";
|
|
768
|
+
const chunkKey = (index: number): string => `catalog-${index}`;
|
|
769
|
+
|
|
770
|
+
async function readStored(key: string): Promise<JsonValue | undefined> {
|
|
771
|
+
try {
|
|
772
|
+
return await storage.get({ key });
|
|
773
|
+
} catch {
|
|
774
|
+
state.error = "storage_unavailable";
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function loadStoredCatalog(): Promise<string> {
|
|
780
|
+
const meta = await readStored(CATALOG_KEY);
|
|
781
|
+
if (!isObject(meta) || typeof meta.chunks !== "number" || !Number.isInteger(meta.chunks)
|
|
782
|
+
|| meta.chunks < 1 || meta.chunks > MAX_CHUNKS) {
|
|
783
|
+
return "";
|
|
784
|
+
}
|
|
785
|
+
const chunks: JsonValue[] = [];
|
|
786
|
+
for (let index = 0; index < meta.chunks; index += 1) {
|
|
787
|
+
const chunk = await readStored(chunkKey(index));
|
|
788
|
+
if (chunk === undefined) {
|
|
789
|
+
return "";
|
|
790
|
+
}
|
|
791
|
+
chunks.push(chunk);
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
const builder = new CatalogBuilder();
|
|
795
|
+
const decoder = new CatalogDecoder(chunks, builder);
|
|
796
|
+
await sliced(() => decoder.step());
|
|
797
|
+
useCatalog(builder.finish());
|
|
798
|
+
return typeof meta.version === "string" && meta.version.length <= MAX_VERSION_CHARS
|
|
799
|
+
? meta.version
|
|
800
|
+
: "";
|
|
801
|
+
} catch {
|
|
802
|
+
// An invalid stored catalog cannot authorize results or request paths.
|
|
803
|
+
return "";
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function storeCatalog(catalog: readonly Item[], version: string): Promise<void> {
|
|
808
|
+
const encoder = new CatalogEncoder(catalog);
|
|
809
|
+
await pause();
|
|
810
|
+
await sliced(() => encoder.step());
|
|
811
|
+
const chunks = encoder.chunks();
|
|
812
|
+
try {
|
|
813
|
+
// The index goes first and last: a crash in between leaves no index,
|
|
814
|
+
// so the next start fetches again rather than reading mixed chunks.
|
|
815
|
+
await storage.remove({ key: CATALOG_KEY });
|
|
816
|
+
if (!chunks) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
820
|
+
await storage.set({ key: chunkKey(index), value: chunk });
|
|
821
|
+
}
|
|
822
|
+
for (let index = chunks.length; index < MAX_CHUNKS; index += 1) {
|
|
823
|
+
await storage.remove({ key: chunkKey(index) });
|
|
824
|
+
}
|
|
825
|
+
await storage.set({ key: CATALOG_KEY, value: { version, chunks: chunks.length } });
|
|
826
|
+
} catch {
|
|
827
|
+
state.error = "storage_unavailable";
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let saveTimer: Timer | undefined;
|
|
832
|
+
|
|
833
|
+
function saveQuery(): void {
|
|
834
|
+
saveTimer?.cancel();
|
|
835
|
+
saveTimer = timers.after(SAVE_DELAY_MS, () => {
|
|
836
|
+
saveTimer = undefined;
|
|
837
|
+
storage.set({ key: STATE_KEY, value: { query: state.query } }).catch(() => {
|
|
838
|
+
state.error = "storage_unavailable";
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
// Start: the saved search, the stored catalog, then the provider's version.
|
|
845
|
+
|
|
846
|
+
async function start(): Promise<void> {
|
|
847
|
+
const saved = await readStored(STATE_KEY);
|
|
848
|
+
state.query = normalizeQuery(isObject(saved) ? saved.query : "");
|
|
849
|
+
const version = await loadStoredCatalog();
|
|
850
|
+
let remote = "";
|
|
851
|
+
try {
|
|
852
|
+
remote = parseVersion(await getJson("/v2/versions"));
|
|
853
|
+
} catch {
|
|
854
|
+
// Without a version, a stored catalog stays in use.
|
|
855
|
+
}
|
|
856
|
+
if (items.length > 0 && (!remote || remote === version)) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
let catalog: Item[];
|
|
860
|
+
try {
|
|
861
|
+
const builder = new CatalogBuilder();
|
|
862
|
+
const scanner = new CatalogScanner(await getText("/v2/items"), builder);
|
|
863
|
+
await sliced(() => scanner.step());
|
|
864
|
+
catalog = builder.finish();
|
|
865
|
+
} catch {
|
|
866
|
+
state.error = "catalog_unavailable";
|
|
867
|
+
state.phase = items.length > 0 ? "ready" : "unavailable";
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
useCatalog(catalog);
|
|
871
|
+
await storeCatalog(catalog, remote || version);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
void start();
|
|
875
|
+
|
|
876
|
+
onHost((changed) => {
|
|
877
|
+
if (changed.includes("mode")) {
|
|
878
|
+
state.interactive = host.mode === "interactive";
|
|
879
|
+
}
|
|
880
|
+
if (changed.includes("options")) {
|
|
881
|
+
scheduleRefresh();
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// ---------------------------------------------------------------------------
|
|
886
|
+
// Handlers.
|
|
887
|
+
|
|
888
|
+
let selection = 0;
|
|
889
|
+
let wanted: string | null = null;
|
|
890
|
+
let orderTimer: Timer | undefined;
|
|
891
|
+
/** A request for the shown item is under way (answer read included). */
|
|
892
|
+
let fetching = false;
|
|
893
|
+
let refreshTimer: Timer | undefined;
|
|
894
|
+
/** Failed automatic refreshes in a row: each doubles the wait, up to 8 times. */
|
|
895
|
+
let refreshFailures = 0;
|
|
896
|
+
const MAX_REFRESH_BACKOFF = 8;
|
|
897
|
+
/** Milliseconds between the slices of an orders answer. */
|
|
898
|
+
const ORDER_SLICE_PAUSE_MS = 100;
|
|
899
|
+
|
|
900
|
+
/** The `auto-refresh` menu value. */
|
|
901
|
+
export function autoRefresh(options: Readonly<Record<string, unknown>>): AutoRefresh {
|
|
902
|
+
const value = options["auto-refresh"];
|
|
903
|
+
return value === "every-30s" || value === "every-1m" ? value : "off";
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Milliseconds between automatic refreshes, 0 when off. */
|
|
907
|
+
export function refreshInterval(value: AutoRefresh): number {
|
|
908
|
+
return value === "every-30s" ? 30_000 : value === "every-1m" ? 60_000 : 0;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Arms the next automatic refresh of the shown offers, or none: only with
|
|
913
|
+
* an item shown, never while a request runs (the end of each one arms the
|
|
914
|
+
* next). Host timers never tick while the widget is hidden; a refresh that
|
|
915
|
+
* fell due meanwhile runs when it is shown again.
|
|
916
|
+
*/
|
|
917
|
+
function scheduleRefresh(): void {
|
|
918
|
+
refreshTimer?.cancel();
|
|
919
|
+
refreshTimer = undefined;
|
|
920
|
+
const interval = refreshInterval(autoRefresh(host.options));
|
|
921
|
+
if (interval === 0 || !state.detail || fetching || orderTimer) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const backoff = 2 ** Math.min(refreshFailures, Math.log2(MAX_REFRESH_BACKOFF));
|
|
925
|
+
refreshTimer = timers.after(interval * backoff, () => {
|
|
926
|
+
refreshTimer = undefined;
|
|
927
|
+
refresh();
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function stopRefresh(): void {
|
|
932
|
+
refreshTimer?.cancel();
|
|
933
|
+
refreshTimer = undefined;
|
|
934
|
+
refreshFailures = 0;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** `input` of the search field: results as the user types. */
|
|
938
|
+
export function search(detail: { value: unknown }): void {
|
|
939
|
+
state.query = typeof detail.value === "string" ? detail.value : "";
|
|
940
|
+
state.results = searchItems(items, lowered, state.query);
|
|
941
|
+
state.detail = null;
|
|
942
|
+
state.loadingOrders = false;
|
|
943
|
+
state.refreshing = false;
|
|
944
|
+
state.copy = null;
|
|
945
|
+
if (state.error === "orders_unavailable") {
|
|
946
|
+
state.error = null;
|
|
947
|
+
}
|
|
948
|
+
selection += 1;
|
|
949
|
+
stopRefresh();
|
|
950
|
+
saveQuery();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export function clear(): void {
|
|
954
|
+
search({ value: "" });
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Back to the results of the current search. */
|
|
958
|
+
export function back(): void {
|
|
959
|
+
selection += 1;
|
|
960
|
+
state.detail = null;
|
|
961
|
+
state.loadingOrders = false;
|
|
962
|
+
state.refreshing = false;
|
|
963
|
+
state.copy = null;
|
|
964
|
+
if (state.error === "orders_unavailable") {
|
|
965
|
+
state.error = null;
|
|
966
|
+
}
|
|
967
|
+
stopRefresh();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** `activate` of a tab: sellers or buyers. */
|
|
971
|
+
export function showTab(side: Side): void {
|
|
972
|
+
state.tab = side;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function readOrders(item: Item): Promise<Detail> {
|
|
976
|
+
// The slug passed `safeSlug`, the route's `slug` parameter grammar.
|
|
977
|
+
const response = await inLane(() => {
|
|
978
|
+
nextStart = Date.now() + REQUEST_SPACING_MS;
|
|
979
|
+
return http.fetch(`${API}/v2/orders/item/${item.slug}`, { as: "text" });
|
|
980
|
+
});
|
|
981
|
+
if (response.status !== 200) {
|
|
982
|
+
throw new Error("request failed");
|
|
983
|
+
}
|
|
984
|
+
const collector = new OrderCollector();
|
|
985
|
+
const reader = new OrderReader(response.body, collector);
|
|
986
|
+
// One slice per turn, a host timer apart.
|
|
987
|
+
while (!reader.step()) {
|
|
988
|
+
await wait(ORDER_SLICE_PAUSE_MS);
|
|
989
|
+
}
|
|
990
|
+
return collector.finish(item.slug, item.name);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function fetchOrders(): void {
|
|
994
|
+
const slug = wanted;
|
|
995
|
+
const item = items.find((entry) => entry.slug === slug);
|
|
996
|
+
if (!item) {
|
|
997
|
+
state.loadingOrders = false;
|
|
998
|
+
state.refreshing = false;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const mine = selection;
|
|
1002
|
+
fetching = true;
|
|
1003
|
+
readOrders(item)
|
|
1004
|
+
.then((detail) => {
|
|
1005
|
+
if (mine === selection) {
|
|
1006
|
+
state.detail = detail;
|
|
1007
|
+
state.error = null;
|
|
1008
|
+
refreshFailures = 0;
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
.catch(() => {
|
|
1012
|
+
if (mine === selection) {
|
|
1013
|
+
// Offers already shown stay; the status says they could not reload.
|
|
1014
|
+
state.error = "orders_unavailable";
|
|
1015
|
+
refreshFailures += 1;
|
|
1016
|
+
}
|
|
1017
|
+
})
|
|
1018
|
+
.finally(() => {
|
|
1019
|
+
fetching = false;
|
|
1020
|
+
if (mine === selection) {
|
|
1021
|
+
state.loadingOrders = false;
|
|
1022
|
+
state.refreshing = false;
|
|
1023
|
+
scheduleRefresh();
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Starts the request for `wanted`, paced for the provider. */
|
|
1029
|
+
function requestOrders(): void {
|
|
1030
|
+
refreshTimer?.cancel();
|
|
1031
|
+
refreshTimer = undefined;
|
|
1032
|
+
if (orderTimer) {
|
|
1033
|
+
return; // the pending start takes the latest selection
|
|
1034
|
+
}
|
|
1035
|
+
const delay = Math.max(0, nextStart - Date.now());
|
|
1036
|
+
if (delay === 0) {
|
|
1037
|
+
fetchOrders();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
orderTimer = timers.after(delay, () => {
|
|
1041
|
+
orderTimer = undefined;
|
|
1042
|
+
fetchOrders();
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** `activate` of a result: its offers, one request at a time for the provider. */
|
|
1047
|
+
export function select(slug: string): void {
|
|
1048
|
+
selection += 1;
|
|
1049
|
+
wanted = slug;
|
|
1050
|
+
state.detail = null;
|
|
1051
|
+
state.copy = null;
|
|
1052
|
+
state.loadingOrders = true;
|
|
1053
|
+
state.refreshing = false;
|
|
1054
|
+
state.tab = "sell";
|
|
1055
|
+
refreshFailures = 0;
|
|
1056
|
+
if (state.error === "orders_unavailable") {
|
|
1057
|
+
state.error = null;
|
|
1058
|
+
}
|
|
1059
|
+
requestOrders();
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* `activate` of the refresh button, and each automatic refresh: the shown
|
|
1064
|
+
* item's offers again, kept on screen until the new ones arrive. Ignored
|
|
1065
|
+
* while a request for it runs.
|
|
1066
|
+
*/
|
|
1067
|
+
export function refresh(): void {
|
|
1068
|
+
if (!state.detail || state.refreshing || state.loadingOrders || fetching) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
selection += 1;
|
|
1072
|
+
wanted = state.detail.slug;
|
|
1073
|
+
state.refreshing = true;
|
|
1074
|
+
state.copy = null;
|
|
1075
|
+
requestOrders();
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** The whisper pasted in Warframe chat; English, as the game's trade chat. */
|
|
1079
|
+
export function whisperLine(order: Order, item: string): string {
|
|
1080
|
+
const intent = order.side === "sell" ? "WTB" : "WTS";
|
|
1081
|
+
const quantity = order.perTrade > 1 ? `${order.perTrade} x ` : "";
|
|
1082
|
+
const variant = variantLabel(order);
|
|
1083
|
+
return `/w ${order.trader} Hi, ${intent} ${quantity}${item}${variant ? ` (${variant})` : ""} for ${order.platinum}p${order.perTrade > 1 ? " total" : ""}`;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Rank, charges, stars and subtype, in the game's English terms. */
|
|
1087
|
+
export function variantLabel(order: Order): string {
|
|
1088
|
+
const parts: string[] = [];
|
|
1089
|
+
if (order.rank !== undefined) parts.push(`Rank ${order.rank}`);
|
|
1090
|
+
if (order.charges !== undefined) parts.push(`${order.charges} charges`);
|
|
1091
|
+
if (order.amberStars !== undefined || order.cyanStars !== undefined) {
|
|
1092
|
+
parts.push(`${order.amberStars ?? 0} amber / ${order.cyanStars ?? 0} cyan stars`);
|
|
1093
|
+
}
|
|
1094
|
+
if (order.subtype) parts.push(order.subtype.replaceAll("_", " "));
|
|
1095
|
+
return parts.join(", ");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** `activate` of a copy button: the user's gesture authorizes the write. */
|
|
1099
|
+
export function copy(side: Side, id: string): void {
|
|
1100
|
+
const detail = state.detail;
|
|
1101
|
+
if (!detail || state.copy?.state === "pending") {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const order = (side === "sell" ? detail.sells : detail.buys).find((entry) => entry.id === id);
|
|
1105
|
+
if (!order) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const mine = selection;
|
|
1109
|
+
state.copy = { side, id, state: "pending" };
|
|
1110
|
+
clipboard.writeText({ text: whisperLine(order, detail.name) }).then(
|
|
1111
|
+
() => {
|
|
1112
|
+
if (mine === selection) state.copy = { side, id, state: "copied" };
|
|
1113
|
+
},
|
|
1114
|
+
() => {
|
|
1115
|
+
if (mine === selection) state.copy = { side, id, state: "failed" };
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
// View helpers.
|
|
1122
|
+
|
|
1123
|
+
export function price(value: number): string {
|
|
1124
|
+
return `${formatNumber(value, { maximumFractionDigits: 2 })}p`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** The lowest sell and highest buy price per item, or `—`. */
|
|
1128
|
+
export function unitPrice(orders: readonly Order[], lowest: boolean): string {
|
|
1129
|
+
if (orders.length === 0) {
|
|
1130
|
+
return "—";
|
|
1131
|
+
}
|
|
1132
|
+
const units = orders.map((order) => order.platinum / order.perTrade);
|
|
1133
|
+
return price(lowest ? Math.min(...units) : Math.max(...units));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** A tab's offer count, those beyond `MAX_OFFERS_SHOWN` included. */
|
|
1137
|
+
export function tabCount(detail: Detail, side: Side): string {
|
|
1138
|
+
return formatNumber(side === "sell" ? detail.sellTotal : detail.buyTotal);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
export function tabClass(tab: Side, side: Side): string {
|
|
1142
|
+
return tab === side ? "tab active" : "tab";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** The offers of the active tab. */
|
|
1146
|
+
export function tabOrders(detail: Detail, tab: Side): readonly Order[] {
|
|
1147
|
+
return tab === "sell" ? detail.sells : detail.buys;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** `+N more` below a tab that keeps only `MAX_OFFERS_SHOWN` offers, else empty. */
|
|
1151
|
+
export function moreOffers(detail: Detail, tab: Side): string {
|
|
1152
|
+
const total = tab === "sell" ? detail.sellTotal : detail.buyTotal;
|
|
1153
|
+
const kept = tab === "sell" ? detail.sells.length : detail.buys.length;
|
|
1154
|
+
return total > kept ? t("more", { count: formatNumber(total - kept) }) : "";
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** The refresh button is off without an item or while its offers load. */
|
|
1158
|
+
export function refreshBusy(loadingOrders: boolean, refreshing: boolean): boolean {
|
|
1159
|
+
return loadingOrders || refreshing;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
export function statusText(
|
|
1163
|
+
phase: Phase,
|
|
1164
|
+
count: number,
|
|
1165
|
+
error: Failure | null,
|
|
1166
|
+
detail: Detail | null,
|
|
1167
|
+
loadingOrders: boolean,
|
|
1168
|
+
): string {
|
|
1169
|
+
if (error === "catalog_unavailable") {
|
|
1170
|
+
return count > 0 ? t("status-catalog-stale") : t("status-catalog-unavailable");
|
|
1171
|
+
}
|
|
1172
|
+
if (error) {
|
|
1173
|
+
return t(`status-${error}`);
|
|
1174
|
+
}
|
|
1175
|
+
if (phase === "loading") {
|
|
1176
|
+
return t("status-loading");
|
|
1177
|
+
}
|
|
1178
|
+
if (phase === "unavailable" || count === 0) {
|
|
1179
|
+
return t("status-catalog-unavailable");
|
|
1180
|
+
}
|
|
1181
|
+
if (loadingOrders) {
|
|
1182
|
+
return t("status-loading-orders");
|
|
1183
|
+
}
|
|
1184
|
+
return t(detail ? "status-detail" : "status-ready", { count: formatNumber(count) });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** The status line while shown offers reload: they stay until the new ones arrive. */
|
|
1188
|
+
export function liveStatus(
|
|
1189
|
+
phase: Phase,
|
|
1190
|
+
count: number,
|
|
1191
|
+
error: Failure | null,
|
|
1192
|
+
detail: Detail | null,
|
|
1193
|
+
loadingOrders: boolean,
|
|
1194
|
+
refreshing: boolean,
|
|
1195
|
+
): string {
|
|
1196
|
+
if (refreshing && !error) {
|
|
1197
|
+
return t("status-refreshing");
|
|
1198
|
+
}
|
|
1199
|
+
if (error === "orders_unavailable" && detail) {
|
|
1200
|
+
return t("status-refresh-failed");
|
|
1201
|
+
}
|
|
1202
|
+
return statusText(phase, count, error, detail, loadingOrders);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
export function statusClass(error: Failure | null): string {
|
|
1206
|
+
return error ? "status error" : "status";
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
export function searchHint(interactive: boolean): string {
|
|
1210
|
+
return t(interactive ? "hint-interactive" : "hint-passive");
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export function emptyText(query: string, phase: Phase): string {
|
|
1214
|
+
if (phase === "loading") {
|
|
1215
|
+
return t("empty-loading");
|
|
1216
|
+
}
|
|
1217
|
+
if (phase === "unavailable") {
|
|
1218
|
+
return t("empty-unavailable");
|
|
1219
|
+
}
|
|
1220
|
+
return t(normalizeQuery(query) ? "empty-no-match" : "empty-start");
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function sideTitle(side: Side): string {
|
|
1224
|
+
return t(side === "sell" ? "sellers" : "buyers");
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function noOffers(side: Side): string {
|
|
1228
|
+
return t(side === "sell" ? "no-sell-offers" : "no-buy-offers");
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
export function presenceLabel(value: Presence): string {
|
|
1232
|
+
return t(`presence-${value}`);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** A dot in the semantic colour of the player's state, named by its label. */
|
|
1236
|
+
export function presenceClass(value: Presence): string {
|
|
1237
|
+
return `presence ${value}`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Quantity and variant under an offer: `1 item`, `3 items · total price · Rank 5`. */
|
|
1241
|
+
export function orderDetail(order: Order): string {
|
|
1242
|
+
const quantity = order.perTrade > 1
|
|
1243
|
+
? t("per-trade", { count: formatNumber(order.perTrade) })
|
|
1244
|
+
: t("one-item");
|
|
1245
|
+
const variant = variantLabel(order);
|
|
1246
|
+
return variant ? `${quantity} · ${variant}` : quantity;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function feedbackFor(copyState: CopyFeedback | null, side: Side, id: string): CopyState | null {
|
|
1250
|
+
return copyState && copyState.side === side && copyState.id === id ? copyState.state : null;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
export function copyLabel(copyState: CopyFeedback | null, side: Side, id: string): string {
|
|
1254
|
+
const feedback = feedbackFor(copyState, side, id);
|
|
1255
|
+
return t(feedback ? `copy-${feedback}` : "copy");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export function copyClass(copyState: CopyFeedback | null, side: Side, id: string): string {
|
|
1259
|
+
const feedback = feedbackFor(copyState, side, id);
|
|
1260
|
+
return feedback ? `copy ${feedback}` : "copy";
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
export function copyBusy(copyState: CopyFeedback | null): boolean {
|
|
1264
|
+
return copyState?.state === "pending";
|
|
1265
|
+
}
|