├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yaml └── workflows │ └── python-app.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── CloudRequests_Template.sb3 ├── CloudStorage_Template.sb3 └── Encoder.sprite3 ├── docs ├── Makefile ├── _build │ ├── doctrees │ │ ├── environment.pickle │ │ ├── index.doctree │ │ ├── modules.doctree │ │ └── scratchattach.doctree │ └── html │ │ ├── .buildinfo │ │ ├── _modules │ │ ├── index.html │ │ ├── scratchattach.html │ │ └── scratchattach │ │ │ ├── cloud.html │ │ │ ├── cloud_requests.html │ │ │ ├── encoder.html │ │ │ ├── exceptions.html │ │ │ ├── forum.html │ │ │ ├── project.html │ │ │ ├── session.html │ │ │ ├── studio.html │ │ │ └── user.html │ │ ├── _sources │ │ ├── index.rst.txt │ │ ├── modules.rst.txt │ │ └── scratchattach.rst.txt │ │ ├── _static │ │ ├── basic.css │ │ ├── css │ │ │ ├── badge_only.css │ │ │ ├── fonts │ │ │ │ ├── Roboto-Slab-Bold.woff │ │ │ │ ├── Roboto-Slab-Bold.woff2 │ │ │ │ ├── Roboto-Slab-Regular.woff │ │ │ │ ├── Roboto-Slab-Regular.woff2 │ │ │ │ ├── fontawesome-webfont.eot │ │ │ │ ├── fontawesome-webfont.svg │ │ │ │ ├── fontawesome-webfont.ttf │ │ │ │ ├── fontawesome-webfont.woff │ │ │ │ ├── fontawesome-webfont.woff2 │ │ │ │ ├── lato-bold-italic.woff │ │ │ │ ├── lato-bold-italic.woff2 │ │ │ │ ├── lato-bold.woff │ │ │ │ ├── lato-bold.woff2 │ │ │ │ ├── lato-normal-italic.woff │ │ │ │ ├── lato-normal-italic.woff2 │ │ │ │ ├── lato-normal.woff │ │ │ │ └── lato-normal.woff2 │ │ │ └── theme.css │ │ ├── doctools.js │ │ ├── documentation_options.js │ │ ├── file.png │ │ ├── jquery-3.5.1.js │ │ ├── jquery.js │ │ ├── js │ │ │ ├── badge_only.js │ │ │ ├── html5shiv-printshiv.min.js │ │ │ ├── html5shiv.min.js │ │ │ └── theme.js │ │ ├── language_data.js │ │ ├── minus.png │ │ ├── plus.png │ │ ├── pygments.css │ │ ├── searchtools.js │ │ ├── underscore-1.13.1.js │ │ └── underscore.js │ │ ├── genindex.html │ │ ├── index.html │ │ ├── modules.html │ │ ├── objects.inv │ │ ├── py-modindex.html │ │ ├── scratchattach.html │ │ ├── search.html │ │ └── searchindex.js ├── conf.py ├── index.rst ├── make.bat ├── modules.rst ├── requirements.txt └── scratchattach.rst ├── logos ├── designsawebsite.svg ├── logo.png ├── logo.svg ├── logo_small.svg ├── logo_square_container.png └── logo_square_container.svg ├── requirements.txt ├── scratchattach ├── __init__.py ├── cloud │ ├── __init__.py │ ├── _base.py │ └── cloud.py ├── editor │ ├── __init__.py │ ├── asset.py │ ├── backpack_json.py │ ├── base.py │ ├── block.py │ ├── blockshape.py │ ├── build_defaulting.py │ ├── comment.py │ ├── commons.py │ ├── extension.py │ ├── field.py │ ├── inputs.py │ ├── meta.py │ ├── monitor.py │ ├── mutation.py │ ├── pallete.py │ ├── prim.py │ ├── project.py │ ├── sprite.py │ ├── twconfig.py │ └── vlb.py ├── eventhandlers │ ├── __init__.py │ ├── _base.py │ ├── cloud_events.py │ ├── cloud_recorder.py │ ├── cloud_requests.py │ ├── cloud_server.py │ ├── cloud_storage.py │ ├── combine.py │ ├── filterbot.py │ └── message_events.py ├── other │ ├── __init__.py │ ├── other_apis.py │ └── project_json_capabilities.py ├── site │ ├── __init__.py │ ├── _base.py │ ├── activity.py │ ├── alert.py │ ├── backpack_asset.py │ ├── browser_cookie3_stub.py │ ├── browser_cookies.py │ ├── classroom.py │ ├── cloud_activity.py │ ├── comment.py │ ├── forum.py │ ├── project.py │ ├── session.py │ ├── studio.py │ └── user.py └── utils │ ├── __init__.py │ ├── commons.py │ ├── encoder.py │ ├── enums.py │ ├── exceptions.py │ └── requests.py ├── setup.py ├── tests └── test_import.py ├── website ├── app.py └── source │ ├── css │ └── style.css │ ├── images │ ├── favicon.ico │ ├── logo.svg │ ├── logo_small.svg │ ├── section1_bg.png │ └── section5_image.svg │ ├── index.html │ └── js │ ├── flyInAni.js │ └── loadCommunityProjects.js └── wiki └── images ├── cookies_tut_1.png ├── cookies_tut_2.png ├── cookies_tut_3.png ├── cr_tut_block.png ├── cr_tut_example1.png ├── cr_tut_example2.png ├── cr_tut_example3.png └── cr_tut_restult.png /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug report. 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: input 11 | id: scratchattach-version 12 | attributes: 13 | label: scratchattach version 14 | description: The version of scratchattach you have installed. Can be found using `python -m pip show scratchattach` 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: what-happened 19 | attributes: 20 | label: What happened? 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: code 25 | attributes: 26 | label: Your code. 27 | description: Put your code here. Be careful not to reveal your login data. 28 | render: python 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: traceback 33 | attributes: 34 | label: Traceback 35 | description: If you received a traceback, please copy paste the full traceback here. Otherwise write "none". 36 | render: txt 37 | validations: 38 | required: true 39 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | # This workflow will upload a Python Package using Twine when a release is created 5 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 6 | 7 | # This workflow uses actions that are not certified by GitHub. 8 | # They are provided by a third-party and are governed by 9 | # separate terms of service, privacy policy, and support 10 | # documentation. 11 | 12 | 13 | name: Python application 14 | 15 | 16 | on: 17 | release: 18 | types: [published] 19 | workflow_dispatch: 20 | 21 | permissions: 22 | contents: read 23 | 24 | jobs: 25 | build: 26 | 27 | runs-on: ubuntu-latest 28 | environment: release 29 | permissions: 30 | id-token: write 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up Python 3.12 34 | uses: actions/setup-python@v3 35 | with: 36 | python-version: "3.12" 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | pip install flake8 pytest 41 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 42 | - name: Lint with flake8 43 | run: | 44 | # stop the build if there are Python syntax errors or undefined names 45 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 46 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 47 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 48 | - name: Test with pytest 49 | run: | 50 | pytest tests/ 51 | - run: pip install -U wheel build 52 | - name: Build a binary wheel and a source tarball 53 | run: python -m build 54 | - name: Publish package distributions to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | scratchattach.egg-info/ 4 | __pycache__/ 5 | README.md 6 | setup.py 7 | scratchattach/test.py 8 | scratchattach.code-workspace 9 | **/.DS_Store 10 | setup.py 11 | setup.py 12 | .env 13 | tests/manual_tests/** 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2.0 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | 15 | # Build documentation in the "docs/" directory with Sphinx 16 | sphinx: 17 | configuration: docs/conf.py 18 | 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt 22 | - method: pip 23 | path: . 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TimMcCool (github user) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **scratchattach is a Scratch API wrapper with support for almost all site features.** Created by [TimMcCool](https://scratch.mit.edu/users/TimMcCool/). 2 | 3 | The library allows setting cloud variables, following users, updating your profile, and 4 | so much more! Additionally, it provides frameworks that simplify sending data through cloud variables. 5 | 6 |

7 | 8 | 9 | [![PyPI status](https://img.shields.io/pypi/status/scratchattach.svg)](https://pypi.python.org/pypi/scratchattach/) 10 | [![PyPI download month](https://img.shields.io/pypi/dm/scratchattach.svg)](https://pypi.python.org/pypi/scratchattach/) 11 | [![PyPI version shields.io](https://img.shields.io/pypi/v/scratchattach.svg)](https://pypi.python.org/pypi/scratchattach/) 12 | [![GitHub license](https://badgen.net/github/license/TimMcCool/scratchattach)](https://github.com/TimMcCool/scratchattach/blob/master/LICENSE) 13 | [![Documentation Status](https://readthedocs.org/projects/scratchattach/badge/?version=latest)](https://scratchattach.readthedocs.io/en/latest/?badge=latest) 14 | 15 | # Documentation 16 | 17 | - **[Documentation](https://github.com/TimMcCool/scratchattach/wiki/Documentation)** 18 | 19 | - [Cloud Variables](https://github.com/TimMcCool/scratchattach/wiki/Documentation#cloud-variables) 20 | - [Cloud Requests](https://github.com/TimMcCool/scratchattach/wiki/Cloud-Requests) 21 | - [Cloud Storage](https://github.com/TimMcCool/scratchattach/wiki/Cloud-Storage) 22 | - [Filterbot](https://github.com/TimMcCool/scratchattach/wiki/Filterbot) 23 | - [Self-hosting a TW cloud websocket](https://github.com/TimMcCool/scratchattach/wiki/Documentation#hosting-a-cloud-server) 24 | 25 | # Helpful resources 26 | 27 | - [Get your session id](https://github.com/TimMcCool/scratchattach/wiki/Get-your-session-id) 28 | 29 | - [Examples](https://github.com/TimMcCool/scratchattach/wiki/Examples) 30 | - [Hosting](https://github.com/TimMcCool/scratchattach/wiki/Hosting) 31 | 32 | Report bugs by opening an issue on this repository. If you need help or guideance, leave a comment in the [official forum topic](https://scratch.mit.edu/discuss/topic/603418/ 33 | ). Projects made using scratchattach can be added to [this Scratch studio](https://scratch.mit.edu/studios/31478892/). 34 | 35 | # Helpful for contributors 36 | 37 | - **[Structure of the library](https://github.com/TimMcCool/scratchattach/wiki/Structure-of-the-library)** 38 | 39 | - [Extended documentation (WIP)](https://scratchattach.readthedocs.io/en/latest/) 40 | 41 | - [Change log](https://github.com/TimMcCool/scratchattach/blob/main/CHANGELOG.md) 42 | 43 | Contribute code by opening a pull request on this repository. 44 | 45 | # ️Example usage 46 | 47 | Set a cloud variable: 48 | 49 | ```py 50 | import scratchattach as sa 51 | 52 | session = sa.login("username", "password") 53 | cloud = session.connect_cloud("project_id") 54 | 55 | cloud.set_var("variable", value) 56 | ``` 57 | 58 | **[More examples](https://github.com/TimMcCool/scratchattach/wiki/Examples)** 59 | 60 | # Getting started 61 | 62 | **Installation:** 63 | 64 | Run the following command in your command prompt / shell: 65 | 66 | ``` 67 | pip install -U scratchattach 68 | ``` 69 | 70 | If this doesn't work, try running: 71 | ``` 72 | python -m pip install -U scratchattach 73 | ``` 74 | 75 | 76 | **Logging in with username / password:** 77 | 78 | ```python 79 | import scratchattach as sa 80 | 81 | session = sa.login("username", "password") 82 | ``` 83 | 84 | `login()` returns a `Session` object that saves your login and can be used to connect objects like users, projects, clouds etc. 85 | 86 | **Logging in with a sessionId:** *You can get your session id from your browser's cookies. [More information](https://github.com/TimMcCool/scratchattach/wiki/Get-your-session-id)* 87 | ```python 88 | import scratchattach as sa 89 | 90 | session = sa.login_by_id("sessionId", username="username") #The username field is case sensitive 91 | ``` 92 | 93 | **Cloud variables:** 94 | 95 | ```py 96 | cloud = session.connect_cloud("project_id") # connect to the cloud 97 | 98 | value = cloud.get_var("variable") 99 | cloud.set_var("variable", "value") # the variable name is specified without the cloud emoji 100 | ``` 101 | 102 | **Cloud events:** 103 | 104 | ```py 105 | cloud = session.connect_cloud('project_id') 106 | events = cloud.events() 107 | 108 | @events.event 109 | def on_set(activity): 110 | print("variable", activity.var, "was set to", activity.value) 111 | events.start() 112 | ``` 113 | 114 | **Follow users, love their projects and comment:** 115 | 116 | ```python 117 | user = session.connect_user('username') 118 | user.follow() 119 | 120 | project = user.projects()[0] 121 | project.love() 122 | project.post_comment('Great project!') 123 | ``` 124 | 125 | **All scratchattach features are documented in the [documentation](https://github.com/TimMcCool/scratchattach/wiki/Documentation).** 126 | -------------------------------------------------------------------------------- /assets/CloudRequests_Template.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/assets/CloudRequests_Template.sb3 -------------------------------------------------------------------------------- /assets/CloudStorage_Template.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/assets/CloudStorage_Template.sb3 -------------------------------------------------------------------------------- /assets/Encoder.sprite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/assets/Encoder.sprite3 -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_build/doctrees/environment.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/doctrees/environment.pickle -------------------------------------------------------------------------------- /docs/_build/doctrees/index.doctree: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/doctrees/index.doctree -------------------------------------------------------------------------------- /docs/_build/doctrees/modules.doctree: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/doctrees/modules.doctree -------------------------------------------------------------------------------- /docs/_build/doctrees/scratchattach.doctree: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/doctrees/scratchattach.doctree -------------------------------------------------------------------------------- /docs/_build/html/.buildinfo: -------------------------------------------------------------------------------- 1 | # Sphinx build info version 1 2 | # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. 3 | config: 32128858d58f7337b5504ad546097c3d 4 | tags: 645f666f9bcd5a90fca523b33c5a78b7 5 | -------------------------------------------------------------------------------- /docs/_build/html/_modules/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Overview: module code — scratchattach 1.4.0 documentation 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |

24 | 49 | 50 |
54 | 55 |
56 |
57 |
58 |
    59 |
  • 60 | 61 |
  • 62 |
  • 63 |
64 |
65 |
66 |
67 | 83 |
84 | 98 |
99 |
100 |
101 |
102 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /docs/_build/html/_sources/index.rst.txt: -------------------------------------------------------------------------------- 1 | .. scratchattach documentation master file, created by 2 | sphinx-quickstart on Sun Sep 3 15:55:08 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to scratchattach's documentation! 7 | ========================================= 8 | 9 | This is an extended documentation of all scratchattach features. It's currently work-in-progress. 10 | 11 | Warning: 12 | If you're new to scratchattach and want to find out how to use it, then you probably won't find helpful information here. 13 | 14 | Visit the GitHub documentation instead: 15 | https://github.com/TimMcCool/scratchattach/wiki 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Contents: 20 | 21 | modules 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/_build/html/_sources/modules.rst.txt: -------------------------------------------------------------------------------- 1 | scratchattach 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | scratchattach 8 | -------------------------------------------------------------------------------- /docs/_build/html/_sources/scratchattach.rst.txt: -------------------------------------------------------------------------------- 1 | scratchattach package 2 | ===================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | scratchattach.cloud module 8 | -------------------------- 9 | 10 | .. automodule:: scratchattach.cloud 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | scratchattach.cloud\_requests module 16 | ------------------------------------ 17 | 18 | .. automodule:: scratchattach.cloud_requests 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | scratchattach.encoder module 24 | ---------------------------- 25 | 26 | .. automodule:: scratchattach.encoder 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | scratchattach.exceptions module 32 | ------------------------------- 33 | 34 | .. automodule:: scratchattach.exceptions 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | scratchattach.forum module 40 | -------------------------- 41 | 42 | .. automodule:: scratchattach.forum 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | scratchattach.project module 48 | ---------------------------- 49 | 50 | .. automodule:: scratchattach.project 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | scratchattach.session module 56 | ---------------------------- 57 | 58 | .. automodule:: scratchattach.session 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | scratchattach.studio module 64 | --------------------------- 65 | 66 | .. automodule:: scratchattach.studio 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | scratchattach.user module 72 | ------------------------- 73 | 74 | .. automodule:: scratchattach.user 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | Module contents 80 | --------------- 81 | 82 | .. automodule:: scratchattach 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | -------------------------------------------------------------------------------- /docs/_build/html/_static/css/badge_only.css: -------------------------------------------------------------------------------- 1 | .clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}} -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-bold-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-bold-italic.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-bold-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-bold-italic.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-bold.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-bold.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-normal-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-normal-italic.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-normal-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-normal-italic.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-normal.woff -------------------------------------------------------------------------------- /docs/_build/html/_static/css/fonts/lato-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/css/fonts/lato-normal.woff2 -------------------------------------------------------------------------------- /docs/_build/html/_static/documentation_options.js: -------------------------------------------------------------------------------- 1 | var DOCUMENTATION_OPTIONS = { 2 | URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'), 3 | VERSION: '1.4.0', 4 | LANGUAGE: 'None', 5 | COLLAPSE_INDEX: false, 6 | BUILDER: 'html', 7 | FILE_SUFFIX: '.html', 8 | LINK_SUFFIX: '.html', 9 | HAS_SOURCE: true, 10 | SOURCELINK_SUFFIX: '.txt', 11 | NAVIGATION_WITH_KEYS: false 12 | }; -------------------------------------------------------------------------------- /docs/_build/html/_static/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/_static/file.png -------------------------------------------------------------------------------- /docs/_build/html/_static/js/badge_only.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=4)}({4:function(e,t,r){}}); -------------------------------------------------------------------------------- /docs/_build/html/_static/js/html5shiv-printshiv.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @preserve HTML5 Shiv 3.7.3-pre | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed 3 | */ 4 | !function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=y.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=y.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),y.elements=c+" "+a,j(b)}function f(a){var b=x[a[v]];return b||(b={},w++,a[v]=w,x[w]=b),b}function g(a,c,d){if(c||(c=b),q)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():u.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||t.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),q)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return y.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(y,b.frag)}function j(a){a||(a=b);var d=f(a);return!y.shivCSS||p||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),q||i(a,d),a}function k(a){for(var b,c=a.getElementsByTagName("*"),e=c.length,f=RegExp("^(?:"+d().join("|")+")$","i"),g=[];e--;)b=c[e],f.test(b.nodeName)&&g.push(b.applyElement(l(b)));return g}function l(a){for(var b,c=a.attributes,d=c.length,e=a.ownerDocument.createElement(A+":"+a.nodeName);d--;)b=c[d],b.specified&&e.setAttribute(b.nodeName,b.nodeValue);return e.style.cssText=a.style.cssText,e}function m(a){for(var b,c=a.split("{"),e=c.length,f=RegExp("(^|[\\s,>+~])("+d().join("|")+")(?=[[\\s,>+~#.:]|$)","gi"),g="$1"+A+"\\:$2";e--;)b=c[e]=c[e].split("}"),b[b.length-1]=b[b.length-1].replace(f,g),c[e]=b.join("}");return c.join("{")}function n(a){for(var b=a.length;b--;)a[b].removeNode()}function o(a){function b(){clearTimeout(g._removeSheetTimer),d&&d.removeNode(!0),d=null}var d,e,g=f(a),h=a.namespaces,i=a.parentWindow;return!B||a.printShived?a:("undefined"==typeof h[A]&&h.add(A),i.attachEvent("onbeforeprint",function(){b();for(var f,g,h,i=a.styleSheets,j=[],l=i.length,n=Array(l);l--;)n[l]=i[l];for(;h=n.pop();)if(!h.disabled&&z.test(h.media)){try{f=h.imports,g=f.length}catch(o){g=0}for(l=0;g>l;l++)n.push(f[l]);try{j.push(h.cssText)}catch(o){}}j=m(j.reverse().join("")),e=k(a),d=c(a,j)}),i.attachEvent("onafterprint",function(){n(e),clearTimeout(g._removeSheetTimer),g._removeSheetTimer=setTimeout(b,500)}),a.printShived=!0,a)}var p,q,r="3.7.3",s=a.html5||{},t=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,u=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",w=0,x={};!function(){try{var a=b.createElement("a");a.innerHTML="",p="hidden"in a,q=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){p=!0,q=!0}}();var y={elements:s.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:r,shivCSS:s.shivCSS!==!1,supportsUnknownElements:q,shivMethods:s.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=y,j(b);var z=/^$|\b(?:all|print)\b/,A="html5shiv",B=!q&&function(){var c=b.documentElement;return!("undefined"==typeof b.namespaces||"undefined"==typeof b.parentWindow||"undefined"==typeof c.applyElement||"undefined"==typeof c.removeNode||"undefined"==typeof a.attachEvent)}();y.type+=" print",y.shivPrint=o,o(b),"object"==typeof module&&module.exports&&(module.exports=y)}("undefined"!=typeof window?window:this,document); -------------------------------------------------------------------------------- /docs/_build/html/_static/js/html5shiv.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed 3 | */ 4 | !function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.3-pre",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b),"object"==typeof module&&module.exports&&(module.exports=t)}("undefined"!=typeof window?window:this,document); -------------------------------------------------------------------------------- /docs/_build/html/_static/js/theme.js: -------------------------------------------------------------------------------- 1 | !function(n){var e={};function t(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return n[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=n,t.c=e,t.d=function(n,e,i){t.o(n,e)||Object.defineProperty(n,e,{enumerable:!0,get:i})},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.t=function(n,e){if(1&e&&(n=t(n)),8&e)return n;if(4&e&&"object"==typeof n&&n&&n.__esModule)return n;var i=Object.create(null);if(t.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:n}),2&e&&"string"!=typeof n)for(var o in n)t.d(i,o,function(e){return n[e]}.bind(null,o));return i},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.p="",t(t.s=0)}([function(n,e,t){t(1),n.exports=t(3)},function(n,e,t){(function(){var e="undefined"!=typeof window?window.jQuery:t(2);n.exports.ThemeNav={navBar:null,win:null,winScroll:!1,winResize:!1,linkScroll:!1,winPosition:0,winHeight:null,docHeight:null,isRunning:!1,enable:function(n){var t=this;void 0===n&&(n=!0),t.isRunning||(t.isRunning=!0,e((function(e){t.init(e),t.reset(),t.win.on("hashchange",t.reset),n&&t.win.on("scroll",(function(){t.linkScroll||t.winScroll||(t.winScroll=!0,requestAnimationFrame((function(){t.onScroll()})))})),t.win.on("resize",(function(){t.winResize||(t.winResize=!0,requestAnimationFrame((function(){t.onResize()})))})),t.onResize()})))},enableSticky:function(){this.enable(!0)},init:function(n){n(document);var e=this;this.navBar=n("div.wy-side-scroll:first"),this.win=n(window),n(document).on("click","[data-toggle='wy-nav-top']",(function(){n("[data-toggle='wy-nav-shift']").toggleClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift")})).on("click",".wy-menu-vertical .current ul li a",(function(){var t=n(this);n("[data-toggle='wy-nav-shift']").removeClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift"),e.toggleCurrent(t),e.hashChange()})).on("click","[data-toggle='rst-current-version']",(function(){n("[data-toggle='rst-versions']").toggleClass("shift-up")})),n("table.docutils:not(.field-list,.footnote,.citation)").wrap("
"),n("table.docutils.footnote").wrap("
"),n("table.docutils.citation").wrap("
"),n(".wy-menu-vertical ul").not(".simple").siblings("a").each((function(){var t=n(this);expand=n(''),expand.on("click",(function(n){return e.toggleCurrent(t),n.stopPropagation(),!1})),t.prepend(expand)}))},reset:function(){var n=encodeURI(window.location.hash)||"#";try{var e=$(".wy-menu-vertical"),t=e.find('[href="'+n+'"]');if(0===t.length){var i=$('.document [id="'+n.substring(1)+'"]').closest("div.section");0===(t=e.find('[href="#'+i.attr("id")+'"]')).length&&(t=e.find('[href="#"]'))}if(t.length>0){$(".wy-menu-vertical .current").removeClass("current").attr("aria-expanded","false"),t.addClass("current").attr("aria-expanded","true"),t.closest("li.toctree-l1").parent().addClass("current").attr("aria-expanded","true");for(let n=1;n<=10;n++)t.closest("li.toctree-l"+n).addClass("current").attr("aria-expanded","true");t[0].scrollIntoView()}}catch(n){console.log("Error expanding nav for anchor",n)}},onScroll:function(){this.winScroll=!1;var n=this.win.scrollTop(),e=n+this.winHeight,t=this.navBar.scrollTop()+(n-this.winPosition);n<0||e>this.docHeight||(this.navBar.scrollTop(t),this.winPosition=n)},onResize:function(){this.winResize=!1,this.winHeight=this.win.height(),this.docHeight=$(document).height()},hashChange:function(){this.linkScroll=!0,this.win.one("hashchange",(function(){this.linkScroll=!1}))},toggleCurrent:function(n){var e=n.closest("li");e.siblings("li.current").removeClass("current").attr("aria-expanded","false"),e.siblings().find("li.current").removeClass("current").attr("aria-expanded","false");var t=e.find("> ul li");t.length&&(t.removeClass("current").attr("aria-expanded","false"),e.toggleClass("current").attr("aria-expanded",(function(n,e){return"true"==e?"false":"true"})))}},"undefined"!=typeof window&&(window.SphinxRtdTheme={Navigation:n.exports.ThemeNav,StickyNav:n.exports.ThemeNav}),function(){for(var n=0,e=["ms","moz","webkit","o"],t=0;t 2 | 3 | 4 | 5 | 6 | 7 | Welcome to scratchattach’s documentation! — scratchattach 1.4.0 documentation 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 51 | 52 |
56 | 57 |
58 |
59 |
60 |
    61 |
  • 62 | 63 |
  • 64 | View page source 65 |
  • 66 |
67 |
68 |
69 |
70 |
71 | 72 |
73 |

Welcome to scratchattach’s documentation!

74 |

This is an extended documentation of all scratchattach features. It’s currently work-in-progress.

75 |
76 |
Warning:

If you’re new to scratchattach and want to find out how to use it, then you probably won’t find helpful information here.

77 |

Visit the GitHub documentation instead: 78 | https://github.com/TimMcCool/scratchattach/wiki

79 |
80 |
81 |
82 |

Contents:

83 | 89 |
90 |
91 |
92 |

Indices and tables

93 | 98 |
99 | 100 | 101 |
102 |
103 |
106 | 107 |
108 | 109 |
110 |

© Copyright 2023, TimMcCool.

111 |
112 | 113 | Built with Sphinx using a 114 | theme 115 | provided by Read the Docs. 116 | 117 | 118 |
119 |
120 |
121 |
122 |
123 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /docs/_build/html/modules.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | scratchattach — scratchattach 1.4.0 documentation 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 55 | 56 |
60 | 61 |
62 |
63 |
64 | 71 |
72 |
73 | 101 |
105 | 106 |
107 | 108 |
109 |

© Copyright 2023, TimMcCool.

110 |
111 | 112 | Built with Sphinx using a 113 | theme 114 | provided by Read the Docs. 115 | 116 | 117 |
118 |
119 |
120 |
121 |
122 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /docs/_build/html/objects.inv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/docs/_build/html/objects.inv -------------------------------------------------------------------------------- /docs/_build/html/py-modindex.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Python Module Index — scratchattach 1.4.0 documentation 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 52 | 53 |
57 | 58 |
59 |
60 |
61 |
    62 |
  • 63 | 64 |
  • 65 |
  • 66 |
67 |
68 |
69 |
70 |
71 | 72 | 73 |

Python Module Index

74 | 75 |
76 | s 77 |
78 | 79 | 80 | 81 | 83 | 84 | 86 | 89 | 90 | 91 | 94 | 95 | 96 | 99 | 100 | 101 | 104 | 105 | 106 | 109 | 110 | 111 | 114 | 115 | 116 | 119 | 120 | 121 | 124 | 125 | 126 | 129 | 130 | 131 | 134 |
 
82 | s
87 | scratchattach 88 |
    92 | scratchattach.cloud 93 |
    97 | scratchattach.cloud_requests 98 |
    102 | scratchattach.encoder 103 |
    107 | scratchattach.exceptions 108 |
    112 | scratchattach.forum 113 |
    117 | scratchattach.project 118 |
    122 | scratchattach.session 123 |
    127 | scratchattach.studio 128 |
    132 | scratchattach.user 133 |
135 | 136 | 137 |
138 |
139 |
140 | 141 |
142 | 143 |
144 |

© Copyright 2023, TimMcCool.

145 |
146 | 147 | Built with Sphinx using a 148 | theme 149 | provided by Read the Docs. 150 | 151 | 152 |
153 |
154 |
155 |
156 |
157 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /docs/_build/html/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Search — scratchattach 1.4.0 documentation 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 52 | 53 |
57 | 58 |
59 |
60 |
61 |
    62 |
  • 63 | 64 |
  • 65 |
  • 66 |
67 |
68 |
69 |
70 |
71 | 72 | 79 | 80 | 81 |
82 | 83 |
84 | 85 |
86 |
87 |
88 | 89 |
90 | 91 |
92 |

© Copyright 2023, TimMcCool.

93 |
94 | 95 | Built with Sphinx using a 96 | theme 97 | provided by Read the Docs. 98 | 99 | 100 |
101 |
102 |
103 |
104 |
105 | 110 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = 'scratchattach' 20 | copyright = '2023, TimMcCool' 21 | author = 'TimMcCool' 22 | 23 | # The full version, including alpha/beta/rc tags 24 | release = '2.0.0' 25 | 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.viewcode', 35 | 'sphinx.ext.autosummary', 36 | 'sphinx.ext.napoleon' 37 | ] 38 | 39 | # Automatically generate summary .rst files for modules 40 | autosummary_generate = True 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 49 | 50 | # -- Options for HTML output ------------------------------------------------- 51 | 52 | # The theme to use for HTML and HTML Help pages. See the documentation for 53 | # a list of builtin themes. 54 | # 55 | html_theme = 'sphinx_rtd_theme' 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ['_static'] 61 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. scratchattach documentation master file, created by 2 | sphinx-quickstart on Sun Sep 3 15:55:08 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to scratchattach's documentation! 7 | ========================================= 8 | 9 | This is an extended documentation of all scratchattach features. It's currently work-in-progress. 10 | 11 | Warning: 12 | If you're new to scratchattach and want to find out how to use it, then you probably won't find helpful information here. 13 | 14 | Visit the GitHub documentation instead: 15 | https://github.com/TimMcCool/scratchattach/wiki 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Contents: 20 | 21 | modules 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | scratchattach 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | scratchattach 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | -------------------------------------------------------------------------------- /logos/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/logos/logo.png -------------------------------------------------------------------------------- /logos/logo_square_container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/logos/logo_square_container.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | websocket-client 2 | requests 3 | bs4 4 | SimpleWebSocketServer 5 | typing-extensions 6 | browser_cookie3 7 | -------------------------------------------------------------------------------- /scratchattach/__init__.py: -------------------------------------------------------------------------------- 1 | from .cloud.cloud import ScratchCloud, TwCloud, get_cloud, get_scratch_cloud, get_tw_cloud 2 | from .cloud._base import BaseCloud, AnyCloud 3 | 4 | from .eventhandlers.cloud_server import init_cloud_server 5 | from .eventhandlers._base import BaseEventHandler 6 | from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter 7 | from .eventhandlers.cloud_storage import Database 8 | from .eventhandlers.combine import MultiEventHandler 9 | 10 | from .other.other_apis import * 11 | from .other.project_json_capabilities import ProjectBody, get_empty_project_pb, get_pb_from_dict, read_sb3_file, download_asset 12 | from .utils.encoder import Encoding 13 | from .utils.enums import Languages, TTSVoices 14 | from .utils.exceptions import LoginDataWarning 15 | 16 | from .site.activity import Activity 17 | from .site.backpack_asset import BackpackAsset 18 | from .site.comment import Comment 19 | from .site.cloud_activity import CloudActivity 20 | from .site.forum import ForumPost, ForumTopic, get_topic, get_topic_list, youtube_link_to_scratch 21 | from .site.project import Project, get_project, search_projects, explore_projects 22 | from .site.session import Session, login, login_by_id, login_by_session_string, login_by_io, login_by_file, login_from_browser 23 | from .site.studio import Studio, get_studio, search_studios, explore_studios 24 | from .site.classroom import Classroom, get_classroom 25 | from .site.user import User, get_user 26 | from .site._base import BaseSiteComponent 27 | from .site.browser_cookies import Browser, ANY, FIREFOX, CHROME, CHROMIUM, VIVALDI, EDGE, EDGE_DEV, SAFARI 28 | 29 | from . import editor 30 | -------------------------------------------------------------------------------- /scratchattach/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | from .cloud import * 2 | from ._base import * -------------------------------------------------------------------------------- /scratchattach/cloud/cloud.py: -------------------------------------------------------------------------------- 1 | """v2 ready: ScratchCloud, TwCloud and CustomCloud classes""" 2 | 3 | from __future__ import annotations 4 | 5 | from ._base import BaseCloud 6 | from typing import Type 7 | from scratchattach.utils.requests import requests 8 | from scratchattach.utils import exceptions, commons 9 | from scratchattach.site import cloud_activity 10 | 11 | 12 | class ScratchCloud(BaseCloud): 13 | def __init__(self, *, project_id, _session=None): 14 | super().__init__() 15 | 16 | self.project_id = project_id 17 | 18 | # Configure this object's attributes specifically for being used with Scratch's cloud: 19 | self.cloud_host = "wss://clouddata.scratch.mit.edu" 20 | self.length_limit = 256 21 | self._session = _session 22 | if self._session is not None: 23 | self.username = self._session.username 24 | self.cookie = "scratchsessionsid=" + self._session.id + ";" 25 | self.origin = "https://scratch.mit.edu" 26 | 27 | def connect(self): 28 | self._assert_auth() # Connecting to Scratch's cloud websocket requires a login to the Scratch website 29 | super().connect() 30 | 31 | def set_var(self, variable, value): 32 | self._assert_auth() # Setting a cloud var requires a login to the Scratch website 33 | super().set_var(variable, value) 34 | 35 | def set_vars(self, var_value_dict, *, intelligent_waits=True): 36 | self._assert_auth() 37 | super().set_vars(var_value_dict, intelligent_waits=intelligent_waits) 38 | 39 | def logs(self, *, filter_by_var_named=None, limit=100, offset=0) -> list[cloud_activity.CloudActivity]: 40 | """ 41 | Gets the data from Scratch's clouddata logs. 42 | 43 | Keyword Arguments: 44 | filter_by_var_named (str or None): If you only want to get data for one cloud variable, set this argument to its name. 45 | limit (int): Max. amount of returned activity. 46 | offset (int): Offset of the first activity in the returned list. 47 | log_url (str): If you want to get the clouddata from a cloud log API different to Scratch's normal cloud log API, set this argument to the URL of the API. Only set this argument if you know what you are doing. If you want to get the clouddata from the normal API, don't put this argument. 48 | """ 49 | try: 50 | data = requests.get(f"https://clouddata.scratch.mit.edu/logs?projectid={self.project_id}&limit={limit}&offset={offset}", timeout=10).json() 51 | if filter_by_var_named is not None: 52 | filter_by_var_named = filter_by_var_named.removeprefix("☁ ") 53 | data = list(filter(lambda k: k["name"] == "☁ "+filter_by_var_named, data)) 54 | for x in data: 55 | x["cloud"] = self 56 | return commons.parse_object_list(data, cloud_activity.CloudActivity, self._session, "name") 57 | except Exception as e: 58 | raise exceptions.FetchError(str(e)) 59 | 60 | def get_var(self, var, *, use_logs=False): 61 | var = var.removeprefix("☁ ") 62 | if self._session is None or use_logs: 63 | filtered = self.logs(limit=100, filter_by_var_named="☁ "+var) 64 | if len(filtered) == 0: 65 | return None 66 | return filtered[0].value 67 | else: 68 | if self.recorder is None: 69 | initial_values = self.get_all_vars(use_logs=True) 70 | return super().get_var("☁ "+var, recorder_initial_values=initial_values) 71 | else: 72 | return super().get_var("☁ "+var) 73 | 74 | def get_all_vars(self, *, use_logs=False): 75 | if self._session is None or use_logs: 76 | logs = self.logs(limit=100) 77 | logs.reverse() 78 | clouddata = {} 79 | for activity in logs: 80 | clouddata[activity.name] = activity.value 81 | return clouddata 82 | else: 83 | if self.recorder is None: 84 | initial_values = self.get_all_vars(use_logs=True) 85 | return super().get_all_vars(recorder_initial_values=initial_values) 86 | else: 87 | return super().get_all_vars() 88 | 89 | def events(self, *, use_logs=False): 90 | if self._session is None or use_logs: 91 | from scratchattach.eventhandlers.cloud_events import CloudLogEvents 92 | return CloudLogEvents(self) 93 | else: 94 | return super().events() 95 | 96 | 97 | class TwCloud(BaseCloud): 98 | def __init__(self, *, project_id, cloud_host="wss://clouddata.turbowarp.org", purpose="", contact="", 99 | _session=None): 100 | super().__init__() 101 | 102 | self.project_id = project_id 103 | 104 | # Configure this object's attributes specifically for being used with TurboWarp's cloud: 105 | self.cloud_host = cloud_host 106 | self.ws_shortterm_ratelimit = 0 # TurboWarp doesn't enforce a wait time between cloud variable sets 107 | self.ws_longterm_ratelimit = 0 108 | self.length_limit = 100000 # TurboWarp doesn't enforce a cloud variable length 109 | purpose_string = "" 110 | if purpose != "" or contact != "": 111 | purpose_string = f" (Purpose:{purpose}; Contact:{contact})" 112 | self.header = {"User-Agent":f"scratchattach/2.0.0{purpose_string}"} 113 | 114 | class CustomCloud(BaseCloud): 115 | 116 | def __init__(self, *, project_id, cloud_host, **kwargs): 117 | super().__init__() 118 | 119 | self.project_id = project_id 120 | self.cloud_host = cloud_host 121 | 122 | # Configure this object's attributes specifically for the cloud that the developer wants to connect to: 123 | # -> For this purpose, all additional keyword arguments (kwargs) will be set as attributes of the CustomCloud object 124 | # This allows the maximum amount of attribute customization 125 | # See the docstring for the cloud._base.BaseCloud class to find out what attributes can be set / specified as keyword args 126 | self.__dict__.update(kwargs) 127 | 128 | # If even more customization is needed, the developer can create a class inheriting from cloud._base.BaseCloud to override functions like .set_var etc. 129 | 130 | 131 | def get_cloud(project_id, *, CloudClass:Type[BaseCloud]=ScratchCloud) -> BaseCloud: 132 | """ 133 | Connects to a cloud (by default Scratch's cloud) as logged out user. 134 | 135 | Warning: 136 | Since this method doesn't connect a login / session to the returned object, setting Scratch cloud variables won't be possible with it. 137 | 138 | To set Scratch cloud variables, use `scratchattach.site.session.Session.connect_scratch_cloud` instead. 139 | 140 | Args: 141 | project_id: 142 | 143 | Keyword arguments: 144 | CloudClass: The class that the returned object should be of. By default this class is scratchattach.cloud.ScratchCloud. 145 | 146 | Returns: 147 | Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any class inheriting from BaseCloud. 148 | """ 149 | print("Warning: To set Scratch cloud variables, use session.connect_cloud instead of get_cloud") 150 | return CloudClass(project_id=project_id) 151 | 152 | def get_scratch_cloud(project_id): 153 | """ 154 | Warning: 155 | Since this method doesn't connect a login / session to the returned object, setting Scratch cloud variables won't be possible with it. 156 | 157 | To set Scratch cloud variables, use `scratchattach.Session.connect_scratch_cloud` instead. 158 | 159 | 160 | Returns: 161 | scratchattach.cloud.ScratchCloud: An object representing the Scratch cloud of a project. 162 | """ 163 | print("Warning: To set Scratch cloud variables, use session.connect_scratch_cloud instead of get_scratch_cloud") 164 | return ScratchCloud(project_id=project_id) 165 | 166 | def get_tw_cloud(project_id, *, purpose="", contact="", cloud_host="wss://clouddata.turbowarp.org"): 167 | """ 168 | Returns: 169 | scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project. 170 | """ 171 | return TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host) 172 | -------------------------------------------------------------------------------- /scratchattach/editor/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | scratchattach.editor (sbeditor v2) - a library for all things sb3 3 | """ 4 | 5 | from .asset import Asset, Costume, Sound 6 | from .project import Project 7 | from .extension import Extensions, Extension 8 | from .mutation import Mutation, Argument, parse_proc_code 9 | from .meta import Meta, set_meta_platform 10 | from .sprite import Sprite 11 | from .block import Block 12 | from .prim import Prim, PrimTypes 13 | from .backpack_json import load_script as load_script_from_backpack 14 | from .twconfig import TWConfig, is_valid_twconfig 15 | from .inputs import Input, ShadowStatuses 16 | from .field import Field 17 | from .vlb import Variable, List, Broadcast 18 | from .comment import Comment 19 | from .monitor import Monitor 20 | 21 | from .build_defaulting import add_chain, add_comment, add_block 22 | -------------------------------------------------------------------------------- /scratchattach/editor/asset.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from hashlib import md5 5 | import requests 6 | 7 | from . import base, commons, sprite, build_defaulting 8 | from typing import Optional 9 | 10 | 11 | @dataclass(init=True, repr=True) 12 | class AssetFile: 13 | """ 14 | Represents the file information for an asset 15 | - stores the filename, data, and md5 hash 16 | """ 17 | filename: str 18 | _data: bytes = field(repr=False, default=None) 19 | _md5: str = field(repr=False, default=None) 20 | 21 | @property 22 | def data(self): 23 | """ 24 | Return the contents of the asset file, as bytes 25 | """ 26 | if self._data is None: 27 | # Download and cache 28 | rq = requests.get(f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}/get/") 29 | if rq.status_code != 200: 30 | raise ValueError(f"Can't download asset {self.filename}\nIs not uploaded to scratch! Response: {rq.text}") 31 | 32 | self._data = rq.content 33 | 34 | return self._data 35 | 36 | @property 37 | def md5(self): 38 | """ 39 | Compute/retrieve the md5 hash value of the asset file data 40 | """ 41 | if self._md5 is None: 42 | self._md5 = md5(self.data).hexdigest() 43 | 44 | return self._md5 45 | 46 | 47 | class Asset(base.SpriteSubComponent): 48 | def __init__(self, 49 | name: str = "costume1", 50 | file_name: str = "b7853f557e4426412e64bb3da6531a99.svg", 51 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 52 | """ 53 | Represents a generic asset. Can be a sound or an image. 54 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Assets 55 | """ 56 | try: 57 | asset_id, data_format = file_name.split('.') 58 | except ValueError: 59 | raise ValueError(f"Invalid file name: {file_name}, # of '.' in {file_name} ({file_name.count('.')}) != 2; " 60 | f"(too many/few values to unpack)") 61 | self.name = name 62 | 63 | self.id = asset_id 64 | self.data_format = data_format 65 | 66 | super().__init__(_sprite) 67 | 68 | def __repr__(self): 69 | return f"Asset<{self.name!r}>" 70 | 71 | @property 72 | def folder(self): 73 | """ 74 | Get the folder name of this asset, based on the asset name. Uses the turbowarp syntax 75 | """ 76 | return commons.get_folder_name(self.name) 77 | 78 | @property 79 | def name_nfldr(self): 80 | """ 81 | Get the asset name after removing the folder name 82 | """ 83 | return commons.get_name_nofldr(self.name) 84 | 85 | @property 86 | def file_name(self): 87 | """ 88 | Get the exact file name, as it would be within an sb3 file 89 | equivalent to the md5ext value using in scratch project JSON 90 | """ 91 | return f"{self.id}.{self.data_format}" 92 | 93 | @property 94 | def md5ext(self): 95 | """ 96 | Get the exact file name, as it would be within an sb3 file 97 | equivalent to the md5ext value using in scratch project JSON 98 | """ 99 | return self.file_name 100 | 101 | @property 102 | def parent(self): 103 | """ 104 | Return the project that this asset is attached to. If there is no attached project, 105 | try returning the attached sprite 106 | """ 107 | if self.project is None: 108 | return self.sprite 109 | else: 110 | return self.project 111 | 112 | @property 113 | def asset_file(self) -> AssetFile: 114 | """ 115 | Get the associated asset file object for this asset object 116 | """ 117 | for asset_file in self.parent.asset_data: 118 | if asset_file.filename == self.file_name: 119 | return asset_file 120 | 121 | # No pre-existing asset file object; create one and add it to the project 122 | asset_file = AssetFile(self.file_name) 123 | self.project.asset_data.append(asset_file) 124 | return asset_file 125 | 126 | @staticmethod 127 | def from_json(data: dict): 128 | """ 129 | Load asset data from project.json 130 | """ 131 | _name = data.get("name") 132 | _file_name = data.get("md5ext") 133 | if _file_name is None: 134 | if "dataFormat" in data and "assetId" in data: 135 | _id = data["assetId"] 136 | _data_format = data["dataFormat"] 137 | _file_name = f"{_id}.{_data_format}" 138 | 139 | return Asset(_name, _file_name) 140 | 141 | def to_json(self) -> dict: 142 | """ 143 | Convert asset data to project.json format 144 | """ 145 | return { 146 | "name": self.name, 147 | 148 | "assetId": self.id, 149 | "md5ext": self.file_name, 150 | "dataFormat": self.data_format, 151 | } 152 | 153 | # todo: implement below: 154 | """ 155 | @staticmethod 156 | def from_file(fp: str, name: str = None): 157 | image_types = ("png", "jpg", "jpeg", "svg") 158 | sound_types = ("wav", "mp3") 159 | 160 | # Should save data as well so it can be uploaded to scratch if required (add to project asset data) 161 | ... 162 | """ 163 | 164 | 165 | class Costume(Asset): 166 | def __init__(self, 167 | name: str = "Cat", 168 | file_name: str = "b7853f557e4426412e64bb3da6531a99.svg", 169 | 170 | bitmap_resolution=None, 171 | rotation_center_x: int | float = 48, 172 | rotation_center_y: int | float = 50, 173 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 174 | """ 175 | A costume (image). An asset with additional properties 176 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Costumes 177 | """ 178 | super().__init__(name, file_name, _sprite) 179 | 180 | self.bitmap_resolution = bitmap_resolution 181 | self.rotation_center_x = rotation_center_x 182 | self.rotation_center_y = rotation_center_y 183 | 184 | @staticmethod 185 | def from_json(data): 186 | """ 187 | Load costume data from project.json 188 | """ 189 | _asset_load = Asset.from_json(data) 190 | 191 | bitmap_resolution = data.get("bitmapResolution") 192 | 193 | rotation_center_x = data["rotationCenterX"] 194 | rotation_center_y = data["rotationCenterY"] 195 | return Costume(_asset_load.name, _asset_load.file_name, 196 | 197 | bitmap_resolution, rotation_center_x, rotation_center_y) 198 | 199 | def to_json(self) -> dict: 200 | """ 201 | Convert costume to project.json format 202 | """ 203 | _json = super().to_json() 204 | _json.update({ 205 | "bitmapResolution": self.bitmap_resolution, 206 | "rotationCenterX": self.rotation_center_x, 207 | "rotationCenterY": self.rotation_center_y 208 | }) 209 | return _json 210 | 211 | 212 | class Sound(Asset): 213 | def __init__(self, 214 | name: str = "pop", 215 | file_name: str = "83a9787d4cb6f3b7632b4ddfebf74367.wav", 216 | 217 | rate: Optional[int] = None, 218 | sample_count: Optional[int] = None, 219 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 220 | """ 221 | A sound. An asset with additional properties 222 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Sounds 223 | """ 224 | super().__init__(name, file_name, _sprite) 225 | 226 | self.rate = rate 227 | self.sample_count = sample_count 228 | 229 | @staticmethod 230 | def from_json(data): 231 | """ 232 | Load sound from project.json 233 | """ 234 | _asset_load = Asset.from_json(data) 235 | 236 | rate = data.get("rate") 237 | sample_count = data.get("sampleCount") 238 | return Sound(_asset_load.name, _asset_load.file_name, rate, sample_count) 239 | 240 | def to_json(self) -> dict: 241 | """ 242 | Convert Sound to project.json format 243 | """ 244 | _json = super().to_json() 245 | commons.noneless_update(_json, { 246 | "rate": self.rate, 247 | "sampleCount": self.sample_count 248 | }) 249 | return _json 250 | -------------------------------------------------------------------------------- /scratchattach/editor/backpack_json.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to deal with the backpack's weird JSON format, by overriding with new load methods 3 | """ 4 | from __future__ import annotations 5 | 6 | from . import block, prim, field, inputs, mutation, sprite 7 | 8 | 9 | def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]: 10 | """ 11 | Function for reading the fields in a backpack **primitive** 12 | """ 13 | for key, value in _fields.items(): 14 | key: str 15 | value: dict[str, str] 16 | prim_value, prim_name, prim_id = (None,) * 3 17 | if key == "NUM": 18 | prim_value = value.get("value") 19 | else: 20 | prim_name = value.get("value") 21 | prim_id = value.get("id") 22 | 23 | # There really should only be 1 item, and this function can only return for that item 24 | return prim_value, prim_name, prim_id 25 | return (None,) * 3 26 | 27 | 28 | class BpField(field.Field): 29 | """ 30 | A normal field but with a different load method 31 | """ 32 | 33 | @staticmethod 34 | def from_json(data: dict[str, str]) -> field.Field: 35 | # We can very simply convert it to the regular format 36 | data = [data.get("value"), data.get("id")] 37 | return field.Field.from_json(data) 38 | 39 | 40 | class BpInput(inputs.Input): 41 | """ 42 | A normal input but with a different load method 43 | """ 44 | 45 | @staticmethod 46 | def from_json(data: dict[str, str]) -> inputs.Input: 47 | # The actual data is stored in a separate prim block 48 | _id = data.get("shadow") 49 | _obscurer_id = data.get("block") 50 | 51 | if _obscurer_id == _id: 52 | # If both the shadow and obscurer are the same, then there is no actual obscurer 53 | _obscurer_id = None 54 | # We cannot work out the shadow status yet since that is located in the primitive 55 | return inputs.Input(None, _id=_id, _obscurer_id=_obscurer_id) 56 | 57 | 58 | class BpBlock(block.Block): 59 | """ 60 | A normal block but with a different load method 61 | """ 62 | 63 | @staticmethod 64 | def from_json(data: dict) -> prim.Prim | block.Block: 65 | """ 66 | Load a block in the **backpack** JSON format 67 | :param data: A dictionary (not list) 68 | :return: A new block/prim object 69 | """ 70 | _opcode = data["opcode"] 71 | 72 | _x, _y = data.get("x"), data.get("y") 73 | if prim.is_prim_opcode(_opcode): 74 | # This is actually a prim 75 | prim_value, prim_name, prim_id = parse_prim_fields(data["fields"]) 76 | return prim.Prim(prim.PrimTypes.find(_opcode, "opcode"), 77 | prim_value, prim_name, prim_id) 78 | 79 | _next_id = data.get("next") 80 | _parent_id = data.get("parent") 81 | 82 | _shadow = data.get("shadow", False) 83 | _top_level = data.get("topLevel", _parent_id is None) 84 | 85 | _inputs = {} 86 | for _input_code, _input_data in data.get("inputs", {}).items(): 87 | _inputs[_input_code] = BpInput.from_json(_input_data) 88 | 89 | _fields = {} 90 | for _field_code, _field_data in data.get("fields", {}).items(): 91 | _fields[_field_code] = BpField.from_json(_field_data) 92 | 93 | if "mutation" in data: 94 | _mutation = mutation.Mutation.from_json(data["mutation"]) 95 | else: 96 | _mutation = None 97 | 98 | return block.Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id, 99 | _parent_id=_parent_id) 100 | 101 | 102 | def load_script(_script_data: list[dict]) -> sprite.Sprite: 103 | """ 104 | Loads a script into a sprite from the backpack JSON format 105 | :param _script_data: Backpack script JSON data 106 | :return: a blockchain object containing the script 107 | """ 108 | # Using a sprite since it simplifies things, e.g. local global loading 109 | _blockchain = sprite.Sprite() 110 | 111 | for _block_data in _script_data: 112 | _block = BpBlock.from_json(_block_data) 113 | _block.sprite = _blockchain 114 | _blockchain.blocks[_block_data["id"]] = _block 115 | 116 | _blockchain.link_subcomponents() 117 | return _blockchain 118 | -------------------------------------------------------------------------------- /scratchattach/editor/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Editor base classes 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import copy 8 | import json 9 | from abc import ABC, abstractmethod 10 | from io import TextIOWrapper 11 | from typing import Optional, Any, TYPE_CHECKING, BinaryIO 12 | 13 | if TYPE_CHECKING: 14 | from . import project, sprite, block, mutation, asset 15 | 16 | from . import build_defaulting 17 | 18 | 19 | class Base(ABC): 20 | """ 21 | Abstract base class for most sa.editor classes. Implements copy functions 22 | """ 23 | def dcopy(self): 24 | """ 25 | :return: A **deep** copy of self 26 | """ 27 | return copy.deepcopy(self) 28 | 29 | def copy(self): 30 | """ 31 | :return: A **shallow** copy of self 32 | """ 33 | return copy.copy(self) 34 | 35 | 36 | class JSONSerializable(Base, ABC): 37 | """ 38 | 'Interface' for to_json() and from_json() methods 39 | Also implements save_json() using to_json() 40 | """ 41 | @staticmethod 42 | @abstractmethod 43 | def from_json(data: dict | list | Any): 44 | pass 45 | 46 | @abstractmethod 47 | def to_json(self) -> dict | list | Any: 48 | pass 49 | 50 | def save_json(self, name: str = ''): 51 | """ 52 | Save a json file 53 | """ 54 | data = self.to_json() 55 | with open(f"{self.__class__.__name__.lower()}{name}.json", "w") as f: 56 | json.dump(data, f) 57 | 58 | 59 | class JSONExtractable(JSONSerializable, ABC): 60 | """ 61 | Interface for objects that can be loaded from zip archives containing json files (sprite/project) 62 | Only has one method - load_json 63 | """ 64 | @staticmethod 65 | @abstractmethod 66 | def load_json(data: str | bytes | TextIOWrapper | BinaryIO, load_assets: bool = True, _name: Optional[str] = None) -> tuple[ 67 | str, list[asset.AssetFile], str]: 68 | """ 69 | Automatically extracts the JSON data as a string, as well as providing auto naming 70 | :param data: Either a string of JSON, sb3 file as bytes or as a file object 71 | :param load_assets: Whether to extract assets as well (if applicable) 72 | :param _name: Any provided name (will automatically find one otherwise) 73 | :return: tuple of the name, asset data & json as a string 74 | """ 75 | ... 76 | 77 | 78 | class ProjectSubcomponent(JSONSerializable, ABC): 79 | """ 80 | Base class for any class with an associated project 81 | """ 82 | def __init__(self, _project: Optional[project.Project] = None): 83 | self.project = _project 84 | 85 | 86 | class SpriteSubComponent(JSONSerializable, ABC): 87 | """ 88 | Base class for any class with an associated sprite 89 | """ 90 | def __init__(self, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 91 | if _sprite is build_defaulting.SPRITE_DEFAULT: 92 | _sprite = build_defaulting.current_sprite() 93 | 94 | self.sprite = _sprite 95 | 96 | @property 97 | def project(self) -> project.Project: 98 | """ 99 | Get associated project by proxy of the associated sprite 100 | """ 101 | return self.sprite.project 102 | 103 | 104 | class IDComponent(SpriteSubComponent, ABC): 105 | """ 106 | Base class for classes with an id attribute 107 | """ 108 | def __init__(self, _id: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 109 | self.id = _id 110 | super().__init__(_sprite) 111 | 112 | def __repr__(self): 113 | return f"<{self.__class__.__name__}: {self.id}>" 114 | 115 | 116 | class NamedIDComponent(IDComponent, ABC): 117 | """ 118 | Base class for Variables, Lists and Broadcasts (Name + ID + sprite) 119 | """ 120 | def __init__(self, _id: str, name: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 121 | self.name = name 122 | super().__init__(_id, _sprite) 123 | 124 | def __repr__(self): 125 | return f"<{self.__class__.__name__} '{self.name}'>" 126 | 127 | 128 | class BlockSubComponent(JSONSerializable, ABC): 129 | """ 130 | Base class for classes with associated blocks 131 | """ 132 | def __init__(self, _block: Optional[block.Block] = None): 133 | self.block = _block 134 | 135 | @property 136 | def sprite(self) -> sprite.Sprite: 137 | """ 138 | Fetch sprite by proxy of the block 139 | """ 140 | return self.block.sprite 141 | 142 | @property 143 | def project(self) -> project.Project: 144 | """ 145 | Fetch project by proxy of the sprite (by proxy of the block) 146 | """ 147 | return self.sprite.project 148 | 149 | 150 | class MutationSubComponent(JSONSerializable, ABC): 151 | """ 152 | Base class for classes with associated mutations 153 | """ 154 | def __init__(self, _mutation: Optional[mutation.Mutation] = None): 155 | self.mutation = _mutation 156 | 157 | @property 158 | def block(self) -> block.Block: 159 | """ 160 | Fetch block by proxy of mutation 161 | """ 162 | return self.mutation.block 163 | 164 | @property 165 | def sprite(self) -> sprite.Sprite: 166 | """ 167 | Fetch sprite by proxy of block (by proxy of mutation) 168 | """ 169 | return self.block.sprite 170 | 171 | @property 172 | def project(self) -> project.Project: 173 | """ 174 | Fetch project by proxy of sprite (by proxy of block (by proxy of mutation)) 175 | """ 176 | return self.sprite.project 177 | -------------------------------------------------------------------------------- /scratchattach/editor/build_defaulting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module which stores the 'default' or 'current' selected Sprite/project (stored as a stack) which makes it easier to write scratch code directly in Python 3 | """ 4 | from __future__ import annotations 5 | 6 | from typing import Iterable, TYPE_CHECKING, Final 7 | 8 | if TYPE_CHECKING: 9 | from . import sprite, block, prim, comment 10 | from . import commons 11 | 12 | 13 | class _SetSprite(commons.Singleton): 14 | def __repr__(self): 15 | return f'' 16 | 17 | 18 | SPRITE_DEFAULT: Final[_SetSprite] = _SetSprite() 19 | 20 | _sprite_stack: list[sprite.Sprite] = [] 21 | 22 | 23 | def stack_add_sprite(_sprite: sprite.Sprite): 24 | _sprite_stack.append(_sprite) 25 | 26 | 27 | def current_sprite() -> sprite.Sprite | None: 28 | """ 29 | Retrieve the default sprite from the top of the sprite stack 30 | """ 31 | if len(_sprite_stack) == 0: 32 | return None 33 | return _sprite_stack[-1] 34 | 35 | 36 | def pop_sprite(_sprite: sprite.Sprite) -> sprite.Sprite | None: 37 | assert _sprite_stack.pop() == _sprite 38 | return _sprite 39 | 40 | 41 | def add_block(_block: block.Block | prim.Prim) -> block.Block | prim.Prim: 42 | return current_sprite().add_block(_block) 43 | 44 | 45 | def add_chain(*chain: Iterable[block.Block, prim.Prim]) -> block.Block | prim.Prim: 46 | return current_sprite().add_chain(*chain) 47 | 48 | 49 | def add_comment(_comment: comment.Comment): 50 | return current_sprite().add_comment(_comment) 51 | -------------------------------------------------------------------------------- /scratchattach/editor/comment.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import base, block, sprite, build_defaulting 4 | from typing import Optional 5 | 6 | 7 | class Comment(base.IDComponent): 8 | """ 9 | Represents a comment in the scratch editor. 10 | """ 11 | def __init__(self, _id: Optional[str] = None, _block: Optional[block.Block] = None, x: int = 0, y: int = 0, width: int = 200, 12 | height: int = 200, minimized: bool = False, text: str = '', *, _block_id: Optional[str] = None, 13 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT, pos: Optional[tuple[int, int]] = None): 14 | self.block = _block 15 | self._block_id = _block_id 16 | """ 17 | ID of connected block. Will be set to None upon sprite initialization when the block attribute is updated to the relevant Block. 18 | """ 19 | if pos is not None: 20 | x, y = pos 21 | 22 | self.x = x 23 | self.y = y 24 | 25 | self.width = width 26 | self.height = height 27 | 28 | self.minimized = minimized 29 | self.text = text 30 | 31 | super().__init__(_id, _sprite) 32 | 33 | def __repr__(self): 34 | return f"Comment<{self.text[:10]!r}...>" 35 | 36 | @property 37 | def block_id(self): 38 | """ 39 | Retrieve the id of the associateed block (if applicable) 40 | """ 41 | if self.block is not None: 42 | return self.block.id 43 | elif self._block_id is not None: 44 | return self._block_id 45 | else: 46 | return None 47 | 48 | @staticmethod 49 | def from_json(data: tuple[str, dict]): 50 | assert len(data) == 2 51 | _id, data = data 52 | 53 | _block_id = data.get("blockId") 54 | 55 | x = data.get("x", 0) 56 | y = data.get("y", 0) 57 | 58 | width = data.get("width", 100) 59 | height = data.get("height", 100) 60 | 61 | minimized = data.get("minimized", False) 62 | text = data.get("text") 63 | 64 | ret = Comment(_id, None, x, y, width, height, minimized, text, _block_id=_block_id) 65 | return ret 66 | 67 | def to_json(self) -> dict: 68 | return { 69 | "blockId": self.block_id, 70 | "x": self.x, "y": self.y, 71 | "width": self.width, "height": self.height, 72 | "minimized": self.minimized, 73 | "text": self.text, 74 | } 75 | 76 | def link_using_sprite(self): 77 | if self._block_id is not None: 78 | self.block = self.sprite.find_block(self._block_id, "id") 79 | if self.block is not None: 80 | self._block_id = None 81 | -------------------------------------------------------------------------------- /scratchattach/editor/commons.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shared functions used by the editor module 3 | """ 4 | from __future__ import annotations 5 | 6 | import json 7 | import random 8 | import string 9 | from typing import Optional, Final, Any 10 | 11 | from scratchattach.utils import exceptions 12 | 13 | DIGITS: Final[tuple[str]] = tuple("0123456789") 14 | 15 | ID_CHARS: Final[str] = string.ascii_letters + string.digits # + string.punctuation 16 | 17 | 18 | # Strangely enough, it seems like something in string.punctuation causes issues. Not sure why 19 | 20 | 21 | def _read_json_number(_str: str) -> float | int: 22 | ret = '' 23 | 24 | minus = _str[0] == '-' 25 | if minus: 26 | ret += '-' 27 | _str = _str[1:] 28 | 29 | def read_fraction(sub: str): 30 | sub_ret = '' 31 | if sub[0] == '.': 32 | sub_ret += '.' 33 | sub = sub[1:] 34 | while sub[0] in DIGITS: 35 | sub_ret += sub[0] 36 | sub = sub[1:] 37 | 38 | return sub_ret, sub 39 | 40 | def read_exponent(sub: str): 41 | sub_ret = '' 42 | if sub[0].lower() == 'e': 43 | sub_ret += sub[0] 44 | sub = sub[1:] 45 | 46 | if sub[0] in "-+": 47 | sub_ret += sub[0] 48 | sub = sub[1:] 49 | 50 | if sub[0] not in DIGITS: 51 | raise exceptions.UnclosedJSONError(f"Invalid exponent {sub}") 52 | 53 | while sub[0] in DIGITS: 54 | sub_ret += sub[0] 55 | sub = sub[1:] 56 | 57 | return sub_ret 58 | 59 | if _str[0] == '0': 60 | ret += '0' 61 | _str = _str[1:] 62 | 63 | elif _str[0] in DIGITS[1:9]: 64 | while _str[0] in DIGITS: 65 | ret += _str[0] 66 | _str = _str[1:] 67 | 68 | frac, _str = read_fraction(_str) 69 | ret += frac 70 | 71 | ret += read_exponent(_str) 72 | 73 | return json.loads(ret) 74 | 75 | # todo: consider if this should be moved to util.commons instead of editor.commons 76 | # note: this is currently unused code 77 | def consume_json(_str: str, i: int = 0) -> str | float | int | dict | list | bool | None: 78 | """ 79 | *'gobble up some JSON until we hit something not quite so tasty'* 80 | 81 | Reads a JSON string and stops at the natural end (i.e. when brackets close, or when quotes end, etc.) 82 | """ 83 | # Named by ChatGPT 84 | section = ''.join(_str[i:]) 85 | if section.startswith("true"): 86 | return True 87 | elif section.startswith("false"): 88 | return False 89 | elif section.startswith("null"): 90 | return None 91 | elif section[0] in "0123456789.-": 92 | return _read_json_number(section) 93 | 94 | depth = 0 95 | json_text = '' 96 | out_string = True 97 | 98 | for char in section: 99 | json_text += char 100 | 101 | if char == '"': 102 | if len(json_text) > 1: 103 | unescaped = json_text[-2] != '\\' 104 | else: 105 | unescaped = True 106 | if unescaped: 107 | out_string ^= True 108 | if out_string: 109 | depth -= 1 110 | else: 111 | depth += 1 112 | 113 | if out_string: 114 | if char in "[{": 115 | depth += 1 116 | elif char in "}]": 117 | depth -= 1 118 | 119 | if depth == 0 and json_text.strip(): 120 | return json.loads(json_text.strip()) 121 | 122 | raise exceptions.UnclosedJSONError(f"Unclosed JSON string, read {json_text}") 123 | 124 | 125 | def is_partial_json(_str: str, i: int = 0) -> bool: 126 | try: 127 | consume_json(_str, i) 128 | return True 129 | 130 | except exceptions.UnclosedJSONError: 131 | return False 132 | 133 | except ValueError: 134 | return False 135 | 136 | 137 | def is_valid_json(_str: Any) -> bool: 138 | """ 139 | Try to load a json string, if it fails, return False, else return true. 140 | """ 141 | try: 142 | json.loads(_str) 143 | return True 144 | except (ValueError, TypeError): 145 | return False 146 | 147 | 148 | def noneless_update(obj: dict, update: dict) -> None: 149 | """ 150 | equivalent to dict.update, except and values of None are not assigned 151 | """ 152 | for key, value in update.items(): 153 | if value is not None: 154 | obj[key] = value 155 | 156 | 157 | def remove_nones(obj: dict) -> None: 158 | """ 159 | Removes all None values from a dict. 160 | :param obj: Dictionary to remove all None values. 161 | """ 162 | nones = [] 163 | for key, value in obj.items(): 164 | if value is None: 165 | nones.append(key) 166 | for key in nones: 167 | del obj[key] 168 | 169 | 170 | def safe_get(lst: list | tuple, _i: int, default: Optional[Any] = None) -> Any: 171 | """ 172 | Like dict.get() but for lists 173 | """ 174 | if len(lst) <= _i: 175 | return default 176 | else: 177 | return lst[_i] 178 | 179 | 180 | def trim_final_nones(lst: list) -> list: 181 | """ 182 | Removes the last None values from a list until a non-None value is hit. 183 | :param lst: list which will **not** be modified. 184 | """ 185 | i = len(lst) 186 | for item in lst[::-1]: 187 | if item is not None: 188 | break 189 | i -= 1 190 | return lst[:i] 191 | 192 | 193 | def dumps_ifnn(obj: Any) -> str: 194 | """ 195 | Return json.dumps(obj) if the object is not None 196 | """ 197 | if obj is None: 198 | return None 199 | else: 200 | return json.dumps(obj) 201 | 202 | 203 | def gen_id() -> str: 204 | """ 205 | Generate an id for scratch blocks/variables/lists/broadcasts 206 | 207 | The old 'naïve' method but that chances of a repeat are so miniscule 208 | Have to check if whitespace chars break it 209 | May later add checking within sprites so that we don't need such long ids (we can save space this way) 210 | """ 211 | return ''.join(random.choices(ID_CHARS, k=20)) 212 | 213 | 214 | def sanitize_fn(filename: str): 215 | """ 216 | Removes illegal chars from a filename 217 | :return: Sanitized filename 218 | """ 219 | # Maybe could import a slugify module, but it's a bit overkill 220 | ret = '' 221 | for char in filename: 222 | if char in string.ascii_letters + string.digits + "-_": 223 | ret += char 224 | else: 225 | ret += '_' 226 | return ret 227 | 228 | 229 | def get_folder_name(name: str) -> str | None: 230 | """ 231 | Get the name of the folder if this is a turbowarp-style costume name 232 | """ 233 | if name.startswith('//'): 234 | return None 235 | 236 | if '//' in name: 237 | return name.split('//')[0] 238 | else: 239 | return None 240 | 241 | 242 | def get_name_nofldr(name: str) -> str: 243 | """ 244 | Get the sprite/asset name without the folder name 245 | """ 246 | fldr = get_folder_name(name) 247 | if fldr is None: 248 | return name 249 | else: 250 | return name[len(fldr) + 2:] 251 | 252 | 253 | class Singleton(object): 254 | """ 255 | Singleton base class 256 | """ 257 | _instance: Singleton 258 | 259 | def __new__(cls, *args, **kwargs): 260 | if hasattr(cls, "_instance"): 261 | return cls._instance 262 | else: 263 | cls._instance = super(Singleton, cls).__new__(cls) 264 | return cls._instance 265 | -------------------------------------------------------------------------------- /scratchattach/editor/extension.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enum & dataclass representing extension categories 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | 8 | from dataclasses import dataclass 9 | 10 | from . import base 11 | from scratchattach.utils import enums 12 | 13 | 14 | @dataclass 15 | class Extension(base.JSONSerializable): 16 | """ 17 | Represents an extension in the Scratch block pallete - e.g. video sensing 18 | """ 19 | code: str 20 | name: str = None 21 | 22 | def __eq__(self, other): 23 | return self.code == other.code 24 | 25 | @staticmethod 26 | def from_json(data: str): 27 | assert isinstance(data, str) 28 | _extension = Extensions.find(data, "code") 29 | if _extension is None: 30 | _extension = Extension(data) 31 | 32 | return _extension 33 | 34 | def to_json(self) -> str: 35 | return self.code 36 | 37 | 38 | class Extensions(enums._EnumWrapper): 39 | BOOST = Extension("boost", "LEGO BOOST Extension") 40 | EV3 = Extension("ev3", "LEGO MINDSTORMS EV3 Extension") 41 | GDXFOR = Extension("gdxfor", "Go Direct Force & Acceleration Extension") 42 | MAKEYMAKEY = Extension("makeymakey", "Makey Makey Extension") 43 | MICROBIT = Extension("microbit", "micro:bit Extension") 44 | MUSIC = Extension("music", "Music Extension") 45 | PEN = Extension("pen", "Pen Extension") 46 | TEXT2SPEECH = Extension("text2speech", "Text to Speech Extension") 47 | TRANSLATE = Extension("translate", "Translate Extension") 48 | VIDEOSENSING = Extension("videoSensing", "Video Sensing Extension") 49 | WEDO2 = Extension("wedo2", "LEGO Education WeDo 2.0 Extension") 50 | COREEXAMPLE = Extension("coreExample", "CoreEx Extension") # hidden extension! 51 | -------------------------------------------------------------------------------- /scratchattach/editor/field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional, TYPE_CHECKING, Final 4 | 5 | 6 | if TYPE_CHECKING: 7 | from . import block, vlb 8 | 9 | from . import base, commons 10 | 11 | 12 | class Types: 13 | VARIABLE: Final[str] = "variable" 14 | LIST: Final[str] = "list" 15 | BROADCAST: Final[str] = "broadcast" 16 | DEFAULT: Final[str] = "default" 17 | 18 | 19 | class Field(base.BlockSubComponent): 20 | def __init__(self, _value: str | vlb.Variable | vlb.List | vlb.Broadcast, _id: Optional[str] = None, *, _block: Optional[block.Block] = None): 21 | """ 22 | A field for a scratch block 23 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks:~:text=it.%5B9%5D-,fields,element%2C%20which%20is%20the%20ID%20of%20the%20field%27s%20value.%5B10%5D,-shadow 24 | """ 25 | self.value = _value 26 | self.id = _id 27 | """ 28 | ID of associated VLB. Will be used to get VLB object during sprite initialisation, where it will be replaced with 'None' 29 | """ 30 | super().__init__(_block) 31 | 32 | def __repr__(self): 33 | if self.id is not None: 34 | # This shouldn't occur after sprite initialisation 35 | return f"" 36 | else: 37 | return f"" 38 | 39 | @property 40 | def value_id(self): 41 | """ 42 | Get the id of the value associated with this field (if applicable) - when value is var/list/broadcast 43 | """ 44 | if self.id is not None: 45 | return self.id 46 | else: 47 | if hasattr(self.value, "id"): 48 | return self.value.id 49 | else: 50 | return None 51 | 52 | @property 53 | def value_str(self): 54 | """ 55 | Convert the associated value to a string - if this is a VLB, return the VLB name 56 | """ 57 | if not isinstance(self.value, base.NamedIDComponent): 58 | return self.value 59 | else: 60 | return self.value.name 61 | 62 | @property 63 | def name(self) -> str: 64 | """ 65 | Fetch the name of this field using the associated block 66 | """ 67 | for _name, _field in self.block.fields.items(): 68 | if _field is self: 69 | return _name 70 | 71 | @property 72 | def type(self): 73 | """ 74 | Infer the type of value that this field holds 75 | :return: A string (from field.Types) as a name of the type 76 | """ 77 | if "variable" in self.name.lower(): 78 | return Types.VARIABLE 79 | elif "list" in self.name.lower(): 80 | return Types.LIST 81 | elif "broadcast" in self.name.lower(): 82 | return Types.BROADCAST 83 | else: 84 | return Types.DEFAULT 85 | 86 | @staticmethod 87 | def from_json(data: list[str, str | None]): 88 | # Sometimes you may have a stray field with no id. Not sure why 89 | while len(data) < 2: 90 | data.append(None) 91 | data = data[:2] 92 | 93 | _value, _id = data 94 | return Field(_value, _id) 95 | 96 | def to_json(self) -> dict: 97 | return commons.trim_final_nones([ 98 | self.value_str, self.value_id 99 | ]) 100 | -------------------------------------------------------------------------------- /scratchattach/editor/inputs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from typing import Optional, Final 5 | 6 | from . import block 7 | from . import base, commons, prim 8 | from dataclasses import dataclass 9 | 10 | 11 | @dataclass 12 | class ShadowStatus: 13 | """ 14 | Dataclass representing a possible shadow value and giving it a name 15 | """ 16 | idx: int 17 | name: str 18 | 19 | def __repr__(self): 20 | return f"" 21 | 22 | 23 | class ShadowStatuses: 24 | # Not an enum so you don't need to do .value 25 | # Uh why? 26 | HAS_SHADOW: Final[ShadowStatus] = ShadowStatus(1, "has shadow") 27 | NO_SHADOW: Final[ShadowStatus] = ShadowStatus(2, "no shadow") 28 | OBSCURED: Final[ShadowStatus] = ShadowStatus(3, "obscured") 29 | 30 | @classmethod 31 | def find(cls, idx: int) -> ShadowStatus: 32 | for status in (cls.HAS_SHADOW, cls.NO_SHADOW, cls.OBSCURED): 33 | if status.idx == idx: 34 | return status 35 | 36 | if not 1 <= idx <= 3: 37 | raise ValueError(f"Invalid ShadowStatus idx={idx}") 38 | 39 | 40 | class Input(base.BlockSubComponent): 41 | def __init__(self, _shadow: ShadowStatus | None = ShadowStatuses.HAS_SHADOW, _value: Optional[prim.Prim | block.Block | str] = None, _id: Optional[str] = None, 42 | _obscurer: Optional[prim.Prim | block.Block | str] = None, *, _obscurer_id: Optional[str] = None, _block: Optional[block.Block] = None): 43 | """ 44 | An input for a scratch block 45 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks:~:text=inputs,it.%5B9%5D 46 | """ 47 | super().__init__(_block) 48 | 49 | # If the shadow is None, we'll have to work it out later 50 | self.shadow = _shadow 51 | 52 | self.value: prim.Prim | block.Block = _value 53 | self.obscurer: prim.Prim | block.Block = _obscurer 54 | 55 | self._id = _id 56 | """ 57 | ID referring to the input value. Upon project initialisation, this will be set to None and the value attribute will be set to the relevant object 58 | """ 59 | self._obscurer_id = _obscurer_id 60 | """ 61 | ID referring to the obscurer. Upon project initialisation, this will be set to None and the obscurer attribute will be set to the relevant block 62 | """ 63 | 64 | def __repr__(self): 65 | if self._id is not None: 66 | return f"" 67 | else: 68 | return f"" 69 | 70 | @staticmethod 71 | def from_json(data: list): 72 | _shadow = ShadowStatuses.find(data[0]) 73 | 74 | _value, _id = None, None 75 | if isinstance(data[1], list): 76 | _value = prim.Prim.from_json(data[1]) 77 | else: 78 | _id = data[1] 79 | 80 | _obscurer_data = commons.safe_get(data, 2) 81 | 82 | _obscurer, _obscurer_id = None, None 83 | if isinstance(_obscurer_data, list): 84 | _obscurer = prim.Prim.from_json(_obscurer_data) 85 | else: 86 | _obscurer_id = _obscurer_data 87 | return Input(_shadow, _value, _id, _obscurer, _obscurer_id=_obscurer_id) 88 | 89 | def to_json(self) -> list: 90 | data = [self.shadow.idx] 91 | 92 | def add_pblock(pblock: prim.Prim | block.Block | None): 93 | """ 94 | Adds a primitive or a block to the data in the right format 95 | """ 96 | if pblock is None: 97 | return 98 | 99 | if isinstance(pblock, prim.Prim): 100 | data.append(pblock.to_json()) 101 | 102 | elif isinstance(pblock, block.Block): 103 | data.append(pblock.id) 104 | 105 | else: 106 | warnings.warn(f"Bad prim/block {pblock!r} of type {type(pblock)}") 107 | 108 | add_pblock(self.value) 109 | add_pblock(self.obscurer) 110 | 111 | return data 112 | 113 | def link_using_block(self): 114 | # Link to value 115 | if self._id is not None: 116 | new_value = self.sprite.find_block(self._id, "id") 117 | if new_value is not None: 118 | self.value = new_value 119 | self._id = None 120 | 121 | # Link to obscurer 122 | if self._obscurer_id is not None: 123 | new_block = self.sprite.find_block(self._obscurer_id, "id") 124 | if new_block is not None: 125 | self.obscurer = new_block 126 | self._obscurer_id = None 127 | 128 | # Link value to sprite 129 | if isinstance(self.value, prim.Prim): 130 | self.value.sprite = self.sprite 131 | self.value.link_using_sprite() 132 | 133 | # Link obscurer to sprite 134 | if self.obscurer is not None: 135 | self.obscurer.sprite = self.sprite 136 | -------------------------------------------------------------------------------- /scratchattach/editor/meta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass, field 5 | 6 | from . import base, commons 7 | from typing import Optional 8 | 9 | 10 | @dataclass 11 | class PlatformMeta(base.JSONSerializable): 12 | name: str = None 13 | url: str = field(repr=True, default=None) 14 | 15 | def __bool__(self): 16 | return self.name is not None or self.url is not None 17 | 18 | def to_json(self) -> dict: 19 | _json = {"name": self.name, "url": self.url} 20 | commons.remove_nones(_json) 21 | return _json 22 | 23 | @staticmethod 24 | def from_json(data: dict | None): 25 | if data is None: 26 | return PlatformMeta() 27 | else: 28 | return PlatformMeta(data.get("name"), data.get("url")) 29 | 30 | 31 | DEFAULT_VM = "0.1.0" 32 | DEFAULT_AGENT = "scratchattach.editor by https://scratch.mit.edu/users/timmccool/" 33 | DEFAULT_PLATFORM = PlatformMeta("scratchattach", "https://github.com/timMcCool/scratchattach/") 34 | 35 | EDIT_META = True 36 | META_SET_PLATFORM = False 37 | 38 | 39 | def set_meta_platform(true_false: bool = None): 40 | """ 41 | toggle whether to set the meta platform by default (or specify a value) 42 | """ 43 | global META_SET_PLATFORM 44 | if true_false is None: 45 | true_false = bool(1 - true_false) 46 | META_SET_PLATFORM = true_false 47 | 48 | 49 | class Meta(base.JSONSerializable): 50 | def __init__(self, semver: str = "3.0.0", vm: str = DEFAULT_VM, agent: str = DEFAULT_AGENT, 51 | platform: Optional[PlatformMeta] = None): 52 | """ 53 | Represents metadata of the project 54 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Metadata 55 | """ 56 | if platform is None and META_SET_PLATFORM: 57 | platform = DEFAULT_PLATFORM.dcopy() 58 | 59 | self.semver = semver 60 | self.vm = vm 61 | self.agent = agent 62 | self.platform = platform 63 | 64 | if not self.vm_is_valid: 65 | raise ValueError( 66 | f"{vm!r} does not match pattern '^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)' - maybe try '0.0.0'?") 67 | 68 | def __repr__(self): 69 | data = f"{self.semver} : {self.vm} : {self.agent}" 70 | if self.platform: 71 | data += f": {self.platform}" 72 | 73 | return f"Meta<{data}>" 74 | 75 | @property 76 | def vm_is_valid(self): 77 | """ 78 | Check whether the vm value is valid using a regex 79 | Thanks to TurboWarp for this pattern ↓↓↓↓, I just copied it 80 | """ 81 | return re.match("^([0-9]+\\.[0-9]+\\.[0-9]+)($|-)", self.vm) is not None 82 | 83 | def to_json(self): 84 | _json = { 85 | "semver": self.semver, 86 | "vm": self.vm, 87 | "agent": self.agent 88 | } 89 | 90 | if self.platform: 91 | _json["platform"] = self.platform.to_json() 92 | return _json 93 | 94 | @staticmethod 95 | def from_json(data): 96 | if data is None: 97 | data = "" 98 | 99 | semver = data["semver"] 100 | vm = data.get("vm") 101 | agent = data.get("agent") 102 | platform = PlatformMeta.from_json(data.get("platform")) 103 | 104 | if EDIT_META or vm is None: 105 | vm = DEFAULT_VM 106 | 107 | if EDIT_META or agent is None: 108 | agent = DEFAULT_AGENT 109 | 110 | if EDIT_META: 111 | if META_SET_PLATFORM and not platform: 112 | platform = DEFAULT_PLATFORM.dcopy() 113 | 114 | return Meta(semver, vm, agent, platform) 115 | -------------------------------------------------------------------------------- /scratchattach/editor/monitor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional, TYPE_CHECKING 4 | 5 | from typing_extensions import deprecated 6 | 7 | if TYPE_CHECKING: 8 | from . import project 9 | 10 | from . import base 11 | 12 | 13 | class Monitor(base.ProjectSubcomponent): 14 | def __init__(self, reporter: Optional[base.NamedIDComponent] = None, 15 | mode: str = "default", 16 | opcode: str = "data_variable", 17 | params: Optional[dict] = None, 18 | sprite_name: Optional[str] = None, 19 | value=0, 20 | width: int | float = 0, 21 | height: int | float = 0, 22 | x: int | float = 5, 23 | y: int | float = 5, 24 | visible: bool = False, 25 | slider_min: int | float = 0, 26 | slider_max: int | float = 100, 27 | is_discrete: bool = True, *, reporter_id: Optional[str] = None, _project: Optional[project.Project] = None): 28 | """ 29 | Represents a variable/list monitor 30 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Monitors 31 | 32 | Instantiating these yourself and attaching these to projects can lead to interesting results! 33 | """ 34 | assert isinstance(reporter, base.SpriteSubComponent) or reporter is None 35 | 36 | self.reporter_id = reporter_id 37 | """ 38 | ID referencing the VLB being referenced. Replaced with None during project instantiation, where the reporter attribute is updated 39 | """ 40 | 41 | self.reporter = reporter 42 | if params is None: 43 | params = {} 44 | 45 | self.mode = mode 46 | 47 | self.opcode = opcode 48 | self.params = params 49 | 50 | self.sprite_name = sprite_name 51 | 52 | self.value = value 53 | 54 | self.width, self.height = width, height 55 | self.x, self.y = x, y 56 | 57 | self.visible = visible 58 | 59 | self.slider_min, self.slider_max = slider_min, slider_max 60 | self.is_discrete = is_discrete 61 | 62 | super().__init__(_project) 63 | 64 | def __repr__(self): 65 | return f"Monitor<{self.opcode}>" 66 | 67 | @property 68 | def id(self): 69 | if self.reporter is not None: 70 | return self.reporter.id 71 | # if isinstance(self.reporter, str): 72 | # return self.reporter 73 | # else: 74 | # return self.reporter.id 75 | else: 76 | return self.reporter_id 77 | 78 | @staticmethod 79 | def from_json(data: dict): 80 | _id = data["id"] 81 | # ^^ NEED TO FIND REPORTER OBJECT 82 | 83 | mode = data["mode"] 84 | 85 | opcode = data["opcode"] 86 | params: dict = data["params"] 87 | 88 | sprite_name = data["spriteName"] 89 | 90 | value = data["value"] 91 | 92 | width, height = data["width"], data["height"] 93 | x, y = data["x"], data["y"] 94 | 95 | visible = data["visible"] 96 | 97 | if "isDiscrete" in data.keys(): 98 | slider_min, slider_max = data["sliderMin"], data["sliderMax"] 99 | is_discrete = data["isDiscrete"] 100 | else: 101 | slider_min, slider_max, is_discrete = None, None, None 102 | 103 | return Monitor(None, mode, opcode, params, sprite_name, value, width, height, x, y, visible, slider_min, 104 | slider_max, is_discrete, reporter_id=_id) 105 | 106 | def to_json(self): 107 | _json = { 108 | "id": self.id, 109 | "mode": self.mode, 110 | 111 | "opcode": self.opcode, 112 | "params": self.params, 113 | 114 | "spriteName": self.sprite_name, 115 | 116 | "value": self.value, 117 | 118 | "width": self.width, 119 | "height": self.height, 120 | 121 | "x": self.x, 122 | "y": self.y, 123 | 124 | "visible": self.visible 125 | } 126 | if self.is_discrete is not None: 127 | _json["sliderMin"] = self.slider_min 128 | _json["sliderMax"] = self.slider_max 129 | _json["isDiscrete"] = self.is_discrete 130 | 131 | return _json 132 | 133 | def link_using_project(self): 134 | assert self.project is not None 135 | 136 | if self.opcode in ("data_variable", "data_listcontents", "event_broadcast_menu"): 137 | new_vlb = self.project.find_vlb(self.reporter_id, "id") 138 | if new_vlb is not None: 139 | self.reporter = new_vlb 140 | self.reporter_id = None 141 | 142 | # todo: consider reimplementing this 143 | @deprecated("This method does not work correctly (This may be fixed in the future)") 144 | @staticmethod 145 | def from_reporter(reporter: Block, _id: str = None, mode: str = "default", 146 | opcode: str = None, sprite_name: str = None, value=0, width: int | float = 0, 147 | height: int | float = 0, 148 | x: int | float = 5, y: int | float = 5, visible: bool = False, slider_min: int | float = 0, 149 | slider_max: int | float = 100, is_discrete: bool = True, params: dict = None): 150 | if "reporter" not in reporter.stack_type: 151 | warnings.warn(f"{reporter} is not a reporter block; the monitor will return '0'") 152 | elif "(menu)" in reporter.stack_type: 153 | warnings.warn(f"{reporter} is a menu block; the monitor will return '0'") 154 | # Maybe add note that length of list doesn't work fsr?? idk 155 | if _id is None: 156 | _id = reporter.opcode 157 | if opcode is None: 158 | opcode = reporter.opcode # .replace('_', ' ') 159 | 160 | if params is None: 161 | params = {} 162 | for field in reporter.fields: 163 | if field.value_id is None: 164 | params[field.id] = field.value 165 | else: 166 | params[field.id] = field.value, field.value_id 167 | 168 | return Monitor( 169 | _id, 170 | mode, 171 | opcode, 172 | 173 | params, 174 | sprite_name, 175 | value, 176 | 177 | width, height, 178 | x, y, 179 | visible, 180 | slider_min, slider_max, is_discrete 181 | ) 182 | -------------------------------------------------------------------------------- /scratchattach/editor/pallete.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of block information, stating input/field names and opcodes 3 | New version of sbuild.py 4 | 5 | May want to completely change this later 6 | """ 7 | from __future__ import annotations 8 | 9 | from dataclasses import dataclass 10 | 11 | from . import prim 12 | from scratchattach.utils.enums import _EnumWrapper 13 | 14 | 15 | @dataclass 16 | class FieldUsage: 17 | name: str 18 | value_type: prim.PrimTypes = None 19 | 20 | 21 | @dataclass 22 | class SpecialFieldUsage(FieldUsage): 23 | name: str 24 | attrs: list[str] = None 25 | if attrs is None: 26 | attrs = [] 27 | 28 | value_type: None = None 29 | 30 | 31 | @dataclass 32 | class InputUsage: 33 | name: str 34 | value_type: prim.PrimTypes = None 35 | default_obscurer: BlockUsage = None 36 | 37 | 38 | @dataclass 39 | class BlockUsage: 40 | opcode: str 41 | fields: list[FieldUsage] = None 42 | if fields is None: 43 | fields = [] 44 | 45 | inputs: list[InputUsage] = None 46 | if inputs is None: 47 | inputs = [] 48 | 49 | 50 | class BlockUsages(_EnumWrapper): 51 | # Special Enum blocks 52 | MATH_NUMBER = BlockUsage( 53 | "math_number", 54 | [SpecialFieldUsage("NUM", ["name", "value"])] 55 | ) 56 | MATH_POSITIVE_NUMBER = BlockUsage( 57 | "math_positive_number", 58 | [SpecialFieldUsage("NUM", ["name", "value"])] 59 | ) 60 | MATH_WHOLE_NUMBER = BlockUsage( 61 | "math_whole_number", 62 | [SpecialFieldUsage("NUM", ["name", "value"])] 63 | ) 64 | MATH_INTEGER = BlockUsage( 65 | "math_integer", 66 | [SpecialFieldUsage("NUM", ["name", "value"])] 67 | ) 68 | MATH_ANGLE = BlockUsage( 69 | "math_angle", 70 | [SpecialFieldUsage("NUM", ["name", "value"])] 71 | ) 72 | COLOUR_PICKER = BlockUsage( 73 | "colour_picker", 74 | [SpecialFieldUsage("COLOUR", ["name", "value"])] 75 | ) 76 | TEXT = BlockUsage( 77 | "text", 78 | [SpecialFieldUsage("TEXT", ["name", "value"])] 79 | ) 80 | EVENT_BROADCAST_MENU = BlockUsage( 81 | "event_broadcast_menu", 82 | [SpecialFieldUsage("BROADCAST_OPTION", ["name", "id", "value", "variableType"])] 83 | ) 84 | DATA_VARIABLE = BlockUsage( 85 | "data_variable", 86 | [SpecialFieldUsage("VARIABLE", ["name", "id", "value", "variableType"])] 87 | ) 88 | DATA_LISTCONTENTS = BlockUsage( 89 | "data_listcontents", 90 | [SpecialFieldUsage("LIST", ["name", "id", "value", "variableType"])] 91 | ) 92 | -------------------------------------------------------------------------------- /scratchattach/editor/prim.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from dataclasses import dataclass 5 | from typing import Optional, Callable, Final 6 | 7 | from . import base, sprite, vlb, commons, build_defaulting 8 | from scratchattach.utils import enums, exceptions 9 | 10 | 11 | @dataclass 12 | class PrimType(base.JSONSerializable): 13 | code: int 14 | name: str 15 | attrs: list = None 16 | opcode: str = None 17 | 18 | def __eq__(self, other): 19 | if isinstance(other, str): 20 | return self.name == other 21 | elif isinstance(other, enums._EnumWrapper): 22 | other = other.value 23 | return super().__eq__(other) 24 | 25 | @staticmethod 26 | def from_json(data: int): 27 | return PrimTypes.find(data, "code") 28 | 29 | def to_json(self) -> int: 30 | return self.code 31 | 32 | 33 | BASIC_ATTRS: Final[tuple[str]] = ("value",) 34 | VLB_ATTRS: Final[tuple[str]] = ("name", "id", "x", "y") 35 | 36 | 37 | class PrimTypes(enums._EnumWrapper): 38 | # Yeah, they actually do have opcodes 39 | NUMBER = PrimType(4, "number", BASIC_ATTRS, "math_number") 40 | POSITIVE_NUMBER = PrimType(5, "positive number", BASIC_ATTRS, "math_positive_number") 41 | POSITIVE_INTEGER = PrimType(6, "positive integer", BASIC_ATTRS, "math_whole_number") 42 | INTEGER = PrimType(7, "integer", BASIC_ATTRS, "math_integer") 43 | ANGLE = PrimType(8, "angle", BASIC_ATTRS, "math_angle") 44 | COLOR = PrimType(9, "color", BASIC_ATTRS, "colour_picker") 45 | STRING = PrimType(10, "string", BASIC_ATTRS, "text") 46 | BROADCAST = PrimType(11, "broadcast", VLB_ATTRS, "event_broadcast_menu") 47 | VARIABLE = PrimType(12, "variable", VLB_ATTRS, "data_variable") 48 | LIST = PrimType(13, "list", VLB_ATTRS, "data_listcontents") 49 | 50 | @classmethod 51 | def find(cls, value, by: str, apply_func: Optional[Callable] = None) -> PrimType: 52 | return super().find(value, by, apply_func=apply_func) 53 | 54 | 55 | def is_prim_opcode(opcode: str): 56 | return opcode in PrimTypes.all_of("opcode") and opcode is not None 57 | 58 | 59 | class Prim(base.SpriteSubComponent): 60 | def __init__(self, _primtype: PrimType | PrimTypes, _value: Optional[str | vlb.Variable | vlb.List | vlb.Broadcast] = None, 61 | _name: Optional[str] = None, _id: Optional[str] = None, _x: Optional[int] = None, 62 | _y: Optional[int] = None, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 63 | """ 64 | Class representing a Scratch string, number, angle, variable etc. 65 | Technically blocks but behave differently 66 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=A%20few%20blocks,13 67 | """ 68 | if isinstance(_primtype, PrimTypes): 69 | _primtype = _primtype.value 70 | 71 | self.type = _primtype 72 | 73 | self.value = _value 74 | 75 | self.name = _name 76 | """ 77 | Once you get the object associated with this primitive (sprite.link_prims()), 78 | the name will be removed and the value will be changed from ``None`` 79 | """ 80 | self.value_id = _id 81 | """ 82 | It's not an object accessed by id, but it may reference an object with an id. 83 | 84 | ---- 85 | 86 | Once you get the object associated with it (sprite.link_prims()), 87 | the id will be removed and the value will be changed from ``None`` 88 | """ 89 | 90 | self.x = _x 91 | self.y = _y 92 | 93 | super().__init__(_sprite) 94 | 95 | def __repr__(self): 96 | if self.is_basic: 97 | return f"Prim<{self.type.name}: {self.value}>" 98 | elif self.is_vlb: 99 | return f"Prim<{self.type.name}: {self.value}>" 100 | else: 101 | return f"Prim<{self.type.name}>" 102 | 103 | @property 104 | def is_vlb(self): 105 | return self.type.attrs == VLB_ATTRS 106 | 107 | @property 108 | def is_basic(self): 109 | return self.type.attrs == BASIC_ATTRS 110 | 111 | @staticmethod 112 | def from_json(data: list): 113 | assert isinstance(data, list) 114 | 115 | _type_idx = data[0] 116 | _prim_type = PrimTypes.find(_type_idx, "code") 117 | 118 | _value, _name, _value_id, _x, _y = (None,) * 5 119 | if _prim_type.attrs == BASIC_ATTRS: 120 | assert len(data) == 2 121 | _value = data[1] 122 | 123 | elif _prim_type.attrs == VLB_ATTRS: 124 | assert len(data) in (3, 5) 125 | _name, _value_id = data[1:3] 126 | 127 | if len(data) == 5: 128 | _x, _y = data[3:] 129 | 130 | return Prim(_prim_type, _value, _name, _value_id, _x, _y) 131 | 132 | def to_json(self) -> list: 133 | if self.type.attrs == BASIC_ATTRS: 134 | return [self.type.code, self.value] 135 | else: 136 | return commons.trim_final_nones([self.type.code, self.value.name, self.value.id, self.x, self.y]) 137 | 138 | def link_using_sprite(self): 139 | # Link prim to var/list/broadcast 140 | if self.is_vlb: 141 | if self.type.name == "variable": 142 | self.value = self.sprite.find_variable(self.value_id, "id") 143 | 144 | elif self.type.name == "list": 145 | self.value = self.sprite.find_list(self.value_id, "id") 146 | 147 | elif self.type.name == "broadcast": 148 | self.value = self.sprite.find_broadcast(self.value_id, "id") 149 | else: 150 | # This should never happen 151 | raise exceptions.BadVLBPrimitiveError(f"{self} claims to be VLB, but is {self.type.name}") 152 | 153 | if self.value is None: 154 | if not self.project: 155 | new_vlb = vlb.construct(self.type.name.lower(), self.value_id, self.name) 156 | self.sprite.add_local_global(new_vlb) 157 | self.value = new_vlb 158 | 159 | else: 160 | new_vlb = vlb.construct(self.type.name.lower(), self.value_id, self.name) 161 | self.sprite.stage.add_vlb(new_vlb) 162 | 163 | warnings.warn( 164 | f"Prim has unknown {self.type.name} id; adding as global variable") 165 | self.name = None 166 | self.value_id = None 167 | 168 | @property 169 | def can_next(self): 170 | return False 171 | -------------------------------------------------------------------------------- /scratchattach/editor/twconfig.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parser for TurboWarp settings configuration 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import json 8 | import math 9 | from dataclasses import dataclass 10 | from typing import Any 11 | 12 | from . import commons, base 13 | 14 | _START = """Configuration for https://turbowarp.org/ 15 | You can move, resize, and minimize this comment, but don't edit it by hand. This comment can be deleted to remove the stored settings. 16 | """ 17 | _END = " // _twconfig_" 18 | 19 | 20 | @dataclass 21 | class TWConfig(base.JSONSerializable): 22 | framerate: int = None, 23 | interpolation: bool = False, 24 | hq_pen: bool = False, 25 | max_clones: float | int | None = None, 26 | misc_limits: bool = True, 27 | fencing: bool = True 28 | width: int = None 29 | height: int = None 30 | 31 | @staticmethod 32 | def from_json(data: dict) -> TWConfig: 33 | # Non-runtime options 34 | _framerate = data.get("framerate") 35 | _interpolation = data.get("interpolation", False) 36 | _hq_pen = data.get("hq", False) 37 | 38 | # Runtime options 39 | _runtime_options = data.get("runtimeOptions", {}) 40 | 41 | # Luckily for us, the JSON module actually accepts the 'Infinity' literal. Otherwise, it would be a right pain 42 | _max_clones = _runtime_options.get("maxClones") 43 | _misc_limits = _runtime_options.get("miscLimits", True) 44 | _fencing = _runtime_options.get("fencing", True) 45 | 46 | # Custom stage size 47 | _width = data.get("width") 48 | _height = data.get("height") 49 | 50 | return TWConfig(_framerate, _interpolation, _hq_pen, _max_clones, _misc_limits, _fencing, _width, _height) 51 | 52 | def to_json(self) -> dict: 53 | runtime_options = {} 54 | commons.noneless_update( 55 | runtime_options, 56 | { 57 | "maxClones": self.max_clones, 58 | "miscLimits": none_if_eq(self.misc_limits, True), 59 | "fencing": none_if_eq(self.fencing, True) 60 | }) 61 | 62 | data = {} 63 | commons.noneless_update(data, { 64 | "framerate": self.framerate, 65 | "runtimeOptions": runtime_options, 66 | "interpolation": none_if_eq(self.interpolation, False), 67 | "hq": none_if_eq(self.hq_pen, False), 68 | "width": self.width, 69 | "height": self.height 70 | }) 71 | return data 72 | 73 | @property 74 | def infinite_clones(self): 75 | return self.max_clones == math.inf 76 | 77 | @staticmethod 78 | def from_str(string: str): 79 | return TWConfig.from_json(get_twconfig_data(string)) 80 | 81 | 82 | def is_valid_twconfig(string: str) -> bool: 83 | """ 84 | Checks if some text is TWConfig (does not check the JSON itself) 85 | :param string: text (from a comment) 86 | :return: Boolean whether it is TWConfig 87 | """ 88 | 89 | if string.startswith(_START) and string.endswith(_END): 90 | json_part = string[len(_START):-len(_END)] 91 | if commons.is_valid_json(json_part): 92 | return True 93 | return False 94 | 95 | 96 | def get_twconfig_data(string: str) -> dict | None: 97 | try: 98 | return json.loads(string[len(_START):-len(_END)]) 99 | except ValueError: 100 | return None 101 | 102 | 103 | # todo: move this to commons.py? 104 | def none_if_eq(data, compare) -> Any | None: 105 | """ 106 | Returns None if data and compare are the same 107 | :param data: Original data 108 | :param compare: Data to compare 109 | :return: Either the original data or None 110 | """ 111 | if data == compare: 112 | return None 113 | else: 114 | return data 115 | -------------------------------------------------------------------------------- /scratchattach/editor/vlb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Variables, lists & broadcasts 3 | """ 4 | # Perhaps ids should not be stored in these objects, but in the sprite, similarly 5 | # to how blocks/prims are stored 6 | 7 | from __future__ import annotations 8 | 9 | from typing import Optional, Literal 10 | 11 | from . import base, sprite, build_defaulting 12 | from scratchattach.utils import exceptions 13 | 14 | 15 | class Variable(base.NamedIDComponent): 16 | def __init__(self, _id: str, _name: str, _value: Optional[str | int | float] = None, _is_cloud: bool = False, 17 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 18 | """ 19 | Class representing a variable. 20 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=variables,otherwise%20not%20present 21 | """ 22 | if _value is None: 23 | _value = 0 24 | 25 | self.value = _value 26 | self.is_cloud = _is_cloud 27 | 28 | super().__init__(_id, _name, _sprite) 29 | 30 | @property 31 | def is_global(self): 32 | """ 33 | Works out whethere a variable is global based on whether the sprite is a stage 34 | :return: Whether this variable is a global variable. 35 | """ 36 | return self.sprite.is_stage 37 | 38 | @staticmethod 39 | def from_json(data: tuple[str, tuple[str, str | int | float] | tuple[str, str | int | float, bool]]): 40 | """ 41 | Read data in format: (variable id, variable JSON) 42 | """ 43 | assert len(data) == 2 44 | _id, data = data 45 | 46 | assert len(data) in (2, 3) 47 | _name, _value = data[:2] 48 | 49 | if len(data) == 3: 50 | _is_cloud = data[2] 51 | else: 52 | _is_cloud = False 53 | 54 | return Variable(_id, _name, _value, _is_cloud) 55 | 56 | def to_json(self) -> tuple[str, str | int | float, bool] | tuple[str, str | int | float]: 57 | """ 58 | Returns Variable data as a tuple 59 | """ 60 | if self.is_cloud: 61 | _ret = self.name, self.value, True 62 | else: 63 | _ret = self.name, self.value 64 | 65 | return _ret 66 | 67 | 68 | class List(base.NamedIDComponent): 69 | def __init__(self, _id: str, _name: str, _value: Optional[list[str | int | float]] = None, 70 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 71 | """ 72 | Class representing a list. 73 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=lists,as%20an%20array 74 | """ 75 | if _value is None: 76 | _value = [] 77 | 78 | self.value = _value 79 | super().__init__(_id, _name, _sprite) 80 | 81 | @staticmethod 82 | def from_json(data: tuple[str, tuple[str, str | int | float] | tuple[str, str | int | float, bool]]): 83 | """ 84 | Read data in format: (variable id, variable JSON) 85 | """ 86 | assert len(data) == 2 87 | _id, data = data 88 | 89 | assert len(data) == 2 90 | _name, _value = data 91 | 92 | return List(_id, _name, _value) 93 | 94 | def to_json(self) -> tuple[str, tuple[str, str | int | float, bool] | tuple[str, str | int | float]]: 95 | """ 96 | Returns List data as a tuple 97 | """ 98 | return self.name, self.value 99 | 100 | 101 | class Broadcast(base.NamedIDComponent): 102 | def __init__(self, _id: str, _name: str, _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT): 103 | """ 104 | Class representing a broadcast. 105 | https://en.scratch-wiki.info/wiki/Scratch_File_Format#Targets:~:text=broadcasts,in%20the%20stage 106 | """ 107 | super().__init__(_id, _name, _sprite) 108 | 109 | @staticmethod 110 | def from_json(data: tuple[str, str]): 111 | assert len(data) == 2 112 | _id, _name = data 113 | 114 | return Broadcast(_id, _name) 115 | 116 | def to_json(self) -> str: 117 | """ 118 | :return: Broadcast as JSON (just a string of its name) 119 | """ 120 | return self.name 121 | 122 | 123 | def construct(vlb_type: Literal["variable", "list", "broadcast"], _id: Optional[str] = None, _name: Optional[str] = None, 124 | _sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT) -> Variable | List | Broadcast: 125 | if vlb_type == "variable": 126 | vlb_type = Variable 127 | elif vlb_type == "list": 128 | vlb_type = List 129 | elif vlb_type == "broadcast": 130 | vlb_type = Broadcast 131 | else: 132 | raise exceptions.InvalidVLBName(f"Bad VLB {vlb_type!r}") 133 | 134 | return vlb_type(_id, _name, _sprite) 135 | -------------------------------------------------------------------------------- /scratchattach/eventhandlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/scratchattach/eventhandlers/__init__.py -------------------------------------------------------------------------------- /scratchattach/eventhandlers/_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from collections import defaultdict 5 | from threading import Thread 6 | from collections.abc import Callable 7 | import traceback 8 | from scratchattach.utils.requests import requests 9 | from scratchattach.utils import exceptions 10 | 11 | class BaseEventHandler(ABC): 12 | _events: defaultdict[str, list[Callable]] 13 | _threaded_events: defaultdict[str, list[Callable]] 14 | 15 | def __init__(self): 16 | self._thread = None 17 | self.running = False 18 | self._events = defaultdict(list) 19 | self._threaded_events = defaultdict(list) 20 | 21 | def start(self, *, thread=True, ignore_exceptions=True): 22 | """ 23 | Starts the event handler. 24 | 25 | Keyword Arguments: 26 | thread (bool): Whether the event handler should be run in a thread. 27 | ignore_exceptions (bool): Whether to catch exceptions that happen in individual events 28 | """ 29 | if self.running is False: 30 | self.ignore_exceptions = ignore_exceptions 31 | self.running = True 32 | if thread: 33 | self._thread = Thread(target=self._updater, args=()) 34 | self._thread.start() 35 | else: 36 | self._thread = None 37 | self._updater() 38 | 39 | def call_event(self, event_name, args : list = []): 40 | try: 41 | if event_name in self._threaded_events: 42 | for func in self._threaded_events[event_name]: 43 | Thread(target=func, args=args).start() 44 | if event_name in self._events: 45 | for func in self._events[event_name]: 46 | func(*args) 47 | except Exception as e: 48 | if self.ignore_exceptions: 49 | print( 50 | f"Warning: Caught error in event '{event_name}' - Full error below" 51 | ) 52 | try: 53 | traceback.print_exc() 54 | except Exception: 55 | print(e) 56 | else: 57 | raise(e) 58 | 59 | @abstractmethod 60 | def _updater(self): 61 | pass 62 | 63 | def stop(self): 64 | """ 65 | Permanently stops the event handler. 66 | """ 67 | self.running = False 68 | if self._thread is not None: 69 | self._thread = None 70 | 71 | def pause(self): 72 | """ 73 | Pauses the event handler. 74 | """ 75 | self.running = False 76 | 77 | def resume(self): 78 | """ 79 | Resumes the event handler. 80 | """ 81 | if self.running is False: 82 | self.start() 83 | 84 | def event(self, function=None, *, thread=False): 85 | """ 86 | Decorator function. Adds an event. 87 | """ 88 | def inner(function): 89 | # called directly if the decorator provides arguments 90 | if thread is True: 91 | self._threaded_events[function.__name__].append(function) 92 | else: 93 | self._events[function.__name__].append(function) 94 | 95 | if function is None: 96 | # => the decorator provides arguments 97 | return inner 98 | else: 99 | # => the decorator doesn't provide arguments 100 | inner(function) -------------------------------------------------------------------------------- /scratchattach/eventhandlers/cloud_events.py: -------------------------------------------------------------------------------- 1 | """CloudEvents class""" 2 | from __future__ import annotations 3 | 4 | from scratchattach.cloud import _base 5 | from ._base import BaseEventHandler 6 | from scratchattach.site import cloud_activity 7 | import time 8 | import json 9 | from collections.abc import Iterator 10 | 11 | class CloudEvents(BaseEventHandler): 12 | """ 13 | Class that calls events when on cloud updates that are received through a websocket connection. 14 | """ 15 | def __init__(self, cloud: _base.AnyCloud): 16 | super().__init__() 17 | self.cloud = cloud 18 | self._session = cloud._session 19 | self.source_stream = cloud.create_event_stream() 20 | self.startup_time = time.time() * 1000 21 | 22 | def disconnect(self): 23 | self.source_stream.close() 24 | 25 | def _updater(self): 26 | """ 27 | A process that listens for cloud activity and executes events on cloud activity 28 | """ 29 | 30 | self.call_event("on_ready") 31 | 32 | if self.running is False: 33 | return 34 | while True: 35 | try: 36 | while True: 37 | for data in self.source_stream.read(): 38 | try: 39 | _a = cloud_activity.CloudActivity(timestamp=time.time()*1000, _session=self._session, cloud=self.cloud) 40 | if _a.timestamp < self.startup_time + 500: # catch the on_connect message sent by TurboWarp's (and sometimes Scratch's) cloud server 41 | continue 42 | data["variable_name"] = data["name"] 43 | data["name"] = data["variable_name"].replace("☁ ", "") 44 | _a._update_from_dict(data) 45 | self.call_event("on_"+_a.type, [_a]) 46 | except Exception as e: 47 | pass 48 | except Exception: 49 | print("CloudEvents: Disconnected. Reconnecting ...", time.time()) 50 | time.sleep(0.1) # cooldown 51 | 52 | print("CloudEvents: Reconnected.", time.time()) 53 | self.call_event("on_reconnect", []) 54 | 55 | class ManualCloudLogEvents: 56 | """ 57 | Class that calls events on cloud updates that are received from a clouddata log. 58 | """ 59 | def __init__(self, cloud: _base.LogCloud): 60 | if not isinstance(cloud, _base.LogCloud): 61 | raise ValueError("Cloud log events can't be used with a cloud that has no logs available") 62 | self.cloud = cloud 63 | self.source_cloud = cloud 64 | self._session = cloud._session 65 | self.last_timestamp = 0 66 | 67 | def update(self) -> Iterator[tuple[str, list[cloud_activity.CloudActivity]]]: 68 | """ 69 | Update once and yield all packets 70 | """ 71 | try: 72 | data = self.source_cloud.logs(limit=25) 73 | for _a in data[::-1]: 74 | if _a.timestamp <= self.last_timestamp: 75 | continue 76 | self.last_timestamp = _a.timestamp 77 | yield ("on_"+_a.type, [_a]) 78 | except Exception: 79 | pass 80 | 81 | 82 | class CloudLogEvents(BaseEventHandler): 83 | """ 84 | Class that calls events on cloud updates that are received from a clouddata log. 85 | """ 86 | def __init__(self, cloud: _base.LogCloud, *, update_interval=0.1): 87 | super().__init__() 88 | if not isinstance(cloud, _base.LogCloud): 89 | raise ValueError("Cloud log events can't be used with a cloud that has no logs available") 90 | self.cloud = cloud 91 | self.source_cloud = cloud 92 | self.update_interval = update_interval 93 | self._session = cloud._session 94 | self.last_timestamp = 0 95 | self.manual_cloud_log_events = ManualCloudLogEvents(cloud) 96 | 97 | def _updater(self): 98 | logs = self.source_cloud.logs(limit=25) 99 | self.last_timestamp = 0 100 | if len(logs) != 0: 101 | self.last_timestamp = logs[0].timestamp 102 | 103 | self.call_event("on_ready") 104 | 105 | while True: 106 | if self.running is False: 107 | return 108 | for event_type, event_data in self.manual_cloud_log_events.update(): 109 | self.call_event(event_type, event_data) 110 | time.sleep(self.update_interval) 111 | -------------------------------------------------------------------------------- /scratchattach/eventhandlers/cloud_recorder.py: -------------------------------------------------------------------------------- 1 | """CloudRecorder class (used by ScratchCloud, TwCloud and other classes inheriting from BaseCloud to deliver cloud var values)""" 2 | from __future__ import annotations 3 | 4 | from .cloud_events import CloudEvents 5 | from typing import Optional 6 | 7 | 8 | class CloudRecorder(CloudEvents): 9 | def __init__(self, cloud, *, initial_values: Optional[dict] = None): 10 | if initial_values is None: 11 | initial_values = {} 12 | 13 | super().__init__(cloud) 14 | self.cloud_values = initial_values 15 | self.event(self.on_set) 16 | 17 | def get_var(self, var): 18 | if var not in self.cloud_values: 19 | return None 20 | return self.cloud_values[var] 21 | 22 | def get_all_vars(self): 23 | return self.cloud_values 24 | 25 | def on_set(self, activity): 26 | self.cloud_values[activity.var] = activity.value 27 | -------------------------------------------------------------------------------- /scratchattach/eventhandlers/cloud_storage.py: -------------------------------------------------------------------------------- 1 | """CloudStorage class""" 2 | from __future__ import annotations 3 | 4 | from .cloud_requests import CloudRequests 5 | import json 6 | import time 7 | from threading import Thread 8 | 9 | class Database: 10 | 11 | """ 12 | A Database is a simple key-value storage that stores data in a JSON file saved locally (other database services like MongoDB can be implemented) 13 | """ 14 | 15 | def __init__(self, name, *, json_file_path, save_interval=30): 16 | self.save_event_function = None 17 | self.set_event_function = None 18 | self.name = name 19 | 20 | # Import from JSON file 21 | if not json_file_path.endswith(".json"): 22 | json_file_path = json_file_path+".json" 23 | self.json_file_path = json_file_path 24 | 25 | try: 26 | with open(json_file_path, 'r') as json_file: 27 | self.data = json.load(json_file) 28 | except FileNotFoundError: 29 | print(f"Creating file {json_file_path}. Your database {name} will be stored there.") 30 | self.data = {} 31 | self.save_to_json() 32 | 33 | if isinstance(self.data , list): 34 | raise ValueError( 35 | "Invalid JSON file content: Top-level object must be a dict, not a list" 36 | ) 37 | 38 | # Start autosaving 39 | self.save_interval = save_interval 40 | if self.save_interval is not None: 41 | Thread(target=self._autosaver).start() 42 | 43 | def save_to_json(self): 44 | with open(self.json_file_path, 'w') as json_file: 45 | json.dump(self.data, json_file, indent=4) 46 | 47 | if self.save_event_function is not None: 48 | self.save_event_function() 49 | 50 | def keys(self) -> list: 51 | return list(self.data.keys()) 52 | 53 | def get(self, key) -> str: 54 | if not key in self.data: 55 | return None 56 | return self.data[key] 57 | 58 | def set(self, key, value): 59 | self.data[key] = value 60 | 61 | if self.set_event_function is not None: 62 | self.set_event_function(key, value) 63 | 64 | def event(self, event_function): 65 | # Decorator function for adding the on_save event that is called when a save is performed 66 | if event_function.__name__ == "on_save": 67 | self.save_event_function = event_function 68 | if event_function.__name__ == "on_set": 69 | self.set_event_function = event_function 70 | 71 | def _autosaver(self): 72 | # Task autosaving the db. save interval specified in .save_interval attribute 73 | while True: 74 | time.sleep(self.save_interval) 75 | self.save_to_json() 76 | 77 | class CloudStorage(CloudRequests): 78 | 79 | """ 80 | A CloudStorage object saves multiple databases and allows the connected Scratch project to access and modify the data of these databases through cloud requests 81 | 82 | The CloudStorage class is built upon CloudRequests 83 | """ 84 | 85 | def __init__(self, cloud, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], no_packet_loss=False): 86 | super().__init__(cloud, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss) 87 | # Setup 88 | self._databases = {} 89 | self.request(self.get, thread=False) 90 | self.request(self.set, thread=False) 91 | self.request(self.keys, thread=False) 92 | self.request(self.database_names, thread=False) 93 | self.request(self.ping, thread=False) 94 | 95 | def ping(self): 96 | return "Database backend is running" 97 | 98 | def get(self, db_name, key) -> str: 99 | try: 100 | return self.get_database(db_name).get(key) 101 | except Exception: 102 | if self.get_database(db_name) is None: 103 | return f"Error: Database {db_name} doesn't exist" 104 | else: 105 | return f"Error: Key {key} doesn't exist in database {db_name}" 106 | 107 | def set(self, db_name, key, value): 108 | print(db_name, key, value, self._databases) 109 | return self.get_database(db_name).set(key, value) 110 | 111 | def keys(self, db_name) -> list: 112 | try: 113 | return self.get_database(db_name).keys() 114 | except Exception: 115 | return f"Error: Database {db_name} doesn't exist" 116 | 117 | def databases(self) -> list: 118 | return list(self._databases.values()) 119 | 120 | def database_names(self) -> list: 121 | return list(self._databases.keys()) 122 | 123 | def add_database(self, database:Database): 124 | self._databases[database.name] = database 125 | 126 | def get_database(self, name) -> Database: 127 | if name in self._databases: 128 | return self._databases[name] 129 | return None 130 | 131 | def save(self): 132 | """ 133 | Saves the data in the JSON files for all databases in self._databases 134 | """ 135 | for dbname in self._databases: 136 | self._databases[dbname].save_to_json() -------------------------------------------------------------------------------- /scratchattach/eventhandlers/combine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | class MultiEventHandler: 4 | 5 | def __init__(self, *handlers): 6 | self.handlers = handlers 7 | 8 | def request(self, function, *args, **kwargs): 9 | for handler in self.handlers: 10 | handler.request(function, *args, **kwargs) 11 | 12 | def event(self, function, *args, **kwargs): 13 | for handler in self.handlers: 14 | handler.event(function, *args, **kwargs) 15 | 16 | def start(self, *args, **kwargs): 17 | for handler in self.handlers: 18 | handler.start(*args, **kwargs) 19 | 20 | def stop(self, *args, **kwargs): 21 | for handler in self.handlers: 22 | handler.stop(*args, **kwargs) 23 | 24 | def pause(self, *args, **kwargs): 25 | for handler in self.handlers: 26 | handler.pause(*args, **kwargs) 27 | 28 | def resume(self, *args, **kwargs): 29 | for handler in self.handlers: 30 | handler.resume(*args, **kwargs) 31 | -------------------------------------------------------------------------------- /scratchattach/eventhandlers/filterbot.py: -------------------------------------------------------------------------------- 1 | """FilterBot class""" 2 | from __future__ import annotations 3 | 4 | from .message_events import MessageEvents 5 | import time 6 | 7 | class HardFilter: 8 | 9 | def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False): 10 | self.equals=equals 11 | self.contains=contains 12 | self.author_name=author_name 13 | self.project_id=project_id 14 | self.profile=profile 15 | self.case_sensitive=case_sensitive 16 | self.filter_name = filter_name 17 | 18 | def apply(self, content, author_name, source_id): 19 | if not self.case_sensitive: 20 | content = content.lower() 21 | if self.equals is not None: 22 | if self.case_sensitive: 23 | if self.equals == content: 24 | return True 25 | else: 26 | if self.equals.lower() == content: 27 | return True 28 | if self.contains is not None: 29 | if self.case_sensitive: 30 | if self.contains.lower() in content: 31 | return True 32 | else: 33 | if self.contains in content: 34 | return True 35 | if self.author_name == author_name: 36 | return True 37 | if self.project_id == source_id or self.profile == source_id: 38 | return True 39 | return False 40 | 41 | class SoftFilter(HardFilter): 42 | def __init__(self, score:float, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False): 43 | self.score = score 44 | super().__init__(filter_name, equals=equals, contains=contains, author_name=author_name, project_id=project_id, profile=profile, case_sensitive=case_sensitive) 45 | 46 | class SpamFilter(HardFilter): 47 | def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False): 48 | self.memory = [] 49 | super().__init__(filter_name, equals=equals, contains=contains, author_name=author_name, project_id=project_id, profile=profile, case_sensitive=case_sensitive) 50 | 51 | def apply(self, content, author_name, source_id): 52 | applies = super().apply(content, author_name, source_id) 53 | if not applies: 54 | return False 55 | self.memory.insert(0, {"content":content, "time":time.time()}) 56 | print(content, self.memory) 57 | for comment in list(self.memory)[1:]: 58 | if comment["time"] < time.time() -300: 59 | self.memory.remove(comment) 60 | if comment["content"].lower() == content.lower(): 61 | return True 62 | return False 63 | 64 | class Filterbot(MessageEvents): 65 | 66 | # The Filterbot class is built upon MessageEvents, similar to how CloudEvents is built upon CloudEvents 67 | 68 | def __init__(self, user, *, log_deletions=True): 69 | super().__init__(user) 70 | self.hard_filters = [] 71 | self.soft_filters = [] 72 | self.spam_filters = [] 73 | self.log_deletions = log_deletions 74 | self.event(self.on_message, thread=False) 75 | self.update_interval = 2 76 | 77 | def add_filter(self, filter_obj): 78 | if isinstance(filter_obj, SoftFilter): 79 | self.soft_filters.append(filter_obj) 80 | elif isinstance(filter_obj, SpamFilter): # careful: SpamFilter is also HardFilter due to inheritence 81 | self.spam_filters.append(filter_obj) 82 | elif isinstance(filter_obj, HardFilter): 83 | self.hard_filters.append(filter_obj) 84 | 85 | def add_f4f_filter(self): 86 | self.add_filter(HardFilter("(f4f_filter) 'f4f'", contains="f4f")) 87 | self.add_filter(HardFilter("(f4f_filter) 'follow me'", contains="follow me")) 88 | self.add_filter(HardFilter("(f4f_filter) 'follow @'", contains="follow @")) 89 | self.add_filter(HardFilter("(f4f_filter) f 4 f'", contains="f 4 f")) 90 | self.add_filter(HardFilter("(f4f_filter) 'follow for'", contains="follow for")) 91 | 92 | def add_ads_filter(self): 93 | self.add_filter(SoftFilter(1, "(ads_filter) links", contains="scratch.mit.edu/projects/")) 94 | self.add_filter(SoftFilter(-1, "(ads_filter) feedback", contains="feedback")) 95 | self.add_filter(HardFilter("(ads_filter) 'check out my'", contains="check out my")) 96 | self.add_filter(HardFilter("(ads_filter) 'play my'", contains="play my")) 97 | self.add_filter(SoftFilter(1, "(ads_filter) 'advertis'", contains="advertis")) 98 | 99 | def add_spam_filter(self): 100 | self.add_filter(SpamFilter("(spam_filter)", contains="")) 101 | 102 | def add_genalpha_nonsense_filter(self): 103 | self.add_filter(HardFilter("(genalpha_nonsene_filter) 'skibidi'", contains="skibidi")) 104 | self.add_filter(HardFilter("[genalpha_nonsene_filter) 'rizzler'", contains="rizzler")) 105 | self.add_filter(HardFilter("(genalpha_nonsene_filter) 'fanum tax'", contains="fanum tax")) 106 | 107 | def on_message(self, message): 108 | if message.type == "addcomment": 109 | delete = False 110 | content = message.comment_fragment 111 | 112 | if message.comment_type == 0: # project comment 113 | source_id = message.comment_obj_id 114 | if self.user._session.connect_project(message.comment_obj_id).author_name != self.user.username: 115 | return # no permission to delete 116 | if message.comment_type == 1: # profile comment 117 | source_id = message.comment_obj_title 118 | if message.comment_obj_title != self.user.username: 119 | return # no permission to delete 120 | if message.comment_type == 2: # studio comment 121 | return # studio comments aren't handled 122 | 123 | # Apply hard filters 124 | for hard_filter in self.hard_filters: 125 | if hard_filter.apply(content, message.actor_username, source_id): 126 | delete=True 127 | if self.log_deletions: 128 | print(f"DETECTED: #{message.comment_id} violates hard filter: {hard_filter.filter_name}") 129 | break 130 | 131 | # Apply spam filters 132 | if delete is False: 133 | for spam_filter in self.spam_filters: 134 | if spam_filter.apply(content, message.actor_username, source_id): 135 | delete=True 136 | if self.log_deletions: 137 | print(f"DETECTED: #{message.comment_id} violates spam filter: {spam_filter.filter_name}") 138 | break 139 | 140 | # Apply soft filters 141 | if delete is False: 142 | score = 0 143 | violated_filers = [] 144 | for soft_filter in self.soft_filters: 145 | if soft_filter.apply(content, message.actor_username, source_id): 146 | score += soft_filter.score 147 | violated_filers.append(soft_filter.name) 148 | if score >= 1: 149 | print(f"DETECTED: #{message.comment_id} violates too many soft filters: {violated_filers}") 150 | delete = True 151 | 152 | if delete is True: 153 | try: 154 | message.target().delete() 155 | if self.log_deletions: 156 | print(f"DELETED: #{message.comment_id} by f{message.actor_username}: '{content}'") 157 | except Exception as e: 158 | if self.log_deletions: 159 | print(f"DELETION FAILED: #{message.comment_id} by f{message.actor_username}: '{content}'") 160 | 161 | -------------------------------------------------------------------------------- /scratchattach/eventhandlers/message_events.py: -------------------------------------------------------------------------------- 1 | """MessageEvents class""" 2 | from __future__ import annotations 3 | 4 | from scratchattach.site import user 5 | from ._base import BaseEventHandler 6 | import time 7 | 8 | class MessageEvents(BaseEventHandler): 9 | """ 10 | Class that calls events when you receive messages on your Scratch account. Data fetched from Scratch's API. 11 | """ 12 | def __init__(self, user, *, update_interval=2): 13 | super().__init__() 14 | self.user = user 15 | self.current_message_count = 0 16 | self.update_interval = update_interval 17 | 18 | def _updater(self): 19 | """ 20 | A process that listens for cloud activity and executes events on cloud activity 21 | """ 22 | self.current_message_count = int(self.user.message_count()) 23 | 24 | self.call_event("on_ready") 25 | 26 | while True: 27 | if self.running is False: 28 | return 29 | message_count = int(self.user.message_count()) 30 | if message_count != self.current_message_count: 31 | self.call_event("on_count_change", [int(self.current_message_count), int(message_count)]) 32 | if message_count != 0: 33 | if message_count < self.current_message_count: 34 | self.current_message_count = 0 35 | if self.user._session is not None: # authentication check 36 | if self.user._session.username == self.user.username: # authorization check 37 | new_messages = self.user._session.messages(limit=message_count-self.current_message_count) 38 | for message in new_messages[::-1]: 39 | self.call_event("on_message", [message]) 40 | self.current_message_count = int(message_count) 41 | time.sleep(self.update_interval) 42 | -------------------------------------------------------------------------------- /scratchattach/other/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/scratchattach/other/__init__.py -------------------------------------------------------------------------------- /scratchattach/site/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/scratchattach/site/__init__.py -------------------------------------------------------------------------------- /scratchattach/site/_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import TypeVar, Optional 5 | 6 | import requests 7 | from scratchattach.utils import exceptions, commons 8 | from . import session 9 | 10 | C = TypeVar("C", bound="BaseSiteComponent") 11 | class BaseSiteComponent(ABC): 12 | _session: Optional[session.Session] 13 | update_api: str 14 | _headers: dict[str, str] 15 | _cookies: dict[str, str] 16 | @abstractmethod 17 | def __init__(self): 18 | pass 19 | 20 | def update(self): 21 | """ 22 | Updates the attributes of the object by performing an API response. Returns True if the update was successful. 23 | """ 24 | response = self.update_function( 25 | self.update_api, 26 | headers=self._headers, 27 | cookies=self._cookies, timeout=10 28 | ) 29 | # Check for 429 error: 30 | # Note, this is a bit naïve 31 | if "429" in str(response): 32 | return "429" 33 | 34 | if response.text == '{\n "response": "Too many requests"\n}': 35 | return "429" 36 | 37 | # If no error: Parse JSON: 38 | response = response.json() 39 | if "code" in response: 40 | return False 41 | 42 | return self._update_from_dict(response) 43 | 44 | @abstractmethod 45 | def _update_from_dict(self, data) -> bool: 46 | """ 47 | Parses the API response that is fetched in the update-method. Class specific, must be overridden in classes inheriting from this one. 48 | """ 49 | 50 | def _assert_auth(self): 51 | if self._session is None: 52 | raise exceptions.Unauthenticated( 53 | "You need to use session.connect_xyz (NOT get_xyz) in order to perform this operation.") 54 | 55 | def _make_linked_object(self, identificator_id, identificator, Class: type[C], NotFoundException) -> C: 56 | """ 57 | Internal function for making a linked object (authentication kept) based on an identificator (like a project id or username) 58 | Class must inherit from BaseSiteComponent 59 | """ 60 | return commons._get_object(identificator_id, identificator, Class, NotFoundException, self._session) 61 | 62 | update_function = requests.get 63 | """ 64 | Internal function run on update. Function is a method of the 'requests' module/class 65 | """ 66 | -------------------------------------------------------------------------------- /scratchattach/site/backpack_asset.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import time 5 | import logging 6 | 7 | from ._base import BaseSiteComponent 8 | from scratchattach.utils import exceptions 9 | from scratchattach.utils.requests import requests 10 | 11 | 12 | 13 | class BackpackAsset(BaseSiteComponent): 14 | """ 15 | Represents an asset from the backpack. 16 | 17 | Attributes: 18 | 19 | :.id: 20 | 21 | :.type: The asset type (costume, script etc.) 22 | 23 | :.mime: The format in which the content of the backpack asset is saved 24 | 25 | :.name: The name of the backpack asset 26 | 27 | :.filename: Filename of the file containing the content of the backpack asset 28 | 29 | :.thumbnail_url: Link that leads to the asset's thumbnail (the image shown in the backpack UI) 30 | 31 | :.download_url: Link that leads to a file containing the content of the backpack asset 32 | """ 33 | 34 | def __init__(self, **entries): 35 | # Set attributes every BackpackAsset object needs to have: 36 | self._session = None 37 | 38 | # Update attributes from entries dict: 39 | self.__dict__.update(entries) 40 | 41 | def update(self): 42 | print("Warning: BackpackAsset objects can't be updated") 43 | return False # Objects of this type cannot be updated 44 | 45 | 46 | def _update_from_dict(self, data) -> bool: 47 | try: 48 | self.id = data["id"] 49 | except Exception: 50 | pass 51 | try: 52 | self.type = data["type"] 53 | except Exception: 54 | pass 55 | try: 56 | self.mime = data["mime"] 57 | except Exception: 58 | pass 59 | try: 60 | self.name = data["name"] 61 | except Exception: 62 | pass 63 | try: 64 | self.filename = data["body"] 65 | except Exception: 66 | pass 67 | try: 68 | self.thumbnail_url = "https://backpack.scratch.mit.edu/" + data["thumbnail"] 69 | except Exception: 70 | pass 71 | try: 72 | self.download_url = "https://backpack.scratch.mit.edu/" + data["body"] 73 | except Exception: 74 | pass 75 | return True 76 | 77 | @property 78 | def _data_bytes(self) -> bytes: 79 | try: 80 | return requests.get(self.download_url).content 81 | except Exception as e: 82 | raise exceptions.FetchError(f"Failed to download asset: {e}") 83 | 84 | @property 85 | def file_ext(self): 86 | return self.filename.split(".")[-1] 87 | 88 | @property 89 | def is_json(self): 90 | return self.file_ext == "json" 91 | 92 | @property 93 | def data(self) -> dict | list | int | None | str | bytes | float: 94 | if self.is_json: 95 | return json.loads(self._data_bytes) 96 | else: 97 | # It's either a zip 98 | return self._data_bytes 99 | 100 | def download(self, *, fp: str = ''): 101 | """ 102 | Downloads the asset content to the given directory. The given filename is equal to the value saved in the .filename attribute. 103 | 104 | Args: 105 | fp (str): The path of the directory the file will be saved in. 106 | """ 107 | if not (fp.endswith("/") or fp.endswith("\\")): 108 | fp = fp + "/" 109 | open(f"{fp}{self.filename}", "wb").write(self._data_bytes) 110 | 111 | def delete(self): 112 | self._assert_auth() 113 | 114 | return requests.delete( 115 | f"https://backpack.scratch.mit.edu/{self._session.username}/{self.id}", 116 | headers=self._session._headers, 117 | timeout=10, 118 | ).json() 119 | -------------------------------------------------------------------------------- /scratchattach/site/browser_cookie3_stub.py: -------------------------------------------------------------------------------- 1 | # browser_cookie3.pyi 2 | 3 | import http.cookiejar 4 | from typing import Optional 5 | 6 | def chrome(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 7 | def chromium(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 8 | def firefox(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 9 | def opera(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 10 | def edge(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 11 | def brave(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 12 | def vivaldi(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 13 | def safari(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 14 | def lynx(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 15 | def w3m(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented 16 | 17 | def load() -> http.cookiejar.CookieJar: return NotImplemented 18 | -------------------------------------------------------------------------------- /scratchattach/site/browser_cookies.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TYPE_CHECKING 2 | from typing_extensions import assert_never 3 | from http.cookiejar import CookieJar 4 | from enum import Enum, auto 5 | browsercookie_err = None 6 | try: 7 | if TYPE_CHECKING: 8 | from . import browser_cookie3_stub as browser_cookie3 9 | else: 10 | import browser_cookie3 11 | except Exception as e: 12 | browsercookie = None 13 | browsercookie_err = e 14 | 15 | class Browser(Enum): 16 | ANY = auto() 17 | FIREFOX = auto() 18 | CHROME = auto() 19 | EDGE = auto() 20 | SAFARI = auto() 21 | CHROMIUM = auto() 22 | VIVALDI = auto() 23 | EDGE_DEV = auto() 24 | 25 | 26 | FIREFOX = Browser.FIREFOX 27 | CHROME = Browser.CHROME 28 | EDGE = Browser.EDGE 29 | SAFARI = Browser.SAFARI 30 | CHROMIUM = Browser.CHROMIUM 31 | VIVALDI = Browser.VIVALDI 32 | ANY = Browser.ANY 33 | EDGE_DEV = Browser.EDGE_DEV 34 | 35 | def cookies_from_browser(browser : Browser = ANY) -> dict[str, str]: 36 | """ 37 | Import cookies from browser to login 38 | """ 39 | if not browser_cookie3: 40 | raise browsercookie_err or ModuleNotFoundError() 41 | cookies : Optional[CookieJar] = None 42 | if browser is Browser.ANY: 43 | cookies = browser_cookie3.load() 44 | elif browser is Browser.FIREFOX: 45 | cookies = browser_cookie3.firefox() 46 | elif browser is Browser.CHROME: 47 | cookies = browser_cookie3.chrome() 48 | elif browser is Browser.EDGE: 49 | cookies = browser_cookie3.edge() 50 | elif browser is Browser.SAFARI: 51 | cookies = browser_cookie3.safari() 52 | elif browser is Browser.CHROMIUM: 53 | cookies = browser_cookie3.chromium() 54 | elif browser is Browser.VIVALDI: 55 | cookies = browser_cookie3.vivaldi() 56 | elif browser is Browser.EDGE_DEV: 57 | raise ValueError("EDGE_DEV is not supported anymore.") 58 | else: 59 | assert_never(browser) 60 | assert isinstance(cookies, CookieJar) 61 | return {cookie.name: cookie.value for cookie in cookies if "scratch.mit.edu" in cookie.domain and cookie.value} -------------------------------------------------------------------------------- /scratchattach/site/cloud_activity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from ._base import BaseSiteComponent 5 | 6 | 7 | 8 | class CloudActivity(BaseSiteComponent): 9 | """ 10 | Represents a cloud activity (a cloud variable set / creation / deletion). 11 | 12 | Attributes: 13 | 14 | :.username: The user who caused the cloud event (the user who added / set / deleted the cloud variable) 15 | 16 | :.var: The name of the cloud variable that was updated (specified without the cloud emoji) 17 | 18 | :.name: The name of the cloud variable that was updated (specified without the cloud emoji) 19 | 20 | :.type: The activity type 21 | 22 | :.timestamp: Then timestamp of when the action was performed 23 | 24 | :.value: If the cloud variable was set, then this attribute provides the value the cloud variable was set to 25 | 26 | :.cloud: The cloud (as object inheriting from scratchattach.Cloud.BaseCloud) that the cloud activity corresponds to 27 | """ 28 | 29 | def __init__(self, **entries): 30 | # Set attributes every CloudActivity object needs to have: 31 | self._session = None 32 | self.cloud = None 33 | self.user = None 34 | self.username = None 35 | self.type = None 36 | self.timestamp = time.time() 37 | 38 | # Update attributes from entries dict: 39 | self.__dict__.update(entries) 40 | 41 | def update(self): 42 | print("Warning: CloudActivity objects can't be updated") 43 | return False # Objects of this type cannot be updated 44 | 45 | def __eq__(self, activity2): 46 | # CloudLogEvents needs to check if two activites are equal (to finde new ones), therefore CloudActivity objects need to be comparable 47 | return self.user == activity2.user and self.type == activity2.type and self.timestamp == activity2.timestamp and self.value == activity2.value and self.name == activity2.name 48 | 49 | def _update_from_dict(self, data) -> bool: 50 | try: self.name = data["name"] 51 | except Exception: pass 52 | try: self.var = data["name"] 53 | except Exception: pass 54 | try: self.value = data["value"] 55 | except Exception: pass 56 | try: self.user = data["user"] 57 | except Exception: pass 58 | try: self.username = data["user"] 59 | except Exception: pass 60 | try: self.timestamp = data["timestamp"] 61 | except Exception: pass 62 | try: self.type = data["verb"].replace("_var","") 63 | except Exception: pass 64 | try: self.type = data["method"] 65 | except Exception: pass 66 | try: self.cloud = data["cloud"] 67 | except Exception: pass 68 | return True 69 | 70 | def load_log_data(self): 71 | if self.cloud is None: 72 | print("Warning: There aren't cloud logs available for this cloud, therefore the user and exact timestamp can't be loaded") 73 | else: 74 | if hasattr(self.cloud, "logs"): 75 | logs = self.cloud.logs(filter_by_var_named=self.var, limit=100) 76 | matching = list(filter(lambda x: x.value == self.value and x.timestamp <= self.timestamp, logs)) 77 | if matching == []: 78 | return False 79 | activity = matching[0] 80 | self.username = activity.username 81 | self.user = activity.username 82 | self.timestamp = activity.timestamp 83 | return True 84 | else: 85 | print("Warning: There aren't cloud logs available for this cloud, therefore the user and exact timestamp can't be loaded") 86 | return False 87 | 88 | def actor(self): 89 | """ 90 | Returns the user that performed the cloud activity as scratchattach.user.User object 91 | """ 92 | if self.username is None: 93 | return None 94 | from scratchattach.site import user 95 | from scratchattach.utils import exceptions 96 | return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound) 97 | 98 | def project(self): 99 | """ 100 | Returns the project where the cloud activity was performed as scratchattach.project.Project object 101 | """ 102 | if self.cloud is None: 103 | return None 104 | from scratchattach.site import project 105 | from scratchattach.utils import exceptions 106 | return self._make_linked_object("id", self.cloud.project_id, project.Project, exceptions.ProjectNotFound) 107 | 108 | -------------------------------------------------------------------------------- /scratchattach/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/scratchattach/utils/__init__.py -------------------------------------------------------------------------------- /scratchattach/utils/encoder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import math 3 | from . import exceptions 4 | 5 | letters = [ 6 | None, 7 | None, 8 | None, 9 | None, 10 | None, 11 | None, 12 | None, 13 | None, 14 | None, 15 | None, 16 | "1", 17 | "2", 18 | "3", 19 | "4", 20 | "5", 21 | "6", 22 | "7", 23 | "8", 24 | "9", 25 | "0", 26 | " ", 27 | "a", 28 | "A", 29 | "b", 30 | "B", 31 | "c", 32 | "C", 33 | "d", 34 | "D", 35 | "e", 36 | "E", 37 | "f", 38 | "F", 39 | "g", 40 | "G", 41 | "h", 42 | "H", 43 | "i", 44 | "I", 45 | "j", 46 | "J", 47 | "k", 48 | "K", 49 | "l", 50 | "L", 51 | "m", 52 | "M", 53 | "n", 54 | "N", 55 | "o", 56 | "O", 57 | "p", 58 | "P", 59 | "q", 60 | "Q", 61 | "r", 62 | "R", 63 | "s", 64 | "S", 65 | "t", 66 | "T", 67 | "u", 68 | "U", 69 | "v", 70 | "V", 71 | "w", 72 | "W", 73 | "x", 74 | "X", 75 | "y", 76 | "Y", 77 | "z", 78 | "Z", 79 | "*", 80 | "/", 81 | ".", 82 | ",", 83 | "!", 84 | '"', 85 | "§", 86 | "$", 87 | "%", 88 | "_", 89 | "-", 90 | "(", 91 | "´", 92 | ")", 93 | "`", 94 | "?", 95 | "new line", 96 | "@", 97 | "#", 98 | "~", 99 | ";", 100 | ":", 101 | "+", 102 | "&", 103 | "|", 104 | "^", 105 | "'" 106 | ] 107 | 108 | 109 | class Encoding: 110 | """ 111 | Class that contains tools for encoding / decoding strings. The strings encoded / decoded with these functions can be decoded / encoded with Scratch using this sprite: https://scratch3-assets.1tim.repl.co/Encoder.sprite3 112 | """ 113 | @staticmethod 114 | def decode(inp): 115 | """ 116 | Args: 117 | inp (str): The encoded input. 118 | 119 | Returns: 120 | str: The decoded output. 121 | """ 122 | try: 123 | inp = str(inp) 124 | except Exception: 125 | raise(exceptions.InvalidDecodeInput) 126 | outp = "" 127 | for i in range(0, math.floor(len(inp) / 2)): 128 | letter = letters[int(f"{inp[i*2]}{inp[(i*2)+1]}")] 129 | outp = f"{outp}{letter}" 130 | return outp 131 | 132 | @staticmethod 133 | def encode(inp): 134 | """ 135 | Args: 136 | inp (str): The decoded input. 137 | 138 | Returns: 139 | str: The encoded output. 140 | """ 141 | inp = str(inp) 142 | outp = "" 143 | for i in inp: 144 | if i in letters: 145 | outp = f"{outp}{letters.index(i)}" 146 | else: 147 | outp += str(letters.index(" ")) 148 | return outp 149 | 150 | @staticmethod 151 | def replace_char(old_char, new_char): 152 | """ 153 | Replaces a character in the list that the encoder uses to encode / decode values. 154 | You can access this list using `scratchattach.encoder.letters` 155 | """ 156 | i = letters.index(old_char) 157 | letters[i] = new_char 158 | -------------------------------------------------------------------------------- /scratchattach/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # Authentication / Authorization: 2 | from __future__ import annotations 3 | 4 | class Unauthenticated(Exception): 5 | """ 6 | Raised when a method that requires a login / session is called on an object that wasn't created with a session. 7 | 8 | If you create Project, Studio, or User objects using :meth:`scratchattach.get_project`, :meth:`scratchattach.get_studio`, or :meth:`scratchattach.get_user`, they cannot be used for actions that require authentication. Instead, use the following methods to ensure the objects are connected to an authenticated session: 9 | 10 | - :meth:`scratchattach.Session.connect_project` 11 | 12 | - :meth:`scratchattach.Session.connect_user` 13 | 14 | - :meth:`scratchattach.Session.connect_studio` 15 | 16 | This also applies to cloud variables, forum topics, and forum posts. 17 | """ 18 | 19 | def __init__(self, message=""): 20 | self.message = "No login / session connected.\n\nThe object on which the method was called was created using scratchattach.get_xyz()\nUse session.connect_xyz() instead (xyz is a placeholder for user / project / cloud / ...).\n\nMore information: https://scratchattach.readthedocs.io/en/latest/scratchattach.html#scratchattach.utils.exceptions.Unauthenticated" 21 | super().__init__(self.message) 22 | 23 | 24 | class Unauthorized(Exception): 25 | """ 26 | Raised when an action is performed that the user associated with the session that the object was created with is not allowed to do. 27 | 28 | Example: Changing the "about me" of other users will raise this error. 29 | """ 30 | 31 | def __init__(self, message=""): 32 | self.message = ( 33 | f"The user corresponding to the connected login / session is not allowed to perform this action. " 34 | f"{message}") 35 | super().__init__(self.message) 36 | 37 | 38 | class XTokenError(Exception): 39 | """ 40 | Raised when an action can't be performed because there is no XToken available. 41 | 42 | This error can occur if the xtoken couldn't be fetched when the session was created. Some actions (like loving projects) require providing this token. 43 | """ 44 | 45 | 46 | # Not found errors: 47 | 48 | class UserNotFound(Exception): 49 | """ 50 | Raised when a non-existent user is requested. 51 | """ 52 | 53 | 54 | class ProjectNotFound(Exception): 55 | """ 56 | Raised when a non-existent project is requested. 57 | """ 58 | 59 | class ClassroomNotFound(Exception): 60 | """ 61 | Raised when a non-existent Classroom is requested. 62 | """ 63 | 64 | 65 | class StudioNotFound(Exception): 66 | """ 67 | Raised when a non-existent studio is requested. 68 | """ 69 | 70 | 71 | class ForumContentNotFound(Exception): 72 | """ 73 | Raised when a non-existent forum topic / post is requested. 74 | """ 75 | 76 | 77 | class CommentNotFound(Exception): 78 | pass 79 | 80 | 81 | # Invalid inputs 82 | class InvalidLanguage(Exception): 83 | """ 84 | Raised when an invalid language/language code/language object is provided, for TTS or Translate 85 | """ 86 | 87 | 88 | class InvalidTTSGender(Exception): 89 | """ 90 | Raised when an invalid TTS gender is provided. 91 | """ 92 | 93 | # API errors: 94 | 95 | class LoginFailure(Exception): 96 | """ 97 | Raised when the Scratch server doesn't respond with a session id. 98 | 99 | This could be caused by an invalid username / password. Another cause could be that your IP address was banned from logging in to Scratch. If you're using an online IDE (like replit), try running the code on your computer. 100 | """ 101 | 102 | 103 | class FetchError(Exception): 104 | """ 105 | Raised when getting information from the Scratch API fails. This can have various reasons. Make sure all provided arguments are valid. 106 | """ 107 | 108 | 109 | class BadRequest(Exception): 110 | """ 111 | Raised when the Scratch API responds with a "Bad Request" error message. This can have various reasons. Make sure all provided arguments are valid. 112 | """ 113 | 114 | class RateLimitedError(Exception): 115 | """ 116 | Indicates a ratelimit enforced by scratchattach 117 | """ 118 | 119 | class Response429(Exception): 120 | """ 121 | Raised when the Scratch API responds with a 429 error. This means that your network was ratelimited or blocked by Scratch. If you're using an online IDE (like replit.com), try running the code on your computer. 122 | """ 123 | 124 | 125 | class CommentPostFailure(Exception): 126 | """ 127 | Raised when a comment fails to post. This can have various reasons. 128 | """ 129 | 130 | 131 | class APIError(Exception): 132 | """ 133 | For API errors that can't be classified into one of the above errors 134 | """ 135 | 136 | 137 | class ScrapeError(Exception): 138 | """ 139 | Raised when something goes wrong while web-scraping a page with bs4. 140 | """ 141 | 142 | 143 | 144 | # Cloud / encoding errors: 145 | 146 | class CloudConnectionError(Exception): 147 | """ 148 | Raised when connecting to Scratch's cloud server fails. This can have various reasons. 149 | """ 150 | 151 | 152 | 153 | class InvalidCloudValue(Exception): 154 | """ 155 | Raised when a cloud variable is set to an invalid value. 156 | """ 157 | 158 | 159 | 160 | class InvalidDecodeInput(Exception): 161 | """ 162 | Raised when the built-in decoder :meth:`scratchattach.encoder.Encoding.decode` receives an invalid input. 163 | """ 164 | 165 | 166 | 167 | # Cloud Requests errors: 168 | 169 | class RequestNotFound(Exception): 170 | """ 171 | Cloud Requests: Raised when a non-existent cloud request is edited using :meth:`scratchattach.cloud_requests.CloudRequests.edit_request`. 172 | """ 173 | 174 | 175 | 176 | # Websocket server errors: 177 | 178 | class WebsocketServerError(Exception): 179 | """ 180 | Raised when the self-hosted cloud websocket server fails to start. 181 | """ 182 | 183 | 184 | 185 | # Editor errors: 186 | 187 | class UnclosedJSONError(Exception): 188 | """ 189 | Raised when a JSON string is never closed. 190 | """ 191 | 192 | 193 | class BadVLBPrimitiveError(Exception): 194 | """ 195 | Raised when a Primitive claiming to be a variable/list/broadcast actually isn't 196 | """ 197 | 198 | 199 | class UnlinkedVLB(Exception): 200 | """ 201 | Raised when a Primitive cannot be linked to variable/list/broadcast because the provided ID does not have an associated variable/list/broadcast 202 | """ 203 | 204 | 205 | class InvalidStageCount(Exception): 206 | """ 207 | Raised when a project has too many or too few Stage sprites 208 | """ 209 | 210 | 211 | class InvalidVLBName(Exception): 212 | """ 213 | Raised when an invalid VLB name is provided (not variable, list or broadcast) 214 | """ 215 | 216 | 217 | class BadBlockShape(Exception): 218 | """ 219 | Raised when the block shape cannot allow for the operation 220 | """ 221 | 222 | 223 | class BadScript(Exception): 224 | """ 225 | Raised when the block script cannot allow for the operation 226 | """ 227 | 228 | # Warnings 229 | 230 | class LoginDataWarning(UserWarning): 231 | """ 232 | Warns you not to accidentally share your login data. 233 | """ 234 | -------------------------------------------------------------------------------- /scratchattach/utils/requests.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import MutableMapping, Iterator 4 | from typing import Optional 5 | from contextlib import contextmanager 6 | 7 | from typing_extensions import override 8 | from requests import Session as HTTPSession 9 | from requests import Response 10 | 11 | from . import exceptions 12 | 13 | proxies: Optional[MutableMapping[str, str]] = None 14 | 15 | class Requests(HTTPSession): 16 | """ 17 | Centralized HTTP request handler (for better error handling and proxies) 18 | """ 19 | error_handling: bool = True 20 | 21 | def check_response(self, r: Response): 22 | if r.status_code == 403 or r.status_code == 401: 23 | raise exceptions.Unauthorized(f"Request content: {r.content!r}") 24 | if r.status_code == 500: 25 | raise exceptions.APIError("Internal Scratch server error") 26 | if r.status_code == 429: 27 | raise exceptions.Response429("You are being rate-limited (or blocked) by Scratch") 28 | if r.json() == {"code":"BadRequest","message":""}: 29 | raise exceptions.BadRequest("Make sure all provided arguments are valid") 30 | 31 | @override 32 | def get(self, *args, **kwargs): 33 | kwargs.setdefault("proxies", proxies) 34 | try: 35 | r = super().get(*args, **kwargs) 36 | except Exception as e: 37 | raise exceptions.FetchError(e) 38 | if self.error_handling: 39 | self.check_response(r) 40 | return r 41 | 42 | @override 43 | def post(self, *args, **kwargs): 44 | kwargs.setdefault("proxies", proxies) 45 | try: 46 | r = super().post(*args, **kwargs) 47 | except Exception as e: 48 | raise exceptions.FetchError(e) 49 | if self.error_handling: 50 | self.check_response(r) 51 | return r 52 | 53 | @override 54 | def delete(self, *args, **kwargs): 55 | kwargs.setdefault("proxies", proxies) 56 | try: 57 | r = super().delete(*args, **kwargs) 58 | except Exception as e: 59 | raise exceptions.FetchError(e) 60 | if self.error_handling: 61 | self.check_response(r) 62 | return r 63 | 64 | @override 65 | def put(self, *args, **kwargs): 66 | kwargs.setdefault("proxies", proxies) 67 | try: 68 | r = super().put(*args, **kwargs) 69 | except Exception as e: 70 | raise exceptions.FetchError(e) 71 | if self.error_handling: 72 | self.check_response(r) 73 | return r 74 | 75 | @contextmanager 76 | def no_error_handling(self) -> Iterator[None]: 77 | val_before = self.error_handling 78 | self.error_handling = False 79 | try: 80 | yield 81 | finally: 82 | self.error_handling = val_before 83 | 84 | @contextmanager 85 | def yes_error_handling(self) -> Iterator[None]: 86 | val_before = self.error_handling 87 | self.error_handling = True 88 | try: 89 | yield 90 | finally: 91 | self.error_handling = val_before 92 | 93 | requests = Requests() 94 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import codecs 3 | import os 4 | 5 | VERSION = '2.1.13' 6 | DESCRIPTION = 'A Scratch API Wrapper' 7 | with open('README.md', encoding='utf-8') as f: 8 | LONG_DESCRIPTION = f.read() 9 | 10 | with open('requirements.txt', encoding='utf-8') as f: 11 | requirements = f.read().strip().splitlines() 12 | 13 | # Setting up 14 | setup( 15 | name="scratchattach", 16 | version=VERSION, 17 | author="TimMcCool", 18 | author_email="", 19 | description=DESCRIPTION, 20 | long_description_content_type="text/markdown", 21 | long_description=LONG_DESCRIPTION, 22 | packages=find_packages(), 23 | install_requires=requirements, 24 | keywords=['scratch api', 'scratchattach', 'scratch api python', 'scratch python', 'scratch for python', 'scratch', 'scratch cloud', 'scratch cloud variables', 'scratch bot'], 25 | url='https://scratchattach.tim1de.net', 26 | classifiers=[ 27 | "Development Status :: 5 - Production/Stable", 28 | "Intended Audience :: Developers", 29 | "Programming Language :: Python :: 3", 30 | "Operating System :: Unix", 31 | "Operating System :: MacOS :: MacOS X", 32 | "Operating System :: Microsoft :: Windows", 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | import sys 2 | def test_import(): 3 | sys.path.insert(0, ".") 4 | import scratchattach 5 | -------------------------------------------------------------------------------- /website/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | The /website folder contains the source code for scratchattach's website (scratchattach.tim1de.net). 3 | It is NOT part of the scratchattach Python library and won't be downloaded when you install scratchattach. 4 | """ 5 | 6 | from flask import Flask, render_template, send_from_directory, jsonify 7 | import scratchattach as sa 8 | import time 9 | import random 10 | 11 | app = Flask(__name__, template_folder="source") 12 | 13 | @app.route('/') 14 | def home(): 15 | return render_template('index.html') 16 | 17 | @app.route('/css/') 18 | def serve_css(filename): 19 | return send_from_directory('source/css', filename) 20 | 21 | @app.route('/images/') 22 | def serve_image(filename): 23 | return send_from_directory('source/images', filename) 24 | 25 | @app.route('/js/') 26 | def serve_js(filename): 27 | return send_from_directory('source/js', filename) 28 | 29 | # community projects are cached to prevent spamming Scratch's API 30 | community_projects_cache = [] 31 | last_cache_time = 0 32 | 33 | @app.route('/api/community_projects/') 34 | def community_projects(): 35 | if time.time() - 300 > last_cache_time: 36 | projects = sa.Studio(id=31478892).projects(limit=40) 37 | if isinstance(projects[0], dict): #atm the server this is running on still uses scratchattach 1.7.4 that returns a list of dicts here 38 | community_projects_cache = [ 39 | {"project_id":p["id"], "title":p["title"], "author":p["username"], "thumbnail_url":f"https://uploads.scratch.mit.edu/get_image/project/{p['id']}_480x360.png"} for p in projects 40 | ] 41 | else: 42 | community_projects_cache = [ 43 | {"project_id":p.id, "title":p.title, "author":p.author_name, "thumbnail_url":f"https://uploads.scratch.mit.edu/get_image/project/{p.id}_480x360.png"} for p in projects 44 | ] 45 | return jsonify(random.choices(community_projects_cache, k=5)) -------------------------------------------------------------------------------- /website/source/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/website/source/images/favicon.ico -------------------------------------------------------------------------------- /website/source/images/section1_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/website/source/images/section1_bg.png -------------------------------------------------------------------------------- /website/source/js/flyInAni.js: -------------------------------------------------------------------------------- 1 | // Function to handle the entry of observed elements 2 | function handleIntersection(entries) { 3 | entries.forEach(entry => { 4 | if (!entry.isIntersecting) { 5 | var elements = document.getElementsByClassName("header-btn"); 6 | for (let i = 0; i < elements.length; i++) { 7 | const element = elements[i]; 8 | element.classList.add('fly-in'); // Add the fly-in class to trigger the animation 9 | observer.unobserve(element); // Stop observing once the animation has triggered 10 | } 11 | var elements = document.getElementsByClassName("logo-section"); 12 | for (let i = 0; i < elements.length; i++) { 13 | const element = elements[i]; 14 | element.classList.add('fly-in'); // Add the fly-in class to trigger the animation 15 | observer.unobserve(element); // Stop observing once the animation has triggered 16 | } 17 | } 18 | }); 19 | } 20 | 21 | // Options for the Intersection Observer 22 | const options = { 23 | root: null, // Use the viewport as the root 24 | rootMargin: '0px', // No margin 25 | threshold: 0.1 // Trigger when 10% of the element is in view 26 | }; 27 | 28 | // Create an Intersection Observer instance 29 | const observer = new IntersectionObserver(handleIntersection, options); 30 | 31 | // Select elements to observe 32 | const flyInElements = document.querySelectorAll('.section-1'); // Adjust selectors as needed 33 | 34 | // Observe each element 35 | flyInElements.forEach(element => { 36 | observer.observe(element); 37 | }); 38 | 39 | // Function to handle the entry of the expanding box 40 | function handleBoxIntersection(entries) { 41 | entries.forEach(entry => { 42 | if (entry.isIntersecting) { 43 | var box = entry.target; 44 | boxObserver.unobserve(box); // Stop observing once the animation has triggered 45 | 46 | // Add a wait time (e.g., 500ms) before opening the box 47 | setTimeout(() => { 48 | const width = box.dataset.width; // Assuming it's a string (like '400px' or '50%') 49 | box.style.width = width 50 | box.classList.add('open'); // Add class to trigger the expansion 51 | if (width) { 52 | box.style.width = width; // Set the width dynamically 53 | } 54 | 55 | function handleTextBox(query) { 56 | var textBox = box.querySelector(query); 57 | const text = textBox.dataset.text; // Store the original text 58 | textBox.textContent = ""; // Clear the box for typing effect 59 | let index = 0; 60 | 61 | // Function to type text slowly 62 | function type() { 63 | if (index < text.length) { 64 | textBox.textContent += text.charAt(index); // Append the current character 65 | index++; 66 | setTimeout(type, 50); // Schedule the next character typing 67 | } else { 68 | isTyping = false; // Reset flag when typing is done 69 | } 70 | } 71 | 72 | type(); // Start typing the text 73 | } 74 | for (let i = 0; i <= box.children.length; i++) { 75 | handleTextBox("#expandText"+i.toString()); 76 | } 77 | 78 | }, 200); // Adjust this value to set the wait time (in milliseconds) 79 | 80 | } 81 | }); 82 | } 83 | 84 | // Create an Intersection Observer instance for the expanding box 85 | const boxObserver = new IntersectionObserver(handleBoxIntersection, { 86 | root: null, // Use the viewport as the root 87 | rootMargin: '0px', // No margin 88 | threshold: 0.1 // Trigger when 10% of the element is in view 89 | }); 90 | 91 | // Observe the expanding boxes 92 | boxObserver.observe(document.getElementById('pipInstallCmd')); 93 | boxObserver.observe(document.getElementById('pyImportCmd')); 94 | boxObserver.observe(document.getElementById('logInCmd')); 95 | boxObserver.observe(document.getElementById('cloudCmd')); 96 | boxObserver.observe(document.getElementById('siteCmd')); 97 | -------------------------------------------------------------------------------- /website/source/js/loadCommunityProjects.js: -------------------------------------------------------------------------------- 1 | async function fetchData() { 2 | const response = await fetch('/api/community_projects/'); 3 | const data = await response.json(); 4 | addItems(data); 5 | } 6 | 7 | // Function to add items to the DOM 8 | function addItems(items) { 9 | const itemList = document.getElementById('community-project-container'); 10 | itemList.innerText = ""; 11 | items.forEach(item => { 12 | console.log(item) 13 | const onclick_project = "window.open('https://scratch.mit.edu/projects/"+item.project_id.toString()+"/', '_blank');" 14 | const onclick_author = "window.open('https://scratch.mit.edu/users/"+item.author.toString()+"/', '_blank');" 15 | const new_element = '
Project Thumbnail

'+item.title+'

by '+item.author+'

' 16 | itemList.innerHTML += new_element; 17 | }); 18 | } 19 | 20 | // Fetch data when the page loads 21 | window.onload = fetchData; -------------------------------------------------------------------------------- /wiki/images/cookies_tut_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cookies_tut_1.png -------------------------------------------------------------------------------- /wiki/images/cookies_tut_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cookies_tut_2.png -------------------------------------------------------------------------------- /wiki/images/cookies_tut_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cookies_tut_3.png -------------------------------------------------------------------------------- /wiki/images/cr_tut_block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cr_tut_block.png -------------------------------------------------------------------------------- /wiki/images/cr_tut_example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cr_tut_example1.png -------------------------------------------------------------------------------- /wiki/images/cr_tut_example2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cr_tut_example2.png -------------------------------------------------------------------------------- /wiki/images/cr_tut_example3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cr_tut_example3.png -------------------------------------------------------------------------------- /wiki/images/cr_tut_restult.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimMcCool/scratchattach/0591a25ee816ac12bfd8aa521aea8de3f1d9ec21/wiki/images/cr_tut_restult.png --------------------------------------------------------------------------------