├── .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 | [](https://pypi.python.org/pypi/scratchattach/)
10 | [](https://pypi.python.org/pypi/scratchattach/)
11 | [](https://pypi.python.org/pypi/scratchattach/)
12 | [](https://github.com/TimMcCool/scratchattach/blob/master/LICENSE)
13 | [](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 | - Overview: module code
61 | -
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
All modules for which code is available
70 |
81 |
82 |
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 | - Welcome to scratchattach’s documentation!
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 |
90 |
91 |
92 | Indices and tables
93 |
98 |
99 |
100 |
101 |
102 |
103 |
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 |
73 |
74 |
75 |
76 |
77 | scratchattach
78 |
96 |
97 |
98 |
99 |
100 |
101 |
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 | - Python Module Index
64 | -
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
Python Module Index
74 |
75 |
78 |
79 |
135 |
136 |
137 |
138 |
139 |
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 | - Search
64 | -
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
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 = '
'+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
--------------------------------------------------------------------------------