cuju-render 0.1.0__py3-none-any.whl
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.
- cuju/render/__init__.py +759 -0
- cuju/render/__main__.py +1065 -0
- cuju/render/_export/__init__.py +3 -0
- cuju/render/_export/feed.py +542 -0
- cuju/render/_export/sizes.py +157 -0
- cuju/render/_export/video.py +1618 -0
- cuju/render/_export/video_jobs.py +408 -0
- cuju/render/_format/__init__.py +3 -0
- cuju/render/_format/annotations.py +235 -0
- cuju/render/_format/codec.py +307 -0
- cuju/render/_format/keep.py +121 -0
- cuju/render/_format/recording.py +860 -0
- cuju/render/_format/writer.py +54 -0
- cuju/render/_guide/__init__.py +3 -0
- cuju/render/_guide/doctor.py +523 -0
- cuju/render/_guide/help.py +62 -0
- cuju/render/_guide/tutorial.py +254 -0
- cuju/render/_http/__init__.py +3 -0
- cuju/render/_http/commands.py +57 -0
- cuju/render/_http/headers.py +103 -0
- cuju/render/_http/launch.py +500 -0
- cuju/render/_http/launchers.py +408 -0
- cuju/render/_http/pwa.py +128 -0
- cuju/render/_http/server.py +2105 -0
- cuju/render/_http/window.py +360 -0
- cuju/render/_moments/__init__.py +3 -0
- cuju/render/_moments/captions.py +284 -0
- cuju/render/_moments/events.py +1123 -0
- cuju/render/_moments/search.py +694 -0
- cuju/render/_packages/__init__.py +3 -0
- cuju/render/_packages/glsl.py +746 -0
- cuju/render/_packages/loudness.py +334 -0
- cuju/render/_packages/pack.py +1959 -0
- cuju/render/_packages/presentation.py +1376 -0
- cuju/render/_packages/vocabulary.py +609 -0
- cuju/render/_playing/__init__.py +3 -0
- cuju/render/_playing/achievements.py +463 -0
- cuju/render/_playing/bench.py +609 -0
- cuju/render/_playing/challenges.py +1813 -0
- cuju/render/_playing/demo.py +323 -0
- cuju/render/_playing/draw.py +211 -0
- cuju/render/_playing/errors.py +20 -0
- cuju/render/_playing/game.py +645 -0
- cuju/render/_playing/limits.py +339 -0
- cuju/render/_playing/play.py +926 -0
- cuju/render/_playing/policy_worker.py +250 -0
- cuju/render/_playing/strict.py +810 -0
- cuju/render/_registry/__init__.py +3 -0
- cuju/render/_registry/clips.py +394 -0
- cuju/render/_registry/joints.py +108 -0
- cuju/render/_registry/teams.py +2058 -0
- cuju/render/_store/__init__.py +3 -0
- cuju/render/_store/home.py +70 -0
- cuju/render/_store/library.py +604 -0
- cuju/render/_store/samples.py +163 -0
- cuju/render/_text/__init__.py +3 -0
- cuju/render/_text/languages.py +637 -0
- cuju/render/_text/messages.py +1396 -0
- cuju/render/_tournaments/__init__.py +3 -0
- cuju/render/_tournaments/results.py +547 -0
- cuju/render/_tournaments/tournament.py +2742 -0
- cuju/render/_tournaments/tournament_cli.py +587 -0
- cuju/render/_tournaments/tournament_runner.py +1975 -0
- cuju/render/_version.py +6 -0
- cuju/render/_watch/__init__.py +3 -0
- cuju/render/_watch/grid.py +265 -0
- cuju/render/_watch/live.py +664 -0
- cuju/render/_watch/notebook.py +79 -0
- cuju/render/_watch/show.py +171 -0
- cuju/render/samples/sample-11v11.cjr +0 -0
- cuju/render/samples/sample-5v5.cjr +0 -0
- cuju/render/samples/teams/starter/main.py +33 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
- cuju/render/web/assets/adboard_polygon.webp +0 -0
- cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
- cuju/render/web/assets/ball.glb +0 -0
- cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/corner.mp4 +0 -0
- cuju/render/web/assets/challenges/corner.webp +0 -0
- cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
- cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
- cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
- cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
- cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
- cuju/render/web/assets/challenges/empty_goal.webp +0 -0
- cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
- cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
- cuju/render/web/assets/challenges/manifest.json +375 -0
- cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
- cuju/render/web/assets/challenges/one_on_one.webp +0 -0
- cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
- cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
- cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
- cuju/render/web/assets/challenges/run_to_score.webp +0 -0
- cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
- cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
- cuju/render/web/assets/crowd.webp +0 -0
- cuju/render/web/assets/crowd_normal.webp +0 -0
- cuju/render/web/assets/cuju-logo-384.png +0 -0
- cuju/render/web/assets/cuju-logo.png +0 -0
- cuju/render/web/assets/cuju-logo.svg +46 -0
- cuju/render/web/assets/cuju-mark-light.svg +33 -0
- cuju/render/web/assets/cuju-mark.svg +33 -0
- cuju/render/web/assets/goal.glb +0 -0
- cuju/render/web/assets/grass.webp +0 -0
- cuju/render/web/assets/hair_black.png +0 -0
- cuju/render/web/assets/hair_blonde.png +0 -0
- cuju/render/web/assets/hair_brown.png +0 -0
- cuju/render/web/assets/hair_darkblonde.png +0 -0
- cuju/render/web/assets/hair_long01.glb +0 -0
- cuju/render/web/assets/hair_long02.glb +0 -0
- cuju/render/web/assets/hair_medium01.glb +0 -0
- cuju/render/web/assets/hair_red.png +0 -0
- cuju/render/web/assets/hair_short01.glb +0 -0
- cuju/render/web/assets/hair_short02.glb +0 -0
- cuju/render/web/assets/icons/cuju-192.png +0 -0
- cuju/render/web/assets/icons/cuju-512.png +0 -0
- cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
- cuju/render/web/assets/kit_goalkeeper.webp +0 -0
- cuju/render/web/assets/kit_mask.png +0 -0
- cuju/render/web/assets/kit_normal.webp +0 -0
- cuju/render/web/assets/manifest.json +601 -0
- cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
- cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
- cuju/render/web/assets/models/kaykit-knight.glb +0 -0
- cuju/render/web/assets/models/kaykit-knight.json +23 -0
- cuju/render/web/assets/models/kaykit-mage.glb +0 -0
- cuju/render/web/assets/models/kaykit-mage.json +23 -0
- cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
- cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
- cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
- cuju/render/web/assets/models/kaykit-ranger.json +23 -0
- cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
- cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
- cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
- cuju/render/web/assets/models/kaykit-rogue.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
- cuju/render/web/assets/pitch.webp +0 -0
- cuju/render/web/assets/pitch_normal.webp +0 -0
- cuju/render/web/assets/player.glb +0 -0
- cuju/render/web/assets/shoe.webp +0 -0
- cuju/render/web/assets/shoe_sole.png +0 -0
- cuju/render/web/assets/skin_01.png +0 -0
- cuju/render/web/assets/skin_02.png +0 -0
- cuju/render/web/assets/skin_03.png +0 -0
- cuju/render/web/assets/skin_04.png +0 -0
- cuju/render/web/assets/sounds/applause.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
- cuju/render/web/assets/sounds/city-horn.ogg +0 -0
- cuju/render/web/assets/sounds/city-loop.ogg +0 -0
- cuju/render/web/assets/sounds/city-siren.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
- cuju/render/web/assets/sounds/gulls.ogg +0 -0
- cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
- cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
- cuju/render/web/assets/sounds/sounds.json +46 -0
- cuju/render/web/assets/sounds/space-beep.ogg +0 -0
- cuju/render/web/assets/sounds/space-hum.ogg +0 -0
- cuju/render/web/assets/sounds/space-radio.ogg +0 -0
- cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
- cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
- cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
- cuju/render/web/easy.css +357 -0
- cuju/render/web/export.html +23 -0
- cuju/render/web/feed.css +262 -0
- cuju/render/web/feed.html +28 -0
- cuju/render/web/flat.css +120 -0
- cuju/render/web/home.css +837 -0
- cuju/render/web/index.html +373 -0
- cuju/render/web/js/boot.js +50 -0
- cuju/render/web/js/broadcast/audio.js +2141 -0
- cuju/render/web/js/broadcast/celebrate.js +1238 -0
- cuju/render/web/js/broadcast/decisions.js +609 -0
- cuju/render/web/js/broadcast/hud.js +352 -0
- cuju/render/web/js/broadcast/show.js +3614 -0
- cuju/render/web/js/broadcast/transition.js +2231 -0
- cuju/render/web/js/broadcast/voices.js +153 -0
- cuju/render/web/js/capture.js +101 -0
- cuju/render/web/js/core/accessibility.js +90 -0
- cuju/render/web/js/core/api.js +429 -0
- cuju/render/web/js/core/comfort.js +135 -0
- cuju/render/web/js/core/i18n.js +705 -0
- cuju/render/web/js/core/icons.js +167 -0
- cuju/render/web/js/core/input-settings.js +156 -0
- cuju/render/web/js/core/keys.js +277 -0
- cuju/render/web/js/core/language-ui.js +125 -0
- cuju/render/web/js/core/menu.js +853 -0
- cuju/render/web/js/core/names.js +113 -0
- cuju/render/web/js/core/page-structure.js +110 -0
- cuju/render/web/js/core/scenarios.js +109 -0
- cuju/render/web/js/core/spoilers.js +236 -0
- cuju/render/web/js/core/typeahead.js +38 -0
- cuju/render/web/js/export/dom-picture.js +330 -0
- cuju/render/web/js/export/export-host.js +97 -0
- cuju/render/web/js/export/feed-dialog.js +191 -0
- cuju/render/web/js/export/feed-params.js +266 -0
- cuju/render/web/js/export/feed.js +363 -0
- cuju/render/web/js/export/mp4-mux.js +295 -0
- cuju/render/web/js/export/photo-core.js +313 -0
- cuju/render/web/js/export/photo.js +1314 -0
- cuju/render/web/js/export/safe-frame.js +59 -0
- cuju/render/web/js/export/size-fields.js +103 -0
- cuju/render/web/js/export/sizes.js +178 -0
- cuju/render/web/js/export/video-boot.js +55 -0
- cuju/render/web/js/export/video-browser.js +325 -0
- cuju/render/web/js/export/video-export.js +417 -0
- cuju/render/web/js/export/video-plan.js +207 -0
- cuju/render/web/js/export/video-range.js +185 -0
- cuju/render/web/js/export/webm-mux.js +198 -0
- cuju/render/web/js/feed-boot.js +151 -0
- cuju/render/web/js/feed-frame.js +101 -0
- cuju/render/web/js/help/doctor.js +488 -0
- cuju/render/web/js/help/firstrun.js +379 -0
- cuju/render/web/js/help/help-center.js +438 -0
- cuju/render/web/js/help/install-offer.js +164 -0
- cuju/render/web/js/help/install-state.js +104 -0
- cuju/render/web/js/help/lessons-core.js +207 -0
- cuju/render/web/js/help/lessons.js +463 -0
- cuju/render/web/js/help/palette.js +584 -0
- cuju/render/web/js/help/pwa.js +257 -0
- cuju/render/web/js/help/tour-core.js +241 -0
- cuju/render/web/js/help/tour.js +461 -0
- cuju/render/web/js/main.js +2513 -0
- cuju/render/web/js/pages/draw.js +188 -0
- cuju/render/web/js/pages/fieldpage.js +530 -0
- cuju/render/web/js/pages/home.js +521 -0
- cuju/render/web/js/pages/matches.js +779 -0
- cuju/render/web/js/pages/moments-ui.js +359 -0
- cuju/render/web/js/pages/queue-filters.js +63 -0
- cuju/render/web/js/pages/recording-groups.js +80 -0
- cuju/render/web/js/pages/recordings.js +1069 -0
- cuju/render/web/js/play/bench.js +347 -0
- cuju/render/web/js/play/challenges.js +1302 -0
- cuju/render/web/js/play/medals.js +127 -0
- cuju/render/web/js/play/play.js +1953 -0
- cuju/render/web/js/play/teasers.js +196 -0
- cuju/render/web/js/play/touch.js +350 -0
- cuju/render/web/js/play/tournament-layout.js +487 -0
- cuju/render/web/js/play/tournament-results.js +441 -0
- cuju/render/web/js/play/tournament-strict.js +560 -0
- cuju/render/web/js/play/tournament.js +2585 -0
- cuju/render/web/js/players/animate.js +972 -0
- cuju/render/web/js/players/clips.js +224 -0
- cuju/render/web/js/players/kaykit.js +162 -0
- cuju/render/web/js/players/kits.js +676 -0
- cuju/render/web/js/players/number-patch.js +82 -0
- cuju/render/web/js/players/players.js +2694 -0
- cuju/render/web/js/players/recolor.js +893 -0
- cuju/render/web/js/players/retarget.js +325 -0
- cuju/render/web/js/players/rig-numbers.js +174 -0
- cuju/render/web/js/presentation/animator.js +231 -0
- cuju/render/web/js/presentation/assign.js +85 -0
- cuju/render/web/js/presentation/behaviors.js +440 -0
- cuju/render/web/js/presentation/budget.js +209 -0
- cuju/render/web/js/presentation/catalog.js +1674 -0
- cuju/render/web/js/presentation/clocks.js +72 -0
- cuju/render/web/js/presentation/commands.js +301 -0
- cuju/render/web/js/presentation/contract.js +240 -0
- cuju/render/web/js/presentation/effects.js +904 -0
- cuju/render/web/js/presentation/flash.js +605 -0
- cuju/render/web/js/presentation/freeze-worklet.js +22 -0
- cuju/render/web/js/presentation/glsl.js +464 -0
- cuju/render/web/js/presentation/host.js +323 -0
- cuju/render/web/js/presentation/index.js +2066 -0
- cuju/render/web/js/presentation/loudness-worklet.js +235 -0
- cuju/render/web/js/presentation/loudness.js +740 -0
- cuju/render/web/js/presentation/moments.js +536 -0
- cuju/render/web/js/presentation/post.js +327 -0
- cuju/render/web/js/presentation/preview.js +180 -0
- cuju/render/web/js/presentation/protocol.js +174 -0
- cuju/render/web/js/presentation/reach.js +158 -0
- cuju/render/web/js/presentation/rigging.js +130 -0
- cuju/render/web/js/presentation/sandbox.js +157 -0
- cuju/render/web/js/presentation/shaders.js +235 -0
- cuju/render/web/js/presentation/stage.js +402 -0
- cuju/render/web/js/presentation/teamaudio.js +722 -0
- cuju/render/web/js/presentation/tracks.js +197 -0
- cuju/render/web/js/presentation/values.js +81 -0
- cuju/render/web/js/recording/annotation-data.js +203 -0
- cuju/render/web/js/recording/captions.js +491 -0
- cuju/render/web/js/recording/codec.js +185 -0
- cuju/render/web/js/recording/decode-worker.js +70 -0
- cuju/render/web/js/recording/decode.js +94 -0
- cuju/render/web/js/recording/episodes.js +46 -0
- cuju/render/web/js/recording/format.js +461 -0
- cuju/render/web/js/recording/live.js +348 -0
- cuju/render/web/js/recording/moments-query.js +59 -0
- cuju/render/web/js/recording/moments.js +149 -0
- cuju/render/web/js/recording/pose.js +86 -0
- cuju/render/web/js/recording/timeline.js +275 -0
- cuju/render/web/js/teams/art.js +50 -0
- cuju/render/web/js/teams/clip-editor.js +293 -0
- cuju/render/web/js/teams/editor.js +1979 -0
- cuju/render/web/js/teams/library.js +344 -0
- cuju/render/web/js/teams/painter.js +633 -0
- cuju/render/web/js/teams/surprise-ui.js +195 -0
- cuju/render/web/js/teams/surprise.js +497 -0
- cuju/render/web/js/teams/team-check.js +127 -0
- cuju/render/web/js/watch/annotations.js +270 -0
- cuju/render/web/js/watch/flat.js +366 -0
- cuju/render/web/js/watch/grid.js +436 -0
- cuju/render/web/js/watch/pitch2d.js +289 -0
- cuju/render/web/js/watch/player-tools.js +192 -0
- cuju/render/web/js/watch/presets.js +201 -0
- cuju/render/web/js/world/arenas.js +997 -0
- cuju/render/web/js/world/ball.js +204 -0
- cuju/render/web/js/world/cameras.js +982 -0
- cuju/render/web/js/world/crowd.js +376 -0
- cuju/render/web/js/world/field.js +227 -0
- cuju/render/web/js/world/framing.js +145 -0
- cuju/render/web/js/world/occlusion.js +48 -0
- cuju/render/web/js/world/pitch.js +703 -0
- cuju/render/web/js/world/scene.js +395 -0
- cuju/render/web/js/world/sky.js +325 -0
- cuju/render/web/js/world/stadium.js +1262 -0
- cuju/render/web/js/world/vendor-sky.js +367 -0
- cuju/render/web/js/world/weather.js +264 -0
- cuju/render/web/languages/en.json +4750 -0
- cuju/render/web/lessons.json +137 -0
- cuju/render/web/manifest.webmanifest +23 -0
- cuju/render/web/style.css +8399 -0
- cuju/render/web/sw.js +235 -0
- cuju/render/web/vendor/fabric/LICENSE +16 -0
- cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
- cuju/render/web/vendor/lucide/LICENSE +43 -0
- cuju/render/web/vendor/lucide/icons.js +141 -0
- cuju/render/web/vendor/three/LICENSE +21 -0
- cuju/render/web/vendor/three/build/three.core.js +60586 -0
- cuju/render/web/vendor/three/build/three.module.js +19719 -0
- cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
- cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
- cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
- cuju_render-0.1.0.dist-info/METADATA +116 -0
- cuju_render-0.1.0.dist-info/RECORD +382 -0
- cuju_render-0.1.0.dist-info/WHEEL +4 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
- cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# Copyright 2026 The cuju Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The languages of the page, the shipped files and those of the renderer's home folder.
|
|
5
|
+
|
|
6
|
+
A language file is one JSON object keyed by message id, such as `home.title`, whose values are
|
|
7
|
+
texts with placeholders in braces, such as `{count} recordings`, or objects of plural forms keyed
|
|
8
|
+
by the categories of Unicode CLDR, `zero`, `one`, `two`, `few`, `many` and `other`, and by exact
|
|
9
|
+
counts such as `=0`. The key `@language` holds the code, the name, the English name, the locale of
|
|
10
|
+
numbers and dates, the direction of the text and the plural categories of the language.
|
|
11
|
+
|
|
12
|
+
The page ships English in `web/languages`, and a person adds a language by dropping its file,
|
|
13
|
+
named after its code, into the folder `languages` of the home folder, or by importing it on the
|
|
14
|
+
Settings panel. A code is a language tag of BCP 47, such as `de` or `pt-BR`, and two codes that
|
|
15
|
+
differ in case alone name the same language, as BCP 47 has it. English is the reference of every
|
|
16
|
+
other file. `check` reports the messages a file lacks, those English lacks, and malformed ones,
|
|
17
|
+
whose placeholders differ from English or whose plural forms miss a category of the language,
|
|
18
|
+
the categories of the base language before the first hyphen of the code. `template` writes a new
|
|
19
|
+
file from English, and `get`, `post` and `delete` answer `/api/languages`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .._store import home as _home
|
|
33
|
+
from . import messages as _messages
|
|
34
|
+
from .messages import MessageError, counted
|
|
35
|
+
|
|
36
|
+
SHIPPED = Path(__file__).parents[1] / 'web' / 'languages'
|
|
37
|
+
HEADER = '@language'
|
|
38
|
+
# A language file of the home folder holds at most this many bytes.
|
|
39
|
+
MAXIMUM_BYTES = 2 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
_CODE = re.compile(r'[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*')
|
|
42
|
+
_ID = re.compile(r'[A-Za-z0-9_.-]+')
|
|
43
|
+
_PLACEHOLDER = re.compile(r'\{([A-Za-z0-9_]+)\}')
|
|
44
|
+
_EXACT = re.compile(r'=\d+')
|
|
45
|
+
CATEGORIES = ('zero', 'one', 'two', 'few', 'many', 'other')
|
|
46
|
+
|
|
47
|
+
# The plural categories of the cardinal numbers of common languages, by base language, as
|
|
48
|
+
# Intl.PluralRules gives them from Unicode CLDR. A language missing here declares its own.
|
|
49
|
+
_GROUPS = {
|
|
50
|
+
('one', 'other'): (
|
|
51
|
+
'af am az bg bn da de el en et eu fa fi fil gl gu hi hu hy is ka kk kn ky mk ml mn mr nb '
|
|
52
|
+
'ne nl pa ps si sq sv sw ta te tr ur uz zu'
|
|
53
|
+
),
|
|
54
|
+
('zero', 'one', 'two', 'few', 'many', 'other'): 'ar cy',
|
|
55
|
+
('one', 'few', 'many', 'other'): 'be cs lt pl ru sk uk',
|
|
56
|
+
('one', 'few', 'other'): 'bs hr ro sr',
|
|
57
|
+
('one', 'many', 'other'): 'ca es fr it pt',
|
|
58
|
+
('one', 'two', 'few', 'many', 'other'): 'ga',
|
|
59
|
+
('one', 'two', 'other'): 'he',
|
|
60
|
+
('other',): 'id ja km ko lo ms my th vi zh',
|
|
61
|
+
('zero', 'one', 'other'): 'lv',
|
|
62
|
+
('one', 'two', 'few', 'other'): 'sl',
|
|
63
|
+
}
|
|
64
|
+
PLURALS = {code: list(group) for group, codes in _GROUPS.items() for code in codes.split()}
|
|
65
|
+
# Languages written from right to left.
|
|
66
|
+
_RTL = {'ar', 'fa', 'he', 'ps', 'ur', 'yi', 'dv', 'ug'}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def valid_code(code: Any) -> bool:
|
|
70
|
+
"""Returns whether `code` names a language file safely, such as `pt` or `pt-BR`."""
|
|
71
|
+
return isinstance(code, str) and bool(_CODE.fullmatch(code))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _file(place: Path, code: str) -> Path | None:
|
|
75
|
+
"""Returns the file of the language `code` in `place`, whatever the case of its name."""
|
|
76
|
+
exact = place / f'{code}.json'
|
|
77
|
+
if exact.is_file():
|
|
78
|
+
return exact
|
|
79
|
+
for path in place.glob('*.json'):
|
|
80
|
+
if path.stem.lower() == code.lower() and path.is_file():
|
|
81
|
+
return path
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def folder() -> Path:
|
|
86
|
+
"""Returns the folder of the languages a person adds, created when missing."""
|
|
87
|
+
return _home.folder('languages')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read(path: Path) -> Any:
|
|
91
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def english() -> dict[str, Any]:
|
|
95
|
+
"""Returns the shipped English file, the reference of every other."""
|
|
96
|
+
return _read(SHIPPED / 'en.json')
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# The message of the name a person plays under when they type none.
|
|
100
|
+
_DEFAULT_NAME = 'challenges.name.default'
|
|
101
|
+
# The default names of languages that shipped with the page before, which older runs may hold.
|
|
102
|
+
FORMER_DEFAULT_NAMES = ('Jogador',)
|
|
103
|
+
_DEFAULTS: dict[tuple[tuple[str, int], ...], frozenset[str]] = {}
|
|
104
|
+
# The names are read again at most this often, since a leaderboard asks for them by entry.
|
|
105
|
+
_RECHECK_SECONDS = 2.0
|
|
106
|
+
_CHECKED: dict[str, tuple[float, frozenset[str]]] = {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def default_names() -> frozenset[str]:
|
|
110
|
+
"""Returns the default name of a person in every language that the renderer knows.
|
|
111
|
+
|
|
112
|
+
A person who types no name plays under the default name of the page's language, so a name
|
|
113
|
+
among these counts as no name, and the page shows the default of its own language in its
|
|
114
|
+
place. The names come from English, the languages of the folder and the languages that shipped
|
|
115
|
+
with the page before, and stay cached until a file of them changes, which the function looks
|
|
116
|
+
for every two seconds.
|
|
117
|
+
"""
|
|
118
|
+
place = folder()
|
|
119
|
+
now = time.monotonic()
|
|
120
|
+
recent = _CHECKED.get(str(place))
|
|
121
|
+
if recent is not None and now - recent[0] < _RECHECK_SECONDS:
|
|
122
|
+
return recent[1]
|
|
123
|
+
paths = [SHIPPED / 'en.json', *sorted(place.glob('*.json'))]
|
|
124
|
+
key = []
|
|
125
|
+
for path in paths:
|
|
126
|
+
try:
|
|
127
|
+
key.append((str(path), path.stat().st_mtime_ns))
|
|
128
|
+
except OSError:
|
|
129
|
+
continue
|
|
130
|
+
found = _DEFAULTS.get(tuple(key))
|
|
131
|
+
if found is None:
|
|
132
|
+
names = set(FORMER_DEFAULT_NAMES)
|
|
133
|
+
for path, _ in key:
|
|
134
|
+
try:
|
|
135
|
+
value = _read(Path(path)).get(_DEFAULT_NAME)
|
|
136
|
+
except (OSError, ValueError, AttributeError):
|
|
137
|
+
continue
|
|
138
|
+
if isinstance(value, str) and value.strip():
|
|
139
|
+
names.add(value.strip())
|
|
140
|
+
found = frozenset(names)
|
|
141
|
+
_DEFAULTS.clear()
|
|
142
|
+
_DEFAULTS[tuple(key)] = found
|
|
143
|
+
_CHECKED.clear()
|
|
144
|
+
_CHECKED[str(place)] = (now, found)
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def placeholders(value: Any) -> set[str]:
|
|
149
|
+
"""Returns the placeholders of a message, of every form of a plural message."""
|
|
150
|
+
if isinstance(value, str):
|
|
151
|
+
return set(_PLACEHOLDER.findall(value))
|
|
152
|
+
if isinstance(value, dict):
|
|
153
|
+
return {
|
|
154
|
+
name
|
|
155
|
+
for form in value.values()
|
|
156
|
+
if isinstance(form, str)
|
|
157
|
+
for name in _PLACEHOLDER.findall(form)
|
|
158
|
+
}
|
|
159
|
+
return set()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _problem(name: str, **values: Any) -> dict[str, Any]:
|
|
163
|
+
"""Returns a problem of a file as a label of the message `server.language.problem.<name>`."""
|
|
164
|
+
return _messages.label(f'server.language.problem.{name}', **values)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _header_problems(header: Any, code: str | None) -> list[dict[str, Any]]:
|
|
168
|
+
if not isinstance(header, dict):
|
|
169
|
+
return [_problem('header')]
|
|
170
|
+
problems = []
|
|
171
|
+
if not valid_code(header.get('code')):
|
|
172
|
+
problems.append(_problem('code', code=repr(header.get('code'))))
|
|
173
|
+
elif code is not None and header.get('code') != code:
|
|
174
|
+
problems.append(_problem('file_code', code=repr(code), found=repr(header.get('code'))))
|
|
175
|
+
if not isinstance(header.get('name'), str) or not header.get('name').strip():
|
|
176
|
+
problems.append(_problem('name'))
|
|
177
|
+
for key in ('english', 'locale'):
|
|
178
|
+
if key in header and not isinstance(header[key], str):
|
|
179
|
+
problems.append(_problem('text', key=key, value=repr(header[key])))
|
|
180
|
+
if header.get('dir', 'ltr') not in ('ltr', 'rtl'):
|
|
181
|
+
problems.append(_problem('dir', value=repr(header.get('dir'))))
|
|
182
|
+
plurals = header.get('plurals')
|
|
183
|
+
if not isinstance(plurals, list) or not plurals or not all(p in CATEGORIES for p in plurals):
|
|
184
|
+
problems.append(_problem('plurals', categories=', '.join(CATEGORIES), value=repr(plurals)))
|
|
185
|
+
elif 'other' not in plurals:
|
|
186
|
+
problems.append(_problem('other'))
|
|
187
|
+
else:
|
|
188
|
+
base = str(header.get('code', '')).split('-')[0].lower()
|
|
189
|
+
lacking = [p for p in PLURALS.get(base, []) if p not in plurals]
|
|
190
|
+
if lacking:
|
|
191
|
+
problems.append(_problem('categories', language=base, lacking=', '.join(lacking)))
|
|
192
|
+
return problems
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _message_problem(
|
|
196
|
+
key: str, value: Any, reference: Any, plurals: list[str]
|
|
197
|
+
) -> dict[str, Any] | None:
|
|
198
|
+
"""Returns what is malformed about one message against its English as a label, or None."""
|
|
199
|
+
if isinstance(value, str):
|
|
200
|
+
if isinstance(reference, dict) and plurals != ['other']:
|
|
201
|
+
return _problem('forms_expected')
|
|
202
|
+
elif isinstance(value, dict):
|
|
203
|
+
if not value or not all(isinstance(form, str) for form in value.values()):
|
|
204
|
+
return _problem('form_texts')
|
|
205
|
+
if key.startswith('caption.'):
|
|
206
|
+
return _problem('caption')
|
|
207
|
+
unknown = [form for form in value if form not in CATEGORIES and not _EXACT.fullmatch(form)]
|
|
208
|
+
if unknown:
|
|
209
|
+
return _problem('form_names', unknown=', '.join(unknown))
|
|
210
|
+
lacking = [form for form in plurals if form not in value]
|
|
211
|
+
if lacking:
|
|
212
|
+
return _problem('forms_lacking', lacking=', '.join(lacking))
|
|
213
|
+
# A language may count what English leaves alone, when the English text holds {count}.
|
|
214
|
+
if isinstance(reference, str) and 'count' not in placeholders(reference):
|
|
215
|
+
return _problem('text_expected')
|
|
216
|
+
else:
|
|
217
|
+
return _problem('kind')
|
|
218
|
+
if reference is None:
|
|
219
|
+
return None
|
|
220
|
+
own, theirs = placeholders(value), placeholders(reference)
|
|
221
|
+
extra = sorted(own - theirs)
|
|
222
|
+
if extra:
|
|
223
|
+
return _problem('extra', extra=', '.join('{' + p + '}' for p in extra))
|
|
224
|
+
# A plural form may leave the count out, as in "one recording", every other value stays.
|
|
225
|
+
lost = sorted(theirs - own - ({'count'} if isinstance(value, dict) else set()))
|
|
226
|
+
if lost:
|
|
227
|
+
return _problem('lost', lost=', '.join('{' + p + '}' for p in lost))
|
|
228
|
+
# Commands and shortcuts are code and must survive, while bold words are the translator's
|
|
229
|
+
# choice, since each language stresses its own words.
|
|
230
|
+
if _code_spans(value) != _code_spans(reference):
|
|
231
|
+
return _problem('marks')
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _code_spans(value: Any) -> int:
|
|
236
|
+
"""Returns how many code spans a message holds, its commands, names and shortcuts.
|
|
237
|
+
|
|
238
|
+
A plural message counts the spans of its form `other`, which every language has.
|
|
239
|
+
"""
|
|
240
|
+
text = value if isinstance(value, str) else (value or {}).get('other', '')
|
|
241
|
+
found = _messages._MARK.findall(text if isinstance(text, str) else '')
|
|
242
|
+
return sum(1 for _, code in found if code)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def check(
|
|
246
|
+
document: Any, code: str | None = None, reference: dict[str, Any] | None = None
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
"""Checks a language file against English.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
document: The parsed file.
|
|
252
|
+
code: The code the file must name, that of its file name, or None for any.
|
|
253
|
+
reference: The English file, by default the shipped one.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
A report with `code`, `name`, `messages`, `ok`, and lists `missing` and `unused` of ids,
|
|
257
|
+
`malformed` of objects with `id` and `problem`, the English text of the problem, with its
|
|
258
|
+
message in `problem_id` and `problem_values`, and `untranslated`, the ids whose text is
|
|
259
|
+
the English one, which a template starts with and which is no fault of its own.
|
|
260
|
+
"""
|
|
261
|
+
reference = english() if reference is None else reference
|
|
262
|
+
report: dict[str, Any] = {
|
|
263
|
+
'code': None,
|
|
264
|
+
'name': None,
|
|
265
|
+
'messages': 0,
|
|
266
|
+
'missing': [],
|
|
267
|
+
'unused': [],
|
|
268
|
+
'malformed': [],
|
|
269
|
+
'untranslated': [],
|
|
270
|
+
}
|
|
271
|
+
if not isinstance(document, dict):
|
|
272
|
+
problem = _messages.named('problem', _problem('document'))
|
|
273
|
+
report['malformed'].append({'id': HEADER, **problem})
|
|
274
|
+
report['ok'] = False
|
|
275
|
+
return report
|
|
276
|
+
header = document.get(HEADER)
|
|
277
|
+
if isinstance(header, dict):
|
|
278
|
+
report['code'] = header.get('code')
|
|
279
|
+
report['name'] = header.get('name')
|
|
280
|
+
for problem in _header_problems(header, code):
|
|
281
|
+
report['malformed'].append({'id': HEADER, **_messages.named('problem', problem)})
|
|
282
|
+
plurals = (
|
|
283
|
+
header.get('plurals')
|
|
284
|
+
if isinstance(header, dict) and isinstance(header.get('plurals'), list)
|
|
285
|
+
else ['other']
|
|
286
|
+
)
|
|
287
|
+
for key, value in document.items():
|
|
288
|
+
if key == HEADER:
|
|
289
|
+
continue
|
|
290
|
+
if not _ID.fullmatch(key):
|
|
291
|
+
report['malformed'].append({'id': key, **_messages.named('problem', _problem('id'))})
|
|
292
|
+
continue
|
|
293
|
+
report['messages'] += 1
|
|
294
|
+
if key not in reference:
|
|
295
|
+
report['unused'].append(key)
|
|
296
|
+
problem = _message_problem(key, value, reference.get(key), plurals)
|
|
297
|
+
if problem:
|
|
298
|
+
report['malformed'].append({'id': key, **_messages.named('problem', problem)})
|
|
299
|
+
elif report['code'] != 'en' and value == reference.get(key):
|
|
300
|
+
report['untranslated'].append(key)
|
|
301
|
+
report['missing'] = [key for key in reference if key != HEADER and key not in document]
|
|
302
|
+
report['ok'] = not (report['missing'] or report['unused'] or report['malformed'])
|
|
303
|
+
return report
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def template(code: str, name: str | None = None) -> dict[str, Any]:
|
|
307
|
+
"""Returns a new language file for `code` with every English message, for a translator.
|
|
308
|
+
|
|
309
|
+
The plural messages take the categories of the language, each filled with the English text of
|
|
310
|
+
the nearest English form, and the header names the language with its code until it is set.
|
|
311
|
+
"""
|
|
312
|
+
if not valid_code(code):
|
|
313
|
+
raise MessageError('server.language.code', code=code)
|
|
314
|
+
base = code.split('-')[0].lower()
|
|
315
|
+
plurals = PLURALS.get(base, ['one', 'other'])
|
|
316
|
+
document: dict[str, Any] = {
|
|
317
|
+
HEADER: {
|
|
318
|
+
'code': code,
|
|
319
|
+
'name': name or code,
|
|
320
|
+
'english': '',
|
|
321
|
+
'locale': code,
|
|
322
|
+
'dir': 'rtl' if base in _RTL else 'ltr',
|
|
323
|
+
'plurals': plurals,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for key, value in english().items():
|
|
327
|
+
if key == HEADER:
|
|
328
|
+
continue
|
|
329
|
+
if isinstance(value, dict):
|
|
330
|
+
forms = {
|
|
331
|
+
form: value.get(
|
|
332
|
+
form, value.get('one' if form in ('zero', 'one') else 'other', value['other'])
|
|
333
|
+
)
|
|
334
|
+
for form in plurals
|
|
335
|
+
}
|
|
336
|
+
document[key] = forms if plurals != ['other'] else value['other']
|
|
337
|
+
else:
|
|
338
|
+
document[key] = value
|
|
339
|
+
return document
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _entry(document: Any, code: str, source: str, reference: dict[str, Any]) -> dict[str, Any]:
|
|
343
|
+
report = check(document, code, reference)
|
|
344
|
+
header = document.get(HEADER) if isinstance(document, dict) else None
|
|
345
|
+
header = header if isinstance(header, dict) else {}
|
|
346
|
+
return {
|
|
347
|
+
'code': code,
|
|
348
|
+
'name': header.get('name') if isinstance(header.get('name'), str) else code,
|
|
349
|
+
'english': header.get('english') if isinstance(header.get('english'), str) else '',
|
|
350
|
+
'locale': header.get('locale') if isinstance(header.get('locale'), str) else code,
|
|
351
|
+
'dir': 'rtl' if header.get('dir') == 'rtl' else 'ltr',
|
|
352
|
+
'source': source,
|
|
353
|
+
'usable': not report['malformed'],
|
|
354
|
+
'messages': report['messages'],
|
|
355
|
+
'missing': len(report['missing']),
|
|
356
|
+
'problems': [f'{item["id"]}, {item["problem"]}' for item in report['malformed'][:20]],
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def listing() -> list[dict[str, Any]]:
|
|
361
|
+
"""Returns the languages, the shipped ones first, English first among them, then the own.
|
|
362
|
+
|
|
363
|
+
Each entry names its code, name, English name, locale, direction, source `shipped` or `home`,
|
|
364
|
+
whether the page can use it, the counts of its messages and of the English messages it lacks,
|
|
365
|
+
and the problems that make it unusable.
|
|
366
|
+
"""
|
|
367
|
+
reference = english()
|
|
368
|
+
entries = []
|
|
369
|
+
shipped = sorted(SHIPPED.glob('*.json'), key=lambda path: (path.stem != 'en', path.stem))
|
|
370
|
+
for path in shipped:
|
|
371
|
+
entries.append(_entry(_read(path), path.stem, 'shipped', reference))
|
|
372
|
+
names = {entry['code'].lower() for entry in entries}
|
|
373
|
+
for path in sorted(folder().glob('*.json'), key=lambda path: path.stem):
|
|
374
|
+
if path.stem.lower() in names or not valid_code(path.stem):
|
|
375
|
+
continue
|
|
376
|
+
try:
|
|
377
|
+
document = _read(path)
|
|
378
|
+
except (OSError, ValueError) as error:
|
|
379
|
+
entries.append(
|
|
380
|
+
{
|
|
381
|
+
'code': path.stem,
|
|
382
|
+
'name': path.stem,
|
|
383
|
+
'english': '',
|
|
384
|
+
'locale': path.stem,
|
|
385
|
+
'dir': 'ltr',
|
|
386
|
+
'source': 'home',
|
|
387
|
+
'usable': False,
|
|
388
|
+
'messages': 0,
|
|
389
|
+
'missing': 0,
|
|
390
|
+
'problems': [f'the file is not JSON, {error}'],
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
continue
|
|
394
|
+
entries.append(_entry(document, path.stem, 'home', reference))
|
|
395
|
+
return entries
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
_SLOT = b'<script type="application/json" id="cuju-languages"></script>'
|
|
399
|
+
_INLINE: dict[str, Any] = {'key': None, 'text': b''}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def inline(page: bytes) -> bytes:
|
|
403
|
+
"""Fills the empty language block of a page with every shipped language file.
|
|
404
|
+
|
|
405
|
+
The page reads its messages from the block, so it starts without another request. Every `<`
|
|
406
|
+
of the files is escaped, so no text of a message can close the block.
|
|
407
|
+
"""
|
|
408
|
+
if _SLOT not in page:
|
|
409
|
+
return page
|
|
410
|
+
paths = sorted(SHIPPED.glob('*.json'))
|
|
411
|
+
key = tuple((path.name, path.stat().st_mtime_ns) for path in paths)
|
|
412
|
+
if _INLINE['key'] != key:
|
|
413
|
+
files = {path.stem: _read(path) for path in paths}
|
|
414
|
+
text = json.dumps(files, ensure_ascii=False, separators=(',', ':')).replace('<', r'\u003c')
|
|
415
|
+
_INLINE.update(key=key, text=text.encode('utf-8'))
|
|
416
|
+
return page.replace(_SLOT, _SLOT.replace(b'></', b'>' + _INLINE['text'] + b'</', 1))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def read(code: str) -> dict[str, Any]:
|
|
420
|
+
"""Returns the file of a language, shipped or of the home folder."""
|
|
421
|
+
if not valid_code(code):
|
|
422
|
+
raise MessageError('server.language.code', code=code)
|
|
423
|
+
for place in (SHIPPED, folder()):
|
|
424
|
+
path = _file(place, code)
|
|
425
|
+
if path is not None:
|
|
426
|
+
return _read(path)
|
|
427
|
+
raise MessageError('server.language.none', code=code)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def add(document: Any) -> dict[str, Any]:
|
|
431
|
+
"""Keeps a checked language file in the home folder and returns its entry and report.
|
|
432
|
+
|
|
433
|
+
A file whose messages are malformed is refused with the reasons. Missing messages are allowed,
|
|
434
|
+
the page shows them in English, and so are messages English lacks, which the page never reads.
|
|
435
|
+
|
|
436
|
+
Raises:
|
|
437
|
+
MessageError: If the file is malformed or names a shipped language.
|
|
438
|
+
"""
|
|
439
|
+
if not isinstance(document, dict) or not isinstance(document.get(HEADER), dict):
|
|
440
|
+
raise MessageError('server.language.json')
|
|
441
|
+
code = document[HEADER].get('code')
|
|
442
|
+
if not valid_code(code):
|
|
443
|
+
raise MessageError('server.language.code', code=code)
|
|
444
|
+
if _file(SHIPPED, code) is not None:
|
|
445
|
+
raise MessageError('server.language.shipped', code=code)
|
|
446
|
+
report = check(document, code)
|
|
447
|
+
if report['malformed']:
|
|
448
|
+
first = report['malformed'][0]
|
|
449
|
+
problem = {
|
|
450
|
+
'id': first.get('problem_id'),
|
|
451
|
+
'text': first['problem'],
|
|
452
|
+
'values': first.get('problem_values') or {},
|
|
453
|
+
}
|
|
454
|
+
place = _messages.label('server.language.problem_at', id=first['id'], problem=problem)
|
|
455
|
+
raise MessageError('server.language.malformed', first=place)
|
|
456
|
+
body = json.dumps(document, ensure_ascii=False, indent=4) + '\n'
|
|
457
|
+
if len(body.encode('utf-8')) > MAXIMUM_BYTES:
|
|
458
|
+
raise MessageError('server.language.size', most=MAXIMUM_BYTES // (1024 * 1024))
|
|
459
|
+
target = folder() / f'{code}.json'
|
|
460
|
+
temporary = target.with_name(f'.{code}.json.tmp')
|
|
461
|
+
temporary.write_text(body, encoding='utf-8', newline='\n')
|
|
462
|
+
# A file of the same language whose name differs in case gives way to the new one.
|
|
463
|
+
# The names are read from the folder, since a file system that ignores case, as those of
|
|
464
|
+
# Windows and macOS usually do, would answer for the new name with the old file's spelling.
|
|
465
|
+
for earlier in folder().glob('*.json'):
|
|
466
|
+
if earlier.stem.lower() == code.lower() and earlier.name != target.name:
|
|
467
|
+
earlier.unlink()
|
|
468
|
+
os.replace(temporary, target)
|
|
469
|
+
entry = _entry(document, code, 'home', english())
|
|
470
|
+
entry['report'] = {key: report[key] for key in ('missing', 'unused', 'untranslated')}
|
|
471
|
+
return entry
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def remove(code: str) -> dict[str, Any]:
|
|
475
|
+
"""Removes a language of the home folder and returns its code."""
|
|
476
|
+
if not valid_code(code):
|
|
477
|
+
raise MessageError('server.language.code', code=code)
|
|
478
|
+
if _file(SHIPPED, code) is not None:
|
|
479
|
+
raise MessageError('server.language.keep', code=code)
|
|
480
|
+
path = _file(folder(), code)
|
|
481
|
+
if path is None:
|
|
482
|
+
raise MessageError('server.language.none', code=code)
|
|
483
|
+
path.unlink()
|
|
484
|
+
return {'code': code, 'removed': True}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _refusal(error: MessageError, status: int) -> tuple[dict[str, Any], int]:
|
|
488
|
+
return {'error': str(error), 'error_id': error.message_id, 'error_values': error.values}, status
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def get(parts: list[str]) -> tuple[Any, int]:
|
|
492
|
+
"""Answers `GET /api/languages` and `GET /api/languages/CODE`."""
|
|
493
|
+
if not parts:
|
|
494
|
+
return {'languages': listing()}, 200
|
|
495
|
+
if len(parts) == 1:
|
|
496
|
+
try:
|
|
497
|
+
return read(parts[0]), 200
|
|
498
|
+
except MessageError as error:
|
|
499
|
+
return _refusal(error, 404 if error.message_id == 'server.language.none' else 400)
|
|
500
|
+
return _refusal(MessageError('server.no_resource'), 404)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def post(parts: list[str], body: bytes) -> tuple[Any, int]:
|
|
504
|
+
"""Answers `POST /api/languages` with a language file as its body."""
|
|
505
|
+
if parts:
|
|
506
|
+
return _refusal(MessageError('server.no_resource'), 404)
|
|
507
|
+
if len(body) > MAXIMUM_BYTES:
|
|
508
|
+
return _refusal(
|
|
509
|
+
MessageError('server.language.size', most=MAXIMUM_BYTES // (1024 * 1024)), 413
|
|
510
|
+
)
|
|
511
|
+
try:
|
|
512
|
+
document = json.loads(body.decode('utf-8'))
|
|
513
|
+
except (UnicodeDecodeError, ValueError):
|
|
514
|
+
return _refusal(MessageError('server.language.json'), 400)
|
|
515
|
+
try:
|
|
516
|
+
return add(document), 201
|
|
517
|
+
except MessageError as error:
|
|
518
|
+
return _refusal(error, 400)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def delete(parts: list[str]) -> tuple[Any, int]:
|
|
522
|
+
"""Answers `DELETE /api/languages/CODE` for a language of the home folder."""
|
|
523
|
+
if len(parts) != 1:
|
|
524
|
+
return _refusal(MessageError('server.no_resource'), 404)
|
|
525
|
+
try:
|
|
526
|
+
return remove(parts[0]), 200
|
|
527
|
+
except MessageError as error:
|
|
528
|
+
return _refusal(error, 404 if error.message_id == 'server.language.none' else 400)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def add_arguments(parser: Any) -> None:
|
|
532
|
+
"""Adds the arguments of `cuju render language` to its parser."""
|
|
533
|
+
parser.add_argument('action', choices=('list', 'new', 'check', 'add', 'remove'))
|
|
534
|
+
parser.add_argument(
|
|
535
|
+
'target',
|
|
536
|
+
nargs='?',
|
|
537
|
+
help='the code of a new or removed language, or the language file to check or add',
|
|
538
|
+
)
|
|
539
|
+
parser.add_argument('-o', '--out', help='the file that new writes, by default CODE.json')
|
|
540
|
+
parser.add_argument('--name', help='the name of a new language in itself, such as Deutsch')
|
|
541
|
+
parser.add_argument('--json', action='store_true', help='print the result as JSON')
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _print_report(report: dict[str, Any], path: str) -> None:
|
|
545
|
+
name = report.get('name') or report.get('code') or path
|
|
546
|
+
print(f'{path}, {name}, {counted(report["messages"], "message")}')
|
|
547
|
+
for key in report['missing']:
|
|
548
|
+
print(f' missing {key}')
|
|
549
|
+
for key in report['unused']:
|
|
550
|
+
print(f' unused {key}')
|
|
551
|
+
for item in report['malformed']:
|
|
552
|
+
print(f' malformed {item["id"]}, {item["problem"]}')
|
|
553
|
+
if report['untranslated']:
|
|
554
|
+
print(f' {counted(len(report["untranslated"]), "message")} read as in English')
|
|
555
|
+
print('ok' if report['ok'] else 'not ok')
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def run(options: Any) -> int:
|
|
559
|
+
"""Runs `cuju render language`, and returns the exit status.
|
|
560
|
+
|
|
561
|
+
`new CODE` writes a template from English, `check FILE` reports missing, unused and malformed
|
|
562
|
+
messages and exits with 1 when it finds any, `list` prints the languages, `add FILE` keeps a
|
|
563
|
+
checked file in the home folder and `remove CODE` removes one of them.
|
|
564
|
+
"""
|
|
565
|
+
action, target = options.action, options.target
|
|
566
|
+
if action != 'list' and not target:
|
|
567
|
+
print(f'cuju render: expected what language {action} reads or writes', file=sys.stderr)
|
|
568
|
+
return 2
|
|
569
|
+
try:
|
|
570
|
+
if action == 'new':
|
|
571
|
+
document = template(target, options.name)
|
|
572
|
+
path = Path(options.out or f'{target}.json')
|
|
573
|
+
path.write_text(
|
|
574
|
+
json.dumps(document, ensure_ascii=False, indent=4) + '\n',
|
|
575
|
+
encoding='utf-8',
|
|
576
|
+
newline='\n',
|
|
577
|
+
)
|
|
578
|
+
if options.json:
|
|
579
|
+
print(
|
|
580
|
+
json.dumps({'code': target, 'path': str(path), 'messages': len(document) - 1})
|
|
581
|
+
)
|
|
582
|
+
else:
|
|
583
|
+
print(f'wrote {path}, {counted(len(document) - 1, "message")} from English')
|
|
584
|
+
return 0
|
|
585
|
+
if action == 'check':
|
|
586
|
+
path = Path(target)
|
|
587
|
+
try:
|
|
588
|
+
document = json.loads(path.read_text(encoding='utf-8'))
|
|
589
|
+
except ValueError as error:
|
|
590
|
+
report = {
|
|
591
|
+
'code': None,
|
|
592
|
+
'name': None,
|
|
593
|
+
'messages': 0,
|
|
594
|
+
'missing': [],
|
|
595
|
+
'unused': [],
|
|
596
|
+
'malformed': [{'id': HEADER, 'problem': f'the file is not JSON, {error}'}],
|
|
597
|
+
'untranslated': [],
|
|
598
|
+
'ok': False,
|
|
599
|
+
}
|
|
600
|
+
else:
|
|
601
|
+
code = path.stem if valid_code(path.stem) else None
|
|
602
|
+
report = check(document, code)
|
|
603
|
+
if options.json:
|
|
604
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
605
|
+
else:
|
|
606
|
+
_print_report(report, str(path))
|
|
607
|
+
return 0 if report['ok'] else 1
|
|
608
|
+
if action == 'list':
|
|
609
|
+
entries = listing()
|
|
610
|
+
if options.json:
|
|
611
|
+
print(json.dumps(entries, ensure_ascii=False, indent=2))
|
|
612
|
+
else:
|
|
613
|
+
for entry in entries:
|
|
614
|
+
state = 'usable' if entry['usable'] else 'refused'
|
|
615
|
+
print(f'{entry["code"]} {entry["source"]} {state} {entry["name"]}')
|
|
616
|
+
return 0
|
|
617
|
+
if action == 'add':
|
|
618
|
+
entry = add(json.loads(Path(target).read_text(encoding='utf-8')))
|
|
619
|
+
if options.json:
|
|
620
|
+
print(json.dumps(entry, ensure_ascii=False, indent=2))
|
|
621
|
+
else:
|
|
622
|
+
print(
|
|
623
|
+
f'added {entry["code"]} {entry["name"]}, {entry["missing"]} '
|
|
624
|
+
f'{"message shows" if entry["missing"] == 1 else "messages show"} in English'
|
|
625
|
+
)
|
|
626
|
+
return 0
|
|
627
|
+
result = remove(target)
|
|
628
|
+
print(json.dumps(result) if options.json else f'removed {result["code"]}')
|
|
629
|
+
return 0
|
|
630
|
+
except ValueError as error:
|
|
631
|
+
if options.json:
|
|
632
|
+
print(json.dumps({'error': str(error), 'error_id': getattr(error, 'message_id', None)}))
|
|
633
|
+
print(f'cuju render: {error}', file=sys.stderr)
|
|
634
|
+
return 1
|
|
635
|
+
except OSError as error:
|
|
636
|
+
print(f'cuju render: {error}', file=sys.stderr)
|
|
637
|
+
return 1
|