diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..68abfffe0 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +**/node_modules/** +**/deps/** +**/build/** +**/3rd_party/** +extension/ +modules/lo_dash_react_components/lo_dash_react_components/ diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..ae54b838c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": "standard", + "rules": { + "semi": ["error", "always"] + }, + "env": { + "jasmine": true + } +} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..f8cfe2162 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,35 @@ +name: Lint + +on: [push] + +jobs: + lint-python: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install Make + run: sudo apt-get install make + + - name: Lint files + run: make linting-python + lint-node: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install Make + run: sudo apt-get install make + + - name: Lint files + run: make linting-node diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..490d33e0e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Test packages + +on: [push] + +jobs: + test-packages: + runs-on: ubuntu-latest + strategy: + matrix: + package: ['learning_observer/', 'modules/writing_observer/'] + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install Make + run: sudo apt-get install make + + - name: Get list of changed files + id: changes + run: | + git fetch origin master + git diff --name-only origin/master HEAD > changed_files.txt + + - name: Check if package has changes + id: package_check + run: | + if grep -qE "^${{ matrix.package }}" changed_files.txt; then + echo "run_tests=true" >> $GITHUB_ENV + else + echo "run_tests=false" >> $GITHUB_ENV + fi + + - name: Skip tests if no changes + if: env.run_tests == 'false' + run: echo "Skipping tests for ${{ matrix.package }} as there are no changes." + + - name: Install the base Learning Observer + if: env.run_tests == 'true' + run: make install + + - name: Install the package with pip + if: env.run_tests == 'true' + run: pip install -e ${{ matrix.package }} + + - name: Run tests + if: env.run_tests == 'true' + run: make test PKG=${{ matrix.package }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..65002d617 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +*~ +\#* +.\#* +*__pycache__* +webapp/logs +webapp/static_data/teachers.yaml +creds.yaml +CREDS.YAML +uncommitted +extension.crx +extension.pem +extension.zip +*egg-info* +public_key +*/dist +learning_observer/learning_observer/static_data/teachers.yaml +learning_observer/learning_observer/logs/ +learning_observer/learning_observer/static/3rd_party/ +learning_observer/learning_observer/static_data/course_lists/ +learning_observer/learning_observer/static_data/course_rosters/ +learning_observer/learning_observer/static_data/repos/ +learning_observer/learning_observer/static_data/dash_assets/ +learning_observer/learning_observer/static_data/courses.json +learning_observer/learning_observer/static_data/students.json +learning_observer/passwd* +--* +.venv/ +.vscode/ +build/ +dist/ +node_modules +*.orig +lo_event*tgz +*.log +LanguageTool-stable.zip +LanguageTool-5.4 +package-lock.json +learning_observer/learning_observer/static_data/google/ +learning_observer/learning_observer/static_data/admins.yaml +.ipynb_checkpoints/ +.eggs/ +.next/ \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..b6a7d89c6 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..de6952502 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,29 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.11" + # You can also specify other tool versions: + # nodejs: "19" + # rust: "1.64" + # golang: "1.19" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: autodocs/conf.py + +# If using Sphinx, optionally build your docs in additional formats such as PDF +# formats: +# - pdf + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: autodocs/requirements.txt diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 000000000..7a9e48b5a --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,18 @@ +{ + "ignoreFiles": [ + "./**/node_modules/**", + "./**/deps/**", + "./**/build/**", + "./**/3rd_party/**", + "./extension/**" + ], + "extends": "stylelint-config-standard", + "customSyntax": "postcss-scss", + "plugins": [ + "stylelint-scss" + ], + "rules": { + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": true + } +} \ No newline at end of file diff --git a/CONTRIBUTORS.TXT b/CONTRIBUTORS.TXT new file mode 100644 index 000000000..ed669f735 --- /dev/null +++ b/CONTRIBUTORS.TXT @@ -0,0 +1,4 @@ +Piotr Mitros +Oren Livne +Paul Deane +Bradley Erickson diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..61cdb7d65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10 +RUN git config --global --add safe.directory /app +WORKDIR /app + +# TODO start redis in here +# see about docker loopback +RUN apt-get update && \ + apt-get install -y python3-dev && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY . /app + +RUN make install +CMD ["make", "run"] diff --git a/LICENSE.TXT b/LICENSE.TXT new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/LICENSE.TXT @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a45c90b55 --- /dev/null +++ b/Makefile @@ -0,0 +1,110 @@ +# TODO rename these packages to something else +PACKAGES ?= wo + +help: + @echo "Available commands:" + @echo "" + @echo " run Run the learning_observer Python application." + @echo " install-pre-commit-hook Install the pre-commit git hook." + @echo " install Install the learning_observer package in development mode." + @echo " install-dev Install dev dependencies (requires additional setup)." + @echo " install-packages Install specific packages: [${PACKAGES}]." + @echo " test Run tests for the specified package (PKG=)." + @echo " linting-setup Setup linting tools and dependencies." + @echo " linting-python Lint Python files using pycodestyle and pylint." + @echo " linting-node Lint Node files (JS, CSS, and unused CSS detection)." + @echo " linting Perform all linting tasks (Python and Node)." + @echo " build-wo-chrome-extension Build the writing-process extension." + @echo " build-python-distribution Build a distribution for the specified package (PKG=)." + @echo "" + @echo "Note: All commands are executed in the current shell environment." + @echo " Ensure your virtual environment is activated if desired, as installs and actions" + @echo " will occur in the environment where the 'make' command is run." + @echo "" + @echo "Use 'make ' to execute a command. For example: make run" + +run: + # If you haven't done so yet, run: make install + # we need to make sure we are on the virtual env when we do this + cd learning_observer && python learning_observer + +# Install commands +install-pre-commit-hook: + # Adding pre-commit.sh to Git hooks + cp scripts/hooks/pre-commit.sh .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + +install: install-pre-commit-hook + # The following only works with specified packages + # we need to install learning_observer in dev mode to + # more easily pass in specific files we need, such as creds + pip install --no-cache-dir -e learning_observer/ + + # Installing Learning Oberser (LO) Dash React Components + # TODO properly fetch the current version of lodrc. + # We have a symbolic link between `lodrc-current` and the most + # recent version. We would like to directly fetch `lodrc-current`, + # however, the fetch only returns the name of the file it's + # linked to. We do an additional fetch for the linked file. + @LODRC_CURRENT=$$(curl -s https://raw.githubusercontent.com/ETS-Next-Gen/lo_assets/main/lo_dash_react_components/lo_dash_react_components-current.tar.gz); \ + pip install https://raw.githubusercontent.com/ETS-Next-Gen/lo_assets/main/lo_dash_react_components/$${LODRC_CURRENT} + +install-dev: + # TODO create a dev requirements file + pip install --no-cache-dir -e learning_observer/[${PACKAGES}] + . ${HOME}/.nvm/nvm.sh && nvm use && pip install -v -e modules/lo_dash_react_components/ + +install-packages: + pip install -e learning_observer/[${PACKAGES}] + +# Testing commands +test: + @if [ -z "$(PKG)" ]; then echo "No module specified, please try again with \"make test PKG=path/to/module\""; exit 1; fi + ./test.sh $(PKG) + +# Linting commands +linting-python: + # Linting Python modules + pip install pycodestyle pylint + pycodestyle --ignore=E501,W503 $$(git ls-files 'learning_observer/*.py' 'modules/*.py') + pylint -d W0613,W0511,C0301,R0913,too-few-public-methods $$(git ls-files 'learning_observer/*.py' 'modules/*.py') + +linting-node: + npm install + # TODO each of these have lots of errors and block + # the next item from running + # Starting to lint Node modules + # Linting Javascript + npm run lint:js + # Linting CSS + npm run lint:css + # Finding any unused CSS files + npm run find-unused-css + +linting: linting-setup linting-python linting-node + # Finished linting + +# Build commands +build-wo-chrome-extension: + # Installing LO Event + cd modules/lo_event && npm install & npm link lo_event + # Building extension + cd extension/writing-process && npm install && npm run build + +build-python-distribution: + # Building distribution for package + pip install build + # Switching to package directory + cd $(PKG) && python -m build + +# TODO we may want to have a separate command for uploading to testpypi +upload-python-package-to-pypi: build-python-distribution + pip install twine + # TODO we currently only upload to testpypi + # TODO we need to include `TWINE_USERNAME=__token__` + # and `TWINE_PASSWORD={ourTwineToken}` to authenticate + # + # TODO We have not fully tested the following commands. + # Try out the following steps and fix any bugs so the + # Makefile can do it automatically. + # cd $(PKG) && twine upload -r testpypi dist/* diff --git a/README.md b/README.md new file mode 100644 index 000000000..8c56ebba8 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Writing Observer and Learning Observer + +![Writing Observer Logo](learning_observer/learning_observer/static/media/logo-clean.jpg) + +This repository is part of a project to provide an open source +learning analytics dashboard to help instructors be able to manage +student learning processes, and in particular, student writing +processes. + +![linting](https://github.com/ETS-Next-Gen/writing_observer/actions/workflows/pycodestyle.yml/badge.svg) + +## Learning Observer + +_Learning Observer_ is designed as an open source, open science learning +process data dashboarding framework. You write reducers to handle +per-student writing data, and aggegators to make dashboards. We've +tested this in math and writing, but our focus is on writing process +data. + +At a high level, Learning Observer functions as an application platform. +The primary `learning_observer` module bootstraps the system: it loads +configuration, connects to storage and messaging back ends, and brokers +communication with data sources. Other modules plug into that +infrastructure to define the specific reducers, dashboards, and other +items that users interact with, letting teams experiment with new +features without having to reimplement the platform core. + +It's not finished, but it's moving along quickly. + +## Writing Observer + +_Writing Observer_ is a plug-in for Google Docs which visualizes writing +data to teachers. Our immediate goal was to provide a dashboard which +gives rapid, actionable insights to educators supporting remote +learning during this pandemic. We're working to expand this to support +a broad range of write-to-learn and collaborative learning techniques. + +## Status + +There isn't much to see here for external collaborators yet. This +repository has a series of prototypes to confirm we can: + +* collect the data we want; +* extract what we need from it; and +* route it to where we want it to go (there's _a lot_ of data, with + complex dependencies, so this is actually a nontrivial problem) + +Which mitigates most of the technical risk. We also now integrate with +Google Classroom. We also have prototype APIs for making dashboards, and +a few prototype dashboards. + +For this to be useful, we'll need to provide some basic documentation +for developers to be able to navigate this repo (in particular, +explaining _why_ this approach works). + +This system is designed to be _massively_ scalable, but it is not +currently implemented to be so (mostly for trivial reasons; +e.g. scaffolding code which uses static files as a storage model). It +will take work to flush out all of these performance issues, but we'd +like to do that work once we better understand what we're doing and +that the core approach and APIs are correct. + +## Getting Started + +We have a short guide to [installing the system](docs/tutorials/install.md). +Getting the base system working is pretty easy. To create a new module +for the system to use, check out our [cookiecutter module guide](docs/tutorials/cookiecutter-module.md). + +### System requirements + +It depends on what you're planning to use the system for. + +The core _Learning Observer_ system works fine on an AWS nano +instance, and that's how we do most of our testing and small-scale +pilots. These instances have 512MB of RAM, and minimal CPU. It's +important that this configuration remains usable. + +For deployment and more sophisticated uses (e.g. NLP) in larger +numbers of classrooms, we need **heavy** metal. As we're playing with +algorithms, deep learning is turning out to work surprisingly well, +and at the same time, requires surprisingly large amounts of computing +power. A GPGPU with plenty of RAM is helpful if you want to work with +more sophisticated algorithms, and is likely to be a requirement for +many types of uses. + +All _Learning Observer_ development has been on Linux-based platforms +(including Ubuntu and RHEL). There are folks outside of the core team +who have tried to run it on Mac or on WSL, with some success. + +Running on RHEL typically uses the following services: + +* redis +* nginx + +#### bcrypt + +A note on bcrypt. The code uses bcrypt for internal password +management. There is a mess of incompatible versions. Be careful if +installing any way other than the official install to get the right +one. + +## Contributing or learning more + +We're still a small team, and the easiest way is to shoot us a quick +email. We'll gladly walk you through anything you're interested in. + +Contact/core maintainer: Piotr Mitros + +Licensing: Open source / free software. License: AGPL. diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..82977005b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0+2026.02.03T15.21.57.253Z.1310c89e.berickson.20260130.execution.dag.single.value diff --git a/autodocs/.gitignore b/autodocs/.gitignore new file mode 100644 index 000000000..90045b728 --- /dev/null +++ b/autodocs/.gitignore @@ -0,0 +1,4 @@ +_build/ +generated/ +apidocs/ +module_readmes/ diff --git a/autodocs/Makefile b/autodocs/Makefile new file mode 100644 index 000000000..d4bb2cbb9 --- /dev/null +++ b/autodocs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/autodocs/api.rst b/autodocs/api.rst new file mode 100644 index 000000000..2d744d115 --- /dev/null +++ b/autodocs/api.rst @@ -0,0 +1,7 @@ +API +=== + +.. toctree:: + :maxdepth: 4 + + apidocs/index diff --git a/autodocs/concepts.rst b/autodocs/concepts.rst new file mode 100644 index 000000000..8c0002830 --- /dev/null +++ b/autodocs/concepts.rst @@ -0,0 +1,49 @@ +Concepts +============= + +Explanations of key ideas, principles, and background knowledge. +Follow this recommended sequence to build context before diving into +implementation details: + +- :doc:`History ` - establishes the background and + problem space the project is addressing. +- :doc:`System Design ` - explains how the product + strategy and user needs translate into an overall system approach. +- :doc:`Architecture ` - outlines the concrete + architecture that implements the system design. +- :doc:`Technologies ` - surveys the primary tools + and platforms we rely on to realize the architecture. +- :doc:`System Settings ` - describes how the + system loads global and cascading settings. +- :doc:`Events ` - introduces the event model that drives + data flowing through the system. +- :doc:`Reducers ` - details how incoming events are + aggregated into the state our experiences depend on. +- :doc:`Communication Protocol ` - discusses how + the system queries data from reducers for dashboards. +- :doc:`Student Identity Mapping ` - explain + how learners information is mapped across integrations. +- :doc:`Scaling ` - covers strategies for growing the + system once the fundamentals are in place. +- :doc:`Auth ` - describes authentication considerations + that secure access to the system. +- :doc:`Privacy ` - documents how we protect learner data + and comply with privacy expectations. + +.. toctree:: + :hidden: + :maxdepth: 1 + :titlesonly: + + docs/concepts/history + docs/concepts/system_design + docs/concepts/architecture + docs/concepts/technologies + docs/concepts/system_settings + docs/concepts/events + docs/concepts/reducers + docs/concepts/communication_protocol + docs/concepts/student_identity_mapping + docs/concepts/scaling + docs/concepts/auth + docs/concepts/privacy diff --git a/autodocs/conf.py b/autodocs/conf.py new file mode 100644 index 000000000..3af5b92ef --- /dev/null +++ b/autodocs/conf.py @@ -0,0 +1,190 @@ +import os +import pathlib +import re +import shutil +import sphinx.util +import sys +import unicodedata +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Learning Observer' +copyright = '2020-2025, Bradley Erickson' +author = 'Bradley Erickson' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration +sys.path.insert(0, os.path.abspath('../')) + +extensions = [ + 'autodoc2', + 'myst_parser', +] + +autodoc2_packages = [ + '../learning_observer/learning_observer', + '../modules/writing_observer/writing_observer' +] + +autodoc2_output_dir = 'apidocs' +autodoc2_member_order = 'bysource' + +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', + '.txt': 'markdown' +} + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] + +LOGGER = sphinx.util.logging.getLogger(__name__) + + +_MARKDOWN_IMAGE_PATTERN = re.compile(r'!\[[^\]]*\]\(([^)]+)\)') +_RST_IMAGE_PATTERNS = [ + re.compile(r'\.\.\s+image::\s+([^\s]+)'), + re.compile(r'\.\.\s+figure::\s+([^\s]+)'), +] + + +def _extract_local_assets(text): + """Return relative asset paths referenced in the provided README text.""" + + asset_paths = set() + for match in _MARKDOWN_IMAGE_PATTERN.findall(text): + asset_paths.add(match) + for pattern in _RST_IMAGE_PATTERNS: + asset_paths.update(pattern.findall(text)) + + filtered_assets = set() + for raw_path in asset_paths: + candidate = raw_path.strip() + if not candidate: + continue + # Remove optional titles ("path "optional title"") and URL fragments + candidate = candidate.split()[0] + candidate = candidate.split('#', maxsplit=1)[0] + candidate = candidate.split('?', maxsplit=1)[0] + + if candidate.startswith(('http://', 'https://', 'data:')): + continue + if candidate.startswith('#'): + continue + + filtered_assets.add(candidate) + + return sorted(filtered_assets) + + +def _copy_module_assets(readme_path, destination_dir): + """Copy image assets referenced by ``readme_path`` into ``destination_dir``.""" + + module_dir = readme_path.parent.resolve() + readme_text = readme_path.read_text(encoding='utf-8') + asset_paths = _extract_local_assets(readme_text) + for asset in asset_paths: + relative_posix_path = pathlib.PurePosixPath(asset) + if relative_posix_path.is_absolute(): + LOGGER.warning( + "Skipping absolute image path %s referenced in %s", asset, readme_path + ) + continue + + normalized_relative_path = pathlib.Path(*relative_posix_path.parts) + source_path = (module_dir / normalized_relative_path).resolve(strict=False) + + try: + source_path.relative_to(module_dir) + except ValueError: + LOGGER.warning( + "Skipping image outside module directory: %s referenced in %s", + asset, + readme_path, + ) + continue + + if not source_path.exists(): + LOGGER.warning( + "Referenced image %s in %s was not found", asset, readme_path + ) + continue + + destination_path = destination_dir / normalized_relative_path + destination_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination_path) + + +def _extract_readme_title(readme_path: pathlib.Path) -> str: + """Return the first Markdown heading in ``readme_path``. + + Defaults to the parent directory name if no heading can be found. + """ + + try: + for line in readme_path.read_text(encoding='utf-8').splitlines(): + stripped = line.strip() + if stripped.startswith('#'): + title = stripped.lstrip('#').strip() + if title: + return title + except OSError as exc: # pragma: no cover - filesystem error propagation + LOGGER.warning("unable to read %s: %s", readme_path, exc) + + return readme_path.parent.name + + +def _slugify(text: str) -> str: + """Convert ``text`` to a lowercase filename-safe slug.""" + + normalized = unicodedata.normalize('NFKD', text) + without_diacritics = ''.join(ch for ch in normalized if not unicodedata.combining(ch)) + slug = re.sub(r'[^a-z0-9]+', '-', without_diacritics.casefold()).strip('-') + return slug or 'module' + + +def _copy_module_readmes(app): + """Populate ``module_readmes`` with module README files and assets.""" + + docs_root = pathlib.Path(__file__).parent.resolve() + modules_root = docs_root.parent / 'modules' + destination_root = docs_root / 'module_readmes' + + if not modules_root.exists(): + LOGGER.warning("modules directory %s was not found", modules_root) + return + + if destination_root.exists(): + shutil.rmtree(destination_root) + destination_root.mkdir(parents=True, exist_ok=True) + + readme_info = [] + for readme_path in modules_root.glob('*/README.md'): + title = _extract_readme_title(readme_path) + readme_info.append((title, readme_path)) + + readme_info.sort(key=lambda item: item[0].casefold()) + + for title, readme_path in readme_info: + module_name = readme_path.parent.name + slug = _slugify(title) + module_destination = destination_root / f'{slug}--{module_name}' + module_destination.mkdir(parents=True, exist_ok=True) + destination_path = module_destination / "README.md" + shutil.copy2(readme_path, destination_path) + _copy_module_assets(readme_path, module_destination) + + +def setup(app): + app.connect('builder-inited', _copy_module_readmes) diff --git a/autodocs/docs b/autodocs/docs new file mode 120000 index 000000000..6246dffc3 --- /dev/null +++ b/autodocs/docs @@ -0,0 +1 @@ +../docs/ \ No newline at end of file diff --git a/autodocs/how-to.rst b/autodocs/how-to.rst new file mode 100644 index 000000000..9b97f0992 --- /dev/null +++ b/autodocs/how-to.rst @@ -0,0 +1,31 @@ +How-to +============= + +Practical instructions for achieving specific goals within Learning Observer. Use these guides when you know what outcome you need and want a proven recipe to follow: + +- :doc:`Communication Protocol ` - How to query data from reducers or system endpoints for dashboards. +- :doc:`Build Dashboards ` - Walk through creating dashboards from reducer outputs, including layout choices and data wiring. +- :doc:`Offline Reducer Replay ` - Explain how to repopulate reducer content with study logs. +- :doc:`Serve as LTI application` - Cover how to install Learning Observer as an LTI application. +- :doc:`Connect LO Blocks to Canvas via Learning Observer` - Show how to connect launch LO Blocks through Learning Observer from within Canvas. +- :doc:`Configure Multiple Roster Sources` - Allow the system to dynamically choose a roster source given a user's context. +- :doc:`Run with Docker ` - Learn how to containerize the stack, manage images, and operate the project using Docker Compose. +- :doc:`Writing Observer Extension ` - Install, configure, and validate the Writing Observer browser extension for capturing events. +- :doc:`Interactive Environments ` - Connect Learning Observer to Jupyter and other live coding setups for iterative development. +- :doc:`Impersonate Users ` - Start and stop acting as another user while keeping dashboards informed. + +.. toctree:: + :hidden: + :maxdepth: 1 + :titlesonly: + + docs/how-to/communication_protocol.md + docs/how-to/dashboards.md + docs/how-to/offline_replay.md + docs/how-to/lti.md + docs/how-to/connect_lo_blocks_to_canvas.md + docs/how-to/multiple_roster_sources.md + docs/how-to/docker.md + docs/how-to/extension.md + docs/how-to/interactive_environments.md + docs/how-to/impersonation.md diff --git a/autodocs/index.rst b/autodocs/index.rst new file mode 100644 index 000000000..10bedee6f --- /dev/null +++ b/autodocs/index.rst @@ -0,0 +1,42 @@ +.. Learning Observer documentation master file, created by + sphinx-quickstart on Mon May 1 13:11:55 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Learning Observer +================= + +Learning Observer is designed as an open source, open science learning +process data dashboarding framework. You write reducers to handle +per-student writing data, and aggegators to make dashboards. We've +tested this in math and writing, but our focus is on writing process +data. + +At a high level, Learning Observer operates as an application platform: +the core :mod:`learning_observer` package boots the system, loads +configured modules, and manages shared data services, while each module +provides the specific dashboards, reducers, and other artifacts that +users interact with. + +Our documentation is organized into four main categories, each serving a different purpose. You can explore them below: + +- :doc:`Tutorials ` - Step-by-step guides to help you learn by doing. +- :doc:`Concepts ` - Explanations of key ideas and background knowledge. +- :doc:`How-To ` - Practical instructions to solve specific goals. +- :doc:`Reference ` - Detailed API/configuration information. + +.. toctree:: + :hidden: + :maxdepth: 3 + + tutorials + concepts + how-to + reference + +Additional Information +---------------------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/autodocs/make.bat b/autodocs/make.bat new file mode 100644 index 000000000..32bb24529 --- /dev/null +++ b/autodocs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/autodocs/modules.rst b/autodocs/modules.rst new file mode 100644 index 000000000..a8395ea34 --- /dev/null +++ b/autodocs/modules.rst @@ -0,0 +1,9 @@ +Modules +----------- +The module READMEs are collected automatically during the Sphinx build. + +.. toctree:: + :maxdepth: 1 + :glob: + + module_readmes/*/README diff --git a/autodocs/reference.rst b/autodocs/reference.rst new file mode 100644 index 000000000..ba358f17b --- /dev/null +++ b/autodocs/reference.rst @@ -0,0 +1,27 @@ +Reference +============= + +Detailed, structured information about APIs, configurations, and technical details. Consult these resources when you need definitive answers about how the system behaves or how to integrate with it: + +- :doc:`Code Quality Standards ` - Understand our expectations for readability, style, and continuous improvement. +- :doc:`System Settings ` - Review what each system setting does and where it is used. +- :doc:`Documentation Conventions ` - Learn how we structure docs, what tools we use, and how to contribute updates. +- :doc:`Linting Rules ` - Review the automated checks that keep the codebase healthy and how to run them locally. +- :doc:`Testing Strategy ` - Explore the testing layers we rely on and guidelines for writing reliable tests. +- :doc:`Versioning and Releases ` - See how we tag releases, manage dependencies, and maintain backward compatibility. +- :doc:`Module Reference ` - Dive into the autogenerated API reference for Python modules within Learning Observer. +- :doc:`API Reference ` - Inspect the internal functionality of the system. + +.. toctree:: + :hidden: + :maxdepth: 1 + :titlesonly: + + docs/reference/code_quality.md + docs/reference/system_settings.md + docs/reference/documentation.md + docs/reference/linting.md + docs/reference/testing.md + docs/reference/versioning.md + modules + api diff --git a/autodocs/requirements.txt b/autodocs/requirements.txt new file mode 100644 index 000000000..719fea2af --- /dev/null +++ b/autodocs/requirements.txt @@ -0,0 +1,3 @@ +myst_parser +sphinx +sphinx-autodoc2 diff --git a/autodocs/tutorials.rst b/autodocs/tutorials.rst new file mode 100644 index 000000000..0d6771b53 --- /dev/null +++ b/autodocs/tutorials.rst @@ -0,0 +1,15 @@ +Tutorials +============= + +Step-by-step guides that teach by doing. Follow these tutorials to get hands-on experience with core workflows: + +- :doc:`Install Learning Observer ` - Set up the development environment, install dependencies, and verify your deployment. +- :doc:`Create a Module with Cookiecutter ` - Generate a new module scaffold, customize it, and understand the key files produced by the template. + +.. toctree:: + :hidden: + :maxdepth: 1 + :titlesonly: + + docs/tutorials/install.md + docs/tutorials/cookiecutter-module.md diff --git a/devops/README.md b/devops/README.md new file mode 100644 index 000000000..352ae056f --- /dev/null +++ b/devops/README.md @@ -0,0 +1,13 @@ +Dev-ops scripts +=============== + +This contains machinery for spinning up, shutting down, and managing +Learning Observer servers. It's usable, but very much not done yet. We +can spin up, spin down, and list machines, but this ought to be more +fault-tolerant, better logged, less hard-coded, etc. + +We would like to be cross-platform, and evenually support both +Debian-based distros and RPM-based distros. We're not there yet +either. We'd also like to support multiple cloud providers. We're not +there yet either. However, we probably won't accept PRs which move us +away from this goal. \ No newline at end of file diff --git a/devops/ansible/files/default b/devops/ansible/files/default new file mode 100644 index 000000000..4c633a57b --- /dev/null +++ b/devops/ansible/files/default @@ -0,0 +1,74 @@ +server { + # We listen for HTTP on port 80. This is helpful for debugging + listen 80 default_server; + listen [::]:80 default_server; + + # We listen for HTTPS on port 443 too. This is managed when we set up certbot. + + # Set this up when installing: + server_name {SERVER_NAME}; + + # We're mostly not using static web files right now, but it's good to have these around. + root /var/www/html; + index index.html index.htm index.nginx-debian.html; + + # We will eventually want to split our (non-CORS) data intake and our (CORS) dashboards + location / { + # First attempt to serve request as file, then + # as directory, then fall back to displaying a 404. + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + + try_files $uri $uri/ =404; + } + + location /app/ { + # For now, this is for debugging and development. We'd like to be able to launch arbitrary + # web apps. In the longer-term, it's likely the whole system might move here (and who knows + # if this comment will update). + # + # Note we don't add CORS headers for now, but we eventually will need to. We'll need to sort + # through where we add them, though. + proxy_pass http://localhost:8080/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + #rewrite ^/app/(.*)$ $1 last; + if ($request_method = OPTIONS ) { + return 200; + } + } + + # This is our HTTP API + # Note that we disable CORS. We may want to have a version with and without CORS + location /webapi/ { + proxy_pass http://localhost:8888/webapi/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + if ($request_method = OPTIONS ) { + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + return 200; + } + } + + # And our websockets API + # We are migrating our streaming analytics to web sockets. + location /wsapi/ { + proxy_pass http://localhost:8888/wsapi/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + + if ($request_method = OPTIONS ) { + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + return 200; + } + } +} diff --git a/devops/ansible/files/nginx-locations b/devops/ansible/files/nginx-locations new file mode 100644 index 000000000..3bb4a0a75 --- /dev/null +++ b/devops/ansible/files/nginx-locations @@ -0,0 +1,30 @@ + + location /webapi/ { + proxy_pass http://localhost:8888/webapi/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + if ($request_method = OPTIONS ) { + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + return 200; + } + } + + location /wsapi/ { + proxy_pass http://localhost:8888/wsapi/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + + if ($request_method = OPTIONS ) { + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + return 200; + } + } + diff --git a/devops/ansible/local.yaml b/devops/ansible/local.yaml new file mode 100644 index 000000000..3d502ca93 --- /dev/null +++ b/devops/ansible/local.yaml @@ -0,0 +1,6 @@ +- name: Provision writing analysis server + hosts: localhost + connection: local + tasks: + - include: tasks/writing-apt.yaml + diff --git a/devops/ansible/scripts/add_nginx_locations.py b/devops/ansible/scripts/add_nginx_locations.py new file mode 100644 index 000000000..f695f9396 --- /dev/null +++ b/devops/ansible/scripts/add_nginx_locations.py @@ -0,0 +1,45 @@ +""" +This script adds the locations for our web API to nginx. It adds +them after the default location. +""" + +import sys +import shutil +import datetime + +lines = open("/etc/nginx/sites-enabled/default", "r").readlines() + +# If we've already added these, do nothing. +for line in lines: + if "webapi" in line: + print("Already configured!") + sys.exit(-1) + +# We will accumulate the new file into this variable +output = "" + +# We step through the file until we find the first 'location' line, and +# keep cycling until we find a single "}" ending that section. +# +# At that point, we add the new set of location + +location_found = False +added = False +for line in lines: + output += line + if line.strip().startswith("location"): + print("Found") + location_found = True + if location_found and line.strip() == "}" and not added: + output += open("../files/nginx-locations").read() + added = True + + +backup_file = "/etc/nginx/sites-enabled-default-" + \ + datetime.datetime.utcnow().isoformat() +shutil.move("/etc/nginx/sites-enabled/default", backup_file) + +with open("/etc/nginx/sites-enabled/default", "w") as fp: + fp.write(output) + +print(output) diff --git a/devops/ansible/scripts/rhel b/devops/ansible/scripts/rhel new file mode 100644 index 000000000..130a66c0f --- /dev/null +++ b/devops/ansible/scripts/rhel @@ -0,0 +1 @@ +yum install ansible emacs nginx redis curl git links lynx screen whois nginx postgresql diff --git a/devops/ansible/tasks/writing-apt.yaml b/devops/ansible/tasks/writing-apt.yaml new file mode 100644 index 000000000..e00d6b134 --- /dev/null +++ b/devops/ansible/tasks/writing-apt.yaml @@ -0,0 +1,57 @@ +- apt: upgrade=dist update_cache=yes + +- name: Basic utils + apt: name={{ item }} + with_items: + - curl + - emacs + - git + - git-core + - links + - lynx + - mosh + - nmap + - whois + - screen + - wipe + - build-essential + - net-tools + +# We don't need all of this per se, but it's convenient. If nothing +# else, it gives prereqs for `pip` +- name: Python + apt: name={{ item }} + with_items: + - ipython3 + - libxml2-dev + - libxslt1-dev + - python3-boto + - python3-bson + - python3-dev + - python3-matplotlib + - python3-numpy + - python3-pandas + - python3-pip + - python3-scipy + - python3-setuptools + - python3-sklearn + - virtualenvwrapper + - libjpeg-dev + - python3-opencv + - python3-virtualenv + - python3-aiohttp + - python3-aiohttp-cors + - python3-tornado + - python3-yaml + - python3-asyncpg + - python3-bcrypt + +- name: Server + apt: name={{ item }} + with_items: + - redis + - nginx + - certbot + - apache2-utils + - fcgiwrap + - python3-certbot-nginx \ No newline at end of file diff --git a/devops/requirements.txt b/devops/requirements.txt new file mode 100644 index 000000000..e548f11b9 --- /dev/null +++ b/devops/requirements.txt @@ -0,0 +1,6 @@ +chevron +boto3 +pyyaml +fabric +invoke +filetype diff --git a/devops/single_server_instances/README.md b/devops/single_server_instances/README.md new file mode 100644 index 000000000..f2123849b --- /dev/null +++ b/devops/single_server_instances/README.md @@ -0,0 +1,74 @@ +# Learning Observer — Instance Control Scripts + +This directory contains two bash scripts to start and stop multiple instances of the `learning_observer` application. + +## Files + +* **`start_lo_instances.sh`** — Launches one or more instances of the app on sequential ports, creates log files, and stores process IDs in a PID directory. +* **`stop_lo_instances.sh`** — Stops all running instances recorded in the PID directory. + +## Configuration + +Before use, edit the scripts to match your system: + +* `LEARNING_OBSERVER_LOC` — Path to your project code. +* `VIRTUALENV_PATH` — Path to your Python virtual environment. +* `LOGFILE_DEST` — Directory for logs (default `/var/log/learning_observer`). +* `START_PORT` — First port to use. +* `SCRIPT_NAME` — Command or Python file to run. + +## Usage + +Start instances (default: 1): + +```bash +./start_lo_instances.sh +./start_lo_instances.sh 3 # start 3 instances +``` + +Stop all instances: + +```bash +./stop_lo_instances.sh +``` + +Logs are saved in `LOGFILE_DEST`, and PIDs are stored in `LOGFILE_DEST/pids`. +You may need to change paths or permissions depending on your environment. + +## Nginx Settings + +The file `nginx.conf.example` provides a sample configration for Nginx when you start 4 instances of LO. +First, these settings split the incoming events and all other traffic between 2 upstream servers. +Each upstream server balances connections between 2 instances of Learning Observer. + +```text +Incoming Request + │ + ▼ ++---------------+ +| NGINX | ++---------------+ + │ + ▼ + Path starts + with "/wsapi/in/"? + ┌───────────────┐ + Yes│ │No + ▼ ▼ ++------------------+ +-----------------+ +| wsapi_in_backend | | general_backend | +| | | | ++-------+----------+ +--------+--------+ + │ │ + +----+----+ +----+----+ Balanced by least + | App 1 | | App 3 | connections `least_conn` + | :9001 | | :9003 | + +---------+ +---------+ + +----+----+ +----+----+ + | App 2 | | App 4 | + | :9002 | | :9004 | + +---------+ +---------+ +``` + +Note: these are settings to add to your nginx configuration. +You will likely have other settings, such as ssl certificates. diff --git a/devops/single_server_instances/nginx.conf.example b/devops/single_server_instances/nginx.conf.example new file mode 100644 index 000000000..571ba0f2d --- /dev/null +++ b/devops/single_server_instances/nginx.conf.example @@ -0,0 +1,55 @@ +# Upstreams +upstream wsapi_in_backend { + least_conn; + server 127.0.0.1:9001; + server 127.0.0.1:9002; +} + +upstream general_backend { + least_conn; + server 127.0.0.1:9003; + server 127.0.0.1:9004; +} + +# Simple CORS preflight detection +map $request_method $cors_preflight { + default 0; + OPTIONS 1; +} + +server { + # Common proxy headers for everything + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # --- Split route for /wsapi/in/ --- + location /wsapi/in/ { + proxy_pass http://wsapi_in_backend; + proxy_read_timeout 86400; + + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, HEAD'; + add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept'; + + if ($cors_preflight) { + return 200; + } + } + + # --- Everything else goes to general backend --- + location / { + proxy_pass http://general_backend; + proxy_read_timeout 86400; + + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, HEAD'; + add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept'; + + if ($cors_preflight) { + return 200; + } + } +} diff --git a/devops/single_server_instances/start_lo_instances.sh b/devops/single_server_instances/start_lo_instances.sh new file mode 100755 index 000000000..c872430dc --- /dev/null +++ b/devops/single_server_instances/start_lo_instances.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# === Config === +NUM_SERVERS=${1:-1} # default 1 server instance +START_PORT=9001 +LOGFILE_DEST="/var/log/learning_observer" +PIDFILE_DIR="$LOGFILE_DEST/pids" +LEARNING_OBSERVER_LOC="/path/to/your/code" +VIRTUALENV_PATH="/path/to/your/venv" +SCRIPT_NAME="learning_observer" + +# Create log + pid dirs if they don't exist +mkdir -p "$LOGFILE_DEST" +mkdir -p "$PIDFILE_DIR" + +# Timestamp for log grouping +LOG_DATE=$(date "+%m-%d-%Y--%H-%M-%S") + +# === Start Servers === +echo "Starting $NUM_SERVERS instances of $SCRIPT_NAME..." + +cd "$LEARNING_OBSERVER_LOC" +source "$VIRTUALENV_PATH/bin/activate" + +for ((i=0; i Log: $LOGFILE_NAME" + nohup python $SCRIPT_NAME --port $PORT > "$LOGFILE_NAME" 2>&1 & + PROCESS_ID=$! + echo $PROCESS_ID > "$PIDFILE_NAME" + echo " -> PID $PROCESS_ID logged to $PIDFILE_NAME" +done + +echo "✅ All servers started." +echo "Run ./scripts/stop_lo_instances.sh to stop server processes." diff --git a/devops/single_server_instances/stop_lo_instances.sh b/devops/single_server_instances/stop_lo_instances.sh new file mode 100755 index 000000000..2a3243445 --- /dev/null +++ b/devops/single_server_instances/stop_lo_instances.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# === Config === +LOGFILE_DEST="/var/log/learning_observer" +PIDFILE_DIR="$LOGFILE_DEST/pids" +SCRIPT_NAME="learning_observer" + +# === Stop All Servers === +echo "Stopping all $SCRIPT_NAME servers..." + +if [ ! -d "$PIDFILE_DIR" ]; then + echo "PID directory not found. Nothing to stop." + exit 1 +fi + +for PIDFILE in "$PIDFILE_DIR"/*.pid; do + if [ -f "$PIDFILE" ]; then + PID=$(cat "$PIDFILE") + if kill -0 "$PID" 2>/dev/null; then + echo "Stopping PID $PID from $PIDFILE" + kill "$PID" + else + echo "PID $PID not running, skipping." + fi + rm -f "$PIDFILE" + fi +done + +echo "✅ All servers stopped." diff --git a/devops/tasks/README.md b/devops/tasks/README.md new file mode 100644 index 000000000..156977314 --- /dev/null +++ b/devops/tasks/README.md @@ -0,0 +1,124 @@ +Deployment Scripts +================== + +Our goals are: + +* We'd like to have a flock of LO servers for dynamic assessment, + Writing Observer, random demos, etc. These should have a common + configuration, with variations. +* We'd like to have a log of how these are configured at every point + in time, and any changes, so we can have context for any process + data we collect. +* We'd like this representation to be interoperable with our process + data storage formats +* We'd like configuation data to be moderately secure. Device + configuration won't allow exploits in itself, but it can make + vulnerabilities more serious. While things like IDs and locations of + resources don't present an attack vector in themselves, knowing them + is sometimes the limiting factor on being able to exploit an attack + vector (for example, if I have an exploit where I can read one + arbitrary file on your system, being able to leverage that attack + hinges on knowing what files you have where) +* However, configuration data also sometimes needs to stores things + which are super-sensitive, like security tokens and similar. +* Making changes should be fast and easy. This happens all the time. +* Digging into archives doesn't need to be easy, just possible. For + research, only a few types of analysis need it. For operations, you + usually only need it for debugging or disaster recovery. + +Our **planned** architecture is: + +* A set of `fabric` script which can spin up / spin down / update + machines (with appropriate logging) +* A baseline configuration in `ansible`. +* Deltas from that configuration stored in an independent `git` repo +* Security tokens stored in a seperate TBD data store. We'll populate + these with templates. +* Log files of when new versions are updated/deployed/brought down, in + the same system as our process data +* The tagging process data with `git` hashes of what state the system + was in when it generated it. + +We're making the baseline `ansible` configuration pretty featureful, +since as a research project, it's helpful to be able to `ssh` into +machines, and e.g. run `Python` scripts locally. + +Whether or not we need `ansible`, `fabric`, or both is a bit of an +open question. + +Where we are +------------ + +This will be out-of-date quickly, but as of this writing: + +* We can provision, terminate, and update machines with a baseline + configuration. +* A lot of stuff is hardcoded, which would make this difficult for + others to use (e.g. learning-observer.org). +* We install packages, grab things from `git`, etc, but don't handle + configuration well yet. +* We don't log. + +We orchestrate servers with [invoke](https://www.pyinvoke.org/): + +* `inv list` will show a listing of deployed machines +* `inv provision [machine]` will spin up a new AWS machine +* `inv update` will update all machines +* `inv terminate [machine]` will shut down a machine +* `inv connect [machine]` will open up an `ssh` session to a machine +* `inv configure [machine]` is typically run after provision, and + will place configuration files (which might vary + machine-by-machine) (mostly finished) +* `inv certbot [machine]` will set up SSL (unfinished) +* `inv downloadconfig [machine]` will copy the configuration back. +* `inv create [machine]` is a shortcut to do everything for a new instance in one step (provision, configure, certbotify, and download the SSL config) + +A lot of this is unfinished, but still, it's already ahead of the AWS +GUI and doing things by hand. The key functionality missing is: + +* High-quality logging +* Fault recovering +* Version control of configurations + +To set up a new machine, run: + +``` +inv provision [machine] +inv configure [machine] +inv certbot [machine] +inv downloadconfig [machine] +``` + +From there, edit configuration files in `config` and to update the +machine to a new version, run + +``` +inv configure [machine] +``` + +Debugging +--------- + +The most annoying part of this setup is getting `systemd` working, +which is poorly documented, inconsistent, and poorly-engineered. The +tool are `journalctl -xe |tail -100`, looking at `lo.err` (currently +in `/home/ubuntu/`, but should move to `/var/log/` eventually), and +`systemctl status --full learning_observer`. The most common issues +are permissions (e.g. running as the wrong user, log files generated +as `root:root` at some point, etc), running from the wrong directory, +and similar sorts of environment issues. + +Logging +------- + +We are logging system configuration with `git`. Note that this is +**NOT** atomic or thread-safe. This is perhaps a bug, and perhaps by +design: + +* Tasks take a _while_ to run, and they need to run in parallel when + managing many machines. +* A better (much more complex) approach would use branches or do + atomic commits at the end (e.g. download to a temporary dir, and + move right before the commit. +* However, it is possible to reverse-engineered exactly what happened, + roughly when. This is good enough for now. \ No newline at end of file diff --git a/devops/tasks/config/creds.yaml b/devops/tasks/config/creds.yaml new file mode 100644 index 000000000..dab0fe6d8 --- /dev/null +++ b/devops/tasks/config/creds.yaml @@ -0,0 +1,33 @@ +hostname: {{hostname}}.{{domain}} +xmpp: + sink: # Receives messages. We'll need many of these. + jid: sink@localhost + password: {{RANDOM1}} + source: # Sends messages. + jid: source@localhost + password: {{RANDOM1}} + stream: # For debugging + jid: stream@localhost + password: {{RANDOM1}} +auth: + password_file: passwd.lo +pubsub: + type: redis +kvs: + type: redis +roster_data: + source: all +aio: + session_secret: {{RANDOM2}} + session_max_age: 3600 +config: + run_mode: dev + debug: [] +theme: + server_name: Learning Observer + front_page_pitch: Learning Observer is an experimental dashboard. If you'd like to be part of the experiment, please contact us. If you're already part of the experiment, log in! + logo_big: /static/media/logo-clean.jpg +event_auth: + local_storage: + userfile: students.yaml + allow_guest: true \ No newline at end of file diff --git a/devops/tasks/config/hostname b/devops/tasks/config/hostname new file mode 100644 index 000000000..8c9fff80a --- /dev/null +++ b/devops/tasks/config/hostname @@ -0,0 +1 @@ +{{hostname}} \ No newline at end of file diff --git a/devops/tasks/config/init.d b/devops/tasks/config/init.d new file mode 100644 index 000000000..6d8b816ad --- /dev/null +++ b/devops/tasks/config/init.d @@ -0,0 +1,42 @@ +#!/bin/bash + +# The world's simplest, stupidest init script. +# +# THIS IS CURRENTLY UNUSED, SINCE WE USE A SYSTEMD SCRIPT + +### BEGIN INIT INFO +# Provides: learning_observer +# Required-Start: mountkernfs $local_fs +# Required-Stop: +# Should-Start: +# X-Start-Before: +# Default-Start: S +# Default-Stop: +# Short-Description: Runs the Learning Observer platform +# Description: This is a part of a larger dev-ops infrastructure. This is unlikely to work in isolation. +### END INIT INFO +# +# written by Piotr Mitros + + +case "$1" in +start) + cd /home/ubuntu/writing_observer/learning_observer/ + setsid -f su ubuntu ./lo.sh +;; +status) + printf "For status, run: ps aux | grep learning_observer\n" +;; +stop) + pkill -f learning_observer +;; + +restart) + $0 stop + $0 start +;; + +*) + echo "Usage: $0 {status|start|stop|restart}" + exit 1 +esac diff --git a/devops/tasks/config/lo.sh b/devops/tasks/config/lo.sh new file mode 100644 index 000000000..d9a05d850 --- /dev/null +++ b/devops/tasks/config/lo.sh @@ -0,0 +1,9 @@ +#!/usr/bin/bash + +# This is a script to start up Learning Observer with it's own process +# name. This is convenient for being able to start / stop the process. + +. /usr/share/virtualenvwrapper/virtualenvwrapper.sh +workon learning_observer +cd /home/ubuntu/writing_observer/learning_observer +bash -c "exec -a learning_observer python learning_observer" >> /home/ubuntu/lo.log 2>> /home/ubuntu/lo.err diff --git a/devops/tasks/config/nginx b/devops/tasks/config/nginx new file mode 100644 index 000000000..229ba6c4a --- /dev/null +++ b/devops/tasks/config/nginx @@ -0,0 +1,40 @@ +server { + # We listen for HTTP on port 80. When we set up certbot, this changes to 443. + listen 80 default_server; + listen [::]:80 default_server; + + server_name {{hostname}}.{{domain}}; + + location / { + # Generally, used to configure permissions. E.g. http basic auth, allow/deny + # IP blocks, etc. Note that for deploy, this should be broken out into several + # blocks (e.g. incoming event, dashboards, etc.) + {{nginx_root_options}} + + proxy_pass http://localhost:8888/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # We disable CORS globally. This should be more granular. + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + } + location /wsapi/ { + proxy_pass http://localhost:8888/wsapi/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + + add_header "Access-Control-Allow-Origin" *; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; + add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; + + if ($request_method = OPTIONS ) { + return 200; + } + } +} \ No newline at end of file diff --git a/devops/tasks/config/passwd.lo b/devops/tasks/config/passwd.lo new file mode 100644 index 000000000..e69de29bb diff --git a/devops/tasks/config/postuploads b/devops/tasks/config/postuploads new file mode 100644 index 000000000..a8dcdc67d --- /dev/null +++ b/devops/tasks/config/postuploads @@ -0,0 +1,10 @@ +sudo hostnamectl set-hostname {hostname} +sudo rm -f /etc/nginx/sites-available/default +sudo rm -f /etc/nginx/sites-enabled/default +if [ -f /etc/nginx/sites-available/{hostname} ]; then sudo ln -f /etc/nginx/sites-available/{hostname} /etc/nginx/sites-enabled/{hostname}; else echo "WARNING: Failed to make symlink in /etc/nginx/sites-available (config/postupload)"; fi + +sudo chown -R ubuntu:ubuntu /home/ubuntu/writing_observer +sudo systemctl daemon-reload +sudo service learning_observer stop +sudo service learning_observer start +sudo service nginx restart diff --git a/devops/tasks/config/rsyslog.conf b/devops/tasks/config/rsyslog.conf new file mode 100644 index 000000000..47ee22ba5 --- /dev/null +++ b/devops/tasks/config/rsyslog.conf @@ -0,0 +1 @@ +if $programname == 'learning_observer' then /var/log/lo.log \ No newline at end of file diff --git a/devops/tasks/config/sync.csv b/devops/tasks/config/sync.csv new file mode 100644 index 000000000..e3592df21 --- /dev/null +++ b/devops/tasks/config/sync.csv @@ -0,0 +1,6 @@ +creds.yaml,root:root,644,/home/ubuntu/writing_observer/learning_observer/creds.yaml,"Learning Observer settings file" +nginx,root:root,644,/etc/nginx/sites-enabled/{hostname},"nginx site configuration" +passwd.lo,root:root,644,/home/ubuntu/writing_observer/learning_observer/passwd.lo,"(Generally blank) passwords file" +lo.sh,ubuntu:ubuntu,744,/home/ubuntu/writing_observer/learning_observer/lo.sh,"Script to start Learning Observer with a nice process name" +systemd,root:root,644,/etc/systemd/system/learning_observer.service,"Systemd init script" +rsyslog.conf,root:root,644,/etc/rsyslog.d/learning_observer.conf,"rsyslog script (for stdout/stderr)" \ No newline at end of file diff --git a/devops/tasks/config/systemd b/devops/tasks/config/systemd new file mode 100644 index 000000000..673d3ffa2 --- /dev/null +++ b/devops/tasks/config/systemd @@ -0,0 +1,11 @@ +[Unit] +Description=Learning Observer + +[Service] +ExecStart=/home/ubuntu/writing_observer/learning_observer/lo.sh +Type=simple +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=learning_observer +User=ubuntu +Group=ubuntu \ No newline at end of file diff --git a/devops/tasks/orchlib/__init__.py b/devops/tasks/orchlib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/devops/tasks/orchlib/aws.py b/devops/tasks/orchlib/aws.py new file mode 100644 index 000000000..ce76b14ba --- /dev/null +++ b/devops/tasks/orchlib/aws.py @@ -0,0 +1,248 @@ +''' +Tools to bring up an AWS nano instance, and to connect it to DNS via +Route 53. We do not want to be AWS-specific, and this file should be +the only place where we import boto. +''' + +import time +import yaml + +import boto3 + +import orchlib.config +import orchlib.fabric_flock +from orchlib.logger import system + + +session = boto3.session.Session() +ec2 = session.resource('ec2') +ec2client = boto3.client('ec2') +r53 = boto3.client('route53') + +UBUNTU_20_04 = "ami-09e67e426f25ce0d7" + +def create_instance(name): + ''' + Launch a machine on EC2. Return the boto instance object. + ''' + blockDeviceMappings = [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "DeleteOnTermination": True, + "VolumeSize": 32, + "VolumeType": "gp2" + } + } + ] + + # Baseline set of tags.... + tags = [ + { + 'Key': 'Name', + 'Value': name + }, + { + 'Key': 'Owner', + 'Value': orchlib.config.creds['owner'] + }, + { + 'Key': 'deploy-group', + 'Value': orchlib.config.creds['deploy-group'] + } + ] + + # And we allow extra tags from the config file. + # + # This should be handled more nicely at some point. We want + # a global config, with per-machine overrides, and we want + # this config common for all templates, etc. + for (key, value) in orchlib.config.creds.get("ec2_tags", {}).items(): + tags.append({ + 'Key': key, + 'Value': value + }) + + # This is kind of a mess. + # Good command to help guide how to make this: + # `aws ec2 describe-instances > template` + # It doesn't correspond 1:1, but it's a good starting + # point. + response = ec2.create_instances( + ImageId=UBUNTU_20_04, + InstanceType='t2.small', + BlockDeviceMappings=blockDeviceMappings, + KeyName=orchlib.config.creds['aws_keyname'], + MinCount=1, + MaxCount=1, + Placement={ + "AvailabilityZone": "us-east-1b" + }, + NetworkInterfaces=[ + { + 'SubnetId': orchlib.config.creds['aws_subnet_id'], + 'DeviceIndex': 0, + 'AssociatePublicIpAddress': True, + 'Groups': [orchlib.config.creds['aws_security_group']] + } + ], + TagSpecifications=[ + { + 'ResourceType': 'instance', + 'Tags': tags + } + ] + ) + + instance = response[0] + instance.wait_until_running() + # Reload, to update with assigned IP, etc. + instance = ec2.Instance(instance.instance_id) + + # Switch to IMDS v2, hopefully, due to security improvements + ec2client.modify_instance_metadata_options( + InstanceId=instance.instance_id, + HttpTokens='required', + HttpEndpoint='enabled' + ) + + print("Launched ", instance.instance_id) + print("IP: ", instance.public_ip_address) + return instance + + +def list_instances(): + ''' + List all of the `learning-observer` instances, in a compact + format, with just the: + + * Instance ID + * Tags + * Public IP Address + ''' + reservations = ec2client.describe_instances(Filters=[ + { + 'Name': 'tag:deploy-group', + 'Values': [orchlib.config.creds['deploy-group']] + }, + ])['Reservations'] + instances = sum([i['Instances'] for i in reservations], []) + summary = [{ + 'InstanceId': i['InstanceId'], + 'Tags': {tag['Key']: tag['Value'] for tag in i['Tags']}, + 'PublicIpAddress': i.get('PublicIpAddress', "--.--.--.--") + } for i in instances] + return summary + +def terminate_instances(name): + ''' + Terminate all instances give the name. + + Returns the number of instances terminated. We might kill more + than one if we assign several the same name. + + Also, wipes their associated DNS. + ''' + instances = list_instances() + print("All instances: ", instances) + matching_instances = [ + i for i in instances if i['Tags']['Name'] == name + ] + # Set to `None` so we don't accidentally touch this again! + instances = None + print("Matching instances: ", matching_instances) + for i in range(10): + print(10-i) + time.sleep(1) + print("Removing DNS") + for instance in matching_instances: + register_dns( + name, + orchlib.config.creds['domain'], + instance['PublicIpAddress'], + unregister=True + ) + print("Terminating") + ec2client.terminate_instances( + InstanceIds = [i['InstanceId'] for i in matching_instances] + ) + system("ssh-keygen -R {host}.{domain}".format( + host=name, + domain=orchlib.config.creds['domain'] + )) + return len(matching_instances) + + +def register_dns(subdomain, domain, ip, unregister=False): + ''' + Assign a domain name to a machine. + ''' + action = 'UPSERT' + if unregister: + action = 'DELETE' + zones = r53.list_hosted_zones_by_name( + DNSName=domain + )['HostedZones'] + + # AWS seems to ignore DNSName=domain. We filter down to the right + # domain AWS does include a dot at the end + # (e.g. 'learning-observer.org.'), and we don't right now + # (e.g. `learning-observer.org`). We don't need the first test, + # but we included it so we don't break if we ever do pass a domain + # in with the dot. + zones = [ + z for z in zones # Take all the zone where.... + if z['Name'].upper() == domain.upper() # The domain name is correct + or z['Name'].upper() == (domain+".").upper() # With a dot at the end + ] + + if len(zones)!= 1: + raise Exception("Wrong number of hosted zones!") + zoneId = zones[0]['Id'] + request = r53.change_resource_record_sets( + HostedZoneId = zoneId, + ChangeBatch = { + 'Changes': [ + { + 'Action': action, + 'ResourceRecordSet' : { + 'Name' : '{subdomain}.{domain}.'.format( + subdomain=subdomain, + domain=domain + ), + 'Type' : 'A', + 'TTL' : 15, + 'ResourceRecords' : [ + {'Value': ip} + ] + } + }, + ] + } + ) + + # If we're setting DNS, wait for changes to propagate, so we + # can use DNS later in the script + while True and not unregister: + print("Propagating DNS....", request['ChangeInfo']['Status']) + time.sleep(1) + id = request['ChangeInfo']['Id'] + request = r53.get_change(Id=id) + if request['ChangeInfo']['Status'] == 'INSYNC': + break + return True + + +def name_to_group(machine_name): + ''' + For a machine name, return a fabric ssh group of machines with + that name. + ''' + pool = [ + i['PublicIpAddress'] + for i in list_instances() + if i['Tags']['Name'] == machine_name + ] + print(pool) + group = orchlib.fabric_flock.machine_group(*pool) + return group diff --git a/devops/tasks/orchlib/config.py b/devops/tasks/orchlib/config.py new file mode 100644 index 000000000..330de195c --- /dev/null +++ b/devops/tasks/orchlib/config.py @@ -0,0 +1,100 @@ +import os +import os.path + +import json +import yaml + +creds_file = "settings/CREDS.YAML" + +if not os.path.exists(creds_file): + print("No credentials file. I'll need a bit of info from you") + print("to make one.") + info = { + "user": "Your username on the remote machine (probably ubuntu)", + "key_filename": "Your AWS key filename (something like /home/me/.ssh/aws.pem)", + "aws_keyname": "Your AWS key id (as AWS knows it; e.g. aws.pem)", + "aws_subnet_id": "AWS subnet (e.g. subnet-012345abc)", + "aws_security_group": "AWS security group (e.g. sg-012345abc)", + "owner": "Your name", + "email": "Your email", + "domain": "Domain name (e.g. learning-observer.org)", + "flock-config": "Path to git repo where we'll store machine config.", + "deploy-group": "Tag to identify all machines (typically, learning-observer)", + "ec2_tags": "JSON dictionary of any additional tags you'd like on your machines. If you're not sure, type {}" + } + print("I'll need:") + for key, value in info.items(): + print("* {value}".format(value=value)) + print("Let's get going") + d = {} + for key, value in info.items(): + print(value) + d[key] = input("{key}: ".format(key=key)).strip() + d['ec2_tags'] = json.loads(d['ec2_tags']) + if not os.path.exists(d['flock-config']): + os.system("git init {path}".format(path=d['flock-config'])) + os.mkdir(os.path.join(d['flock-config'], "config")) + with open("settings/CREDS.YAML", "w") as fp: + yaml.dump(d, fp) + +creds = yaml.safe_load(open(creds_file)) + +def config_filename(machine_name, file_suffix, create=False): + ''' + Search for the name of a config file, checking + * Per-machine config + * System-wide defaults + * Defaults for this for the Learning Observer (defined in this repo) + + Absolute paths (e.g. beginning with '/') are returned as-is. + ''' + if file_suffix.startswith("/"): + return file_suffix + + paths = [ + # First, we try per-machine configuration + os.path.join( + creds["flock-config"], "config", machine_name, file_suffix + ), + # Next, we try the per-machine override + os.path.join( + creds["flock-config"], "config", machine_name, file_suffix+".base" + ), + # Then, system-wide configuration + os.path.join( + creds["flock-config"], "config", file_suffix + ), + # And finally, as a fallback, default files + os.path.join( + "config", file_suffix + ) + ] + + # For making new versions, always return the per-machine git repo + # directory + if create == True: + return paths[0] + + for fn in paths: + print(fn) + if os.path.exists(fn): + return fn + + +def config_lines(machine_name, file_suffix): + ''' + Kind of like a smart `open().readlines()` for reading config files. + + Handle paths, prefixes, missing files (return nothing), + `strip()`ing lines, comments, etc. + ''' + fn = config_filename(machine_name, file_suffix) + # No config file + if fn is None: + print("Skipping; no file for: ", file_suffix) + return + print("Config file: ", fn) + for line in open(fn).readlines(): + line = line.strip() + if len(line) > 0: + yield line diff --git a/devops/tasks/orchlib/fabric_flock.py b/devops/tasks/orchlib/fabric_flock.py new file mode 100644 index 000000000..0876dcd77 --- /dev/null +++ b/devops/tasks/orchlib/fabric_flock.py @@ -0,0 +1,81 @@ +''' +These are baseline script to help orchestrate a flock of machines +via ssh. This is a thin wrapper around `fabric`. +''' + +import yaml +import fabric + +import orchlib.config +import orchlib.logger + +def machine_group(*pool): + # Skip terminated machines. + # Sadly, also skips recently-created machines.... + pool = [ip for ip in pool if ip!="--.--.--.--"] + group = fabric.SerialGroup( + *pool, + user=orchlib.config.creds['user'], + connect_kwargs={"key_filename": orchlib.config.creds['key_filename']} + ) + + class GroupWrapper: + ''' + This is a thin wrapper, designed for logging commands, and in the + future, perhaps return values. + ''' + def __init__(self, group): + self._group = group + + def run(self, command): + command = "source ~/.profile; " + command + orchlib.logger.grouplog( + "run", + [command], + {} + ) + + self._group.run(command) + + def get(self, *args, **kwargs): + orchlib.logger.grouplog( + "get", + args, + kwargs + ) + self._group.get(*args, **kwargs) + + def put(self, *args, **kwargs): + orchlib.logger.grouplog( + "put", + args, + kwargs + ) + self._group.put(*args, **kwargs) + + def sudo(self, *args, **kwargs): + orchlib.logger.grouplog( + "sudo", + args, + kwargs + ) + self._group.sudo(*args, **kwargs) + + wrapper = GroupWrapper(group) + + return wrapper + + +def connection_group(pool = None): + ''' + Return a Fabric connection group + ''' + if pool is None: + pool = machine_pool() + + return fabric.SerialGroup( + pool, + user=orchlib.config.creds['user'], + connect_kwargs={"key_filename": orchlib.config.creds['key_filename']} + ) + diff --git a/devops/tasks/orchlib/logger.py b/devops/tasks/orchlib/logger.py new file mode 100644 index 000000000..2622b4d62 --- /dev/null +++ b/devops/tasks/orchlib/logger.py @@ -0,0 +1,41 @@ +''' +We'd like to log which actions we take. + +This isn't done, but it's a start +''' + +import os + +log = [ +] + +def system(command): + ''' + Run a command on the local system (`os.system`) + + Log the command and return code. + ''' + rc = os.system(command) + log.append({ + 'event': 'system', + 'command': command, + 'return_code': rc + }) + return rc + +def grouplog(command, args, kwargs): + log.append({ + 'event': 'group', + 'command': command, + 'args': args, + 'kwargs': kwargs + }) + + +def exitlog(): + ''' + Not done. + ''' + os.path.join( + orchlib.config.creds["flock-config"], "logs" + ) diff --git a/devops/tasks/orchlib/repos.py b/devops/tasks/orchlib/repos.py new file mode 100644 index 000000000..5ec3ea0e0 --- /dev/null +++ b/devops/tasks/orchlib/repos.py @@ -0,0 +1,54 @@ +import os + +import orchlib.config + +import remote_scripts.gitpaths + + +# Working command: GIT_SSH_COMMAND="ssh -i KEY.pem" git --git-dir=/tmp/foo/.git push -f --mirror ssh://ubuntu@SOME_SERVER/home/ubuntu/baregit/foo + + +# This command will forcefully push a local repo to a remote server, including all branches +GIT_PUSH =''' +GIT_SSH_COMMAND="ssh -i {key} -o 'StrictHostKeyChecking no'" git + --git-dir={localrepo}/.git + push -f + --mirror + ssh://ubuntu@{mn}.{domain}/home/ubuntu/baregit/{reponame} +'''.strip().replace('\n', '') + + +def force_push(machine, localrepo): + print("LOCAL REPO: ", localrepo) + command = GIT_PUSH.format( + mn=machine, + domain=orchlib.config.creds['domain'], + key=orchlib.config.creds['key_filename'], + localrepo=localrepo, + reponame=remote_scripts.gitpaths.gitpath_to_name(localrepo) + ) + print(command) + os.system(command) + + +def remote_invoke(group, command): + remote_command = "cd writing_observer/devops/tasks/remote_scripts; inv {command}".format(command=command) + print(remote_command) + group.run(remote_command) + + +def update(group, machine_name): + # In most cases, these would correspond to static sites, or + # Learning Observer modules + print("Grabbing public git packages") + for package in orchlib.config.config_lines(machine_name, "gitclone"): + remote_invoke(group, "cloneupdate {package}".format(package=package)) + print("Pushing private git packages") + + # We can only push to bare repos. + for package in orchlib.config.config_lines(machine_name, "gitpush"): + print("Configuring: ", package) + remote_invoke(group, "init {package}".format(package=package)) + print("Force pushing: ", package) + force_push(machine_name, package) + remote_invoke(group, "cloneupdatelocal {package}".format(package=package)) diff --git a/devops/tasks/orchlib/templates.py b/devops/tasks/orchlib/templates.py new file mode 100644 index 000000000..bce617079 --- /dev/null +++ b/devops/tasks/orchlib/templates.py @@ -0,0 +1,138 @@ +import base64 +import io +import os.path +import uuid + +import chevron +import filetype + +import orchlib.config + +def secure_guid(): + ''' + Mix up a few entropy sources with a few system identifiers... + + This should really be built-in. + ''' + os_random = str(base64.b64encode(os.urandom(32))) + uuid1 = uuid.uuid1() + uuid4 = uuid.uuid4().hex + return uuid.uuid5(uuid1, uuid4).hex + + +def render_file_for_transfer(filename, config): + ''' + This converts a filename and a dictionary into a file-like + object, ready for upload. + ''' + # We don't render binary files. This is not a complete set, and we might extend this + # later + BINARY_TYPES = filetype.audio_matchers + filetype.image_matchers + filetype.video_matchers + endings = [".js", ".css", ".ttf", ".ogg", ".jpg", ".png", ".webm", ".mp4"] + def skip_encode(filename): + '''We don't want to run most binary files, code, etc. through our + templating engine. These are heuristics. + + We probably should be explicit and add a field to the config + file, so we don't need heuristics. This is a little bit more + complex and ad-hoc than I like. + ''' + for e in endings: + if filename.endswith(e): + return True + if filetype.guess(filename) in BINARY_TYPES: + return True + return False + + if skip_encode(filename): + return open(filename, "rb") + + # Other files, we run through our templating engine + with open(filename) as fp: + # We convert to bytes as a hack-around for this bug: https://github.com/paramiko/paramiko/issues/1133 + return io.BytesIO(chevron.render(fp, config).encode('utf-8')) + + +def upload( + group, + machine_name, + filename, + remote_filename, + config, + username=None, + permissions=None): + ''' + This will upload a file to an AWS machine. It will: + + * Find the right file. It might be a system-wide default, + or a machine-specific one. + * Generate a set of secure tokens for use in templates (e.g. for + initial passwords) + * Render the file through `mustache` templates, based on the + configuration + * Upload to the server + * Move to the right place, and set permissions. + ''' + # We can use these for security tokens in templates. + # We should save these at some point + for i in range(10): + key = "RANDOM"+str(i) + if key not in config: + config["RANDOM"+str(i)] = secure_guid() + + local_filename = orchlib.config.config_filename(machine_name, filename) + + # This seems like an odd place, but latest `fabric` has no way + # to handle uploads as root. + group.put( + render_file_for_transfer( + local_filename, + config + ), + "/tmp/inv-upload-tmp" + ) + + group.run("sudo mv /tmp/inv-upload-tmp {remote_filename}".format( + remote_filename=remote_filename, + mn=machine_name + )) + if username is not None: + group.run("sudo chown {username} {remote_filename}".format( + username=username, + remote_filename=remote_filename + )) + if permissions is not None: + group.run("sudo chmod {permissions} {remote_filename}".format( + permissions=permissions, + remote_filename=remote_filename + )) + + +def download( + group, + machine_name, + filename, + remote_filename): + ''' + This will download a configuration file from an AWS machine, as + specified in the machine configuration. It's a simple parallel + to `upload` + ''' + print("Remote file: ", remote_filename) + + local_filename = orchlib.config.config_filename( + machine_name, + filename, + create=True + ) + + print("Local filename: ", local_filename) + + pathname = os.path.split(local_filename)[1] + if not os.path.exists(pathname): + os.mkdir(pathname) + + group.get( + remote_filename, + local_filename + ) diff --git a/devops/tasks/orchlib/ubuntu.py b/devops/tasks/orchlib/ubuntu.py new file mode 100644 index 000000000..af4327d40 --- /dev/null +++ b/devops/tasks/orchlib/ubuntu.py @@ -0,0 +1,55 @@ +''' +These are scripts for preparing an Ubuntu 20.04 machine to run the +Learning Observer +''' + +import fabric.exceptions + +import orchlib.fabric_flock +import orchlib.config + + +def run_script(scriptfile): + ''' + Helper which executes a series of commands on set of machines + ''' + script = open("scripts/{fn}.fab".format(fn=scriptfile)).read() + def run(*machines): + group = orchlib.fabric_flock.machine_group(*machines) + + for line in ['hostname'] + script.split("\n"): + line = line.strip() + if len(line) > 0 and line[0] != "#": + print(line) + group.run(line) + return run + +update = run_script("update") +baseline_packages = run_script("baseline_packages") +python_venv = run_script("python_venv") + + +def reboot(machine): + ''' + Run the reboot script. We expect an exception since the remote machine + reboots while Fabric is connected. + ''' + try: + print("Trying to reboot (this doesn't always work") + run_script("reboot")(machine) + except fabric.exceptions.GroupException: + pass + +def provision(ip): + group = fabric.SerialGroup( + ip, + user=orchlib.config.creds['user'], + connect_kwargs={"key_filename": orchlib.config.creds['key_filename']} + ) + update() + baseline_packages() + python_venv() + reboot() + +if __name__=='__main__': + provision() diff --git a/devops/tasks/remote_scripts/__init__.py b/devops/tasks/remote_scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/devops/tasks/remote_scripts/gitpaths.py b/devops/tasks/remote_scripts/gitpaths.py new file mode 100644 index 000000000..80801ca85 --- /dev/null +++ b/devops/tasks/remote_scripts/gitpaths.py @@ -0,0 +1,56 @@ +import os.path +import os + + +WORKING_REPO_PATH='/home/ubuntu/' +BARE_REPO_PATH='/home/ubuntu/baregit/' + + +def gitpath_to_name(packagepath): + ''' + Convert a git path to the name of the repo. For example: + + `https://github.com/ETS-Next-Gen/writing_observer.git` ==> `writing_observer` + ''' + package = os.path.split(packagepath)[1] + if package.endswith(".git"): + return package[:-4] + else: + return package + + +def working_repopath(repo=None): + ''' + Switch to the path where *working* `git` repo is located. E.g. one + with a working tree, if it exists. + ''' + if repo is None: + os.chdir(WORKING_REPO_PATH) + return WORKING_REPO_PATH + + path = os.path.join(WORKING_REPO_PATH, repo) + if os.path.exists(path): + os.chdir(path) + return path + return False + + +def bare_repopath(repo=None): + ''' + Switch to the path where *bare* `git` repo is located. E.g. one + without a working tree, for pushing and pulling. + ''' + # If we don't have a path for bare repos, create it. + if(os.system("mkdir -p "+BARE_REPO_PATH)): + print("Error creating or accessing bare repository directory") + sys.exit(-1) + + if repo is None: + os.chdir(BARE_REPO_PATH) + return BARE_REPO_PATH + + path = os.path.join(BARE_REPO_PATH, repo) + if os.path.exists(path): + os.chdir(path) + return path + return False diff --git a/devops/tasks/remote_scripts/tasks.py b/devops/tasks/remote_scripts/tasks.py new file mode 100644 index 000000000..73b697427 --- /dev/null +++ b/devops/tasks/remote_scripts/tasks.py @@ -0,0 +1,123 @@ +''' +This is a remote script for random `git` operations (e.g. running +on machines in the Learning Observer flock). + +This is a bit awkward, but we maintain: + +- Public `git` repositories in `/home/ubuntu/` +- Private `git` repositories in `/home/ubuntu/baregit` cloned into + `/home/ubuntu` + +The reason for this design is: + +- Pushing a nonpublic repo to a remote server is a bit awkward. Versions + of `git` in current distros do *not* support `push`ing into a non-bare + repo (although this functionaly was added to bleeding edge git). If + we're pushing, we want to push into a bare repo +- For use (e.g. for + +We would like to do this (relatively) statelessly, so that if a repo +exists, we can do an update. If it's up-to-date, we can do nothing. If +it's not there, we create it. + +As of this writing, this is not fully tested. We're going to test more +fully by finishing the side from where we're orchestrating. + +Note that these scripts are designed to be as flexible as possible in terms +of how a path is specified. E.g.: + + inv init https://gitserver.example.com/a/foo.git + inv init /temp/foo + inv init foo + +Will all do the same thing. They will go into the bare repo path, and crete +an empty repository called `foo` if one doesn't already exist, ready for +pushing. + +In the future, we should have a desired version and perhaps give warnings if +the wrong one is used. +''' + +import os +import os.path + +import sys + +from invoke import task + + +# We would like to use these on the remote machine, but also on the local +# machine. +try: + from gitpaths import bare_repopath, working_repopath, gitpath_to_name +except: + from orchlib.gitpaths import bare_repopath, working_repopath, gitpath_to_name + + +@task +def branch(c, repo, branch): + ''' + Switch to a branch in a repo. + ''' + repo = gitpath_to_name(repo) + print("Going to to: ", working_repopath(repo)) + command = "git checkout "+branch + print(command) + os.system(command) + + +@task +def init(c, repo): + ''' + Create a new bare repo, if one does not exist already. + + Otherwise, continue on silently. + + This is for force pushes of remote repos. + ''' + repo = gitpath_to_name(repo) + path = bare_repopath(repo) + if not path: + bare_repopath() + command = "git --bare init "+repo + print(command) + os.system(command) + print(bare_repopath(repo)) + + +@task +def cloneupdate(c, fullrepo): + ''' + Clone a remote repo. + ''' + repo = gitpath_to_name(fullrepo) + barepath = bare_repopath(repo) + + working_repopath() + if not working_repopath(repo): + print("Cloning...") + command = "git clone "+fullrepo + print(command) + os.system(command) + working_repopath(repo) + + print("Updating all branches") + os.system("git fetch --all") + os.system("git pull") + +@task +def cloneupdatelocal(c, repo): + repo = gitpath_to_name(repo) + cloneupdate(c, bare_repopath(repo)) + + +@task +def pull(c, repo): + ''' + Update a repo to the latest version. + ''' + path = working_repopath(repo) + command = "git pull --all" + print(command) + os.system(command) + return path diff --git a/devops/tasks/scripts/baseline_packages.fab b/devops/tasks/scripts/baseline_packages.fab new file mode 100644 index 000000000..fbd88ece4 --- /dev/null +++ b/devops/tasks/scripts/baseline_packages.fab @@ -0,0 +1,4 @@ +cd +sudo apt-get -y install git ansible awscli +git clone https://github.com/ETS-Next-Gen/writing_observer.git +cd writing_observer/devops/ansible ; sudo ansible-playbook local.yaml diff --git a/devops/tasks/scripts/python_venv.fab b/devops/tasks/scripts/python_venv.fab new file mode 100644 index 000000000..629c773eb --- /dev/null +++ b/devops/tasks/scripts/python_venv.fab @@ -0,0 +1,7 @@ +cd +echo . /usr/share/virtualenvwrapper/virtualenvwrapper.sh >> ~/.profile +source ~/.profile; mkvirtualenv learning_observer +echo workon learning_observer >> ~/.profile +source ~/.profile; pip install --upgrade pip +source ~/.profile; cd writing_observer/ ; pip install -r requirements.txt +source ~/.profile; cd ~/writing_observer/learning_observer/; python setup.py develop diff --git a/devops/tasks/scripts/reboot.fab b/devops/tasks/scripts/reboot.fab new file mode 100644 index 000000000..4abead54f --- /dev/null +++ b/devops/tasks/scripts/reboot.fab @@ -0,0 +1 @@ +sudo init 6 diff --git a/devops/tasks/scripts/update.fab b/devops/tasks/scripts/update.fab new file mode 100644 index 000000000..96978f997 --- /dev/null +++ b/devops/tasks/scripts/update.fab @@ -0,0 +1,5 @@ +sudo apt-get update +sleep 1 +sudo apt-get -y upgrade +sleep 1 +sudo apt-get -y dist-upgrade diff --git a/devops/tasks/settings/README.md b/devops/tasks/settings/README.md new file mode 100644 index 000000000..55e5591ea --- /dev/null +++ b/devops/tasks/settings/README.md @@ -0,0 +1,2 @@ +Add a file called CREDS.YAML here and add your security tokens. Docs in +progress diff --git a/devops/tasks/tasks.py b/devops/tasks/tasks.py new file mode 100644 index 000000000..9339e993d --- /dev/null +++ b/devops/tasks/tasks.py @@ -0,0 +1,351 @@ +import atexit +import csv +import datetime +import itertools +import os +import shlex +import sys + +from invoke import task + +import fabric.exceptions + +import orchlib.aws +import orchlib.config +import orchlib.fabric_flock +import orchlib.templates +import orchlib.ubuntu +import orchlib.repos +from orchlib.logger import system + +import remote_scripts.gitpaths + + +@task +def list(c): + ''' + Give a human-friendly listing of all provisioned machines + ''' + for instance in orchlib.aws.list_instances(): + print("{:21} {:21} {:16} {}".format( + instance['InstanceId'], + instance['Tags']['Name'], + instance['PublicIpAddress'], + instance['Tags'].get("use", "") + )) + + +@task +def provision(c, machine_name): + ''' + Set up a baseline image with all the packages needed for + Learning Observer. Note that this will **not** configure + the machine. + ''' + print("Provisioning...") + machine_info = orchlib.aws.create_instance(machine_name) + print("Updating...") + ip = machine_info.public_ip_address + print("DNS....") + orchlib.aws.register_dns(machine_name, orchlib.config.creds['domain'], ip) + print("IP", ip) + orchlib.ubuntu.update(ip) + print("Baseline...") + orchlib.ubuntu.baseline_packages(ip) + print("Venv...") + orchlib.ubuntu.python_venv(ip) + + +@task +def update(c): + ''' + Update all machines with the latest systems updates and security + patches + ''' + addresses = [i['PublicIpAddress'] for i in orchlib.aws.list_instances()] + # Machines without IPs don't get updates + addresses = [i for i in addresses if i != "--.--.--.--"] + print(addresses) + orchlib.ubuntu.run_script("update")(*addresses) + + +@task +def create(c, machine_name): + ''' + Create a machine end-to-end. This is a shortcut for: + * Provision + * Configure + * Certbot + * Download + * Reboot + ''' + print("Provisioning EC2 instance") + provision(c, machine_name) + print("Configuring the Learning Observer") + configure(c, machine_name) + print("Setting up SSL") + certbot(c, machine_name) + print("Saving config") + downloadconfig(c, machine_name) + print("Rebooting") + reboot(c, machine_name) + + +@task +def terminate(c, machine_name): + ''' + Shut down a machine. + ''' + a = input("Are you sure? ") + if a.strip().lower() not in ['y', 'yes']: + sys.exit(-1) + orchlib.aws.terminate_instances(machine_name) + + +@task +def connect(c, machine_name): + ''' + `ssh` to a machine + ''' + command = "ssh -i {key} ubuntu@{machine_name}".format( + key=orchlib.config.creds['key_filename'], + machine_name = machine_name+"."+orchlib.config.creds['domain'] + ) + print(command) + system(command) + + +@task +def configure(c, machine_name): + ''' + Configure a machine + ''' + group = orchlib.aws.name_to_group(machine_name) + + # We start be setting up `git` repos. This will fail if done later, + # since we need these to install pip packages, etc. + orchlib.repos.update(group, machine_name) + + # Set up Python packages. We need git repos for this, but we might + # want to us these in scripts later. + print("Installing Python packages") + for package in orchlib.config.config_lines(machine_name, "pip"): + group.run("source ~/.profile; pip install {package}".format( + package=package + )) + + template_config = { + "nginx_root_options": "", + "hostname": machine_name, + "domain": orchlib.config.creds['domain'] + } + + print("Uploading files") + uploads = [ + l.strip().split(',') + for l in itertools.chain( + orchlib.config.config_lines(machine_name, "sync.csv"), + orchlib.config.config_lines(machine_name, "uploads.csv"), + ) + ] + # We should consider switching back to csvreader, so we handle commas in + # the description + for [local_file, owner, perms, remote_file, description] in uploads: + print("Uploading: ", description) + remote_path = os.path.dirname(remote_file) + group.run("mkdir -p "+remote_path) + orchlib.templates.upload( + group=group, + machine_name=machine_name, + filename=local_file, + remote_filename=remote_file.format(**template_config), + config=template_config, + username=owner, + permissions=perms + ) + + for command in open("config/postuploads").readlines(): + group.run(command.format(**template_config).strip()) + + +@task +def downloadconfig(c, machine_name): + ''' + After setting up certbot, it's helpful to download the nginx config + file. We also don't want to make changes remotely directly in deploy + settings, but if we have, we want to capture those changes. + ''' + template_config = { + "nginx_root_options": "", + "hostname": machine_name, + "domain": orchlib.config.creds['domain'] + } + + group = orchlib.aws.name_to_group(machine_name) + downloads = [ + l.strip().split(',') + for l in itertools.chain( + orchlib.config.config_lines(machine_name, "sync.csv"), + orchlib.config.config_lines(machine_name, "downloads.csv"), + ) + ] + # We should consider switching back to csvreader, so we handle commas in + # the description + for [local_file, owner, perms, remote_file, description] in downloads: + print("Downloading: ", description) + try: + orchlib.templates.download( + group=group, + machine_name=machine_name, + filename=local_file, + remote_filename=remote_file.format(**template_config) + ) + except fabric.exceptions.GroupException: + # This usually means the file is not found. In most cases, + # this happens when we've added a new file to the config, + # and we're grabbing from an old server. + # + # We should handle this more gracefully. How is TBD + print("Could not download file!") + +@task +def certbot(c, machine_name): + ''' + This sets up SSL. Note that: + - SSL will generally NOT work until everything else is set up + - This change nginx config. You don't want to override config + files later. + - This is untested :) + ''' + group = orchlib.aws.name_to_group(machine_name) + CERT_CMD = "sudo certbot -n --nginx --agree-tos --redirect " \ + "--email {email} --domains {hostname}.{domain}" + group.run(CERT_CMD.format( + email=orchlib.config.creds['email'], + hostname = machine_name, + domain=orchlib.config.creds['domain'] + )) + + +@task +def reboot(c, machine_name): + ''' + Untested: This doesn't seem to work yet.... + ''' + print("Trying to reboot... no promises.") + orchlib.ubuntu.reboot(machine_name) + + +@task +def downloadfile(c, machine_name, remote_filename, local_filename): + ''' + Helper to download a single file. + + This is verbose, and doesn't do wildcards. Perhaps better a helper to + `scp`? Don't use this in scripts until we've figured this out.... + ''' + group = orchlib.aws.name_to_group(machine_name) + group.get( + remote_filename, + local_filename + ) + + +@task +def uploadfile(c, machine_name, remote_filename, local_filename): + ''' + Helper to upload a single file. + + This is verbose, and doesn't do wildcards. Perhaps better a helper to + `scp`? Don't use this in scripts until we've figured this out.... + ''' + group = orchlib.aws.name_to_group(machine_name) + group.put( + remote_filename, + local_filename + ) + + +@task +def runcommand(c, machine_name, command): + ''' + Run a remote command. Don't forget quotes! + ''' + group = orchlib.aws.name_to_group(machine_name) + group.run(command) + + +@task +def hello(c): + ''' + For testing! + + For example, hooks. + ''' + print("Hello, world!") + + +@task +def backup(c, machine_name, target): + ''' + Grab a backup of a given directory by name + ''' + targets = { + 'nginx': "/var/log/nginx/", + 'certs': "/etc/letsencrypt/" + } + + if target not in targets: + print("Invalid target. Should be one of:") + print("\n".join(targets)) + sys.exit(-1) + + ts = datetime.datetime.utcnow().isoformat().replace(":", "-") + filebase = "{ts}-{mn}-{tg}".format( + ts=ts, + mn=machine_name, + tg=target + ) + + command = "tar /tmp/{filebase} {target}".format( + filebase=filebase, + target=target + ) + + group = orchlib.aws.name_to_group(machine_name) + group.get( + remote_filename, + local_filename + ) + + +@task +def commit(c, msg): + ''' + This should probably not be a task but a utility function. It's + helpful for debuggin, though. + ''' + system( + "cd {gitpath} ; git add -A; git commit -m {msg}".format( + gitpath=orchlib.config.creds["flock-config"], + msg=msg + ) + ) + + +START_TIME = datetime.datetime.utcnow().isoformat() + +def committer(): + ''' + On exit, commit changes to repo. This code is not finished. + ''' + command_options = shlex.quote(" ".join(sys.argv)) + stop_time = datetime.datetime.utcnow().isoformat() + log = { + 'start_time': START_TIME, + 'stop_time': stop_time, + 'command_options': command_options + } + + +atexit.register(committer) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..ca86761b2 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,16 @@ +version: '3.8' +services: + app: + build: . + volumes: + - ./:/app + image: learning-observer-image:latest + stdin_open: true # Keep standard input open + tty: true # Allocate a pseudo-TTY + restart: always + ports: + - 8888:8888 + depends_on: + - redis + redis: + image: redis:latest diff --git a/docs/_images/block.png b/docs/_images/block.png new file mode 100755 index 000000000..734a1c4c8 Binary files /dev/null and b/docs/_images/block.png differ diff --git a/docs/_images/block.svg b/docs/_images/block.svg new file mode 100755 index 000000000..9eda47d49 Binary files /dev/null and b/docs/_images/block.svg differ diff --git a/docs/_images/lo_block.png b/docs/_images/lo_block.png new file mode 100755 index 000000000..cb5bb92c4 Binary files /dev/null and b/docs/_images/lo_block.png differ diff --git a/docs/_images/lo_block.svg b/docs/_images/lo_block.svg new file mode 100755 index 000000000..df6dd3236 Binary files /dev/null and b/docs/_images/lo_block.svg differ diff --git a/docs/_images/mmnd.png b/docs/_images/mmnd.png new file mode 100755 index 000000000..360459dcd Binary files /dev/null and b/docs/_images/mmnd.png differ diff --git a/docs/_images/mmnd.svg b/docs/_images/mmnd.svg new file mode 100755 index 000000000..24b0a6d73 Binary files /dev/null and b/docs/_images/mmnd.svg differ diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 000000000..e2b7de766 --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,59 @@ +Project Backlog +=============== + +* Figure out why LO doesn't start on reboot, or how to make it restart + on crashes +* Figure out if/when document ID is missing +* Switch to the annotated canvas +* Be able to pull a document associated with a specific assignment in + Google Classroom +* Implement roll-offs for whole-document operations (e.g. long-running + NLP operations, which should be run periodically) + - Implement simple algorithm, comment on complex algorithms + +Robustness +---------- + +* Confirm what happens with students working in groups +* How do we capture formatting? +* How do we handle an Outline view? +* What happens with large documents? +* What happens with editing outside of the system + +Plumbing +------- + +* Robust queues client-side +* Client auth/auth +* Handle server disconnects +* Proper test frameworks + - Replay +* Refactor rosters + +Additional features +------------------- + +* How do we handle peer groups? +* Create more dashboards +1. Flagging students in need of help? +2. Providing information about use of academic vocabulary? + +APIs +---- + +* Generate dashboards with generic aggregate operations +* Handle client config robustly +* Figure out how to integrate slooow NLP algorithm calls into the + real-time server architecture + +Logging +------- + +* Implement robust data store + +Scaling +------- + +* Database / database schemas for user management if we wish to move + beyond pilot +* Online settings management? \ No newline at end of file diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md new file mode 100644 index 000000000..084a65170 --- /dev/null +++ b/docs/concepts/architecture.md @@ -0,0 +1,74 @@ +# Architecture + +Piotr Mitros + +## Introduction + +Like all such documents, this document should be taken with a grain of +salt. It my be out-of-date, or not fully implemented. + +## Overview + +1. Events come from a web browser over a web socket. +2. The server performs a reduce operation of some kind on these + events. This operation maintains a per-student state (for each + plug-in) inside of a KVS. +3. A subset of the internal state is used to compute state as sent to + an educator dashboard. +4. Whenever an event is processed, consumers are notified via a pubsub. +5. Consumers can aggregate these notifications, inspect the external state, + and make a dashboard. + +## Application platform structure + +Learning Observer acts as the shared platform that hosts and coordinates +modules. The core `learning_observer` package owns the boot process: it +loads configuration, establishes connections to databases and pub/sub +systems, and exposes the APIs modules use to register reducers, +dashboards, and other artifacts. Individual modules focus on defining +those artifacts, relying on the platform to handle data ingestion and +communication so new functionality can be added without duplicating the +runtime infrastructure. + +### Technology choices + +1. Generic student information (e.g. names, auth, etc.) cn live in + flat files on the disk, sqlite, or postgres. As of this writing, this + is not built. +2. The KVS for the reduce can either be an in-memory queue or + redis. Redis can be persistent (for deploy) or ephemeral (for + development). As of this writing, all three work. +3. The pub-sub can be an in-memory queue (for development), redis (fo + easy deployment), or xmpp (for scalable deployment). As of this writing, + all three work, but xmpp is buggy/unfinished. +4. The front-end uses bulma and d3.js. + +### Architectural Constraints + +1. By design, this system should be in a usable (although not + necessarily scalable or reliable) state with just a `pip + install`. There should be no complex webs of dependencies. +2. However, there can be a complex web of dependencies for robust, + scalable deployment. For example, we might use an in-memory + queue in a base system, and switch to a high-performance data + store for deployment. +3. For most abstractions, we want to initially build 2-3 plug-ins. For + example, we're initially building this with 2-3 streaming + modules. We support 2-3 pubsubs, and 2-3 key-balue stores. This is + enough to, in most cases, guarantee the abstractions aren't + specific to one thing. However, it's small enough we can change + both sides of an API boundary. +4. Once we know we have the right set of abstractions, we can open up + the flood gates to more plug-ins. + +### Process constraints + +It's better to say "no" to a feature than to break the +architecture. We're in this for the long haul. It's okay to have +scaffolding, though. Half-built things are okay if they're in the +right place, and can be incrementally evolved to be right. + +We try to avoid any technical debt which carries high interest (higher +maintenance costs down the line) -- bad APIs, etc. We don't mind +low-interest technical debt nearly as much (things which need to get +finished later, but won't blow up). diff --git a/docs/concepts/auth.md b/docs/concepts/auth.md new file mode 100644 index 000000000..a919799e6 --- /dev/null +++ b/docs/concepts/auth.md @@ -0,0 +1,114 @@ +Authentication Framework +======================== + +We have two types of authentication: + +* We would like to know that events coming into the system are coming + from where we believe they are. +* We would like to know that users log into the system who can view + student data are who we think they are. + +For the most part, these have very different security profiles. If a +user can spoof events, the worst-case outcome is: + +* A disrupted study +* A disrupted teacher dashboard + +In small-scale studies, demos, and similar, a high level of security +is not required, especially when running on `localhost`, VPN, or in an +IP-restricted domain. + +On the other hand, we **cannot** leak student data. Authenticating +teachers and staff requires a high level of security. + +Event authentication +-------------------- + +Events are authenticated in the file `events.py`. This is +semi-modular. We have several authentication schemes, most of which +rely on a special header. We used to include auth information with +each event, and we have some backwards-compatibility code there as +well. + +Event authentication isn't super-modular yet; it's all in one file, +but the schemes are pluggable. Schemes include: + +* `guest`. Each session is assigned a unique guest ID. This is nice + for demos, studies, and coglabs. +* `local_storage`. Designed for Chromebooks. Each user is given a + unique token, usually stored in the extension's local storage. The + header sends a unique, secure token mapping to one user. +* `chromebook`. The Chromebook sends a user ID. This is *not secure* + and vulnerable to spoofing. It can be combined with `local_storage` + to be secure. +* `hash_identify`. User sends an identity, which is not + authenticated. This is typically for small coglabs, where we might + have a URL like `http://myserver/user-study-5/#user=zihan` +* `testcase_auth`. Quick, easy, and insecure for running testcases. + +We do maintain providence with events, so we can tell which ones came +from secure or insecure sources. + +We need Google OAuth. + +Teacher authentication +---------------------- + +As authentication schemes, we support: + +* Password authentication +* Trusting HTTP basic auth from nginx +* Google OAuth + +We need to be particularly careful with the second of +these. Delegating authentications to `nginx` means that we need to +have nginx properly configured, or we can be attacked. + +User authentication is intended to be fully modular, and we intend to +support more schemes in the future. Right now, each scheme is in its +own file, with `handlers.py` defining a means to log users in, out, as +well as a middleware which annotates the request with user +information. + +Session framework +----------------- + +We keep track of users through +[aiohttp_session](https://aiohttp-session.readthedocs.io/en/stable/). We +store tokens encrypted client-side, which eliminates the needed for +database fields. + +User information +---------------- + +We keep track of user information in a dictionary. Eventually, this will +probably be a dictionary-like object. + +Current fields: + +* `name`: We keep full name, since not all languages have a first name / + last name order and breakdown. +* `nick`: Short name. For a teacher, this might be "Mrs. Q" or "李老师." + For a student, this might by "Timmy." In the future, we might think + through contexts and relationships (e.g. a person might be a teacher, + a coworker, and student) +* `user_id`: Our internal user ID. In most cases, this is the authentication + scheme, followed by the ID within that scheme. For example, Google user + 1234, we might call 'gc-1234.' Test case user 65432, we might call + `tc-65432` +* `safe_user_id`: An escaped or scrubbed version of the above. In some cases, + we have data from unauthenticated sources, and we don't want injection + attacks. There is an open question as to which of these is canonical, + and whether these ought to be swapped (e.g. `source_user_id` and + `user_id`). It depends on where we do JOIN-style operations more often. +* `picture`: Avatar or photo for the user +* `google_id`: Google username + +We will want to think about how to handle joins. Users often have multiple +accounts which should be merged: + +* A user signs up through two mechanisms (e.g. signs up with passwords and + then Google) +* Users are autoenrolled (e.g. through two educational institutions) +* Automatic accounts convert into permanent accounts (e.g. data begins + streaming in for an unauthenticated / guest user) diff --git a/docs/concepts/communication_protocol.md b/docs/concepts/communication_protocol.md new file mode 100644 index 000000000..3738286a4 --- /dev/null +++ b/docs/concepts/communication_protocol.md @@ -0,0 +1,131 @@ +# Communication Protocol + +The communication protocol is Learning Observer's query and transport +layer. It allows dashboards, notebooks, and other clients to request +aggregated data from the key-value store and supporting services by +submitting a declarative *execution DAG* (directed acyclic graph). The +server evaluates the DAG node-by-node, resolves the required +parameters, executes reducers or helper functions, and returns the +assembled result. This document explains how that process fits +together, the core building blocks you can use in a query, and the +helper utilities that make it easier to integrate those queries into +applications. + +## Lifecycle of a Request + +1. **Query construction** - A client builds a nested query description + in Python (or another language) with the helpers in + `learning_observer.communication_protocol.query`. The helpers mirror + relational concepts such as parameters, joins, and projections and + produce JSON-serialisable dictionaries. (See: query.py L1-L123) +2. **Flattening** - Before execution, the DAG is normalised so every + node has a unique identifier and can reference other nodes via + `variable` pointers. The `flatten` utility rewrites nested + structures such as `select(keys(...))` into separate nodes to make + evaluation straightforward. (See: util.py L1-L59) +3. **Execution** - The executor walks the flattened DAG, dispatching + each node type to a registered handler. Nodes can call Python + functions, fetch keys from the key-value store, join intermediate + datasets, or map functions across collections. The executor + assembles the final payload and enforces error handling through the + `DAGExecutionException` type. (See: executor.py L1-L145, L147-L220) +4. **Exports** - Queries expose named *exports* that identify the DAG + nodes clients may request. The integration layer can bind those + exports to callables so dashboards or notebooks can invoke them as + regular async functions. (See: util.py L64-L104, integration.py L38-L102) + +This flow supports both server-defined queries and open-ended +exploration. Production deployments typically offer curated, predefined +queries while development tooling exposes the full language for +experimentation. (See: README.md L11-L36) + +## Core Node Types + +Every node in the execution DAG has a `dispatch` type that determines +how the executor evaluates it. The query helper functions generate the +correct shape for each node type. (See: query.py L19-L123) The most common nodes are: + +- **`parameter`** - Declares a runtime argument. Parameters can be + required or optional, and the executor substitutes provided values or + defaults before downstream nodes run. (See: query.py L33-L42, executor.py L114-L144) +- **`variable`** - References the output of another node in the DAG. + These indirections are automatically inserted during flattening but + can also be used explicitly when wiring complex queries. (See: query.py L45-L52, util.py L13-L61) +- **`call`** - Invokes a published Python function on the server. + Functions are registered with `publish_function`, which ensures every + callable has a unique name. Called functions may be synchronous or + asynchronous; the executor awaits results as needed. (See: query.py L55-L67, executor.py L61-L112, integration.py L21-L47) +- **`keys`** - Produces the key descriptions required to fetch reducer + outputs from the key-value store. Keys nodes typically wrap the + outputs of roster or metadata queries so downstream `select` nodes + can retrieve the associated reducer documents. (See: query.py L114-L123, util.py L72-L102) +- **`select`** - Retrieves documents from the key-value store for the + provided keys. You can request all fields or limit to specific + projections via `SelectFields` enumerations. (See: query.py L70-L83) +- **`join`** - Merges two lists of dictionaries on matching keys using + dotted-path lookups. Left rows are preserved even without a matching + right-hand record, making it straightforward to enrich reducer + outputs with roster data. (See: query.py L86-L96, executor.py L147-L220) +- **`map`** - Applies a published function to each value in a list, + optionally in parallel, returning the transformed collection. This is + useful for server-side post-processing or feature extraction before a + result is exported. (See: query.py L99-L111) + +## Building Queries Efficiently + +Writing DAGs by hand is verbose, so the protocol provides shorthands +for common access patterns. For example, +`generate_base_dag_for_student_reducer` returns an execution DAG that +retrieves the latest reducer output for every student in a course, +including roster metadata and a preconfigured export entry. Dashboards +use this helper to quickly expose reducer results without writing the +full DAG each time. (See: util.py L63-L101) + +The `integration` module can also bind exports directly to a module so +code can call `await module.student_event_counter_export(course_id=...)` +instead of manually constructing requests. This keeps the protocol's +flexibility while offering ergonomic entry points for UI +components. (See: integration.py L49-L102) + +## WebSocket Endpoint + +Dashboards and other clients interact with the communication protocol +through a dedicated WebSocket endpoint exposed at +`/wsapi/communication_protocol`. The aiohttp application wires that path +to `websocket_dashboard_handler`, making the protocol available to +browser sessions and backend consumers alike. (See: learning_observer/routes.py L195-L213) + +When a client connects, the handler waits for a JSON payload describing +one or more queries. Each entry typically includes the flattened +`execution_dag`, a list of `target_exports` to stream, and optional +`kwargs` that provide runtime parameters. Whenever the client submits a +new payload, the server builds the requested DAG generators, executes +them, and schedules reruns based on the provided settings. Responses are +batched into arrays of `{op, path, value}` records so the client can +efficiently apply partial updates to its local state. (See: learning_observer/dashboard.py L331-L411) + +## Tooling and Debugging + +Two exploratory tools live alongside the protocol implementation: + +- `debugger.py` - Provides an interface for submitting ad-hoc queries + and inspecting intermediate results. +- `explorer.py` - Lists predefined queries already published on the + server so you can execute them interactively. + +Because the protocol is evolving, these tools occasionally require +updates when the underlying schema changes. Keeping the communication +protocol documented and covered by tests makes it easier to spot and +fix those regressions quickly. (See: README.md L45-L72) + +## Security Considerations + +Production deployments default to predefined queries so clients can +only request vetted datasets. Open-query mode should be restricted to +trusted environments—such as local notebooks or read replicas—because +it allows arbitrary function calls and joins that may expose sensitive +information or stress backing stores. (See: README.md L11-L36) + +Understanding these concepts makes it easier to extend the protocol, +design new reducers, and reason about the performance characteristics +of dashboards built on Learning Observer. diff --git a/docs/concepts/events.md b/docs/concepts/events.md new file mode 100644 index 000000000..d4b1d12d2 --- /dev/null +++ b/docs/concepts/events.md @@ -0,0 +1,162 @@ +# Event Format + +Our event format is inspired in part by IMS Caliper, xAPI/Tincan, and the edX +tracking log events. None of these standards are quite right for our +application, but several are close. They're pretty good standards! + +## Limitations of Industry Formats + +* **Verbosity.** Both Caliper and xAPI require a lot of cruft to be appended to + events. For example, we have random GUIDs, URLs, and other redundancy on each + event. Having a little bit of context (e.g. a header) or a little rationale + (e.g. IDs which point into a data store) is sometimes good, but too much is a + problem. With too much redundancy, events can get massive: + * Our speed in handling large data scales with data size. Megabytes can be + done instantly, gigabytes in minutes, and terabytes in hours. Cutting data + sizes makes working with data easier. + * Repeating oneself can lead to inconsistent data. Data formats where data + goes in one place (or where redundancy is intentional and engineered for + data correction) are more robust and less bug-prone. +* **Envelopes.** Caliper payloads are bundled in JSON envelopes. This is a + horrible format because: + * It results in a lot of additional parsing... + * ... of very large JSON objects. + * If there's an error or incompatibility anywhere, you can easily lose a + whole block of data. + * You can't process events in realtime, for example, for formative feedback. + +Text files with one JSON event per line are more robust and more scalable: + +* They can be processed as a stream, without loading the whole file. +* Individual corrupt events don't break the entire pipeline-you can skip bad + events. +* They can be streamed over a network. +* They can be preprocessed without decoding. For example, you can filter a file + for a particular type of event, student ID, or otherwise with a plain text + search. The primary goal of first-stage preprocessing is simply to quickly cut + down data size, so it doesn't need to reject 100% of irrelevant events. + +* **Details.** In many cases, the details of a format are inappropriate for a + given purpose. There are event types which are in neither Tincan/xAPI nor + Caliper, and don't fit neatly into their frameworks. For example: + * Formats specify timestamps with great precision, while coarse events (such + as a student graduating) don't maintain that precision. + * In one of our clients, events are generated without a user identifier, which + is then added by the server once the user is authenticated. For these + events, validation fails. + * Related to the above, fields are sometimes repeated (e.g. client-side + timestamp, server-side timestamp, and further timestamps as the event is + processed by downstream systems). Much of this fits into security; + downstream systems should not trust data from upstream systems. For example, + a student shouldn't be able to fake submitting a homework assignment earlier + than they did, and a school should not be able to backdate a state exam + response. + +There are similar minor mismatches for group events, very frequent events (such +as typing), and other types of events not fully anticipated when the standards +were created. + +I'd like to emphasize that, in contrast to most industry formats, these are +quite good. They're not fundamentally broken. + +## How We'd Like to Leverage Industry Formats + +Fortunately, we don't need 100% compatibility for pretty good interoperability. +Our experience is that event formats are almost never interchangeable between +systems; even with standardized formats, the meaning changes based on the +pedagogical design. This level of compatibility is enough to give interoperability +without being constrained by details of these formats. + +Our goal is to be compatible where convenient. Pieces we'd like to borrow: + +* Critically, the universe has converged on events as JSON lines. This already + allows for common data pipelines. +* We can borrow vocabulary-verbs, nouns, and similar. +* We can borrow field formats, where sensible. + +With this level of standardization, adapting to data differences is typically +already less work than adapting to differences in underlying pedagogy. + +## Where We Are + +We have not yet done more careful engineering of our event format. Aside from a +JSON-event-per-line, the above level of compatibility is mostly aspirational. + +## Incoming Event Flow + +Incoming events reach the Learning Observer through `/wsapi/in/`, which +establishes a long-lived websocket for each client session. The websocket stream +is processed through a series of generators that progressively enrich and +validate each message before it reaches reducers. + +1. **Initial decode and logging.** Every websocket frame is decoded by + `event_decoder_and_logger`, which writes the raw payloads to per-session log + files. When the Merkle feature flag is enabled, the same routine also commits + events to the Merkle store using the configured backend. This stage ensures + that we always have an immutable audit log of the stream. +2. **Lock fields and metadata.** Clients typically send a `lock_fields` event + first to declare metadata such as the `source`, `activity`, or other + immutable context. These fields are cached and injected into subsequent + events so downstream reducers receive consistent metadata. Server-side + metadata like IP and user agent is added separately via `compile_server_data` + and cannot be spoofed by the client. +3. **Authentication.** The pipeline buffers events until + `learning_observer.auth.events.authenticate` confirms the session. Successful + authentication attaches the derived `auth` context-containing identifiers + like the `user_id`-to each event before it continues. The websocket + acknowledges authentication so the client can react if credentials are + missing or invalid. +4. **Protection stages.** Events flow through guardrails that: + * stop processing on an explicit `terminate` event and close the associated + log files, + * block sources that appear on the runtime blacklist, notifying the client + when a block occurs, and + * handle optional blob storage interactions (`save_blob` and `fetch_blob`) + that reducers can request. +5. **Reducer preparation.** After authentication and metadata are in place we + call `handle_incoming_client_event`. This builds a pipeline from the declared + client `source`. Each source maps to a set of stream analytics modules that + expose coroutine reducers. Reducers are partially applied with metadata + (including the authenticated user) so they can maintain per-student state. +6. **Reducer execution.** Every canonicalized event passes through the prepared + reducers. Events are logged a second time-now with server metadata and + authentication context-and reducers update their internal state. If the + reducer definitions change during a session (e.g. due to a hot reload in + development) the pipeline is rebuilt on the next event. + +This staged processing allows us to maintain separate concerns for logging, +authentication, safety checks, and analytics while keeping the event format +itself lightweight. Clients only need to agree on the JSON structure of events, +while the server handles durability and routing responsibilities on their +behalf. + +## Configuring domain-based event blacklisting + +Incoming events can be blacklisted through PMSS rules so that specific domains +either continue streaming, are told to retry later, or drop events entirely. +The `blacklist_event_action` setting controls the action and defaults to +`TRANSMIT`. Define rules under the `incoming_events` namespace and include a +`domain` attribute to scope the behavior per organization. When the action is +`MAINTAIN`, the `blacklist_time_limit` setting controls whether the client +should wait a short time or stop sending forever. + +```pmss +incoming_events { + blacklist_event_action: TRANSMIT; +} + +incoming_events[domain="example.org"] { + blacklist_event_action: DROP; +} + +incoming_events[domain="pilot.example.edu"] { + blacklist_event_action: MAINTAIN; + blacklist_time_limit: DAYS; +} +``` + +When a client connects, the server extracts a candidate domain from the event +payload and uses it to resolve the `blacklist_event_action` setting. If +a rule returns `DROP`, the client is instructed to stop sending events. +`MAINTAIN` asks the client to retain events and retry after a delay (as defined +by `blacklist_time_limit`), while `TRANSMIT` streams events normally. diff --git a/docs/concepts/history.md b/docs/concepts/history.md new file mode 100644 index 000000000..5b3e89e97 --- /dev/null +++ b/docs/concepts/history.md @@ -0,0 +1,29 @@ +History +======= + +Second prototype +------- + +The second prototype integrated with Google Classroom, and presented a +(less pretty, more cluttered) consolidated view with: + +* Current student typing +* Time-on-task, idle time, and text length + +First prototype +------- + +Our first version of the tool was a UX mockup (with real front-end +code, but no backend). We had five tabs, of which two are shown. The +typing view showed a block of text around the student cursor in +real-time + +The outline view showed section titles, and how much text students had +written in each section. + +In addition, we had a view which showed summary stats (e.g. amount of +text written), contact info for students, as well as a visualization +of the students' writing process. Teachers wanted a streamlines view +which showed just text around the cursor and three of the summary +stats (amount of text written, idle time, and time-on-task). Most of +the other stuff felt like too much. diff --git a/docs/concepts/key_value_store.md b/docs/concepts/key_value_store.md new file mode 100644 index 000000000..5d994724f --- /dev/null +++ b/docs/concepts/key_value_store.md @@ -0,0 +1,107 @@ +# Key-value store + +Learning Observer reducers and dashboards communicate through a key-value store (KVS). +Reducers write internal state and dashboard-facing summaries to the store, while +queries and presentation layers read those JSON blobs back. The +[`learning_observer.kvs` module](../../learning_observer/learning_observer/kvs.py) +wraps the different storage backends behind a common async API. + +## Router and lifecycle + +The `KVSRouter` constructed during startup owns every configured backend. When +`learning_observer.prestartup` runs it reads the `kvs` section from system +settings, instantiates the requested implementations, and exposes them through +the module-level `KVS` callable. Most code imports `learning_observer.kvs.KVS` +and invokes it to obtain the default backend: + +```python +from learning_observer import kvs + +store = kvs.KVS() # returns the default backend configured in settings +value = await store[key] +``` + +Reducers obtain the store implicitly through decorators such as +`kvs_pipeline` and `student_event_reducer`. Those helpers capture the module +context, derive the reducer keys, and persist the reducer's internal and +external state back to the configured KVS. + +The router also exposes named backends as attributes. If a module needs to +store data in a non-default backend it can call `kvs.KVS.()` where +`` matches the identifier from configuration. + +## Configuring backends + +The `kvs` block in `creds.yaml` (or an equivalent PMSS overlay) declares each +backend. Settings accept either a mapping or an array of key/value tuples. +Every entry must provide a `type` that matches one of the built-in +implementations: + +```yaml +kvs: + default: + type: filesystem + path: .lo_kvs + redis_cache: + type: redis_ephemeral + expiry: 30 +``` + +During startup the router validates the configuration and raises a +`StartupCheck` error if a backend is missing required parameters or references +an unknown type. Once the process finishes booting, the resulting callable is +available to the rest of the system as `learning_observer.kvs.KVS`. + +### Supported types + +| Type | Class | Persistence behavior | Required settings | +|------------------|-----------------------------|------------------------------------------------------------------------|-----------------------------------------------| +| `stub` | `InMemoryKVS` | Data lives only in process memory and disappears on restart. | None | +| `redis_ephemeral`| `EphemeralRedisKVS` | Uses Redis with a per-key TTL for temporary caches. | `expiry` (seconds) | +| `redis` | `PersistentRedisKVS` | Stores data in Redis without an expiry; persistence depends on Redis. | Redis connection parameters in system config. | +| `filesystem` | `FilesystemKVS` | Serializes each key to JSON on disk for simple local persistence. | `path`; optional `subdirs` boolean | + +All backends share the async API: `await store[key]`, `await store.set(key, value)`, +`await store.keys()`, and `await store.dump()` for debugging. + +### Filesystem layout + +The filesystem implementation writes JSON documents under the configured path. +If `subdirs` is true it mirrors slash-separated key prefixes into nested +folders while prefixing directory names with underscores to avoid collisions. +This backend is convenient for workshops or debugging because state survives +restarts as long as the directory remains intact, but it is not designed for +large-scale deployments. + +### Redis variants + +Both Redis implementations rely on the shared connection utilities in +`learning_observer.redis_connection`. The ephemeral variant requires an +`expiry` value so it can set a TTL when calling `SET`, making it suitable for +integration tests or scratch environments. The persistent variant omits the TTL +so keys remain until explicitly deleted or until the Redis server evicts them. +Ensure the Redis instance has persistence enabled (`appendonly` or RDB +snapshots) if the deployment expects reducer state to survive reboots. + +### In-memory stub + +The stub backend keeps a Python dictionary in memory. It is useful for unit +tests or prototype scripts but should not be used when the process restarts or +scales across workers. The module exposes a `clear()` helper to wipe the store +between tests. + +## Working with reducer state + +Reducer keys follow the pattern `,,` where the scope +captures whether the state is internal or external, the module identifies the +producer, and the selector encodes the entity (for example, a student ID). When +reducers process new events they read the previous state from the KVS, compute +an updated value, and call `set()` to write it back. Dashboards and protocol +adapters then fetch the external state by constructing the same key or by using +higher-level query helpers that wrap the KVS API. + +If a dashboard appears empty after restarting the server, confirm which backend +is active. In-memory and ephemeral Redis stores start empty on boot, so the +system needs a fresh stream of events to repopulate reducer state. Filesystem +and persistent Redis backends retain data unless their underlying storage was +cleared. diff --git a/docs/concepts/privacy.md b/docs/concepts/privacy.md new file mode 100644 index 000000000..05a7649a0 --- /dev/null +++ b/docs/concepts/privacy.md @@ -0,0 +1,292 @@ +# Privacy + +Piotr Mitros + +**Disclaimer:** This is a work-in-progress. It is a draft for +discussion. It should not be confused with a legal document (policy, +contract, license, or otherwise). It has no legal weight. As of the +time of this disclaimer, has not been reviewed by anyone other than +Piotr Mitros, who does not speak for either ETS or anyone else. I'm +soliciting feedback fron collaborators (hence, it is here), but it +shouldn't be confused with any sort of policy (yet). It's a working +document to figure stuff out. + +This was written when we were sprinting to respond to COVID19 remote +learning in spring 2020. + +## Introduction + +It is our goal to treat educational data with a hybrid model between +that of data as +[public good](https://en.wikipedia.org/wiki/Public_good_(economics)) +and that of personal data belonging to the individuals to whom the data +pertains. Specifically, we would like to balance the demands of: + +* Family rights and student privacy; +* Transparency and the use of data for advancement of public policy + and education; and +* Scientific integrity and replicability + +This approach contrasts with the current industry trend of treating +student data as a proprietary corporate resource for the maximization +of profit. + +These are fundamental tensions between the demands on any student data +framework. For example, family rights and student privacy suggest that +we should remove student data when asked. On the other hand, +scientific replicability suggests that we maintain data which went +into experiments so people can independently confirm research results. + +Building out both the technology and legal frameworks to do this will +take more time than possible for a pilot project. Until we have built +out such frameworks, student data should be governed by a standard +research Privacy framework, along the lines of what's outlined below. + +If and when appropriate frameworks are available, we hope to +transition and extended research privacy framework described +below. Our thoughts was that we would define a set of guiding +principles and boundaryies right now. If we can find a way to respect +those (build out computer code, legal code, and funding), we would +transition over to this, notifing schools and/or families, giving an +opportunity to opt-out. Should we be unable to implement this +framework within five years, or should we decide to build a different +privacy framework, student data will move over only on an opt-in basis. + +## Standard Research Privacy Framework + +In the short term, this dashboard is intended to address immediate +needs related to COVID19. During the sprint to bring this dashboard to +a pilot, we cannot build out the legal and technical frameworks for +student data management and family rights (e.g. to inspect and erase +data). We would initially use a simple, conservative data policy: + +* Until and unless we have the framework described below (“Extended + Framework”) in place, all student data will be destroyed at most + five years after it was collected. +* The data will live on secure machines controlled by the research + team (currently, ETS and NCSU). +* For the purposes of this project, we can and will share student data + with the student's school. Beyond the school, the parents, and the + student, we would not share your data with anyone outside of the + research team, except as required by law (e.g. in the case of + subpoenas or law enforcement warrants). +* We may perform research and publish based on such data, but only to + the extent that any published results are aggregate to such a level + that it is impossible to re-identify students. + +## Extended Research Privacy Framework + +In order to keep data beyond the five-year window, we would have +technological and organizational frameworks to provide for: + +1. The right of guardians (defined below) to inspect all student data. +2. The right of guardians to have student data removed upon request. +3. The right of guardians to understand how data used, both at + a high-level and a a code level. +4. Reasonable and non-discriminatory access to deidentified data with + sufficient protections to preserve privacy (for example, for + purposes such as research on how students learn or policy-making +5. Transparent and open governance of such data +6. Checks-and-balances to ensure data is managed primarily for the + purpose of helping students and student learning (as opposed to + e.g. as a proprietary corporate resource) +7. An opportunity for guardians to review these frameworks, and to + opt-out if they choose. +8. Review by the ETS IRB. + +Helping students is broadly defined, and includes, for example: + +1. Driving interventions for individual students (for example, + student and teacher dashboards) +2. Allowing interoperability of student records (for example, if a + student completes an assignment in one system, allowing another + system to know about it). +3. Research for the purpose of school improvements (for example, + providing for insights about how students learn, or how different + school systems comparea, in ways analogous to state exams, NAEP, or + PISA). + +It does not include advertising or commercial sale of data (although +it does include basic cost recovery, for example on a cost-plus +basis). + +Depending on context, 'guardian' may refer to: + +1. The student who generated the data; +2. The student's legal parent/guardian; or +3. The student's school / educational institution (for example, acting + as the parent/guardian's agent, as per COPPA) + +We would reserve the right to make the determination of who acts as +the guardian at our own judgement, based on the context. + +## Any other changes + +Any changes to the privacy policy which do not follow all of the above +would require affirmative **opt-in** by the guardian. + +## Rationale and Discussion + +To help contextualize and interpret the above policies. + +### Definitions of Deidentification, anonymization, and aggregation + +* Student data is **deidentified** by removing obvious identifiers, + such as names, student ID numbers, or social security numbers. Note + that deidentified learning data can often be reidentified through + sophisticated algorithms, for example comparing writing style, + skills, typing patterns, etc., often correlating with other + sources. Although such techniques are complex, they tend to be + available as automated tools once discovered. + +* Student data is **anonymized** by removing any data from which a + student may be reidentified. Anonymization involves sophisticated + techniques, such as the use of protocols like k-anonymity/ + l-diversity, or maintaining privacy budgets. + +* Student data may be **aggregated** by providing statistics about + students, for example in the form of averages and standard + deviations. Some care must still be maintained that those + aggregations cannot be combined to make deductions about individual + students. + +For learning data, simple deidentification **cannot** be counted on to +provide security. With data of any depth, it is possible to +re-identify students. However, such obfuscation of obvious identifiers +can still significant reduce risk in some contexts since it prevents +casual, careless errors (such as a researcher accidentally including +the name of a student in a paper, or chancing upon someone they know +in a dataset). With obfuscated identifiers, re-identifying students +generally requires affirmative effort. + +### Scientific integrity and open science + +Over the past few decades, there have been significant changes in +scientific methodology. Two key issues include: + +* **The ability to replicate results.** When a paper is published, + scientist need access to both data and methods (source code) to be + able to confirm results. + +* **Understanding the history of research** Confidence in results + depends not just on the final data and its analysis, but the steps + taken to get there. Scientists need to understand steps taken on + data prior to final publication. + +These suggest maintaining a log of all analyses performed on the data +(which in turn suggests open source code). + +### Educational transparency + +Historically, the general public has had a lot of access to +educational information: + +* PPRA provides for families to have access to school curricular + materials. +* FERPA provides for families to have access to student records, as + well as the ability to correct errors in such records. +* Public records laws (FOIA and state equivalents) provides for + access to substantially everything which does not impact + student privacy or the integrity of assessments. +* In Europe, GDPR provides for people to have the right to + inspect their data, to understand how it is processed, and + to have data removed. + +While FERPA, PPRA, and FOIA were drafted in the seventies (with only +modest reforms since) and do not apply cleanly in digital settings, +the spirit these laws were grounded in a philosophy that the general +public ought to be able to understand school systems. State exams, +NAEP, PISA, and similar exams were likewise created to provide for +transparency. + +This level of transparency has lead to improvements to both the +learning experiences of individual students and to school systems as a +whole, by enabling academic researchers, parent advocates, +policymakers, journalists, and others to understand our schools. + +One of our goals is to translate and to reassert these right as +education moves into the digital era. With digital learning materials, +in many cases, parents, researchers, and others have lost the ability +to meaningfully inspect student records (which are often treated as +corporate property) or curricular materials (which sit on corporate +servers). Increasingly, students' lives are governed by machine +models, to which families have no access. + +Again, this dictates that analysis algorithms (including ML models +where possible without violating student privacy) ought to be open to +inspection, both at a high level (human-friendly descriptions) as well +as at a detailed level (source code). In addition, there ought to be a +way to analyze student data, to the extent this is possible without +violaitng student privacy. + +### Guardianship and Proxy + +Guardianship is a complex question, and hinges on several issues: + +* Age. For example, young elementary school students are unlikely to + be able to make sense of educatonal data, or the complex issues + there-in. High school students may be able to explore such issues in + greater depth, but may have limited legal rights as minors. + +* Identity. Releasing data to an unauthorized party carries high + risk. Robust online identity verification is potentially expensive + and / or brittle. Working through institutions with whom we have + relationships, and who in turn have relationships with students and + families can mitigate that risk. + +* COPPA grants for + [schools to act on behalf of parents](https://www.ftc.gov/tips-advice/business-center/guidance/complying-coppa-frequently-asked-questions#Schools). + First, schools frequently have legal resources and expertise (either + acting individually or in consortia) which parents lack. Second, + reviewing the number of technologies typical students interact with + would be overwhelming to parents. + +However, ultimately, there is a strong argument that access ought to +rest as close to the individual as possible. Where schools act as +agents for families, and parents for students, there is a growing +level of security and competence. On the other hand, there is also a +grwoing level of trust required that those parties are acting in the +best interests of those they are representing. It is incumbent on us, +at all levels, to ensure have appropriate transparency, incentive +structures, and organizational structures to guarantee that proxies do +act for stakeholder benefit, and to balance these based on the +context. + +### Minimizing security perimeter + +Even when all parties act in good faith, broad data sharing exposes +students to high levels of risk of data compromises, whether through +deliberate attacks, disgruntled employees, or human error. + +### Models for data access + +In light of the above constraints, several models for data access have +emerged which allow for both complete transparency and protect student +privacy. + +* In the FSDRC model, deidentified (but not anonymized) data would be + kept in a physically-secure facility. People could visit the + facility and crunch data within the facility. Visitors would be + under both contractual bounds and have physical security to not + remove data, except for sufficiently aggregated results so as to + make reidentification impossible. Access is provided on a cost-plus + basis. + +* People can develop algorithms on synthetic data, and upload + algorithms to a data center, where those algorithms run on student + data. Both code and data are inspected prior to releasing results, + again, on a cost-plus basis. + +* Corporations can run real-time algorithms (such as to drive learning + dashboards) in a data center on a cost-plus basis. Educational + applications can work on shared models of student expertise, without + providing access to student data to the organizations which + developed them. + +* If a student (or proxy there-of) asks to have data removed, that + data is removed within some timeframe. However, for scientific + replicability, there is a before-and-after snapshot of how study + results changed when student data was removed. Note that this has + implications for both perfomance and re-identification. + +... to be continued \ No newline at end of file diff --git a/docs/concepts/reducers.md b/docs/concepts/reducers.md new file mode 100644 index 000000000..2139974dd --- /dev/null +++ b/docs/concepts/reducers.md @@ -0,0 +1,94 @@ +# Event Reducers + +The Learning Observer project uses a reducer system to handle events from various event sources, process them, and provide aggregated data for use in dashboards. This page describes the reducer system's architecture, how it processes events, and its components. + +## Reducer System Architecture + +The reducer system is designed to be modular and flexible, allowing for the addition and removal of event sources, aggregators, and dashboards as needed. The overall system diagram is as follows: + +```bash ++---------------+ +| | +-------------+ +| Event Source ---| | Key-Value | +| | | | Store | +| | | | | ++---------------+ | +-------------+ ++---------------+ | +-----------+ <------|-- Internal | +| | | | | -------|-> State | +---------------+ +------------+ +| Event Source --------|---->| Reducer | | |------>| | | | +| | | | | | +-------------+ | Communication |----> | Dashboard | ++---------------+ | | +-----------+ | Protocol | | | ++---------------+ | | +---------------+ +------------+ +| | | | +| Event Source ----| | +| | | ++---------------+ v + +------------+ + | | + | Archival | + | Repository | + | | + +------------+ +``` + +The reducer system consists of the following components: + +1. **Event Sources**: These are the sources of events that need to be processed. Each event source is responsible for generating events based on user activities, such as clicks, page views, or interactions with learning materials. + +2. **Reducer**: The reducer is the central component that processes the events from all event sources. It takes the incoming events and applies a specific reduction function to transform the event data into a more concise and meaningful form. The reducer is created using the `student_event_reducer` decorator, which enables modularity and flexibility in defining reduction functions. + +3. **Key-Value Store**: This component stores the internal and external state generated by the reducer. The internal state is used for the reducer's internal processing, while the external state is shared with other components, such as aggregators and dashboards. + +4. **Communication Protocol**: The communication protocol handles fetching and transforming data from the key-value store using an SQL-like structure. + +5. **Dashboard**: The dashboard is the user interface that displays the data from the communication protocol, providing insights into user activities and learning outcomes. + +6. **Archival Repository**: This component is responsible for archiving event data, ensuring that historical data is available for analysis and reporting purposes. + +## Using the Reducer System + +To create a new reducer, use the `student_event_reducer` decorator. This allows you to define custom reduction functions that process events and transform them into meaningful insights. As the system evolves, it will be possible to plug in different aggregators, state types, and keys (e.g., per-student, per-resource) to the reducer system. + +In the long term, the goal is to have pluggable, independent modules that can be connected to create a versatile and extensible analytics system. The current reducer system serves as a foundation for building such a system. + +An example of a simple reducer to count events can be defined as + +```python +# import student scope reducer decorator +from learning_observer.stream_analytics.helpers import student_event_reducer + +@student_event_reducer(null_state={"count": 0}) +async def student_event_counter(event, internal_state): + # do something with the internal state, such as increment + state = {"count": internal_state.get('count', 0) + 1} + + # return internal state, external state (no longer used) + return state, state +``` + +To add a reducer to a module, we much define a `REDUCERS` section in a module's `module.py` file like so + +```python +# module.py +# ...other items + +REDUCERS = [ + { + 'context': 'org.mitros.writing_analytics', + 'scope': Scope([KeyField.STUDENT]), + 'function': module.path.to.reducers.student_event_counter, + 'default': {'count': 0} + } +] +``` + +The `context` value must match the `source` string attached to incoming +events. When an event arrives on the websocket it advertises a `source` +identifier (for example `org.mitros.writing_analytics`). The stream +analytics loader uses that identifier to look up which reducers to +execute. If the reducer `context` does not match the event `source`, the +event will never reach your reducer. Double-check that any new event +emitters (such as browser extensions or test scripts) populate the same +`source` string that your module registers here. + +NOTE: the `default` defined in the `module.py` file is for handling defaults when queries are made, while the `null_state` defined in the reducer decorator is used for initializing state of a new incoming event stream (e.g. a new student started sending events). diff --git a/docs/concepts/scaling.md b/docs/concepts/scaling.md new file mode 100644 index 000000000..a7301573c --- /dev/null +++ b/docs/concepts/scaling.md @@ -0,0 +1,116 @@ +# Scaling Architecture + +The goal is for the Learning Observer to be: + +* Fully horizontally-scaleable in large-scale settings +* Simple to run in small-scale settings + +It is worth noting that some uses of Learning Observer require +long-running processes (e.g. NLP), but the vast majority are small, +simple reducers of the type which would work fine on an 80386 +(e.g. event count, time-on-task, or logging scores / submission). + +## Basic use case + +In the basic use case, there is a single Learning Observer process +running. It is either using redis or, if unavailable, disk/memory as a +storage back-end. + +## Horizontally-scalable use-case + +LO needs to handle a high volume of incoming data. Fortunately, +reducers are sharded on a key. In the present system, the key is +always a student. However, in the future, we may have per-resource, +per-class, etc. reducers. + +A network roundtrip is typically around 30ms, which we would like to +avoid. Therefore, we would like reducers to be able to run keeping +state in-memory (and simply writing the state out to our KVS either +with each event, or periodically e.g. every second). Therefore, we +would like to have a fixed process per key so that reducers can run +without reads. + +Our eventual architecture here is: + +``` +incoming event --> load balancer routing based on key --> process pool +``` + +Events for the same key (typically, the same student) should always +land on the same process. + +Eventually, we will likely want a custom load balancer / router, but +this can likely be accomplished off-the-shelf, for example by +including the key in an HTTP header or in the URL. + +**HACK**: At present, if several web sockets hit a server even with a + common process, they may not share the same in-memory storage. We + should fix this. + +## Task-scalable use-case + +A second issue is that we would like to be able to split work by +reducer, module, or similar (e.g. incoming data versus dashboards). + +Our eventual architecture here is: + +``` +incoming event --> load balancer routing based on module / reducer --> process pool +``` + +The key reason for this is robustness. We expect to have many modules, +at different levels of performance and maturity. If one module is +unstable, uses excessive resources, etc. we'd like it to not be able +to take down the rest of the system. + +This is also true for different views. For example, we might want to +have servers dedicated to: + +* Archiving events into the Merkle tree (must be 100% reliable) +* Other reducers +* Dashboards + +## Rerouting + +In the future, we expect modules to be able to send messages to each +other. + +## Implementation path + +At some point, we expect we will likely need to implement our own +router. However, for now, we hope to be able to use sticky routing and +content-based routing in existing load balancers. This may involve +communcation protocol changes, such as: + +- Moving auth information from the websocket stream to the header +- Moving information into the URL (e.g. `http://server/in#uid=1234`) + +Note that these are short-term solutions, as in the long-term, only +the server will know which modules handle a particular event. Once we +route on modules, an event might need to go to serveral servers. At +that point, we will likely need our own custom router / load balancer. + +In the short-term: + +* [Amazon](https://aws.amazon.com/elasticloadbalancing/application-load-balancer/?nc=sn&loc=2&dn=2) +supports sticky sessions and content-based routing. This can work on data in the headers. +* nginx can be configured to route to different servers based on headers and URLs. This is slightly manual, but would work as well. + +## Homogenous servers + +Our goal is to continue to maintain homogenous servers as much as +possible. The same process can handle incoming sockets of data, render +dashboards, etc. The division is handled in devops and in the load +balancer, e.g. by: + +- Installing LO modules only on specific servers +- Routing events to specific servers + +The goal is to continue to support the single server use-case. + +## To do + +We need to further think through: + +- Long-running processes (e.g. NLP) +- Batched tasks (e.g. nightly processes) diff --git a/docs/concepts/student_identity_mapping.md b/docs/concepts/student_identity_mapping.md new file mode 100644 index 000000000..73d4fc123 --- /dev/null +++ b/docs/concepts/student_identity_mapping.md @@ -0,0 +1,37 @@ +# Student Identity Mapping + +This document describes the current approach for reconciling a student's identity across the Google Workspace context used by Writing Observer and external platforms that only surface an email address (for example when the application is launched as an LTI tool). + +## Why the mapping exists + +When Writing Observer runs inside Google Workspace we naturally have access to the Google user identifier that shows up in event payloads. However, when the product is embedded as an LTI application we receive the learner's email address but do not receive the Google identifier. Many downstream reducers and dashboards expect to look students up by the Google identifier that is emitted by Google Docs events. Without an explicit bridge between those two identifiers we would be unable to join activity data with roster or profile information for LTI launches. + +## Data sources involved + +Two pieces of infrastructure cooperate to keep an email-to-Google-ID lookup table available: + +1. **`student_profile` reducer** – The `student_profile` KVS pipeline in `writing_analysis.py` stores the latest email address and Google identifier (`safe_user_id`) observed for each student. The reducer only updates its state when either value changes. The resulting records live in the reducer's internal key-value namespace and therefore need to be copied to a place where other services can access them. 【F:modules/writing_observer/writing_observer/writing_analysis.py†L233-L253】 +2. **`map_emails_to_ids_in_kvs.py` script** – This maintenance script scans the reducer's internal keys, extracts any records that contain both `email` and `google_id`, and writes a dedicated `email-studentID-mapping:{email}` entry to the key-value store. The explicit mapping gives any service that only knows the email address a way to recover the Google identifier. 【F:scripts/map_emails_to_ids_in_kvs.py†L1-L29】 + +This flow is intentionally simple: the reducer captures whatever the client reports, and the script copies the data to keys that other components already know how to query. + +## Operating the script + +The email mapping script is normally run in the same environment as other KVS maintenance tasks. It requires access to the same credentials file that the main server use. Thus, we need to run the script from the `learning_observer` directory. A manual run looks like this: + +```bash +cd learning_observer/ +python ../scripts/map_emails_to_ids_in_kvs.py +``` + +The script performs a full scan every time it runs, so it is safe to execute multiple times or to schedule as a recurring job. + +## Limitations and future direction + +The current reducer-plus-script approach fills an immediate gap but remains a stopgap solution: + +* **Tight coupling to Google identity** – The reducer only records the Google identifier surfaced by Google Docs. If we ingest events from another platform, there is no canonical place to persist its identifiers. +* **No user object abstraction** – Each consumer must know which KVS keys to query. A shared user object (or identity service) would allow the system to attach multiple external identifiers, roles, and profile attributes to a learner and to expose them through a stable API. +* **Operational overhead** – Because the mapping lives in the KVS, we must remember to run the maintenance script anywhere we expect the lookup table to be fresh. + +In the future we plan to introduce a formal user object that encapsulates identifiers, roles, and cross-system metadata. That abstraction would make this lookup process unnecessary by giving every component a single source of truth for student identity. Until then, this document serves as a reference for the current mapping workflow. diff --git a/docs/concepts/system_design.md b/docs/concepts/system_design.md new file mode 100644 index 000000000..e4a15ae4e --- /dev/null +++ b/docs/concepts/system_design.md @@ -0,0 +1,56 @@ +Learning Observer System Design +------------------------------- + +Piotr Mitros. 2021-07-11 + +This lays out the system design, as planned. This design does not +fully reflect the current implementation, yet. + +Our goal is to build a system which will: + +* Take in process data from a diversity of sources + +* Perform real-time processing on that data, in order to support + teachers in real-time. This is done through a series of pluggable + analytics modules. + +* Archive that data for research purposes and archival analysis + +* Provide open science tools to log such analyses + +In other words: + +![](../_images/block.png) + +Internally, the system takes a stream of events from each learner, and +routes it to one or more analytics modules. Each of these modules +performs a `reduce` operation over that stream in realtime. The +reduced state is stored in a KVS (currently `redis`, although this is +pluggable). These modules run as asynchronous Python co-routines, +which makes them quite scalable. We ought to be able to handle large +numbers of simultanious connections. + +Each time an instructor connects, periodically, such data is +aggregated from redis, and sent back to the instructor. This would be +a logical place to be more clever about scaling; ideally, we'd cycle +through instructors for such an aggregation, and only aggregate where +data has changed, so that with large numbers of instructors, the +system merely updates dashboards less quickly: + +![](../_images/lo_block.png) + +Although at present, reduce operations are per-student, and +aggregations per-class, in the future, we envision: + +* Other ways to shard (e.g. per-resource, per-document, etc.). +* Being able to cascade events, either by generating new events, or in + much the same way as we handle the current aggregation +* Potentially, being more clever about routing the same student to a + common process each time. Right now, we're connected per-session, + but we may have concurrency issues if a student connects twice. + +Data will be stored in a git-like Merkle tree format: + +![](../_images/mmnd.png) + +We'll document this in more detail later. \ No newline at end of file diff --git a/docs/concepts/system_settings.md b/docs/concepts/system_settings.md new file mode 100644 index 000000000..f1749dc40 --- /dev/null +++ b/docs/concepts/system_settings.md @@ -0,0 +1,149 @@ +# System settings + +Learning Observer depends on a single source of truth for everything from +server ports to which pieces of modules are enabled. We rely on the +[PMSS](https://github.com/ETS-Next-Gen/pmss) registry because it gives us a +predictable, type-checked way to describe those concerns once and reuse them +across the whole stack. This article explains why the settings layer exists, +how `creds.yaml` fits into the picture, and why we support cascading +``*.pmss`` files that behave a bit like CSS for configuration. + +To view all settings and what they do, checkout the [System Settings Reference](../reference/system_settings.md). + +## Why centralize configuration? + +* **Shared vocabulary.** Modules, reducers, and integrations all speak the same + language when they ask for `hostname`, `redis_connection.port`, or + `modules.writing_observer.use_nlp`. PMSS enforces the field definitions so we + can freely move code between services without wondering what the knobs are + called. +* **Type safety and validation.** Every field is registered with a type and + optional parser. PMSS refuses to start if a value is missing or malformed, + surfacing errors during boot instead of in the middle of a request. +* **Operational portability.** Teams deploy Learning Observer to wildly + different environments. A single registry allows a site to describe network + boundaries, third-party credentials, or feature flags in one place and keep + those choices under version control. + +## Defining which settings files to use + +To load alternate or additional PMSS rulesets, start the server with +`--pmss-rulesets` and pass one or more file paths or a directory. The startup +logic expands directories into sorted file lists, then loads each file as a +`YAMLFileRuleset` when it ends in `.yaml`/`.yml` or a `PMSSFileRuleset` when it +ends in `.pmss`. Any other file suffix is skipped with a warning so you can +keep README files or notes alongside the rulesets without breaking startup. + +## The role of `creds.yaml` + +Most installations load configuration from `creds.yaml`. When the process +starts, `learning_observer.settings` initializes PMSS with a +`YAMLFileRuleset`, parses that file, and registers the run mode and other core +fields. The YAML mirrors the namespace hierarchy, so options live exactly where +operators expect to see them: + +```yaml +server: + port: 5000 +modules: + writing_observer: + use_nlp: true +``` + +`creds.yaml` gives us a stable default: it travels with the deployment, can be +checked into private infrastructure repositories, and is easy to audit during +reviews. Even when we introduce additional sources, the YAML baseline remains +the anchor that documents the "intent" of the environment. + +## Cascading ``*.pmss`` overlays + +While one file covers global defaults, we often need tweaks that depend on +**who** is asking for data or **where** a request originates. PMSS supports +multiple rule sets, so we extend the base YAML with optional ``*.pmss`` +overlays. Each overlay is a small PMSS file whose contents look just like the +YAML fragment they augment, but they add selectors that encode *specificity*. + +Think of these selectors like CSS. We start with the low-specificity default +rule and then layer on increasingly precise matches: + +```pmss +roster_data { + source: all; +} + +roster_data[domain="learning-observer.org"] { + source: google; +} +``` + +When `learning_observer/learning_observer/rosters.py` asks for `roster_data` +it supplies attributes such as the caller's email domain or provider. PMSS +walks the rule set, finds the most specific block that matches the request, and +returns that value. In the example above a teacher from +`learning-observer.org` would receive the `google` roster source, while any +other user would keep the global `all` default. Additional selectors can layer +on top for providers, schools, or classrooms with each more specific rule +overriding the broader ones. + +At runtime we still assemble a deterministic cascade: + +1. Load the global `creds.yaml` defaults. +2. Apply any environment overlays (for example, a `district.pmss` file that + swaps OAuth credentials for that customer). +3. Resolve request-scoped overlays that match the supplied selectors, letting + the most specific rule win. + +PMSS tracks the provenance of each value so developers can inspect which file +supplied the final answer when troubleshooting. Because overlays reuse the +same registered fields, we retain all of the type checking that protects the +base configuration. + +## How code consumes the cascade + +Once the cascade is assembled, code does not care whether a value came from the +YAML baseline or an overlay. Components call +`learning_observer.settings.pmss_settings.()` (optionally via +`module_setting()` helpers) and PMSS resolves the field using the active rule +stack. That means a request handled for an instructor can pick up +instructor-specific defaults while a system job, using the same accessor, still +observes the site-wide configuration. + +### What context we pass today + +Every call into `pmss_settings` names the setting through the `types` list. We +build that list from the canonical namespace of the setting—`['server']` for the +public port, `['redis_connection']` for Redis, `['modules', module_name]` for +module flags, and so on. Because the list mirrors the hierarchy defined in +`creds.yaml`, we get deterministic lookups even when overlays layer additional +rules on top. + +Selectors (the `attributes` argument) are rarer. Only features that genuinely +vary per request provide them today. For example, roster resolution passes the +requesting user's email domain and the LTI provider so the `roster_data` +configuration can pick the correct backend, and the dashboard logging toggle +adds the user's domain to honour tenant-specific overrides. Most other settings +still rely solely on the namespace lookup. + +### Where we want to go + +We want every lookup that depends on request context to assemble the same +attribute payload in the same place. Rather than sprinkling ad-hoc conditionals +around the codebase, helpers should gather the domain, provider, role, or other +selectors once and pass them through every relevant PMSS call. This keeps the +setting definitions declarative, makes it obvious which selectors operators can +target in overlays, and avoids drift between different parts of the system. + +## Extending the system settings surface + +Adding a new capability follows a consistent pattern: + +1. Register the field with PMSS, giving it a name, type, description, and + default if appropriate. +2. Update `creds.yaml` (or the reference documentation) to teach operators what + the new setting does. +3. Optionally create overlay files where the value should vary by tenant, user, + or integration partner. + +By keeping configuration declarative and cascading, we get the flexibility to +serve many partners without branching the codebase, all while preserving the +predictability administrators expect from a single system settings registry. diff --git a/docs/concepts/technologies.md b/docs/concepts/technologies.md new file mode 100644 index 000000000..aac422a7f --- /dev/null +++ b/docs/concepts/technologies.md @@ -0,0 +1,46 @@ +# Technologies in the _Learning Observer_ + +### Technologies + + +You are welcome to use your own instance of redis; however, `docker compose` allows us to spin up an instance of Redis and connect to it. See the Docker Compose section for more information. + +The provided run commands all include watchdog turned on to ease development time on re-running the application. + + +Several potential contributors have asked for a list of technologies +needed to be productive helping developing the *Learning Observer* or +modules for the *Learning Observer*. A short list: + +* We use [Python](https://www.python.org/) on the server side, and JavaScript on the client side. We do rely on current Python (dev systems are mostly 3.10 as of this writing). +* Since we're managing large numbers of web socket connections, we make heavy use of [asynchronous Python](https://docs.python.org/3/library/asyncio.html). If you haven't done async programming before, there is deep theory behind it. However, we again recommend any short tutorial for aiohttp, and then learning in context. +* Our web framework is [aiohttp](https://docs.aiohttp.org/en/stable/). +* We are moving towards [react](https://react.dev/) and [redux](https://redux.js.org/). +* Simple dashboards can be built with [plot.ly](https://plotly.com/python/) +* Our main database is the original [redis](https://redis.io/), but we plan to switch to a different redis due to licensing and other nasty changes by a company which coopted this from the open source community. We have a simple key-value store abstraction, so this is easy to swap out. +* We make heavy use of `git`, as well as of data structures which are `git`-like. I recommend reading [Git Internals](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain) + and following [Write Yourself a Git](https://wyag.thb.lt/) +* Our CSS framework is currently [Bulma](https://bulma.io/), but that may change. +* Our icon library is [Font Awesome](https://fontawesome.com/) +* For rapid prototyping, we use [P5.js](https://p5js.org/), although we hope to avoid this beyond the prototype phase. This is super-easy to learn (even for little kids), and super-fast to develop in. It doesn't do to production-grade software, though (responsive, i18n, a11y, testability, etc.). The best way to learn this is by helping a child do the Khan Academy JavaScript courses :) +* Our web server is [nginx](https://nginx.org/en/), but that's easy to + change. +* Our dev-ops framework is home baked, but uses [boto](http://boto.cloudhackers.com/), [invoke](https://www.pyinvoke.org/), [Fabric](https://www.fabfile.org/), and a + little bit of [ansible](https://docs.ansible.com/ansible/latest/dev_guide/developing_python_3.html). +* We recommend Debian/Ubuntu, but run on Fedora/Red Hat. People have successfully run this on MacOS and on Windows/WSL, but this is not well-tested. +* At some point, we do plan do add [postgresql](https://www.postgresql.org/). +* For a while, when we thought we'd need queues, we used an XMPP server. I don't think we need queues, but if we do, it will come back. + +For grad students, interns, student volunteers, and other contributors who are here primarily to learn: One of the fun things here is that most of these are _deeply interesting tools_ with a strong theoretical basis in their design. + +On the whole, our goal is to keep a *small set of dependencies*. To add a new tool to the system, it will need to do something _substantially_ different than what's in the system already. We do plan on adding Postgresql once needed, but not too much beyond that. + +Note that some modules within the system (including and especially the _Writing Observer_) do have more extensive dependencies. The _Writing Observer_ uses _a lot_ of different NLP libraries, and until we streamline that, can be quite annoying to install. + +# Deprecations + +* We are deprecating [D3](https://d3js.org/) for displaying data in + real-time on the client, and otherwise, as a front-end framework. D3 + is a relatively small and simple library with a fairly steep + learning curve (in much the same way as Go is a small and simple + game). Much of the use of this is obsoleted by our use of react. \ No newline at end of file diff --git a/docs/how-to/communication_protocol.md b/docs/how-to/communication_protocol.md new file mode 100644 index 000000000..a0725329a --- /dev/null +++ b/docs/how-to/communication_protocol.md @@ -0,0 +1,286 @@ +# How to Build and Run Communication Protocol Queries + +This guide explains the end-to-end workflow for turning a reporting idea into a runnable query on the Learning Observer communication protocol. Follow the steps in order—both humans and language models can use this as a checklist when creating or automating queries. + +## 1. Frame the Data Task + +1. Write a one-sentence description of the insight or dataset you need (e.g., *“Return the latest writing sample for each student in a course”*). +2. Identify the reducers, helper functions, or key-value store documents that expose the data. Concept docs provide summaries of the executor lifecycle, node types, and utilities for transforming reducer outputs. 【F:docs/concepts/communication_protocol.md†L1-L100】 +3. Check whether an existing helper (e.g., `generate_base_dag_for_student_reducer`) already provides most of the DAG structure. Reusing helpers keeps queries consistent and concise. 【F:docs/concepts/communication_protocol.md†L62-L87】 + +## 2. Confirm Your Goal and Required Data + +1. Identify the data source(s): + + * **Reducers** - Aggregated documents stored in the key-value store. + * **Helper functions** - Python callables published with `publish_function`. + * **Roster/metadata** - Collections that need to be joined with reducer data. +2. Decide which fields must appear in the output. Note whether you need the entire document or only specific fields. +3. List all runtime values (course ID, time range, student list, etc.). These become `parameter` nodes later. + +Document these choices (in comments or metadata) so you can refer to them in later steps. + +## 3. Declare Parameters and Defaults + +Each runtime input must be expressed as a `parameter` node. Parameters can be required or optional and may include default values: + +```python +course_id = query.parameter("course_id", required=True) +student_id = query.parameter("student_id", required=False, default=None) +``` + +For each parameter, document: + +* **Name** - Identifier passed to the DAG node. +* **Type** - String, UUID, ISO date, etc. +* **Required** - Boolean flag. +* **Default** - Optional fallback value. + +> Tip: Emit parameter declarations first so later steps can reuse variables like `course_id["variable"]` consistently. + +For fixed values (e.g., reducer names or field lists), define constants once near where they are used. + +## 4. Plan the Data Flow (DAG Skeleton) + +Translate the goal into a linear sequence of operations. A typical reducer query involves: + +1. Fetching roster metadata or other context. +2. Producing keys for each entity (`keys` nodes). +3. Retrieving reducer documents with `select`. +4. Joining reducer outputs with metadata. +5. (Optional) Post-processing with `map` or `call`. + +Example outline: + +``` +roster = call("get_course_roster", course_id) +reducer_keys = keys(reducer_name, roster.students) +reducer_docs = select(reducer_keys, fields=[...]) +enriched = join(reducer_docs, roster, left_on="student.id", right_on="id") +export enriched +``` + +Verify that every step depends only on earlier outputs, and adjust until the flow is acyclic. + +## 5. Construct Nodes with Query Helpers + +Use `query.py` helpers to implement the skeleton: + +```python +from learning_observer.communication_protocol import query + +roster = query.call("get_course_roster", args={"course_id": course_id}) +reducer_keys = query.keys( + "reading_fluency", + scope_fields={ + "student": {"values": query.variable(roster), "path": "user_id"}, + }, +) +reducer_docs = query.select( + keys=reducer_keys, + fields=query.SelectFields.SUMMARY, +) +enriched = query.join( + left=reducer_docs, + right=query.variable(roster), + left_on="student_id", + right_on="id", +) +``` + +Guidelines: + +* Use `query.variable(node, path=None)` for downstream access to prior outputs. +* Encapsulate repeated or complex logic in functions for reuse and testing. +* Use explicit names and keyword arguments—avoid positional arguments for clarity. + +### Defining reducer scopes for `keys` (preferred vs. legacy) + +Reducers define a scope (e.g., student, student+document, student+document+tab). When +building a `keys` node, pass scope values that align with the reducer scope so the +executor can build the right Redis keys. + +**Preferred: `scope_fields` (supports arbitrary scopes)** + +Use `scope_fields` to supply each scope axis with either a `values` iterable or a +single value (applied across all items), plus an optional `path` into each item. +The scope field names should match the reducer scope: `student`, +`doc_id`, `tab_id`, `page_id`, etc. + +```python +reducer_keys = query.keys( + "writing_observer.some_tabbed_reducer", + scope_fields={ + "student": {"values": query.variable("roster"), "path": "user_id"}, + "doc_id": {"values": query.variable("documents"), "path": "doc_id"}, + "tab_id": {"values": query.variable("tabs"), "path": "tab_id"}, + # or a single value + "student": "bobs_user_id" + }, +) +``` + +**Legacy: `STUDENTS`/`RESOURCES`** + +The older hack only supported student-only or student+document scopes. It is still +accepted for backward compatibility, but prefer `scope_fields` for new work. + +```python +reducer_keys = query.keys( + "writing_observer.last_document", + STUDENTS=query.variable("roster"), + STUDENTS_path="user_id", + RESOURCES=query.variable("documents"), + RESOURCES_path="doc_id", +) +``` + +## 6. Define Exports and Integrations + +Choose which nodes should be externally accessible: + +```python +exports = { + "reading_fluency": query.export("reading_fluency", enriched) +} +``` + +If integrating with the async helper layer, pass `exports` to `learning_observer.communication_protocol.integration.bind_exports`. + +Document required parameters and defaults with the export definitions. + +## 7. Flatten, Validate, and Serialise + +1. Convert the nested DAG into executor-ready form: + + ```python + from learning_observer.communication_protocol import util + dag = util.flatten(exports) + ``` + +2. Confirm all node IDs are unique and reference earlier nodes. Inspect the flattened DAG if generated automatically. + +3. Serialise to JSON (e.g., `json.dumps(dag)`) when sending over the wire. + +4. Add automated tests—at minimum a smoke test against a fixture store. + +## 8. Expose the DAG to Clients + +To make the DAG discoverable over the websocket interface: + +* Define `EXECUTION_DAG` in the module file and register it with the loader. +* On server start, the DAG will be advertised under the module’s namespace. + +Production deployments should prefer predefined DAGs for security. Open-query mode is optional and must be explicitly enabled. + +## 9. Execute the Query + +Submit the flattened DAG to the communication protocol endpoint with runtime parameters: + +```json +{ + "parameters": { + "course_id": "course-123", + "start_date": "2023-09-01" + }, + "exports": ["reading_fluency"], + "dag": { ... flattened nodes ... } +} +``` + +On success, the response includes export payloads keyed by export name. Inspect `DAGExecutionException` for error details. + +The executor validates each requested export before any DAG work begins. If an +export name is unknown - or if its declared `returns` node cannot be found - the +server responds with a `DAGExecutionException` describing the missing export or +node. Surfacing these errors in logs or UI telemetry helps diagnose typos and +stale configuration quickly. + +When using integration bindings, call the generated async function with the same parameters. + +## 10. Construct Websocket Requests + +Clients interact with `/wsapi/communication_protocol` via JSON messages. Each message contains: + +* `execution_dag` - Name of a predefined DAG or a full DAG object. +* `target_exports` - List of exports to run. +* `kwargs` - Runtime parameters. + +Example: + +```json +{ + "docs_request": { + "execution_dag": "writing_observer", + "target_exports": ["docs_with_roster"], + "kwargs": { "course_id": "COURSE-123" } + } +} +``` + +The server streams back updates in messages shaped like: + +```json +[ + { + "op": "update", + "path": "students.student-1", + "value": { "text": "...", "provenance": { ... } } + } +] +``` + +If `rerun_dag_delay` is set, the server automatically re-executes the DAG and pushes updates. + +### Manual testing with the generic websocket dashboards + +Two helper scripts live in `scripts/` for exercising websocket flows without running a full dashboard UI: + +* `generic_websocket_dashboard.py` (Python + `aiohttp`) +* `generic_websocket_dashboard.js` (Node.js + `ws`) + +Both scripts ship with a template payload under the `REQUEST` constant. Update the payload to target the exports and parameters you want to test—for example, changing `execution_dag`, `target_exports`, or `kwargs.course_id`. + +To run the Python version: + +```bash +python scripts/generic_websocket_dashboard.py +``` + +The script opens a websocket to `/wsapi/communication_protocol`, sends the JSON request, and pretty-prints any responses. Install dependencies with `pip install aiohttp` if needed. + +The Node.js version follows the same pattern. After adjusting `REQUEST`, run: + +```bash +node scripts/generic_websocket_dashboard.js +``` + +If you copy the script into a browser console, delete the `require('ws')` line so the native `WebSocket` implementation is used. + +Use these scripts to confirm executor behaviour during development—for example, to observe partial updates or to verify that query parameters are wired correctly before embedding a request in a Dash dashboard. + +## 11. Iterate and Maintain + +* Profile slow queries; large joins may need new helpers or precomputed reducers. +* Keep DAGs version-controlled. Update dependent queries when reducers or helpers change. +* Review security before exposing exports to untrusted clients. + +## 12. Test End-to-End + +* **Unit-test** reducers and helpers independently. +* **Reference** `learning_observer/learning_observer/communication_protocol/test_cases.py` for DAG tests. +* **Exercise websocket flows** manually or with automated integration tests. + +## 13. Document Parameters and Outputs + +Update module documentation with: + +* Export descriptions, parameter types, and return structures. +* Sample request payloads. +* Notes on authentication or runtime context. + +Good documentation ensures developers and tooling can invoke queries reliably. + +### Summary + +Following this workflow ensures queries are consistent, testable, and safe to expose across dashboards, notebooks, and automation tools. diff --git a/docs/how-to/connect_lo_blocks_to_canvas.md b/docs/how-to/connect_lo_blocks_to_canvas.md new file mode 100644 index 000000000..ef99b2f8a --- /dev/null +++ b/docs/how-to/connect_lo_blocks_to_canvas.md @@ -0,0 +1,364 @@ +# Setting up LO Blocks with Canvas Integration + +This guide walks you through integrating LO Blocks with Learning Observer and Canvas via LTI. This setup allows Canvas users to access LO Blocks dashboards while maintaining proper authentication and data flow between all three systems. + +## Prerequisites + +You'll need access to the following systems: + +- **Learning Observer** - Base platform installation +- **LO Blocks** - Dashboard application +- **Canvas** - LMS instance with administrative rights + +## Part 1: Canvas Configuration + +### Initial Setup + +1. **Sign in to Canvas** with administrative privileges + +2. **Create test environment** (recommended for initial setup): + - Create a sample course + - Add sample students + + > **Note for local testing**: If running Canvas locally via Docker Compose: + > - No email server is configured by default + > - All "sent" emails print to the console + > - You must find confirmation email URLs in the console when adding users + +### Configure LTI Application + +Follow the [detailed LTI configuration guide](https://learning-observer.readthedocs.io/en/latest/docs/how-to/lti.html) in our documentation. + +Within Canvas, you'll want to: + +1. Navigate to the Admin portal +2. Click `Developer Keys`, then click `+ Developer Key` +3. Select `LTI Key` +4. Populate the configuration +5. Save the key +6. Enable the key for use + +> **Note**: You may need to revisit these settings after completing the Learning Observer configuration (see "Putting it all together" section below). + +## Part 2: Learning Observer Configuration + +### Base Installation + +1. **Install Learning Observer** base platform using the [Tutorial: Install](../tutorials/install.md) + +### Module Setup + +2. **Create a module** to connect to a reducer: + - [Tutorial: Build and Run a Module from the Cookiecutter Template](../tutorials/cookiecutter-module.md) + - Match the `context` in your module to the `source` in your LO Event (see LO Blocks) + - Add an endpoint in `COURSE_DASHBOARDS` defining the connection to the LO Blocks server + +3. **Configure authentication**: + - Set up password file login using `scripts/lo_passwd.py` (place the outputted file within the `learning_observer/` directory) + +### Canvas Integration Settings + +4. **Modify roster source settings** + + Edit `learning_observer/rosters.py` to update available PMSS values for `roster_source`. + + ```python + pmss.parser('roster_source', parent='string', choices=['google', 'demo-canvas', 'schoology', 'all', 'test', 'filesystem'], transform=None) + ``` + +5. **Update core settings** in your configuration: + + ```yaml + auth: + lti: + demo-canvas: # Allows users to sign in via LTI + + event_auth: + lti_session: # Allows websocket events from LTI-authenticated users + + feature_flags: + canvas_routes: true # Enables Canvas LTI API calls + + roster_data: # See roster PMSS configuration below + ``` + + > **Important**: Replace `demo-canvas` with an identifier specific to your Canvas instance (e.g., `middleton-canvas`, `easttownhigh-canvas`) + +6. **Create roster PMSS file** (`rosters.pmss`): + + ```pmss + roster_data { + source: all; + } + + roster_data[provider="demo-canvas"] { + source: demo-canvas; + } + ``` + + Any user with the provider `demo-canvas` will use the roster source `demo-canvas` whereas the rest of the users will use rouster source equal to `all`. + +7. **Register PMSS file** in `learning_observer/settings.py`: + + ```python + pmss_settings = pmss.init( + prog=__name__, + description="A system for monitoring", + epilog="For more information, see PMSS documentation.", + rulesets=[ + pmss.YAMLFileRuleset(filename=learning_observer.paths.config_file()), + pmss.PMSSFileRuleset(filename='rosters.pmss') + ] + ) + ``` + +**TODO**: Document any other settings needed "for data to flow properly" (referenced but incomplete in original document) + +## Part 3: LO Blocks Configuration + +1. **Configure websocket connection** + + Inside of `src/lib/state/store.ts` check the following: + + - `WEBSOCKET_URL` points to the Learning Observer instance + - `websocketLogger` is included in our list of available `loggers` + - Ensure the `lo_event.init` function uses the same source as defined in the reducer created earlier + +2. **Build the application**: + + ```bash + npx next build + ``` + +3. **Start the application**: + + ```bash + npx next start + ``` + +## Part 4: Putting It All Together + +### Understanding the Architecture + +LO Blocks is a Next.js application with both client and server-side components. This means: + +- We cannot serve it directly from Learning Observer (which requires static builds) +- We must run both applications side-by-side on the same machine +- We need a reverse proxy to route traffic between them + +### Nginx Reverse Proxy Configuration + +We'll use Nginx to route traffic between Learning Observer and LO Blocks. + +#### Routing Strategy + +- **Default traffic** → Learning Observer +- **`/lo-blocks` path** → LO Blocks application +- Users navigate to LO Blocks via a course dashboard link in Learning Observer + +#### Step 1: Configure Next.js Base Path + +Edit your `next.config.js` to set the base path: + +```javascript +const nextConfig = { + basePath: '/lo-blocks' +}; + +export default nextConfig; +``` + +Then rebuild LO Blocks: + +```bash +npx next build +``` + +> **Important**: The `basePath` setting only affects `next/link` and `next/router`. It does NOT affect `fetch()` calls made by the application. + +#### Step 2: Configure Nginx + +Create an Nginx configuration to handle both applications: + +``` +upstream learning_observer { + server localhost:8002; +} + +upstream lo_blocks { + server localhost:3000; +} + +proxy_cache_path /var/cache/nginx/auth levels=1:2 keys_zone=auth_cache:10m max_size=100m inactive=60m; + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 8001; + server_name localhost; + + # Default: primary service + location / { + proxy_pass http://learning_observer; + + # WebSocket bits + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Common headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + } + + # 1) Next.js app mounted at /special-route + # (this is essentially your original block; I only added a slash to the prefix + # for clarity in matching deeper paths). + location /special-route { + # auth for anything under /special-route + auth_request /auth-check; + + auth_request_set $user_id $upstream_http_x_user_id; + auth_request_set $user_email $upstream_http_x_user_email; + auth_request_set $user_name $upstream_http_x_user_name; + + proxy_pass http://lo_blocks; # passes /special-route[...] as-is to Next + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header X-User-ID $user_id; + proxy_set_header X-User-Email $user_email; + proxy_set_header X-User-Name $user_name; + + proxy_set_header Authorization $http_authorization; + } + + # 2) NEW: route /api/* -> Next's /special-route/api/* + # + # This is the key bit: we don't use 'rewrite' here, we let proxy_pass + # do the path mapping via its trailing slash behavior. + location /api/ { + # Apply the same auth behavior as /special-route + auth_request /auth-check; + + auth_request_set $user_id $upstream_http_x_user_id; + auth_request_set $user_email $upstream_http_x_user_email; + auth_request_set $user_name $upstream_http_x_user_name; + + # Map: + # /api/content -> /special-route/api/content + # /api/foo/bar?x=1 -> /special-route/api/foo/bar?x=1 + proxy_pass http://lo_blocks/special-route/api/; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header X-User-ID $user_id; + proxy_set_header X-User-Email $user_email; + proxy_set_header X-User-Name $user_name; + + proxy_set_header Authorization $http_authorization; + } + + # Internal location for auth checking + location = /auth-check { + internal; + + proxy_pass http://learning_observer/auth/userinfo; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + + proxy_set_header Authorization $http_authorization; + proxy_set_header Cookie $http_cookie; + + proxy_cache auth_cache; + proxy_cache_valid 200 5m; + proxy_cache_key "$http_authorization$cookie_session"; + } + + location /auth/userinfo { + proxy_pass http://learning_observer/auth/userinfo; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + error_page 401 403 = @auth_error; + + location @auth_error { + return 401 '{"error": "Authentication required or user does not exist"}'; + add_header Content-Type application/json; + } +} +``` + +The configuration should include: + +- Proxy pass rules for Learning Observer (default) +- Proxy pass rules for `/lo-blocks` → LO Blocks +- **API fetch() workaround**: Rewrite `/api/*` requests to include the `/lo-blocks` prefix + - This is necessary because Next.js `fetch()` calls don't respect `basePath` + - Safe because Learning Observer doesn't use `/api` routes + +> **Assumption**: LO Blocks runs on port 3000, Learning Observer runs on port 8002 +> To change the Learning Observer port: Add `--port 8002` to the Makefile run command or adjust in `creds.yaml` + +### HTTPS Configuration for Canvas + +Canvas requires HTTPS for LTI integrations. For local development: + +#### Option 1: Cloudflare Tunnel (Recommended for local testing) + +1. **Create a secure tunnel**: + + ```bash + cloudflared tunnel --url http://localhost:8001 + ``` + + This creates a tunnel between your local port and a public HTTPS URL. + +2. **Update configurations** with the tunnel URL: + + **In `creds.yaml`**: + + ```yaml + hostname: your-tunnel-url.trycloudflare.com # Without https:// + + auth: + lti: + demo-canvas: + redirect_uri: https://your-tunnel-url.trycloudflare.com/auth/lti/callback + ``` + + **In Canvas LTI configuration**: + - Update all URL fields with the proper domain `https://your-tunnel-url.trycloudflare.com` + - `target_link_uri: domain/lti/demo-canvas/login` + - `oidc_initiation_url: domain/lti/demo-canvas/login` + - `redirect_uris: domain/lti/demo-canvas/launch` + +## Verification and Testing + +Once everything is configured: + +1. **Canvas → Learning Observer connection**: + - Users should be able to launch the LTI tool from Canvas + - Authentication should work via LTI + +2. **Navigation to LO Blocks**: + - Users should see the dashboard link in Learning Observer + - Clicking should navigate to LO Blocks interface + +3. **Data persistence**: + - User progress in LO Blocks should save properly + - Data flows through websocket connection to Learning Observer diff --git a/docs/how-to/dashboards.md b/docs/how-to/dashboards.md new file mode 100644 index 000000000..f0f767fc5 --- /dev/null +++ b/docs/how-to/dashboards.md @@ -0,0 +1,281 @@ +# Dashboards + +We can create custom dashboards for the system. + +## Dash + +Dash is a package for writing and serving web applications directly in Python. In Dash, there are 2 primary items, 1) page components such as headers, divs, spans, etc. and 2) callbacks. + +### Getting Started with Dash + +Page components can be set up similar to other `html` layouts, like so + +```python +from dash import html + +layout = html.Div([ + html.H1(children='This is a header'), + html.Div(id='A'), + html.Div(id='B'), + html.Input(id='input') +]) +# html version +#
+#

This is a header

+#
+# +#
+``` + +Adding callbacks can introduce interactivity to the dashboard. Dash listens for the value of any `Input` item to change, then runs code and updates the value of the `Output` components. The updated `Output` components could be the `Input` trigger for other callbacks. + +```python +from dash import callback, Output, Input + +@callback( + Output('A', 'children'), + Input('input', 'value') +) +def update_output_children(value): + '''This callback will trigger whenever the contents of `input`'s `value` + property changes. It will update the `children` property of `A`. + ''' + return f'The callback value is: {value}' +``` + +Callbacks are handled on the server, since we are running Python code. This creates an increase in the network and server resources. Instead, we can use `clientside_callbacks` to run Javascript code on the client's browser. + +```python +from dash import clientside_callback, ClientsideFunction, Output, Input + +# note this is no longer a decorator, Dash handles adding this code +# to the pages it serves +clientside_callback( + ClientsideFunction(namespace='my_module', function_name='updateOutputChildren') + Output('B', 'children'), + Input('input', 'value') +) +``` + +```javascript +// `my_module/assets/scripts.js` + +// make sure `dash_clientside` is defined first +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +// create a dictionary of functions +window.dash_clientside.my_module = { + updateOutputChildren: function(value) { + return `The callback value is: ${value}` + } +} +``` + +### Dash in the Learning Observer + +The `lo_dash_react_components` offers a variety of components (written in React, ported to Python). This includes a handy websocket component for connecting directly to the communication protocol. We can build components based on the information we receive from the communcation protocol. The protocol may eventually offer partial updates in the future, we so any time we get a new message, we should update a stored object. This stored object should be used to build the components. + +```python +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input +import lo_dash_react_components as lodrc + +layout = html.Div([ + lodrc.LOConnectionStatusAIO(aio_id=_websocket), + dcc.Store(id=_websocket_storage), + html.H2('Output from reducers'), + html.Div(id=_output) +]) + +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), + Output(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash') +) + +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='receiveWSMessage'), + Output(_websocket_storage, 'data'), + Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'message'), + prevent_initial_call=True +) + +@callback( + Output(_output, 'children'), + Input(_websocket_storage, 'data'), +) +def populate_output(data): + if not data: + return 'No students' + output = [html.Div([ + lodrc.LONameTag( + profile=s['profile'], className='d-inline-block student-name-tag', + includeName=True, id=f'{s["user_id"]}-name-tag' + ), + html.Span(f' - {s["count"]} events') + ]) for s in data] + return output +``` + +And here are the relevant Javascript functions: + +```javascript +window.dash_clientside.learning_observer_template = { + sendToLOConnection: async function (wsReadyState, urlHash) { + if (wsReadyState === undefined) { + return window.dash_clientside.no_update + } + if (wsReadyState.readyState === 1) { + // decode url parameters from hash + if (urlHash.length === 0) { return window.dash_clientside.no_update } + const decodedParams = decode_string_dict(urlHash.slice(1)) + if (!decodedParams.course_id) { return window.dash_clientside.no_update } + // send our request to LO + const outgoingMessage = { + learning_observer_template_query: { + execution_dag: 'learning_observer_template', + target_exports: ['student_event_counter_export'], + kwargs: decodedParams + } + }; + return JSON.stringify(outgoingMessage); + } + return window.dash_clientside.no_update; + }, + + receiveWSMessage: async function (incomingMessage) { + // parse incoming message + const messageData = JSON.parse(incomingMessage.data).learning_observer_template_query.student_event_counter_join_roster || []; + if (messageData.error !== undefined) { + console.error('Error received from server', messageData.error); + return []; + } + return messageData; + } +} +``` + +The websocket helper always reads its connection details from the page +hash. Without a hash (or without a `course_id` entry in the decoded +parameters) the client returns `no_update`, meaning the dashboard never +sends a query to the communication protocol. When you add links to a +dashboard, ensure they preserve whatever hash parameters the module +expects—typically at least `course_id` and sometimes other filter values. + +To add a dashboard to a module, add the following to the module's `module.py` file + +```python +# module.py +# ...other definitions +DASH_PAGES = [ + { + 'MODULE': module.path.dash_dashboard, + 'LAYOUT': module.path.dash_dashboard.layout, + 'ASSETS': 'assets', # define where to find addtional js, css files are + 'TITLE': 'My dashboard title', + 'DESCRIPTION': 'My dashboard description.', + 'SUBPATH': 'my-dashboard-subpath', + # additional js, css files we want to included + 'CSS': [ + thirdparty_url("css/fontawesome_all.css") + ], + 'SCRIPTS': [ + static_url("liblo.js") + ] + } +] +``` + +## NextJS + +NextJS is web framework for building React-based web applications along with additional server-side functionality. + +### Getting Started with NextJS + +Follow the [Getting Started Guide](https://nextjs.org/docs/app/getting-started) in the official NextJS documentation. + +### Serving NextJS in the Learning Observer + +Before NextJS application can be built and added to the system, a few configuration changes need to be made. The built application will not access the server-side code. Any server-side API endpoints need to be implemented in Python. The code that calls these endpoints will need to be updated to point to the correct path. + +Additionally, we need to add a `basePath` to our `next.config.js` file. When building the application, this prefixes all paths with the defined base path. This allows links to function appropriately while being served from Learning Observer. Using a base path is especially important when multiple modules serve NextJS dashboards, because it prevents routing conflicts by ensuring that each module's assets are namespaced by the module name. + +```js +const nextConfig = { + // ... the rest of your config + basePath: '/_next//', + output: 'export', +} +``` + +With this configuration: + +* Without a base path, multiple modules exporting dashboards to `/` will conflict with one another and with Learning Observer's own root path. +* With a base path, each module is served from `/_next///`, which avoids those conflicts by including the module name in the URL path. +* Avoid absolute paths inside the application (for example, `href="/students"`). Absolute paths ignore the configured `basePath`, which breaks routing when the dashboard is served from Learning Observer. Prefer relative links or the [`next/link`](https://nextjs.org/docs/pages/api-reference/components/link) component's `basePath`-aware helpers. + +Use query parameters instead of dynamic path segments for any routing that needs to work after static export. For example, prefer `students/compare?id=123` instead of `/students/[id]/compare` so that the static export can generate the page. + +During development it can be helpful to mock the data that will normally arrive via the Learning Observer websocket. A simple placeholder object keeps the dashboard usable before the websocket connection is wired up: + +```js +const data = { + students: { + martha: { + documents: { + history_essay: { + text: 'this is my history essay' + } + } + } + } +}; +``` + +To add a NextJS project to a module: + +1. Build the project with `npm run build`. The static export requires the `output: 'export'` setting shown above. A directory named `out` will be created and the built application will be placed there. +2. Copy the contents of `out` to `modules///`. +3. Add the path to the built application to the module's `module.py` file + + ```python + # module.py + # ...other definitions + ''' + Next js dashboards + ''' + NEXTJS_PAGES = [ + {'path': '/'} + ] + ``` + +4. Install the module in editable mode with `pip install -e modules/`. +5. Start Learning Observer with `make run` to serve the dashboard. + +### Connecting to the Communication Protocol + +When you are ready to connect to Learning Observer's Communication Protocol, install the `lo_event` module to gain access to the shared websocket utilities. + +Installing `lo_event` makes the `LOConnectionDataManager` React hook available to your NextJS project. The hook manages websocket connection state and incoming messages so that your components can focus on rendering data. + +Adding `lo_event` may surface build-time errors if your environment lacks Node polyfills. For example, some systems report `fs` being unavailable. You can instruct Webpack to ignore the `fs` module when bundling the client by extending `next.config.js`: + +```js +const nextConfig = { + // ...existing config + webpack: ( + config, + { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack } + ) => { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, // tells Webpack to ignore fs in client bundle + }; + return config; + }, +}; + +module.exports = nextConfig; +``` diff --git a/docs/how-to/docker.md b/docs/how-to/docker.md new file mode 100644 index 000000000..34658fa0f --- /dev/null +++ b/docs/how-to/docker.md @@ -0,0 +1,44 @@ +# Docker setup + +## Docker + +We also support spinning up a Docker container. First build the Docker image, then run it + +```bash +docker build -t lo_workshop . # build the root directory and tag it lo_workshop +docker run -it -p 8888:8888 lo_workshop # -it attaches a terminal, -p attaches local port 8888 to dockers 8888 port +``` + +Note that building a docker image may take a few minutes. + +## Docker Compose + +Docker compose can manage both the normal Dockerfile and an instance of Redis. To both build and turn them on, run + +```bash +docker compose up --build + +# NOTE: older versions of docker use separate commands for +# building the images and turning them on +docker compose build +docker compose up +``` + +Watchdog will automatically re-run the command used to run application, `make run`. If we wish to develop while the Docker container is open, we need to modify the `run` command to re-install any packages when it restarts. Your local repository is being shared as a mount to the Docker container. Adding an install command makes sure that latest changes are used. + +```Makefile +run: + pip install -e learning_observer/ + cd learning_observer && python learning_observer --watchdog=restart +``` + +## Active development + +We can add commands to re-install our local instances of the packages in Docker. This will allow us to do active development while the docker is running. + +```Makefile +run: + pip install -e learning_observer/ + pip install -e modules/learning_observer_template/ + cd learning_observer && python learning_observer --watchdog=restart +``` diff --git a/docs/how-to/extension.md b/docs/how-to/extension.md new file mode 100644 index 000000000..a89476ea0 --- /dev/null +++ b/docs/how-to/extension.md @@ -0,0 +1,136 @@ +# Writing Observer Extension + +The Writing Observer browser extension collects rich event data while a writer works in Google Docs so that the Learning Observer platform can analyse the writing process. The extension code lives in [`extension/writing-process/`](../../extension/writing-process/) and is packaged with [Extension CLI](https://oss.mobilefirst.me/extension-cli/) and webpack. + +## Project Layout + +The directory contains a standard Extension CLI project: + +- `src/` - extension source files. Webpack writes the bundled content scripts and background bundles back into this directory as `*.bundle.js` files. + - `writing.js` and `writing_common.js` run in the Google Docs page and capture keystrokes and context. + - `background.js` and `service_worker.js` coordinate logging, open the websocket connection to Learning Observer, and respond to browser lifecycle events. + - `pages/` stores the popup, options page, and other extension UI assets. +- `assets/` - icons used by the published extension. +- `webpack.config.js` - bundles the source code and excludes Node-specific dependencies required by the `lo-event` package. +- `test/` - placeholder for unit tests executed by the Extension CLI test runner. + +## Prerequisites + +- Node.js and npm (the project was last updated with Node 18.x, though later LTS versions should also work). +- Google Chrome for manual testing. +- Access to the [`lo-event`](../../modules/lo_event/) package, which provides messaging utilities that the extension uses to communicate with Learning Observer services. + +## Install Dependencies + +Install everything from inside the extension directory: + +```bash +cd extension/writing-process +npm install +``` + +The `lo-event` dependency is declared as a `file:` package in `package.json`, so it is linked from the local repository when you install. If `npm install` cannot resolve it automatically, install it explicitly: + +```bash +npm install ../../modules/lo_event +# or link it for iterative development +cd ../../modules/lo_event +npm link +cd - +npm link lo-event +``` + +## Everyday development tasks + +Most workflows are exposed through npm scripts. Run them from `extension/writing-process/`. + +### Bundle source files + +Use webpack to generate the bundled scripts that the extension loads at runtime. + +```bash +npm run bundle # one-off build of background.js, writing.js, etc. +npm run bundle:watch # continuously rebuild while editing +``` + +The bundles are written to `src/*.bundle.js`. Ensure these bundles exist before packaging the extension or running tests. + +### Run the extension in development + +Extension CLI can watch the project, rebuild on changes, and output an unpacked extension in `dist/`. + +```bash +npm run ext:start # Chrome/Chromium development build +``` + +Both commands keep watching the filesystem and rebuild when files change. Load the unpacked directory they produce (see [Loading the extension locally](#loading-the-extension-locally)). + +### Run automated tests + +```bash +npm run ext:test # run the Extension CLI test suite +npm run coverage # generate an lcov coverage report via nyc +``` + +The default tests under `test/` are minimal; extend them as new functionality is added. + +### Clean build artefacts and generate docs + +```bash +npm run ext:clean # remove dist/ +npm run ext:docs # produce API docs in public/documentation/ +npm run ext:sync # refresh Extension CLI config files +``` + +Use these scripts before packaging to ensure the release contains only fresh bundles. + +## Building for release + +`npm run build` is the primary release command. It removes any existing `dist/` directory, builds the webpack bundles, and then invokes `ext:build`. + +```bash +npm run build +``` + +After the command completes you will have: + +1. `dist/` – the unpacked extension directory that can be loaded directly in Chromium-based browsers. +2. `release.zip` – an archive suitable for uploading to the Chrome Web Store or distributing manually. + +You can also call the lower-level packaging commands when needed: + +```bash +npm run ext:build # Chrome release bundle only +``` + +## Loading the extension locally + +1. Run `npm run build` (or `npm run ext:start`) to populate the `dist/` directory. +2. Navigate to `chrome://extensions/` in Chrome. +3. Enable **Developer mode** and click **Load unpacked**. +4. Select the `extension/writing-process/dist` directory. + +## Deploying updates + +- Upload the generated `release.zip` to the Chrome Web Store dashboard when publishing new versions. +- Update the `version` field in `src/manifest.json` to match the release number before packaging. +- If the Learning Observer websocket endpoint changes, update `WEBSOCKET_SERVER_URL` in `src/background.js` so that the extension sends analytics to the correct server. + +## System design overview + +The extension relies on two complementary streams of information: + +- **Content scripts (`writing.js`, `writing_common.js`)** run inside Google Docs. They capture keystrokes, document metadata, and lifecycle events, and send structured messages to the background service worker. +- **Background/service worker (`service_worker.js`, `background.js`)** manages the analytics pipeline. It listens for messages from content scripts, observes Google Docs network requests (notably `save` and `bind` endpoints), and forwards data to Learning Observer via the `lo-event` logging framework. It activates only when a Google Docs tab has injected the content script to avoid unnecessary logging. + +This design protects against frequent changes in Google Docs. The network listener provides redundancy when Google modifies the document structure, while the keystroke data keeps precise typing information. + +## Maintaining compatibility with Google Docs + +Google occasionally introduces changes that can disrupt the extension, especially around Manifest V3 requirements and Google Docs rendering updates. To reduce churn: + +- Keep an eye on [Chrome extension updates](https://developer.chrome.com/docs/extensions/whatsnew/). Manifest-level changes (e.g. the MV2 → MV3 migration) often require updates to background scripts and permissions. +- When Google Docs changes its network endpoints, temporarily enable `RAW_DEBUG` in `src/background.js` to capture raw traffic and help reverse-engineer new request formats. +- Test the extension after major Google Docs updates to confirm that keystroke logging and document reconstruction still work as expected. + +For deeper architectural details, consult the inline documentation within each source file in `extension/writing-process/src/`. diff --git a/docs/how-to/impersonation.md b/docs/how-to/impersonation.md new file mode 100644 index 000000000..149fbe5ca --- /dev/null +++ b/docs/how-to/impersonation.md @@ -0,0 +1,31 @@ +# Impersonating users + +Learning Observer includes a lightweight impersonation flow so administrators can review dashboards and data as another user. The implementation relies on two HTTP routes that adjust the authenticated session and a Dash banner that surfaces the active impersonation. + +## Start impersonating + +The `start_impersonation` handler is protected by the `@learning_observer.auth.admin` decorator, so only administrators can trigger the flow. It accepts the target user ID as a path parameter and stores that identifier in the encrypted session under the `impersonating_as` key. + +``` +GET /start-impersonation/{user_id} +``` + +Once the route runs, the session entry looks like `{ "user_id": }`, and any request that reads the active user from the session will treat this impersonated identity as the current user. + +## Stop impersonating + +The stop route clears the impersonation entry from the session. + +``` +GET /stop-impersonation +``` + +If no impersonation was active, the handler returns a message indicating that nothing changed. + +## How impersonation affects user lookup + +Whenever user information is needed during a request, `learning_observer.auth.utils.get_active_user` checks for the `impersonating_as` session entry first. When present, it returns the impersonated profile instead of the authenticated user stored under `user`. This makes downstream code unaware of whether a request is genuine or impersonated. + +## Dash banner for impersonation state + +Dash pages include a small banner at the top of the layout. On page load, `update_impersonation_header` reads the session from the underlying `aiohttp` request. When an impersonated user is present, the banner renders a label showing the impersonated identity and a “Stop” button that links to `/stop-impersonation`. diff --git a/docs/how-to/interactive_environments.md b/docs/how-to/interactive_environments.md new file mode 100644 index 000000000..1c6fa24c0 --- /dev/null +++ b/docs/how-to/interactive_environments.md @@ -0,0 +1,96 @@ +# Interactive Environments + +The Learning Observer can launch itself in a variety of ways. These +include launching itself stand-alone, or from within an IPython +kernel. The latter allows for users to directly interact with the LO +system. Users can connect to the kernel through the command line or a +Jupyter clients, such as the `ipython` console, Jupyter Lab, or +Jupyter Notebooks. This is useful for debugging or rapidly prototyping +within the system. When starting a kernel, the Learning Observer +application can be started alongside the kernel. + +## IPython Kernel Commmunications + +We will give an overview of the IPython kernel architecture, and how +we fit in. First, the IPython kernel architecture: + +1. The `IPython` kernel handles event loops for different + commmunications that occur within itself. +1. These event loops handle code requests from the user or shutdown + requests from a system message. +1. The events are communicated using the [ZMQ Protocol](https://zeromq.org/). + +There are 5 dedicated sockets for communications where events occur: + +1. **Shell**: Requests for code execution from clients come in +1. **IOPub**: Broadcast channel which includes all side effects +1. **stdin**: Raw input from user +1. **Control**: Dedicated to shutdown and restart requests +1. **Heartbeat**: Ensure continuous connection + +Upon startup, we create a separate thread to subscribe to, monitor and +log events on the information rich IOPub socket. + +## Files + +* The **kernel file** is [describe, provide a simplified example or link to one] +* The **connection file** is [describe, provide a simplified example or link to one] + +## Launching Learning Observer from an IPython Kernel + +We use an +[aiohttp runner](https://docs.aiohttp.org/en/stable/web_reference.html#running-applications) +to serve the LO application through the internal `ipykernel.io_loop` +[Tornado](https://www.tornadoweb.org/en/stable/) event loop. The +runner method attaches itself to the provided event loop instead of +the normal running method which creates a new event loop. + +## IPython Shell/Kernel + +We can startup the server as: + +* A kernel we connect to (e.g. from Jupyter Lab) +* An interactive shell including a kernel + +```bash +# Start an interactive shell +python learning_observer/ --loconsole +``` + +The IPython kernel parses specific arguments, which we should +block. It also does not like blank arguments (e.g. --bar). + +```bash +# Start an ipython kernel +# note: the 1 is needed to make the ipython kernel instance we launch happy +python learning_observer/ --lokernel 1 +# this will provide you a specific kernel json file to use +# Connect to the specified kernel +jupyter console --existing kernel-123456.json +``` + +## Jupyter Clients + +### Connect with Jupyter + +Jupyter clients have a set of directories they will look for kernels in. +We need to create the LO kernel files so the client will be able to choose the LO kernel. +Running the LO platform once will automatically create the kernel file in the `//share/jupyter/kernels/` directory. + +```bash +# run once to create the kernel file +python learning_observer/ +# open jupyter client of your choice +jupyter lab +# select the LO kernel from the kernel dropdown +``` + +### Helpers + +The system offers some helpers for working with the LO platform from a Jupyter client. +The `local_reducer.ipynb` is an example notebook where we create a simple `event_count` reducer and create a corresponding dashboard. +This notebook calls `jupyter_helpers.add_reducer_to_lo` which handles adding your created reducer to all relavant aspects of the system. + +The goal here is to be able to rapidly prototype reducers, queries, and dashboards. In the longer term, we would like to be able to compile these into a module, and perhaps even inject them into a running Learning Observer system. + +A long-term goal in building out this file is to have a smooth pathway from research code to production dashboards, using common tools and frameworks. diff --git a/docs/how-to/lti.md b/docs/how-to/lti.md new file mode 100644 index 000000000..e2af168b4 --- /dev/null +++ b/docs/how-to/lti.md @@ -0,0 +1,180 @@ +# Serve Learning Observer as an LTI 1.3 tool + +Learning Observer ships with an IMS LTI 1.3 implementation that relies on the platform's OpenID Connect (OIDC) handshake to authenticate users and obtain scoped API access tokens from the learning management system (LMS). Use this guide to register the tool in an LMS and connect it to a course shell. + +## Prerequisites + +* A deployment of Learning Observer reachable by the LMS (typically via HTTPS). +* An LMS that supports LTI 1.3 with dynamic registration or manual developer keys (e.g., Canvas or Schoology). +* Administrative access in that LMS to create a developer key / external tool. + +## 1. Generate and share a signing key + +Learning Observer signs client assertions with an RSA private key when it exchanges OIDC launch data for an LMS access token. Generate a keypair, store the private key somewhere on the application host, and upload the public key to the LMS when you create the developer key. + +```bash +# create a 4096-bit RSA keypair +openssl genrsa -out secrets/lti-tool-private.pem 4096 +openssl rsa -in secrets/lti-tool-private.pem -pubout > secrets/lti-tool-public.pem +``` + +Record the filesystem path to the private key; you'll reference it in the application configuration in the next step. + +## 2. Configure `creds.yaml` + +Enable the LTI provider in `learning_observer/creds.yaml` by adding an entry under `auth.lti.`. Each provider requires the LMS endpoints, the Learning Observer redirect URL, and the private key location. A Canvas example looks like this: + +```yaml +auth: + lti: + sample-canvas: + client_id: "10000000000000" + auth_uri: "https://canvas.example.edu/api/lti/authorize_redirect" + jwks_uri: "https://canvas.example.edu/api/lti/security/jwks" + token_uri: "https://canvas.example.edu/login/oauth2/token" + redirect_uri: "https://lo.example.edu/lti/sample-canvas/launch" + private_key_path: "secrets/lti-tool-private.pem" + api_domain: "https://canvas.example.edu" # Canvas-specific +``` + +Set `redirect_uri` to the public URL that will receive the POST launch request (`/lti//launch`). The login initiation URL for the LMS is the matching `/lti//login` route. + +Restart the application after updating configuration so the new provider registration is loaded. + +## 3. Enable LMS API routes + +Learning Observer only exposes the IMS Names & Roles (NRPS) and Assignment & Grade Service (AGS) proxy routes when the matching feature flag is turned on. Add the appropriate flag to `creds.yaml` so the application registers the routes during startup: + +```yaml +feature_flags: + canvas_routes: true # Canvas NRPS/AGS proxy endpoints + # schoology_routes: true # Schoology NRPS/AGS proxy endpoints +``` + +## 4. Map the roster source with PMSS + +LTI launches identify the LMS provider in the session so roster lookups can decide which backend to call. Create a PMSS overlay that maps the provider to the correct roster source (see [System settings](../concepts/system_settings.md) for more on PMSS). For Canvas, create a file such as `config/roster_source.pmss` alongside `creds.yaml` with the following contents: + +```pmss +roster_data[provider="sample-canvas"] { + source: sample-canvas; +} +``` + +If you support multiple LMS tenants, add additional selector blocks for their email domains or provider names and point them at `schoology`, `filesystem`, or any other supported roster backend. + +## 5. Register the tool in the LMS + +When you create the LTI developer key/external tool inside the LMS: + +1. Supply the **OIDC login initiation URL** as `https:///lti//login`. +2. Supply the **redirect/launch URL** as `https:///lti//launch`. +3. Paste the **public key** generated earlier so the LMS can validate the signed client assertions. +4. Copy the LMS-issued **client ID** and the platform endpoints (authorize, JWKS, token) into `creds.yaml` if you have not done so already. + +Publish the developer key and install the tool in the desired course/context. The LMS will send the context identifiers in the launch claims so Learning Observer can associate sessions with the right course. + +## 6. Verify the launch + +* Add the external tool link to a module or assignment inside the LMS course. +* Launch the tool from within the LMS. Learning Observer should redirect the browser to the LMS's authorize endpoint, validate the launch state and nonce, and then create a session for the user when the LMS returns. +* Successful launches land on the root of the application with LMS-specific authorization headers stored in the session for follow-up roster and grade sync operations. + +If the launch fails, inspect the Learning Observer logs for messages beginning with `LTI Launch`—they include detailed context whenever state validation, token exchange, or JWT verification fails. Once the LMS recognizes the tool, you can remove or hide other authentication methods; Learning Observer will automatically expose the LTI login routes whenever `auth.lti` providers are configured. + +## 7. Plan student identity mapping + +LTI launch data only includes the learner's email address, while Writing Observer's Google Workspace integrations emit a Google-specific user identifier. To keep downstream reducers and dashboards working for LTI cohorts, plan to run the maintenance workflow that maps emails to Google IDs. Refer to the [Student Identity Mapping guide](../concepts/student_identity_mapping.md) for an overview of how the reducer and maintenance script cooperate and how to operate the sync in production. + +## 8. Submit grades through the AGS proxy + +Once the feature flag for your LMS is enabled (for example `canvas_routes: true`), Learning Observer registers proxy endpoints that forward IMS Assignment & Grade Service (AGS) calls using the authorization headers captured during the LTI launch. You can submit grades for a Canvas course line item with a simple POST to the proxy URL: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -b cookies.txt \ # session created by an LTI launch + https://lo.example.edu/sample-canvas/lineitem_scores/12345/67890 \ + -d '{ + "userId": "student-lti-id", + "scoreGiven": 8, + "scoreMaximum": 10, + "activityProgress": "Completed", + "gradingProgress": "FullyGraded", + "timestamp": "2024-06-30T15:04:05Z" + }' +``` + +* Replace `sample-canvas` with your configured provider name. +* `12345` is the Canvas course ID and `67890` is the AGS line item ID returned by the `/course_lineitems` endpoint. +* The JSON body follows the [IMS AGS score](https://www.imsglobal.org/spec/lti-ags/v2p0#score-media-type) format and is forwarded verbatim to the LMS. + +For server-side use, the corresponding local function accepts the same parameters via `json_body`: + +```python +await canvas_functions['raw_lineitem_scores']( + runtime, + courseId='12345', + lineItemId='67890', + json_body={ + 'userId': 'student-lti-id', + 'scoreGiven': 8, + 'scoreMaximum': 10, + 'activityProgress': 'Completed', + 'gradingProgress': 'FullyGraded', + 'timestamp': '2024-06-30T15:04:05Z' + } +) +``` + +`canvas_functions` is the dictionary returned by `setup_canvas_provider(...)(app)` during app initialization. Both the HTTP route and the local call reuse the LTI access token stored in the user's session, so no additional authentication headers are required. + +### Try it with the sample LTI Grade Demo module + +The repository ships with a sample module you can use to poke at the LTI launch context and post grades through a browser form. Install the module (e.g., `pip install -e modules/wo_lti_grade_demo`) and visit `/views/wo_lti_grade_demo/lti-grade-demo/` after launching via LTI. The page shows: + +- A session summary pulled from the launch claims (provider, course context IDs, and the current `userId`). +- A minimal grade submission form that forwards to `/views/wo_lti_grade_demo/submit-score/`, which in turn calls the AGS score endpoint registered for the provider detected in the session. +- A "Load line items" helper that calls `/views/wo_lti_grade_demo/line-items/` for the current course (or a manually entered `courseId`) and fills a datalist so you can pick a valid AGS line item ID. + +The helper endpoints `/views/wo_lti_grade_demo/session-summary/` and `/views/wo_lti_grade_demo/line-items/` are also available if you prefer to pull the launch metadata and line items directly and post scores with your own client. + +### Getting Canvas line items to appear + +Canvas only exposes LTI Assignment & Grade Service (AGS) line items for assignments that are tied to an external tool launch (or that were created through the AGS API). A plain Canvas assignment without an LTI tool configured will not show up in `/course_lineitems`. To create a testable line item: + +1. In Canvas, open the course and click **Assignments → + Assignment**. +2. Set a name and points, then under **Submission Type** choose **External Tool** and pick your installed LTI tool. Save and publish the assignment. +3. Launch the tool from the assignment at least once so Canvas associates the line item with the tool. +4. Re-run the demo module's "Load line items" helper or call `/course_lineitems` and you should see the new line item label and ID. + +If you prefer to create a line item programmatically, POST an AGS line item object to `/api/lti/courses/{courseId}/line_items` (or the proxy route registered by `canvas_routes`). A minimal body looks like: + +```json +{ + "scoreMaximum": 10, + "label": "My LTI Practice Assignment", + "resourceId": "practice-1" +} +``` + +Canvas returns the new `id` URL in the response, which is the `lineItemId` you can plug into the grade submission examples above. + +### Managing multiple assignments from one LTI tool + +If your Writing Observer deployment hosts several learning experiences (for example, multiple dashboards or writing tasks) but you want to keep using a single LTI tool installation, create **one Canvas assignment per experience** and route inside the tool based on the launch claims: + +1. In Canvas, add a separate **External Tool** assignment for each activity. All of them can point to the same LTI launch URL; Canvas will still generate distinct AGS line items and a unique `resource_link_id` per assignment. +2. (Optional but recommended) Add a Canvas *Custom Field* on each assignment such as `module_slug=revision` or `activity_id=essay-1`. These become `custom` claims in the LTI launch payload and let you steer users to the right part of your app. +3. In your request handler, read the launch session and route to the correct module using the custom field or `resource_link_id`. A minimal example: + + ```python + launch = request.session['lti_launch'] + custom = launch.get('custom', {}) + target = custom.get('module_slug') or MODULE_BY_RESOURCE_LINK.get(launch['resource_link_id']) + return RedirectResponse(f"/views/{target}/") + ``` + +4. When posting grades, reuse the AGS line item associated with the launch. You can pull it from `/views/wo_lti_grade_demo/line-items/` (filtered by `courseId` and matched via `label`/`resourceId`) or store the returned `lineItem` claim from the launch for the active assignment. + +This approach keeps a single LTI registration while giving instructors separate Canvas assignments and gradebook columns for each experience in your app. diff --git a/docs/how-to/multiple_roster_sources.md b/docs/how-to/multiple_roster_sources.md new file mode 100644 index 000000000..fe0823342 --- /dev/null +++ b/docs/how-to/multiple_roster_sources.md @@ -0,0 +1,101 @@ +# Configuring Multiple Roster Sources + +This guide explains how to configure Learning Observer to load rosters from +multiple providers using [PMSS (Preference Management Style Sheets)](https://github.com/ETS-Next-Gen/pmss). + +## 1. Define roster sources in PMSS + +Create a PMSS file (for example `rosters.pmss`) that declares the roster sources +and the provider/domain specific overrides. The default rule selects `all` +roster data, while provider- and domain-specific rules replace that value when a +match is found. + +```pmss +roster_data { + source: all; +} + +roster_data[provider="example-schoology"] { + source: schoology; +} + +/* Note that Canvas requires a slightly different source format */ +roster_data[provider="example-canvas"] { + source: example-canvas; +} + +roster_data[domain="learning-observer.org"] { + source: google; +} +``` + +Save the file alongside your configuration so that it can be referenced by the +application. + +## 2. Load the PMSS ruleset in `settings.py` + +Add the new PMSS file to the `pmss.init` call in `settings.py`. This loads both +the standard YAML configuration and the roster rules you just defined. + +```python +# settings.py +pmss_settings = pmss.init( + prog=__name__, + description="A system for monitoring", + epilog="For more information, see PMSS documentation.", + rulesets=[ + pmss.YAMLFileRuleset(filename=learning_observer.paths.config_file()), + pmss.PMSSFileRuleset(filename="rosters.pmss"), + ], +) +``` + +> **Note:** The PMSS file path is currently hard-coded. Future work will make +> this discovery automatic. + +## 3. Ensure roster sources are supported + +Each roster source requires the associated integration to be configured: + +- `google` requires Google OAuth credentials and callback URLs to be set up. +- `example-canvas` (and other Canvas instances) may need additional PMSS options to + expose Canvas-specific configuration, such as API tokens and instance URLs. +- `schoology` requires the Schoology integration to be enabled and configured. + +Verify that the necessary credentials and configuration options exist before +enabling a source. + +## 4. Enable feature flags for roster routes + +In the system settings, confirm that the relevant feature flags are enabled so the +system exposes the API routes for each provider. Flags follow the +`_routes` naming pattern, for example: + +```yml +# creds.yaml +feature_flags: {google_routes, canvas_routes, schoology_routes} +``` + +These flags allow the application to access the provider-specific endpoints and +retrieve roster data. + +## 5. Test with multiple contexts + +Access the system through each supported context to verify that rosters appear +as expected: + +- Sign in with Google to check the `google` roster data. +- Launch from Canvas via LTI to verify the `example-canvas` roster. +- Launch from Schoology to validate the `example-schoology` roster. + +The user's session context (provider and domain) is evaluated in +`learning_observer/rosters.py` to choose the appropriate PMSS setting. As you +switch between providers, confirm that the roster displayed matches the source +specified in your PMSS configuration. + +## 6. PMSS as a user-specific configuration tool + +This setup demonstrates how PMSS can tailor settings per user or institution by +matching on provider and domain. While roster selection is the primary use case +now, the same approach can be extended to differentiate other settings for +specific audiences in the future. diff --git a/docs/how-to/offline_replay.md b/docs/how-to/offline_replay.md new file mode 100644 index 000000000..12c97328e --- /dev/null +++ b/docs/how-to/offline_replay.md @@ -0,0 +1,84 @@ +# Offline replay with study logs + +This guide covers how to replay Learning Observer study logs for offline analysis using `learning_observer/offline.py`. + +## Prerequisites + +- Install dependencies so the Learning Observer package and its modules are importable +- Change into the Learning Observer directory for proper discoverability of files + +```bash +pip install -e learning_observer/ +cd learning_observer/ +``` + +- Collect the study logs you want to replay. Study logs are produced with a `.study.log` suffix (optionally `.gz` when compressed). They include replay metadata that the offline pipeline expects; do **not** use the raw `.log` files. + +## Processing a single study log file + +Use the helper functions in `learning_observer/offline.py` to initialize the environment and process a file. The examples below assume you are running them from the repository root. + +```bash +python - <<'PY' +import asyncio +from learning_observer import offline + +# Prepare the in-memory KVS and reducers for offline replay +offline.init('creds.yaml') + +# TODO: Offline replay currently initializes PMSS with default rulesets only. +# If you need custom `.pmss` overlays or alternate YAML files, update the +# offline initializer to accept ruleset paths. + + +# Replace this path with the study log you want to replay +log_path = "/path/to/your/session.study.log" + +async def main(): + processed, source, user = await offline.process_file(file_path=log_path) + print(f"Processed {processed} events from {source} as user {user}") + +asyncio.run(main()) +PY +``` + +- `process_file` only accepts study logs ending in `.study.log` or `.study.log.gz` and will reject other log types. +- If you do not provide a `userid`, a random safe username is generated to avoid inadvertently reusing PII from the log. + +## Processing all study logs in a directory + +To replay multiple study logs, point `process_dir` at a directory. It automatically filters to `*.study.log` and `*.study.log.gz` files. + +```bash +python - <<'PY' +import asyncio +from learning_observer import offline + +offline.init('creds.yaml') + +async def main(): + files, events = await offline.process_dir("/path/to/study/logs") + print(f"Processed {events} events across {files} study logs") + +asyncio.run(main()) +PY +``` + +## Resetting state between runs + +Offline processing stores data in the in-memory key-value store (KVS). To clear previously replayed events before another run, call `offline.reset()`: + +```bash +python - <<'PY' +import asyncio +from learning_observer import offline + +offline.init('creds.yaml') + +async def main(): + await offline.reset() + print("KVS cleared") + +asyncio.run(main()) +PY +``` diff --git a/docs/messaging.md b/docs/messaging.md new file mode 100644 index 000000000..ada9d6e69 --- /dev/null +++ b/docs/messaging.md @@ -0,0 +1,25 @@ +Messaging Technologies +====================== + +We may need to route a lot of messages. The best protocol is +XMPP. [ejabberd](https://www.ejabberd.im/) is super-scalable, but +requires a lot maintance. [prosody](https://prosody.im/) is +(relatively) quick and easy. + +There are a lot of clients for Python. We did an eval of a few. If I +recall, we tried [xmpppy](https://github.com/xmpppy/xmpppy), +[SleekXMPP](http://sleekxmpp.com/), and eventually settled on +[Slixmpp](https://slixmpp.readthedocs.io/en/latest/). + +A step up in simplicty are AWS hosted services like SQS/SNS; those +have proprietary lock-in, and for our use-case, rather high pricing. + +Another step up in simplicy are the pub-subs built into redis and +postgresql. These work fine in moderate-size installs, but it's not +clear these would scale to where we hope this system goes. + +For development, we support in-memory pubsub. + +In the current use-case (classroom dashboards), polling beats +pub-sub. Students generate many events per second, and teachers need +updates perhaps every few hundred milliseconds at most. \ No newline at end of file diff --git a/docs/reference/code_quality.md b/docs/reference/code_quality.md new file mode 100644 index 000000000..b4488cd4e --- /dev/null +++ b/docs/reference/code_quality.md @@ -0,0 +1,120 @@ +Code Quality +=========== + +In general, we develop code in multiple passes. We try to build +proofs-of-concept and prototypes to figure out what we're doing. These +try to explore: + +- Product issues. E.g. mockups to show teachers in focus groups +- Understanding capabilities. E.g. can we build an NLP algorithm + to do something? +- Integrations. What information does Google Classroom give us? + +These help us understand what we're doing and mitigate risks. Once we +have a clear idea, we move code into the system, either rewriting from +scratch or reusing. Here, the goal is to get the overall architecture +right: + +- Put big pieces in the right modules +- Put correct interfaces between those modules + +Usually, in this stage, the goal is to get to a minimum working (not +necessarily viable) system to iterate from. This often involves a lot +of _scaffolding code_. Scaffolding code is intended to be thrown away. + +Once that's done, we make successive cleanups to make the code +readable, deployable, and production-ready. + +We generally don't do a lot of test-driven development. We usually add +tests towards the end of the process for two reasons: + +1. Tests can make code less agile, before we know what we're doing and +have the right interfaces. Big changes involve modifying tests. At early +stages, broken / non-working code is a lot less harmful. +2. TDD sometimes leads to code which mirror bugs in tests. Tests should +independently validate code. + +We do have a lot of simple tests (`doctest` or +`if __name__ == '__main__:' kinds of things) purely for +speed-of-development. As of this writing, we need many more system tests. + +Code Goals and Invariants +----------- + +As a research system, our goal is to have an **archival log of +everything that happened**. We'd like to be able to remove pieces of +that (e.g. to comply with GDPR requests), which is documented in more +detail in the Merkle tree docs. You'll see a lot of code which will, +for example, annotate with data from `git` so we know what version of +the system generated a particular piece of data. That's important. The +level of integration with tools like `git` is not a hack. We pick +technologies (like `jupyter notebooks`) which allow good logging as +well. + +We would like the system to be **simple to develop, deploy, and +maintain**. +* For the most part, when we add external technologies, we want to + include simple alternatives which don't require a lot of dev-ops. We + won't tie ourselves to SaaS services, and if we add a scalable data + store, we'll usually have a disk-based or memory-based alternative. +* Anything which needs to be done to set up the system should either + be done automatically at startup, or give clear instructions at + startup. + +We would like the system to be modular, and scalable to a broad set of +analytics modules. The *Writing Observer* is just one module plugged +into the system. We aim (but don't yet achieve) a level of simplicity +where undergrads can develop such modules, and they can work reliably +and scalably. Here, the customer is the developer. + +Proofs of concepts and prototypes +----------- + +**Proof-of-concepts and prototypes**. We have no particular +standards. The goal is to show a new UX, NLP algorithm, visualization, +integration, or what-not. However, this should be isolated from the +main codebase. We might have a `prototypes` directory, forks, +branches, or simply keep these on our device. We do like to have a +version history here, since it's often helpful to look back (things we +decide not to do sometimes turn out to be useful later). + +System code +----------- + +As we move code into the main system, standards go up a little bit. + +1. We expect code to comply with `pycodestyle`, ignoring the + restriction on line length. We do run `pylint` as well, but we + don't expect 100 percent compliance. Before making a PR, please + check your code with `make codestyle`. +2. We are starting to work hard to have a clean commit record. Each + commit and each PR should, to the extent possible, do one thing and + one thing only. + +We don't expect initial versions of code to be perfect, but we do +expect successive passes over code to iteratively improve code quality +(documentation, robustness, modularity, etc.). We may increase +standards for initial code quality as the system matures. Initial +low-quality code is best kept behind a feature flag. + +Scaffolding code +----------- + +Getting interface right and having a working system to develop in +often requires scaffolding code. Scaffolding code shouldn't be used in +production, but is often critical during development. For example, if +we need a high-performance queue, we might use a Python list in the +interrim. + +**The major problem we've had is with developers treating scaffolding +code as either a prototype or as final code. Again, scaffolding code +is intended to be thrown away.** + +Taking time to improve code quality on scaffolding is wasted time, +since it is going away. If you have time, please work to replace it. + +Documentation +----------- + +We need a lot more. This makes more sense to do once the system is +more mature. \ No newline at end of file diff --git a/docs/reference/documentation.md b/docs/reference/documentation.md new file mode 100644 index 000000000..52ce073de --- /dev/null +++ b/docs/reference/documentation.md @@ -0,0 +1,33 @@ +# Documentation + +## Build process + +Using [Sphinx](https://www.sphinx-doc.org/en/master/) we automatically build the documentation using markdown files and docstrings from the code. + +On pushes or pull requests to the main branch, the documentation is auto-built and available on [Readthedocs](https://readthedocs.org/). + +## Including documentation + +Since documentation is built from the code, every contribution should clearly state what component the documentation describes (for example a module, CLI tool, or feature page). After creating the content, make sure a reference to the source file lives in the appropriate subsection so that Sphinx can locate and render it. + +### Markdown file + +To document a new page that lives in a standalone markdown file, follow these steps: + +1. Place the markdown file under the appropriate section in `docs/`. For example, how-to guides live in `docs/how-to/` and reference material belongs in `docs/reference/`. Any images should be placed in `docs/_images`. +2. In the pull request description (and any related communication), specify which page the new file documents so reviewers understand the context. +3. Update the corresponding section index (`autodocs/how-to.rst`, `autodocs/reference.rst`, etc.) to include your new page in its `.. toctree::`. Each index file keeps its toctree organized by the section's subsections, so add a line that points to the new markdown file (for example, `docs/how-to/new_guide.md`). + +This ensures the page is discoverable from the rendered documentation and is built automatically by Sphinx. + +### Module + +To document a Python package or module, follow these steps: + +1. In your change description, call out the module the documentation targets so the reviewer can verify coverage. +2. Add or update the module's `README.md` under `modules//README.md`. During the Sphinx build, `autodocs/conf.py` copies each module README into `autodocs/module_readmes/`, and `autodocs/modules.rst` automatically includes every file in that directory via a globbed toctree. +3. Make sure the module's docstrings are accurate. The `autodoc2_packages` setting in `autodocs/conf.py` lists the packages whose code documentation is generated automatically, so keeping docstrings up to date ensures the API reference stays correct. + +Because the READMEs are gathered automatically, you only need to make sure the README exists alongside the module. The build will pick it up and render it in the Modules section. + +By following these steps, you can ensure that your new markdown files and modules are properly integrated into the documentation, which will then be automatically built and made available on Readthedocs. diff --git a/docs/reference/linting.md b/docs/reference/linting.md new file mode 100644 index 000000000..937aca358 --- /dev/null +++ b/docs/reference/linting.md @@ -0,0 +1,64 @@ +# Linting + +This documentation provides an overview of the linting configurations and tools used in the project. Linting is an essential step in the development process, helping to ensure code consistency and quality by detecting potential errors and enforcing stylistic conventions. + +## Linting the code + +### Python + +To lint any Python code, run: + +`pycodestyle --ignore=E501,W503 $(git ls-files 'learning_observer/*.py' 'modules/*.py')` + +### CSS and JS + +In the `package.json` file, we define several npm scripts to run the linting tasks: + +- `npm run lint:css`: Runs `stylelint` on all CSS and SCSS files. +- `npm run lint:js`: Runs `eslint` on all JavaScript files. +- `npm run lint`: Runs both `lint:css` and `lint:js`. +- `npm run find-unused-css`: Runs the custom script to find unused CSS. + +## GitHub Action + +We use a GitHub Action to handle the linting process for our codebase. This action is triggered on every push to the repository. It consists of two jobs: `lint-python` and `lint-node`. + +### Linting Python Code + +The `lint-python` job is responsible for linting Python code in the project. It uses `pycodestyle` to check for style issues in the Python code. The configuration for this job is as follows: + +- Runs on the latest version of Ubuntu with Python versions 3.9 and 3.10. +- Runs `pycodestyle` to analyze Python files, ignoring specific error codes + - E501: Line too long + - W503: line break before binary operator (not enforced by PEP8) + +### Linting CSS and JavaScript Code + +The `lint-node` job is responsible for linting CSS and JavaScript code in the project. It uses `stylelint` for CSS and `eslint` for JavaScript. The configuration for this job is as follows: + +- Runs on the latest version of Ubuntu with Node version 16.x. +- Finds unused CSS using a custom script (`list-unused-css.js`). +- Lints CSS and JavaScript code using the configured `stylelint` and `eslint`. + +These commands ignore the following globs: + +- `**/node_modules/**` +- `**/deps/**` +- `**/build/**` +- `**/3rd_party/**` +- `extension/` + +These paths are auto-generated or auto-downloaded by various steps in the build process. Additionally, we ignore `extension` until we update it to have a better build process. + +#### Stylelint Configuration + +We use `stylelint` to lint our CSS code, with the following configuration: + +- Ignores specific directories (listed above). +- Extends the `stylelint-config-standard` configuration. +- Uses the `postcss-scss` custom syntax for SCSS. +- Enables the `stylelint-scss` plugin to lint SCSS. + +#### ESLint Configuration + +We use `eslint` to lint our JavaScript code. This follows a standard configuration. diff --git a/docs/reference/system_settings.md b/docs/reference/system_settings.md new file mode 100644 index 000000000..354a27840 --- /dev/null +++ b/docs/reference/system_settings.md @@ -0,0 +1,171 @@ +# System Settings + +This page lists every field registered with PMSS for the **Learning Observer +base application**, grouped by the namespace you use in `creds.yaml`. Each +entry includes the YAML path, purpose, default (if any), and the code that +relies on it. Module-specific settings (such as the Writing Observer module) +should be documented alongside their module guides in the reference section. + +Refer back to the [system settings concept](../concepts/system_settings.md) +concept guide for details on how these values are loaded and consumed by the +runtime. + +## Global application keys + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `run_mode` | Selects `dev`, `deploy`, or `interactive` runtime profiles to control logging and startup behaviour. | required | [`learning_observer/learning_observer/settings.py`](../../learning_observer/learning_observer/settings.py), [`learning_observer/learning_observer/main.py`](../../learning_observer/learning_observer/main.py) | +| `hostname` | Public host name for OAuth callbacks and secure cookie scopes. Include the port when not using 80/443. | required | [`learning_observer/learning_observer/auth/social_sso.py`](../../learning_observer/learning_observer/auth/social_sso.py), [`learning_observer/learning_observer/webapp_helpers.py`](../../learning_observer/learning_observer/webapp_helpers.py) | +| `protocol` | Protocol (`http` or `https`) that governs cookie security and OAuth redirect URLs. | required | [`learning_observer/learning_observer/auth/social_sso.py`](../../learning_observer/learning_observer/auth/social_sso.py), [`learning_observer/learning_observer/webapp_helpers.py`](../../learning_observer/learning_observer/webapp_helpers.py) | +| `clone_module_git_repos` | Controls whether module git repositories are cloned automatically (`y`), skipped (`n`), or prompted (`prompt`). | `prompt` | [`learning_observer/learning_observer/module_loader.py`](../../learning_observer/learning_observer/module_loader.py) | +| `dangerously_allow_insecure_dags` | Enables uploading arbitrary dashboard DAG definitions for development experiments. Leave disabled in production. | `false` | [`learning_observer/learning_observer/dashboard.py`](../../learning_observer/learning_observer/dashboard.py) | +| `fetch_additional_info_from_teacher_on_login` | Starts a background job after Google login to fetch extra teacher documents immediately. | `false` | [`learning_observer/learning_observer/auth/social_sso.py`](../../learning_observer/learning_observer/auth/social_sso.py) | + +### `config` namespace + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `config.run_mode` | Mirror of the root `run_mode` so runtime helpers can resolve the value within the `config` namespace. | required | [`learning_observer/learning_observer/main.py`](../../learning_observer/learning_observer/main.py) | +| `config.debug` | List of diagnostic toggles (for example `"tracemalloc"`) that enable extra debugging helpers during development. | `[]` | [`learning_observer/learning_observer/routes.py`](../../learning_observer/learning_observer/routes.py) | + +### `server` namespace + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `server.port` | Port that the aiohttp web server binds to. Falling back to an open port in development remains TODO. | `8888` | [`learning_observer/learning_observer/main.py`](../../learning_observer/learning_observer/main.py) | + +### Session management (`aio` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `aio.session_secret` | Secret used to encrypt and sign aiohttp session cookies. Generate a unique value per deployment. | required | [`learning_observer/learning_observer/webapp_helpers.py`](../../learning_observer/learning_observer/webapp_helpers.py) | +| `aio.session_max_age` | Session lifetime in seconds. | required | [`learning_observer/learning_observer/webapp_helpers.py`](../../learning_observer/learning_observer/webapp_helpers.py) | + +### Dashboard Settings (`dashboard_settings` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `dashboard_settings.logging_enabled` | Determine if we should log dashboard sessions. | `false` | [`learning_observer/learning_observer/dashboard.py`](../../learning_observer/learning_observer/dashboard.py) | + +### LMS Integration (`lms_integration` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `lms_integration.logging_enabled` | Determine if we should log lms integration calls. | `false` | [`learning_observer/learning_observer/log_event.py`](../../learning_observer/learning_observer/log_event.py) | + +### Redis connection (`redis_connection` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `redis_connection.redis_host` | Hostname for Redis. | `localhost` | [`learning_observer/learning_observer/redis_connection.py`](../../learning_observer/learning_observer/redis_connection.py) | +| `redis_connection.redis_port` | Port for Redis. | `6379` | [`learning_observer/learning_observer/redis_connection.py`](../../learning_observer/learning_observer/redis_connection.py) | +| `redis_connection.redis_password` | Password for Redis (set to `null` when unused). | `null` | [`learning_observer/learning_observer/redis_connection.py`](../../learning_observer/learning_observer/redis_connection.py) | + +### Logging (`logging` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `logging.debug_log_level` | Chooses how verbose diagnostic logging should be (`NONE`, `SIMPLE`, or `EXTENDED`). | inherits environment default | [`learning_observer/learning_observer/log_event.py`](../../learning_observer/learning_observer/log_event.py) | +| `logging.debug_log_destinations` | Ordered list of destinations that should receive debug logs (`CONSOLE`, `FILE`). | `['CONSOLE', 'FILE']` in development | [`learning_observer/learning_observer/log_event.py`](../../learning_observer/learning_observer/log_event.py) | + +### Key-value stores (`kvs` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `kvs.default.type` | Backend used for the default key-value store (`stub`, `redis`, `redis_ephemeral`, or `filesystem`). | required | [`learning_observer/learning_observer/kvs.py`](../../learning_observer/learning_observer/kvs.py) | +| `kvs.default.expiry` | Expiration (seconds) required when `type` is `redis_ephemeral`. | required for `redis_ephemeral` | [`learning_observer/learning_observer/kvs.py`](../../learning_observer/learning_observer/kvs.py) | +| `kvs.default.path` | Filesystem directory for persisted values when `type` is `filesystem`; supports optional `subdirs`. | required for `filesystem` | [`learning_observer/learning_observer/kvs.py`](../../learning_observer/learning_observer/kvs.py) | +| `kvs..type` | Additional named KVS pools that modules can request by name. Same accepted values as `kvs.default.type`. | optional | [`learning_observer/learning_observer/kvs.py`](../../learning_observer/learning_observer/kvs.py) | +| `kvs..expiry` | Per-store expiration when the named pool uses the `redis_ephemeral` backend. | required for `redis_ephemeral` pools | [`learning_observer/learning_observer/kvs.py`](../../learning_observer/learning_observer/kvs.py) | +| `kvs..path` | Filesystem location (and optional `subdirs`) for the named store when using the `filesystem` backend. | required for `filesystem` pools | [`learning_observer/learning_observer/kvs.py`](../../learning_observer/learning_observer/kvs.py) | + +### Roster ingestion (`roster_data` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `roster_data.source` | Selects the roster provider: `filesystem`, `google`, `schoology`, `x-canvas`, `all`, or `test`. | required | [`learning_observer/learning_observer/rosters.py`](../../learning_observer/learning_observer/rosters.py) | + +### Core authentication flags (`auth`) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `auth.password_file` | Enables password authentication using the specified credentials file (generated with `lo_passwd.py`). | disabled | [`learning_observer/learning_observer/routes.py`](../../learning_observer/learning_observer/routes.py) | +| `auth.test_case_insecure` | When `true` or configured with overrides, short-circuits authentication for automated tests. | `false` | [`learning_observer/learning_observer/auth/handlers.py`](../../learning_observer/learning_observer/auth/handlers.py) | +| `auth.demo_insecure` | Demo mode that fabricates user sessions for walkthroughs; may be a boolean or a mapping with a fixed `name`. | `false` | [`learning_observer/learning_observer/auth/handlers.py`](../../learning_observer/learning_observer/auth/handlers.py) | + +#### Google OAuth (`auth.google_oauth.web`) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `auth.google_oauth.web.client_id` | Google OAuth client ID. | required | [`learning_observer/learning_observer/auth/social_sso.py`](../../learning_observer/learning_observer/auth/social_sso.py) | +| `auth.google_oauth.web.client_secret` | Google OAuth client secret. | required | [`learning_observer/learning_observer/auth/social_sso.py`](../../learning_observer/learning_observer/auth/social_sso.py) | + +#### LTI providers (`auth.lti.`) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `auth.lti..auth_uri` | LMS endpoint that initiates the OIDC login flow. | required | [`learning_observer/learning_observer/auth/lti_sso.py`](../../learning_observer/learning_observer/auth/lti_sso.py) | +| `auth.lti..jwks_uri` | JWKS endpoint used to validate LMS ID tokens. | required | [`learning_observer/learning_observer/auth/lti_sso.py`](../../learning_observer/learning_observer/auth/lti_sso.py) | +| `auth.lti..token_uri` | OAuth token endpoint for the LMS. | required | [`learning_observer/learning_observer/auth/lti_sso.py`](../../learning_observer/learning_observer/auth/lti_sso.py) | +| `auth.lti..redirect_uri` | Callback URL inside Learning Observer. | required | [`learning_observer/learning_observer/auth/lti_sso.py`](../../learning_observer/learning_observer/auth/lti_sso.py) | +| `auth.lti..private_key_path` | Location of the private key used to sign LTI messages. | required | [`learning_observer/learning_observer/auth/lti_sso.py`](../../learning_observer/learning_observer/auth/lti_sso.py) | +| `auth.lti..api_domain` | Base Canvas API domain for roster and assignment cleaners (Canvas-specific). | required | [`learning_observer/learning_observer/integrations/canvas.py`](../../learning_observer/learning_observer/integrations/canvas.py) | + +#### HTTP Basic authentication (`auth.http_basic`) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `auth.http_basic.password_file` | Credential store used when Learning Observer verifies HTTP basic logins directly. Set to `null` when nginx handles auth. | required when present | [`learning_observer/learning_observer/routes.py`](../../learning_observer/learning_observer/routes.py), [`learning_observer/learning_observer/auth/http_basic.py`](../../learning_observer/learning_observer/auth/http_basic.py) | +| `auth.http_basic.login_page_enabled` | Enables the built-in login endpoint that proxies browser-provided credentials into a session. | `false` | [`learning_observer/learning_observer/auth/http_basic.py`](../../learning_observer/learning_observer/auth/http_basic.py) | +| `auth.http_basic.full_site_auth` | Marks that nginx protects every route so middleware should expect auth headers on each request. | `false` | [`learning_observer/learning_observer/auth/http_basic.py`](../../learning_observer/learning_observer/auth/http_basic.py) | +| `auth.http_basic.delegate_nginx_auth` | Indicates that nginx performs the credential check; Learning Observer only trusts forwarded headers. | `false` | [`learning_observer/learning_observer/auth/http_basic.py`](../../learning_observer/learning_observer/auth/http_basic.py) | + +### Background services and feature flags + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `feature_flags.*` | Optional feature toggles (`uvloop`, `watchdog`, `canvas_routes`, etc.). `True` enables a flag and allows nested configuration. | varies | [`learning_observer/learning_observer/settings.py`](../../learning_observer/learning_observer/settings.py) | + +### Event stream authentication (`event_auth` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `event_auth.local_storage.userfile` | Path (under the data directory) listing device tokens that should be treated as authenticated. | required for `local_storage` | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | +| `event_auth.local_storage.allow_guest` | Permits unauthenticated fallbacks when the Chromebook or extension token is unknown. | `false` | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | +| `event_auth.http_basic` | Presence enables nginx-backed HTTP basic auth for event streams. No additional keys are required. | disabled | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | +| `event_auth.guest` | Enables guest sessions that mint random IDs for browsers without credentials. | disabled | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | +| `event_auth.hash_identify` | Enables hash-based identity hints (e.g., `/page#user=alice`) for one-off experiments. | disabled | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | +| `event_auth.testcase_auth` | Allows automated tests to tag events with deterministic user IDs. | disabled | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | + +### Incoming events blacklist (`incoming_events` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `incoming_events.blacklist_event_action` | Action to take for incoming events (`TRANSMIT`, `MAINTAIN`, or `DROP`) when blacklist rules match. | `TRANSMIT` | [`learning_observer/learning_observer/blacklist.py`](../../learning_observer/learning_observer/blacklist.py) | +| `incoming_events.blacklist_time_limit` | Time limit to return when `blacklist_event_action` is `MAINTAIN` (`PERMANENT`, `MINUTES`, or `DAYS`). | `MINUTES` | [`learning_observer/learning_observer/blacklist.py`](../../learning_observer/learning_observer/blacklist.py) | + +## Modules + +Modules can define their own PMSS namespaces under `modules.`. +Consult each module's reference guide for those settings - for example, the +Writing Observer module documents its configuration alongside the rest of its +module documentation. + +## Example snippet + +```yaml +run_mode: dev +hostname: localhost:8888 +protocol: http +config: + run_mode: dev +server: + port: 8888 +aio: + session_secret: "replace-me" + session_max_age: 3600 +redis_connection: + redis_host: localhost + redis_port: 6379 + redis_password: null +``` diff --git a/docs/reference/testing.md b/docs/reference/testing.md new file mode 100644 index 000000000..1c4d1e99b --- /dev/null +++ b/docs/reference/testing.md @@ -0,0 +1,90 @@ +# Testing + +Testing is an essential part of software development to ensure the reliability and stability of your code. In the Learning Observer project, we currently support Python testing, using the pytest framework. This document provides an overview of how the testing framework is used in the project. + +## Preparing a Module for Testing + +The following workflow is a **HACK** while we work toward better testing infrastructure. +Each module should define a `test.sh` at the root of the module's directory. This script should define how to run the tests for the module. + +### How Testing Ought to be + +We want to have a finite set of testing infrastructure. The types of tests we ought to support are + +- Client / dashboard tests + - Basic: we're not getting 500 errors on any pages (which can be semi-automatic from routes) + - Advanced: things work as expected. +- Python unit tests +- Server-side integration tests +- Data pipeline tests + - The very explicit models, like reducers, are designed to support this + - Also, query language, etc. + - This is related to replicability, which is important in science and in debugging in ways we discussed + +We should have an opinionated, recommended way to do things, included in the example template package. + +## Running Tests + +The Makefile offers a `test` command which calls an overall `test.sh` script. This script takes the passed in module paths and runs each of their respective `test.sh` scripts. To run via the Makefile, use + +```bash +make test PKG=path/to/module +# OR to test multiple packages at once +make test PKG="modules/writing_observer learning_observer" +``` + +## GitHub Actions for Automated Testing + +To automate testing in your project, you can use GitHub Actions. This allows you to run tests automatically when you push changes to your repository. The provided GitHub Action YAML file will iterate over the defined modules and + +1. Check for any changes within that module. If no changes are found, proceed to next module in list. +1. Install Learning Observer - `make install` +1. Install the module itself - `pip install path/to/module` +1. Run tests - `make test PKG=path/to/module` + +To add an additional module to this testing pipeline, add its path to the list of packages under the matrix strategy in `.github/workflows/test.yml`, like so: + +```yml + strategy: + matrix: + package: ['learning_observer/', 'modules/writing_observer/', 'path/to/new/module/'] +``` + +## Python Testing + +We use the [pytest framework](https://docs.pytest.org/) for Python testing. Pytest is a popular testing framework that simplifies writing and running test cases. It provides powerful features, such as fixtures, parametrized tests, and plugins, to help you test your code efficiently and effectively. + +### Setting Up the Testing Environment + +Before writing and running tests, it's essential to set up the testing environment correctly. Here is a step-by-step guide on how to set up the testing environment: + +1. Install Python dependencies required for the project. You can find these dependencies in the `requirements.txt` file in the project's root directory. +2. Install the Python packages for the modules you will be testing. In the Learning Observer project, this includes the `writing_observer`, `lo_dash_react_components`^, and `wo_highlight_dashboard` modules. + +^ This requires node v16 to installed as it needs to build the components during install + +### Writing Test Cases + +To write test cases for your Python code, follow these guidelines: + +1. Create a `tests` directory in the module you want to test, if it doesn't already exist. +2. For each file or functionality you want to test, create a separate test file with a name in the format `test_.py` or `test_.py`. +3. Inside each test file, write test functions that test specific aspects of your code. Start each test function's name with `test_` to ensure that pytest can discover and run the test. + +### Running PyTests + +Once you have written your test cases, you can run them using the pytest command: + +```bash +pytest +``` + +For example, to run tests for the `wo_highlight_dashboard` module, you would run: + +```bash +pytest modules/wo_highlight_dashboard/ +``` + +## Other testing + +Not yet implemented. diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md new file mode 100644 index 000000000..f0efb4d87 --- /dev/null +++ b/docs/reference/versioning.md @@ -0,0 +1,47 @@ +# Versioning + +## Tracking Versions + +Each module should include a `VERSION` file to specify the module's current version. This version is referenced in the module's setup configuration. Typically, this is done in the `setup.cfg` file as follows: + +```cfg +# setup.cfg +[metadata] +name = Module name +version = file:VERSION +``` + +## Version Format + +The version format is split into 2 pieces, the semantic version and the local version string separated by a `+`. The semantic version is primarily used for uploading to PyPI and resolving dependency conflicts. The local version string contains additional metadata about when the code was last modified and the commit it came from. The versions should follow the following format: + +```sh +major.minor.patch+%Y.%m.%dT%H.%M.%S.%3NZ.abc123.branch.name +``` + +A example version string might look like this: + +```sh +0.1.0+2024.12.16T16.42.38.637Z.abc123.branch.name +``` + +## Bumping Versions + +The local version strings that contain metadata are automatically managed using a Git pre-commit hook. Before each commit is made, the appropriate `VERSION` is updated to reflect the current time and commit. + +Bumping the semantic version is done manually. + +## Setting Up the Pre-Commit Hook + +To enable automatic version bumping, use the `install-pre-commit-hook` command provided in the `Makefile`. This command handles copying the pre-commit hook script into the appropriate location and making it executable. Run the following command: + +```bash +make install-pre-commit-hook +``` + +With this setup, every commit will automatically update the version for you. + +## Notes + +- Ensure that `setup.cfg` properly references the `VERSION` file to avoid version mismatches. +- Avoid manually editing the `VERSION` file; rely on the pre-commit hook for consistency. diff --git a/docs/tutorials/cookiecutter-module.md b/docs/tutorials/cookiecutter-module.md new file mode 100644 index 000000000..ad2639cf9 --- /dev/null +++ b/docs/tutorials/cookiecutter-module.md @@ -0,0 +1,102 @@ +# Tutorial: Build and Run a Module from the Cookiecutter Template + +This tutorial walks through generating a new Learning Observer module from the cookiecutter template, installing it, and seeing it run inside the system. We assume you already have the development environment installed and can start the stack with `make run`. + +## 1. Prepare your environment + +1. Activate the Python environment you use for Learning Observer development. +2. Make sure the `cookiecutter` command is available. If you have not installed it yet, run: + + ```bash + pip install cookiecutter + ``` + +3. From the repository root, change into the `modules/` directory: + + ```bash + cd modules/ + ``` + +## 2. Generate a module from the template + +1. Run cookiecutter against the Learning Observer template: + + ```bash + cookiecutter lo_template_module/ + ``` + +2. Fill in the prompts with information for your module. At minimum you will supply: + * **project_name** – Human-friendly title that appears in the UI. + * **project_short_description** – A one-line summary shown with the module. + * **reducer** – The name of the default reducer function the template creates. +3. Cookiecutter writes a new module directory inside `modules/`. If you entered `Revision Counter` as the name, the generated package would live in `modules/revision_counter/`. + +The template scaffolds all of the pieces Learning Observer expects, including a reducer, Dash layout, entry points, and packaging configuration so the module can be installed like any other Python package. You'll find these generated files within `modules//`, including `module.py`, `reducers.py`, the Dash dashboard, and the accompanying `setup.cfg` that defines how the package is exposed. + +## 3. Explore the generated code (optional but recommended) + +1. Inspect `module.py` to see the metadata exposed to Learning Observer, the default execution DAG, reducer list, and Dash page configuration. The file lives in `modules///module.py`. +2. Review `reducers.py` to understand how the template reducer counts events and where to extend it for your own analytics. You can find it next to `module.py` in the generated package directory. +3. Open `dash_dashboard.py` to learn how the generated layout publishes the reducer output on a Dash page. Use this as a starting point for your own visualizations. + + While you examine `module.py`, note that each reducer entry includes a + `context` string. That value must match the `source` identifier the + event producer sends on every message (for example the Google Docs + extension reports `org.mitros.writing_analytics`). The stream + analytics loader uses the pairing to dispatch events to the correct + reducers. If you change the `context` in your module, be sure to + update the emitting client so its `source` field matches—otherwise the + reducer will never receive the events you expect. + +## 4. Install the module in editable mode + +Installing the module registers its entry point so Learning Observer can discover it. From the repository root run: + +```bash +pip install -e modules// +``` + +Replace `` with the directory created in step 2 (for example, `modules/revision_counter/`). The template’s `setup.cfg` already declares the `lo_modules` entry point that exposes `module.py` to the system, so no additional registration is required. + +## 5. Start Learning Observer + +1. Return to a terminal at the repository root. +2. Launch the stack: + + ```bash + make run + ``` + +3. Wait for the services to come up, then open `http://localhost:8888/` in a browser. Your new module should appear on the home screen because the template registers it as a course dashboard card by default inside `module.py`. + + The generated dashboard expects URL hash parameters (for example + `#course_id=`) so the client-side websocket helper + knows which course to query. Navigating through the home page card + fills these values in automatically. If you copy or bookmark the + dashboard URL, make sure to keep the hash segment - without it the + websocket connection will never send a query and the page will stay + blank. + +## 6. Stream sample data to exercise the module + +To see live data, send synthetic writing events using the helper script. + +1. Open a second terminal with your environment activated. +2. Run the streaming script from the repository root: + + ```bash + python scripts/stream_writing.py --streams=5 + ``` + + This sends five concurrent simulated students worth of Google Docs events to the default local endpoint using the helper found at `scripts/stream_writing.py`. +3. Refresh your browser. The default reducer counts incoming events, so you should see the totals increase on the Dash page included with the template. Both the reducer and the dashboard live alongside `module.py` in your generated package. + +A common cause for missing data is having 0 reducer output available in storage. Check your [Key Value Store](../concepts/key_value_store.md) settings to assess how the reducer output is being stored. + +## 7. Next steps + +* Customize the reducer in `reducers.py` to compute the metrics your dashboard requires. See the [reducers concept overview](../concepts/reducers.md) for guidance. +* Expand the Dash layout to visualize your new metrics. The [dashboard how-to](../how-to/dashboards.md) walks through available UI patterns. +* Add additional reducers, exports, or pages to `module.py` as your module grows. Refer back to the [communication protocol concepts](../concepts/communication_protocol.md) when defining new DAG exports. + +With these steps you have a working, template-based module running end-to-end inside Learning Observer. From here you can iterate on analytics and UI changes quickly by editing the generated files and reloading the server. diff --git a/docs/tutorials/install.md b/docs/tutorials/install.md new file mode 100644 index 000000000..412765c04 --- /dev/null +++ b/docs/tutorials/install.md @@ -0,0 +1,125 @@ +# Tutorial: Install + +Use this tutorial to get the Learning Observer running on your machine. It walks you through the exact commands to run and the order to run them in, so you can follow along step by step. + +As you set things up, keep in mind that Learning Observer functions as an application platform: the core `learning_observer` package starts the services and loads modules, and each module contributes dashboards, reducers, or other features that run on top of that core. + +## Before you begin + +Make sure your computer meets these requirements: + +1. **Operating system** – A Unix-style system works best. We regularly test on Ubuntu. macOS generally works as well. Windows users should install the project inside [Windows Subsystem for Linux (WSL)](../workshop/wsl-install.md) before continuing. +2. **Python** – Install Python 3.10 or 3.11 (any version newer than 3.9 is expected to work). +3. **Package manager (recommended)** – Have a virtual environment tool ready. We prefer [`virtualenvwrapper`](https://pypi.org/project/virtualenvwrapper/), but you can use `python -m venv`, Conda, or another tool you like. +4. **Optional tools** – + - [Valkey](https://valkey.io/) or Redis as a key–value store if you plan to run production-style deployments (this tutorial uses on-disk storage instead). + - Docker 26.1 if you want to experiment with the container-based workflow. The steps below focus on the native installation. + +Have two terminal windows or tabs open. We will run the server in one and use the other for helper scripts. + +## Step 1 – Download the project + +1. Open a terminal and clone the repository: + + ```bash + git clone https://github.com/ETS-Next-Gen/writing_observer.git lo_tutorial + ``` + + If you have an SSH key configured with GitHub, you can use: + + ```bash + git clone git@github.com:ETS-Next-Gen/writing_observer.git lo_tutorial + ``` + +2. Change into the new directory: + + ```bash + cd lo_tutorial/ + ``` + + All commands that follow run from this repository root unless the tutorial notes otherwise. + +## Step 2 – Create and activate a Python environment + +Choose one of the options below to create an isolated environment for the tutorial. + +*With `virtualenvwrapper`:* + +```bash +mkvirtualenv lo_env +workon lo_env +``` + +*With `venv` (built into Python):* + +```bash +python -m venv lo_env +source lo_env/bin/activate +``` + +Verify that the shell prompt shows the environment name before continuing. + +```bash +(lo_env) user@pc:~/lo_tutorial$ +``` + +## Step 3 – Install Python dependencies + +1. Make sure `pip` itself is up to date (optional but helpful): + + ```bash + pip install --upgrade pip + ``` + +2. Install all project requirements: + + ```bash + make install + ``` + + This command downloads Python packages and builds local assets. Depending on your network speed, it can take a few minutes. + +## Step 4 – Apply the workshop configuration + +The project ships with an example configuration tailored for workshops. Copy it into place so the application starts with sensible defaults such as file-based storage and relaxed authentication. + +```bash +cp learning_observer/learning_observer/creds.yaml.workshop learning_observer/creds.yaml +``` + +If you are curious about the changes, compare it with the example file: + +```bash +diff -u learning_observer/learning_observer/creds.yaml.example learning_observer/creds.yaml +``` + +## Step 5 – Start the Learning Observer + +1. Launch the development server: + + ```bash + make run + ``` + +2. The first run performs several setup tasks (such as generating role files), so it may exit after downloading dependencies. When the command finishes, run it again: + + ```bash + make run + ``` + + Repeat until the command reports that the system is ready and stays running. + +## Step 6 – Verify everything worked + +Once the server is running, open a browser and go to one of the following URLs: + +- `http://localhost:8888/` +- `http://0.0.0.0:8888/` +- `http://127.0.0.1:8888/` + +You should see the Learning Observer dashboard with a list of courses and analytics modules. Because the workshop configuration disables authentication, you can immediately click around and start experimenting. + +## Next steps + +- Explore the [cookiecutter module tutorial](cookiecutter-module.md) to scaffold a package from the template. +- Review [system settings concepts](../concepts/system_settings.md) before deploying beyond a simple setup. diff --git a/docs/tutorials/workshop.md b/docs/tutorials/workshop.md new file mode 100644 index 000000000..b10d595f7 --- /dev/null +++ b/docs/tutorials/workshop.md @@ -0,0 +1,412 @@ +# Learning Observer Workshop + +This document will step you through the Learning Observer workshop. Our goals for this workshop are: + +* Give an overview of the platform +* Collect feedback on how to make the platform useful for your own work +* Collect feedback on different major components of the platform +* Have fun hacking learning analytics together + +We recommend working in groups of three. This way: + +* You can help each other +* At least one person will (hopefully) have a working machine + +We suggest having at least **2 terminals** ready for this workshop. The first terminal will be for installing and running the system, while the second will be any additional scripts to need to run. + +Prerequisites: + +* Unix-style system + * Ubuntu is most tested + * MacOS should work as well, but is less tested + * Windows should work with WSL, but you'll need to [install it beforehand](workshop/wsl-install.md). +* `python 3`. We tested and recommend 3.10 and 3.11, but anything newer than 3.9 should work + +Recommendations: + +* `virtualenvwrapper`. If you prefer a different package management system, you can use that instead. + +Options: + +* `redis`. We need a key-value store. However, if you don't have this, we can use files on the file system or in-memory. If you use `docker compose`, it will spin this up for you. Beyond this workshop, we strongly recommend using a `redis` (the recommended `redis` going forward is [ValKey](https://en.wikipedia.org/wiki/Valkey), as opposed to redis proprietary) +* `docker`. We're not big fans of `docker` for this type of work, so this pathway is less tested. However, by popularity, we do provide a `docker` option. We tested with docker 26.1. You should only use this if you're fluent in `docker`, since you'll probably need to tweak instructions slightly (especially if you're not on 26.1). + +If you'd like to use `docker`, we have a quick [tutorial](docker.md). + +If you can install the prerequisites before the workshop, it will save a lot of time, and not put us at risk of issues due to hotel bandwidth. + +We have a document with a more in-depth overview of the [technologies](technologies.md) we use. + +### Python environment + +We recommend working in a Python environment of some sort. Our preferred tool is [virtualenvwrapper](https://pypi.org/project/virtualenvwrapper/). You are welcome to use your own (`anaconda`, or as you prefer). `virtualenvwrapper` lets you manage packages and dependencies without making a mess on your computer. + +If you don't have a way of managing Python virtual environments, or would prefer to use `virtualenvwrapper`, we have a [short guide](workshop/workshop-virtualenv.md). *We strongly recommend working in some virtual environment, however*. + +## Download + +First make sure you clone the repository: + +```bash +git clone https://github.com/ETS-Next-Gen/writing_observer.git lo_workshop +``` + +**or**, if you have a github account properly configured with ssh: + +```bash +git clone git@github.com:ETS-Next-Gen/writing_observer.git lo_workshop +``` + +```bash +cd lo_workshop/ +``` + +NOTE: All future commands should be ran starting from the repository's root directory. The command will specify if changing directories is needed. + +## Local environment + +Make sure you are on a fresh virtual environment. In `virtualenvwrapper`: + +You can either run this + +```bash +mkvirtualenv lo_workshop +workon lo_workshop +``` + +or run this to set up the virtual environment + +```bash +python -m venv lo_workshop +source lo_workshop/bin/activate +``` + +Then run the install command: + +```bash +pip install --upgrade pip # Probably not needed, but good form +make install +``` + +This will download required backpages. This might take a while, depending on hotel bandwidth. + +## Configuration + +Before starting the system, let's take care of any extra configuration steps. We are currently in the process of moving configuration formats from YAML to [PMSS](https://github.com/ETS-Next-Gen/pmss). + +We may discuss this in the workshop later, but for now, we will configure using YAML. + +We need a system configuration for this workshop. You can copy over this file with the command below, or you can make the changes yourself as per [these instruction](/docs/workshop_creds.md). In essence, the changes are: + +1. Disable teacher authentication. We have pluggable authentication schemes, and we disable Google oauth and other schemes. +2. Disable learning event authentication. Ditto, but for incoming data. +3. Give a key for session management. This should be unique for security +4. Switch from redis to on-disk storage. We have pluggable databases. On-disk storage means you don't need to install redis. + +Making these yourself is a good exercise. Note we are switching configuration formats, but the options will stay the same. + +Copy the workshop `creds.yaml` file: + +```bash +cp learning_observer/learning_observer/creds.yaml.workshop learning_observer/creds.yaml +``` + +If you have a file comparison tool like `meld`, it might be worth comparing our changes: `meld learning_observer/creds.yaml learning_observer/learning_observer/creds.yaml.example` + +## Test the system + +To run the system, use the run command + +```bash +make run +``` + +*This does a lot of sanity checks on startup, and won't work the first time.* Rather, it will download required files, and create a file files (like `admins.yaml` and `teachers.yaml`, which are one way to define roles for teachers and admins on the system, but which we won't need for this workshop since we are using an insecure login). Once it is done, it will give you an opportunity to check whether it fixed issues correctly (we're working on having nice warnings, but we're not 100% of the way there). It did, so just run it again (perhaps 1-3 more times if it has more things to configure): + +```bash +make run +``` + +You should be able to navigate to either `http://localhost:8888/`, `http://0.0.0.0:8888/`, or `http://127.0.0.1:8888/`, depending on your operating system, and see a list of courses and analytics modules. None are installed. We'll build one next! + +## Build your own module + +### Create from template + +We provide a cookiecutter template for creating new modules for the Learning Observer. If you are using Docker, just create a local virtual environment to run this command. To create one run, + +```bash +cd modules/ +cookiecutter lo_template_module/ +``` + +Cookiecutter will prompt you for naming information and create a new module in the `modules/` directory. By default, this is called `learning_observer_template`, but pick your own name and substitute it into the commands below. + +### Installing + +To install the newly created project, use `pip` like any other Python package. + +```bash +pip install -e [name of your module] +``` + +Reload your web page, and you will see the new module. Click on it. + +## Streaming Data + +We can stream data into the system to simulate a classroom of students working. Once the system is up and running, open **a new terminal** and run + +```bash +workon lo_workshop +python scripts/stream_writing.py --streams=10 +``` + +To avoid cache issues, we recommend this order: + +* Restart your server +* Run the above command +* Load the dashboard + +This will generate events for 10 students typing a set of loremipsum texts and send them to the server. This will send event mimicking those from our Google Docs extension. You should see an event count in the template dashboard. + +## Event Format + +You can look at the format of these specific events in the `/learning_observer/learning_observer/logs/` directory. In the test system, we simply put events into log files, but we are gradually moving towards a more sophisticated, open-science, family-rights oriented data store (shown at the bottom of [this document](system_design.md). This is theoretically interesting, since it gives a cryptographically-verifiable way to audit what data was created and what analyses ran. + +There are several good standards for [event formats](events.md), and to integrate learning data, we will need to support them all. Most of these have converged on a line of JSON per event, but the specifics are format-specific. We are including [some code](https://github.com/ETS-Next-Gen/writing_observer/blob/master/modules/lo_event/lo_event/xapi.cjs) to help support the major ones. However, the events here are somewhat bound to how Google Docs thinks about documents. + +## module.py + +Have a quick look at the `module.py` file. This defines: + +1. A set of reducers to run over event streams. These process data as it comes in. The context tells the system which types of events the reducers handle. +2. A set of queries for that data. These define calls dashboards can make into the system +3. A set of dashboards. `DASH_PAGES` are visible pages, and `COURSE_DASHBOARDS` will typically select a subset of those to show to teachers when they log in. + +## reducers.py + +Have a look at the `reducers.py` file. We define a simple reducer which simply counts events: + +```python +@student_event_reducer(null_state={"count": 0}) +async def student_event_counter(event, internal_state): + ''' + An example of a per-student event counter + ''' + state = {"count": internal_state.get('count', 0) + 1} + + return state, state +``` + +This function takes an event and updates a state. We will expand this in order to measure the median interval between the past 10 edits. This can be a poorman's estimate of typing speed. The function returns two parameters, one is an internal state (which might be a list of the timestamps of the past 10 events), and one is used for the dashboard (which might be the median value). We're planning to eliminate this in the future, though, and just have one state, so that's what we'll do here: + +```python +import numpy + +from learning_observer.stream_analytics.helpers import student_event_reducer + +def median_interval(timestamps): + if len(timestamps) < 2: + return None + + deltas = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)] + deltas.sort() + return int(numpy.median(deltas)) + + +@student_event_reducer(null_state={"count": 0}) +async def student_event_counter(event, internal_state): + ''' + An example of a per-student event counter + ''' + timestamp = event['client'].get('timestamp', None) + count = internal_state.get('count', 0) + 1 + + if timestamp is not None: + ts = internal_state.get('timestamps', []) + ts = ts + [timestamp] + if len(ts) > 10: + ts = ts[1:] + else: + ts = internal_state.get('timestamps', []) + + state = { + "count": count, + "timestamps": ts, # We used to put this in internal_state + "median_interval": median_interval(ts) # And this in external_state + } + + return state, state +``` + +Now, we have a typing speed estimator! It does not yet show up in the dashboard. + +## Queries and communications protocol + +In our first version of this system, we would simply compile the state for all the students, and ship that to the dashboard. However, that didn't allow us to make interactive dashboards, so we created a query language. This is inspired by SQL (with JOIN and friends), but designed for streaming data. It can be written in Python or, soon, JavaScript, which compile queries to an XML object. + +In `module.py`, you see this line: + +```python +EXECUTION_DAG = learning_observer.communication_protocol.util.generate_base_dag_for_student_reducer('student_event_counter', 'my_event_module') +``` + +This is shorthand for a common query which JOINs the class roster with the output of the reducers. The Python code for the query itself is [here](https://github.com/ETS-Next-Gen/writing_observer/blob/berickson/workshop/learning_observer/learning_observer/communication_protocol/util.py#L58), but the jist of the code is: + +```python +'roster': course_roster(runtime=q.parameter('runtime'), course_id=q.parameter("course_id", required=True)), +keys_node: q.keys(f'{module}.{reducer}', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), +select_node: q.select(q.variable(keys_node), fields=q.SelectFields.All), +join_node: q.join(LEFT=q.variable(select_node), RIGHT=q.variable('roster'), LEFT_ON='provenance.provenance.value.user_id', RIGHT_ON='user_id') +``` + +You can add a `print(EXECUTION_DAG)` statement to see the JSON representation this compiles to. + +To see the data protocol, open up develop tools from your browser, click on network, and see the `communication_protocol` response. + +In the interests of time, we won't do a deep dive here, but this is our third iteration at a query language, and we would love feedback on how to make this better. + +## Dashboard framework + +For creating simple dashboards, we use [dash](https://dash.plotly.com/) and [plotly](https://plotly.com/python/). + +* These are rather simple Python frameworks for making plots and dashboards. +* Unfortunately, the code in the template module is still a bit complex. We're working to simplify it, but we're not there yet. + +We'd suggest skimming a few example [visualizations](https://plotly.com/python/pie-charts/) to get a sense of what they do. + +For now, though, all we want to do is add the intercharacter interval to our dashboard. Modify `dash_dashboard.py` to add a span for it: + +```python + html.Span(f' - {s["count"]} events'), + html.Span(f' - {s.get("median_interval", 0)} ICI') +``` + +You should be able to see the intercharacter interval in a new span. + +## Commit your changes + +To avoid losing work, we recommend committing your changes now (and periodically there-after): + +```bash +git add [directory of your module] +git commit -a -m "My changes" +``` + +## `react` dashboards + +Behind the scenes, `dash` uses `react`, and if we want to go beyond what we can do with `plotly` and `dash`, fortunately, it's easy enough to build components directly in `react`. To see how these are build: + +```bash +cd modules/lo_dash_react_components/ +ls src/lib/components +``` + +And have a look at `LONameTag`. This component is used to show a student name with either a photo (if available in their profile) or initials (if not), and is used in the simple template dashboard. We have a broad array of components here, including: + +- Various ways of visualizing what students know and can do. My favorite is a Vygotskian-style display which places concepts as either mastered, in the zone of proximal development (students can understand with supports), and ones students can't do at all +- Various tables and cards of student data +- Various ways of visualizing course content + +We have many more not committed. + +Getting this up-and-running can be a little bit bandwidth-intensive (since these are developed with `node.js`), but if hotel bandwidth suffices, in most cases, it is sufficent to run: + +```bash +npm install +npm run build-css +npm run-script react-start +``` + +And then navigate to `http://localhost:3000`. *NOTE: The default URL is different. Ignore it.* If there is a lint error, ignore it as well. + +Once set up, the development workflow here is rather fast, since the UX updates on code changes. Most of these are either early prototypes or designed to be used in specific contexts, but `LOStudentTable` and `ZPDPlot` look nice. So does `DAProblemDisplay`, if you scroll way down. + +## Better data sources: `lo_event` and `lo_assess` + +### `lo_event` + +Our data streaming library is [lo_event](https://github.com/ETS-Next-Gen/writing_observer/tree/master/modules/lo_event). This library is designed to stream events (typically) from a JavaScript client, and handles all of the complexity of things like persistance, queuing, and retries for you. Cookiecutter cde, but it should save you a bunch of time. + +### `lo_assess` + +Much more interesting, in development (and probably in need of renaming) is [`lo_assess`](https://github.com/ETS-Next-Gen/writing_observer/tree/pmitros/loevent-v2/modules/lo_event/lo_event/lo_assess). + +There is an XML format (based on edX OLX, which is in turn based on LON-CAPA XML) for creating interactives. + +The very neat thing about this tool is that we *guarantee* that the state of the system at any point in time can be reconstructed from process data. The UX is controlled through React events, which are funneled into `lo_event`. You can see this using the time travel function of [Redux dev tools](https://github.com/reduxjs/redux-devtools). We've developed a handful of interactives in this format, including a GPT-powered graphic organizer, a Vygotskian-style dynamic assessment for middle school mathematics, but for this workshop, we have a little demo of a tool which can change text styles using ChatGPT for different audiences. + +To see the format, see the XML inside of `modules/toy-assess/src/app/changer/page.js`. Right now, this is inside of a .jsx file, but it will be stand-alone XML in the near future. + +Running this is a little bit involved, as you may need to configure Azure ChatGPT credentials (Azure provides better privacy compliance frameworks than using OpenAI directly): + +``` +export OPENAI_URL="https://[your-location].api.cognitive.microsoft.com" +export AZURE_OPENAI_ENDPOINT="https://[your-location].api.cognitive.microsoft.com" + +export OPENAI_DEPLOYMENT_ID="[your-azure-deployment-id]" +export AZURE_OPENAI_DEPLOYMENT_ID="[your-azure-deployment-id]" + +export OPENAI_API_KEY=`cat [your-azure-openai-key]` +export AZURE_OPENAI_API_KEY=`cat [your-azure-openai-key]` +``` +(We don't require both, but it's handy if you switch libraries) + +As an alternative, in `modules/toy-assess/src/app/lib/route.js` you can change the line + +``` +const listChatCompletions = openai.listChatCompletions; +``` + +To: + +``` +const listChatCompletions = stub.listChatCompletions; +``` + +Which will disable ChatGPT (and always give the same response). + +At this point, you can run: +``` +cd [base_dir]/modules/lo_event/ +npm pack +cd [basedir]/modules/toy-assess/ +# rm -Rf .next/cache/ # If necessary +npm install +npm install ../lo_event-0.0.1.tgz +npm run dev +``` + +And the server should be running on `localhost:3000`. + +## `pmss` + +We are creating a new settings format, based on css. This is called `pmss`. It works pretty well already. The basic idea is, like CSS, that we would like to be able to cascade settings. The core problem is that, like CSS, we want well-specified exceptions: + +* "Our key-value store is local redis, except for schools in Australia, where our key-value store is hosted in Australia, to comply with local law" +* "We would like student rosters at JHS to come from Google Classroom, except for afterschool programs, which come from files on disk" + +CSS gives a well-understood syntax for expressing these sorts of configurations, and will hopefully help us avoid the mess of special cases which evolve in most learning systems. + +Note that this is a stand-alone library, and can be used in your own system too. That said, as with all code, it is still evolving, and we do not guarantee backwards-compatibility. + +## Jupyter + +We will not demo this, due to time constraints, but it is possible to run a Jupyter instance with access to our data store (see `ipython_integration.py`, `offline.py`, and `interactive_development.py`). We have means to monitor the communication between the python kernel and ipython/jupyter notebook. This should allow us to track all analyses which ran, either for family rights audits (how was my data used?) or open science audits (was there p-hunting?). + +This is in the prototype stage; we are not yet using this for data analysis. + +## Dev-ops + +If you browse the devops directory, which has scripts in progress for +spinning up a cloud instance and managing flocks of _Learning +Observer_ instances. + +## gitserve + +The system can serve static content directly from a git repo. This allows us: + +* Have a git hash for which version of static data we're using (and we do include this in cookies / logs!) +* Have branches (e.g. for AB tests or different uses) + +Some of this can be configured as part of the creds.yaml file. diff --git a/docs/tutorials/workshop_creds.md b/docs/tutorials/workshop_creds.md new file mode 100644 index 000000000..7bfad2f2a --- /dev/null +++ b/docs/tutorials/workshop_creds.md @@ -0,0 +1,74 @@ +### creds.yaml + +The `creds.yaml` is the primary configuration file on the system. The platform will not launch unless this file is present. Create a copy of the example in `learning_observer/learning_observer/creds.yaml.workshop`. We will copy this over, and then set up the pieces needed for the system to work. + +You're welcome to run the `learning observer` between changes. In most cases, it will tell you exactly what needs to be fixed. + +```bash +cp learning_observer/learning_observer/creds.yaml.workshop learning_observer/creds.yaml +``` + +#### User Authentication + +As a research platform, the Learning Observer supports many authentication schemes, since it's designed for anything from small cognitive labs and user studies (with no log-in) to large-scale school deployments (e.g. integrating with Google Classroom). This is pluggable. + +For this workshop, we will disable Google authentication, and set the system up so we can use it with with no authentication: + +```yaml +auth: + # remove google_oauth from auth + # google_oauth: ... + + # enable passwordless insecure log-ins + # useful for quickly seeing the system up and running + test_case_insecure: true +``` + +#### Event authentication + +Learning event authentication is seperate from user authentication. We also have multiple schemes for this, but for testing and development, we will run without authentication. + +```yaml +# Allow all incoming events +event_auth: + # ... + testcase_auth: {} +``` + +#### Session management + +Session management requires a unique key for the system. Type in anything (just make it complex enough): + +```yaml +# update session information +aio: + session_secret: asupersecretsessionkeychosenbyyou + session_max_age: 3600 +``` + +Pro tip: If you start the system missing a command like this, it will usually tell you what's wrong and how to fix it (in the above case, generating a secure GUID to use as your session secret). + +#### KVS + +```yaml +# If you are using Docker compose, you should change the redis host to +redis_connection: + redis_host: redis + redis_port: 6379 +``` + +### admins.yaml & teachers.yaml + +The platform expects both of these files to exist under `learning_observer/learning_observer/static_data/`. If these are missing on start-up, the platform create them for you and exit. Normally these are populated with the allowed Admins/Teachers for the system. + +### passwd.lo + +Each install of the system needs an admin password file associated with it. The `scripts/lo_passwd.py` file can be used to generate this password file. This does not have to be done in the same virtual environment as the main server. If you are using Docker, just create a local virtual environment to run this command. + +```bash +python scripts/lo_passwd.py --username admin --password supersecureadminpassword --filename learning_observer/passwd.lo +``` + +Note that Learning Observer expects the file to be placed in the `learning_observer/` directory, similar to `creds.yaml`. + +Depending on how the `creds.yaml` authorization settings are configured, you may be required to use the password you create. diff --git a/docs/workshop/README.md b/docs/workshop/README.md new file mode 100644 index 000000000..947ed854b --- /dev/null +++ b/docs/workshop/README.md @@ -0,0 +1 @@ +These files are instructions, not specific to Learning Observer, which were used when running workshops. It is not clear they belong in this repo long-term, but this seems an okay place for them for now. \ No newline at end of file diff --git a/docs/workshop/workshop-virtualenv.md b/docs/workshop/workshop-virtualenv.md new file mode 100644 index 000000000..b45398865 --- /dev/null +++ b/docs/workshop/workshop-virtualenv.md @@ -0,0 +1,28 @@ +Setting up virtualenvwrapper +============================ + +[virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/en/latest/) makes it quick and easy to manage Python virtual environments. + +1) Install `virtualenvwrapper`. This can be `apt-get install python3-virtualenvwrapper` on Ubuntu, or `pip install virtualenvwrapper` on most other systems. + +2) Run it using `source` or `.` (so environment changes stay for the current shell). For one installed with `apt-get`, it will most likely be in `/usr/share/virtualenvwrapper/`. For `pip`, it is often in `~/.local/bin` (and you might need to run `export PATH=~/.local/bin:$PATH`). From `brew` on Mac, it is likely somwhere under `/opt/homebrew/` + +We normally add these three lines to our `.bashrc` so it runs on startup, but you can run these manually: + +```bash +# Optionally, pick the place you want your virtual environments +export WORKON_HOME=$HOME/.virtualenvs +# Optionally, pick the Python you want to use. +which python3 +VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3 +# Activate virtualenv wrapper +. /usr/share/virtualenvwrapper/virtualenvwrapper.sh # Wherever you installed the script. Note the dot at the beginning! It's important. +``` + +You now have three commands: + +* `mkvirtualenv lo_workshop` makes a virtual environment named `lo_workshop`. +* `workon lo_workshop` switches you to this environment +* `rmvirtualenv lo_workshop` destroys it + +There are other commands too, but those are the essentials. diff --git a/docs/workshop/wsl-install.md b/docs/workshop/wsl-install.md new file mode 100644 index 000000000..0a8d6142e --- /dev/null +++ b/docs/workshop/wsl-install.md @@ -0,0 +1,11 @@ +Windows Subsystem for Linux Install +=================================== + +Microsoft has instructions for installing [WSL](https://learn.microsoft.com/en-us/windows/wsl/install), but on most systems, this simply involves running `wsl --install` from *PowerShell* (not `cmd`). + +Once installed, run: + +```bash +sudo apt-get update +sudo apt-get install python3-pip python3-virtualenvwrapper git +``` \ No newline at end of file diff --git a/extension/lousier-icon-128.png b/extension/lousier-icon-128.png new file mode 100644 index 000000000..c1cf0bd31 Binary files /dev/null and b/extension/lousier-icon-128.png differ diff --git a/extension/lousy-fountain-pen-48.xcf b/extension/lousy-fountain-pen-48.xcf new file mode 100644 index 000000000..70b79daf8 Binary files /dev/null and b/extension/lousy-fountain-pen-48.xcf differ diff --git a/extension/writing-process/.eslintrc.json b/extension/writing-process/.eslintrc.json new file mode 100644 index 000000000..923c02511 --- /dev/null +++ b/extension/writing-process/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended" + ], + "globals": { + "document": false, + "escape": false, + "navigator": false, + "unescape": false, + "window": false, + "describe": true, + "before": true, + "it": true, + "expect": true, + "sinon": true, + "chrome": true + }, + "plugins": [], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "rules": { + } +} diff --git a/extension/writing-process/.gitignore b/extension/writing-process/.gitignore new file mode 100644 index 000000000..5905017c8 --- /dev/null +++ b/extension/writing-process/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +**/*.bundle.js* +public/ +release.zip diff --git a/extension/writing-process/README.md b/extension/writing-process/README.md new file mode 100644 index 000000000..2441a39d3 --- /dev/null +++ b/extension/writing-process/README.md @@ -0,0 +1,52 @@ +# writing-process + +Tracks writing in Google Docs, and provides nifty insights to you and your teachers! + +## Development + +This extension directory structure was created with [Extension CLI](https://oss.mobilefirst.me/extension-cli/). +The original extension, which was just a handful of javascript files, were then copied into the structure. + +### To Begin + +To get started, run the following: + +```bash +cd extention/writing-process +npm install +``` + +### Available Commands + +We created these commands: + +| Commands | Description | +| --- | --- | +| `npm run bundle` | runs webpack to bundle dependencies into code | +| `npm run build` | cleans dist folder, bundles code, then runs ext:build | + +In addition, for reference, we kept the following commands from `extension-cli`: + +| Commands | Description | +| --- | --- | +| `npm run ext:start` | builds extension into `dist/`, watches for file changes | +| `npm run ext:build` | generate release version - `release.zip` | +| `npm run ext:docs` | generate source code docs into `public/documentation` | +| `npm run ext:clean` | removes `dist/` directory | +| `npm run ext:test` | run unit tests | +| `npm run ext:sync` | update projects config files for `extension-cli` | + +For CLI instructions see [User Guide →](https://oss.mobilefirst.me/extension-cli/) + +### Learn More + +#### Extension Developer guides + +- [Getting started with extension development](https://developer.chrome.com/extensions/getstarted) +- Manifest configuration: [version 3](https://developer.chrome.com/docs/extensions/mv3/intro/) +- [Permissions reference](https://developer.chrome.com/extensions/declare_permissions) +- [Chrome API reference](https://developer.chrome.com/docs/extensions/reference/) + +#### Extension Publishing Guides + +- [Publishing for Chrome](https://developer.chrome.com/webstore/publish) diff --git a/extension/writing-process/assets/locales/en/messages.json b/extension/writing-process/assets/locales/en/messages.json new file mode 100644 index 000000000..049759195 --- /dev/null +++ b/extension/writing-process/assets/locales/en/messages.json @@ -0,0 +1,11 @@ +{ + "appName": { + "message": "writing-process" + }, + "appShortName": { + "message": "writing-process" + }, + "appDescription": { + "message": "Tracks writing in Google Docs, and provides nifty insights to you and your teachers!" + } +} \ No newline at end of file diff --git a/extension/writing-process/assets/lousy-fountain-pen-48.png b/extension/writing-process/assets/lousy-fountain-pen-48.png new file mode 100644 index 000000000..223edca88 Binary files /dev/null and b/extension/writing-process/assets/lousy-fountain-pen-48.png differ diff --git a/extension/writing-process/package.json b/extension/writing-process/package.json new file mode 100644 index 000000000..b789a11c1 --- /dev/null +++ b/extension/writing-process/package.json @@ -0,0 +1,87 @@ +{ + "name": "writing-process", + "description": "Tracks writing in Google Docs, and provides nifty insights to you and your teachers!", + "version": "1.0.0.2", + "homepage": "http://chrome.google.com/webstore", + "author": "Piotr Mitros, Bradley Erickson", + "repository": { + "type": "git", + "url": "https://github.com/ETS-Next-Gen/writing_observer/tree/master/extension/writing-process" + }, + "scripts": { + "ext:start": "xt-build -e dev -w", + "ext:start:firefox": "xt-build -e dev -p firefox -w", + "ext:build": "xt-build -e prod", + "ext:build:firefox": "xt-build -e prod -p firefox", + "ext:clean": "xt-clean", + "ext:docs": "xt-docs", + "ext:test": "xt-test", + "coverage": "nyc --reporter=lcov npm run test", + "ext:sync": "xt-sync", + "bundle:build": "webpack --mode production", + "bundle": "npm run bundle:build", + "bundle:watch": "webpack --watch", + "build": "rm -rf dist/ && npm run bundle && npm run ext:build" + }, + "babel": { + "presets": [ + "@babel/preset-env" + ] + }, + "eslintIgnore": [ + "test/**/*" + ], + "dependencies": { + "lo-event": "file:../../module/lo_event" + }, + "devDependencies": { + "@babel/core": "^7.23.0", + "@babel/preset-env": "^7.22.20", + "babel-loader": "^9.1.3", + "css-loader": "^6.8.1", + "extension-cli": "^1.2.4", + "html-loader": "^4.2.0", + "style-loader": "^3.3.3", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4" + }, + "xtdocs": { + "source": { + "include": [ + "README.md", + "bundle" + ] + } + }, + "xtbuild": { + "copyAsIs": [ + "./src/pages/**/*" + ], + "js_bundles": [ + { + "name": "background", + "src": "./src/background.bundle.js" + }, + { + "name": "inject", + "src": "./src/inject.bundle.js" + }, + { + "name": "service_worker", + "src": "./src/service_worker.bundle.js" + }, + { + "name": "writing_common", + "src": "./src/writing_common.bundle.js" + }, + { + "name": "writing", + "src": "./src/writing.bundle.js" + }, + { + "name": "3rdparty/sha256", + "src": "./src/3rdparty/sha256.js" + } + ] + } +} diff --git a/extension/writing-process/src/3rdparty/sha256.js b/extension/writing-process/src/3rdparty/sha256.js new file mode 100644 index 000000000..e62bf9e52 --- /dev/null +++ b/extension/writing-process/src/3rdparty/sha256.js @@ -0,0 +1,27 @@ +/* + A JavaScript implementation of the SHA family of hashes, as + defined in FIPS PUB 180-4 and FIPS PUB 202, as well as the corresponding + HMAC implementation as defined in FIPS PUB 198a + + Copyright 2008-2018 Brian Turek, 1998-2009 Paul Johnston & Contributors + Distributed under the BSD License + See http://caligatio.github.com/jsSHA/ for more information +*/ +'use strict';(function(I){function w(c,a,d){var l=0,b=[],g=0,f,n,k,e,h,q,y,p,m=!1,t=[],r=[],u,z=!1;d=d||{};f=d.encoding||"UTF8";u=d.numRounds||1;if(u!==parseInt(u,10)||1>u)throw Error("numRounds must a integer >= 1");if(0===c.lastIndexOf("SHA-",0))if(q=function(b,a){return A(b,a,c)},y=function(b,a,l,f){var g,e;if("SHA-224"===c||"SHA-256"===c)g=(a+65>>>9<<4)+15,e=16;else throw Error("Unexpected error in SHA-2 implementation");for(;b.length<=g;)b.push(0);b[a>>>5]|=128<<24-a%32;a=a+l;b[g]=a&4294967295; +b[g-1]=a/4294967296|0;l=b.length;for(a=0;a>>3;g=e/4-1;if(eb/8){for(;a.length<=g;)a.push(0);a[g]&=4294967040}for(b=0;b<=g;b+=1)t[b]=a[b]^909522486,r[b]=a[b]^1549556828;n=q(t,n);l=h;m=!0};this.update=function(a){var c,f,e,d=0,p=h>>>5;c=k(a,b,g);a=c.binLen;f=c.value;c=a>>>5;for(e=0;e>> +5);g=a%h;z=!0};this.getHash=function(a,f){var d,h,k,q;if(!0===m)throw Error("Cannot call getHash after setting HMAC key");k=C(f);switch(a){case "HEX":d=function(a){return D(a,e,k)};break;case "B64":d=function(a){return E(a,e,k)};break;case "BYTES":d=function(a){return F(a,e)};break;case "ARRAYBUFFER":try{h=new ArrayBuffer(0)}catch(v){throw Error("ARRAYBUFFER not supported by this environment");}d=function(a){return G(a,e)};break;default:throw Error("format must be HEX, B64, BYTES, or ARRAYBUFFER"); +}q=y(b.slice(),g,l,p(n));for(h=1;h>>2]>>>8*(3+b%4*-1),l+="0123456789abcdef".charAt(g>>>4&15)+"0123456789abcdef".charAt(g&15);return d.outputUpper?l.toUpperCase():l}function E(c,a,d){var l="",b=a/8,g,f,n;for(g=0;g>>2]:0,n=g+2>>2]:0,n=(c[g>>>2]>>>8*(3+g%4*-1)&255)<<16|(f>>>8*(3+(g+1)%4*-1)&255)<<8|n>>>8*(3+(g+2)%4*-1)&255,f=0;4>f;f+=1)8*g+6*f<=a?l+="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charAt(n>>> +6*(3-f)&63):l+=d.b64Pad;return l}function F(c,a){var d="",l=a/8,b,g;for(b=0;b>>2]>>>8*(3+b%4*-1)&255,d+=String.fromCharCode(g);return d}function G(c,a){var d=a/8,l,b=new ArrayBuffer(d),g;g=new Uint8Array(b);for(l=0;l>>2]>>>8*(3+l%4*-1)&255;return b}function C(c){var a={outputUpper:!1,b64Pad:"=",shakeLen:-1};c=c||{};a.outputUpper=c.outputUpper||!1;!0===c.hasOwnProperty("b64Pad")&&(a.b64Pad=c.b64Pad);if("boolean"!==typeof a.outputUpper)throw Error("Invalid outputUpper formatting option"); +if("string"!==typeof a.b64Pad)throw Error("Invalid b64Pad formatting option");return a}function B(c,a){var d;switch(a){case "UTF8":case "UTF16BE":case "UTF16LE":break;default:throw Error("encoding must be UTF8, UTF16BE, or UTF16LE");}switch(c){case "HEX":d=function(a,b,c){var f=a.length,d,k,e,h,q;if(0!==f%2)throw Error("String of HEX type must be in byte increments");b=b||[0];c=c||0;q=c>>>3;for(d=0;d>>1)+q;for(e=h>>>2;b.length<=e;)b.push(0);b[e]|=k<<8*(3+h%4*-1)}return{value:b,binLen:4*f+c}};break;case "TEXT":d=function(c,b,d){var f,n,k=0,e,h,q,m,p,r;b=b||[0];d=d||0;q=d>>>3;if("UTF8"===a)for(r=3,e=0;ef?n.push(f):2048>f?(n.push(192|f>>>6),n.push(128|f&63)):55296>f||57344<=f?n.push(224|f>>>12,128|f>>>6&63,128|f&63):(e+=1,f=65536+((f&1023)<<10|c.charCodeAt(e)&1023),n.push(240|f>>>18,128|f>>>12&63,128|f>>>6&63,128|f&63)),h=0;h>>2;b.length<=m;)b.push(0);b[m]|=n[h]<<8*(r+p%4*-1);k+=1}else if("UTF16BE"===a||"UTF16LE"===a)for(r=2,n="UTF16LE"===a&&!0||"UTF16LE"!==a&&!1,e=0;e>>8);p=k+q;for(m=p>>>2;b.length<=m;)b.push(0);b[m]|=f<<8*(r+p%4*-1);k+=2}return{value:b,binLen:8*k+d}};break;case "B64":d=function(a,b,c){var f=0,d,k,e,h,q,m,p;if(-1===a.search(/^[a-zA-Z0-9=+\/]+$/))throw Error("Invalid character in base-64 string");k=a.indexOf("=");a=a.replace(/\=/g, +"");if(-1!==k&&k { +// storage.getItem('server').then((data) => resolve(data) +// } /* and other async logic */); +// websocketLogger( callback ); + +// Track which tabs currently have an active content script and the state of +// our logger so that we only initialize logging when needed. +const activeContentTabs = new Set(); +let loEventActive = false; +let loggers = []; +const manifestVersion = chrome.runtime.getManifest().version; + +// We are not sure if this should be done within `websocketLogger()`'s `init` +// or one level up. +function startLogger () { + if (loEventActive) return; + loggers = [ + consoleLogger(), + websocketLogger(WEBSOCKET_SERVER_URL) + ]; + loEvent.init( + 'org.mitros.writing_analytics', + manifestVersion, + loggers, + { + debugLevel: loEventDebug.LEVEL.SIMPLE, + // TODO document what we have currently and what we want + metadata: [ + browserInfo(), + chromeAuth(), + localStorageInfo(), + sessionStorageInfo(), + ] + } + ); + loEvent.go(); + loEventActive = true; + loEvent.logEvent('extension_loaded', {}); + logFromServiceWorker('Extension loaded'); +} + +function stopLogger () { + if (!loEventActive) return; + loEvent.logEvent('terminate', {}); + loEventActive = false; +} + +// Function to serve as replacement for +// chrome.extension.getBackgroundPage().console.log(event); because it is not allowed in V3 +// It logs the event to the console for debugging. +function logFromServiceWorker(event) { + console.log(event); +} + +function this_a_google_docs_save(request) { + /* + Check if this is a Google Docs save request. Return true for something like: + https://docs.google.com/document/d/1lt_lSfEM9jd7Ga6uzENS_s8ZajcxpE0cKuzXbDoBoyU/save?id=dfhjklhsjklsdhjklsdhjksdhkjlsdhkjsdhsdkjlhsd&sid=dhsjklhsdjkhsdas&vc=2&c=2&w=2&smv=2&token=lasjklhasjkhsajkhsajkhasjkashjkasaajhsjkashsajksas&includes_info_params=true + And false otherwise. + + Note that while `save` is often early in the URL, on the first + few requests of a web page load, it can be towards the end. We + went from a conservative regexp to a liberal one. We should + confirm this never catches extra requests, though. + */ + if (request.url.match(/.*:\/\/docs\.google\.com\/document\/(.*)\/save/i)) { + return true; + } + return false; +} + +function this_a_google_docs_bind(request) { + /* + These request correspond to some server-push features, such as collaborative + editing. We still need to reverse-engineer these. + + Note that we cannot monitor request responses without more + complex JavaScript. See: + + https://stackoverflow.com/questions/6831916/is-it-possible-to-monitor-http-traffic-in-chrome-using-an-extension#6832018 + */ + if (request.url.match(/.*:\/\/docs\.google\.com\/document\/(.*)\/bind/i)) { + return true; + } + return false; +} + +// Figure out the system settings. Note this is asynchronous, so we +// chain dequeue_events when this is done. +/* +var WRITINGJS_AJAX_SERVER = null; + +chrome.storage.sync.get(['process_server'], function(result) { + //WRITINGJS_AJAX_SERVER = result['process_server']; + if(!WRITINGJS_AJAX_SERVER) { + WRITINGJS_AJAX_SERVER = "https://writing.learning-observer.org/webapi/"; + } + dequeue_events(); +});*/ + +// Listen for the keystroke messages from the page script and forward to the server. +chrome.runtime.onMessage.addListener( + function (request, sender, sendResponse) { + // Lifecycle messages from content scripts manage the logger state + if (request?.type === 'content_script_ready') { + if (sender.tab?.id !== undefined) { + activeContentTabs.add(sender.tab.id); + if (!loEventActive) { + startLogger(); + } + } + return; + } else if (request?.type === 'content_script_unloading') { + if (sender.tab?.id !== undefined) { + activeContentTabs.delete(sender.tab.id); + if (activeContentTabs.size === 0) { + stopLogger(); + } + } + return; + } + // Forward analytics events only when the logger is active + if (!loEventActive) { + return; + } + request['wa_source'] = 'client_page'; + loEvent.logEvent(request['event'], request); + } +); + +// Listen for web loads, and forward relevant ones (e.g. saves) to the server. +chrome.webRequest.onBeforeRequest.addListener( + /* + This allows us to log web requests. There are two types of web requests: + * Ones we understand (SEMANTIC) + * Ones we don't (RAW/DEBUG) + + There is an open question as to how we ought to handle RAW/DEBUG + events. We will reduce potential issues around collecting data + we don't want (privacy, storage, bandwidth) if we silently drop + these. On the other hand, we significantly increase risk of + losing user data should Google ever change their web API. If we + log everything, we have good odds of being able to + reverse-engineer the new API, and reconstruct what happened. + + Our current strategy is to: + * Log the former requests in a clean way, extracting the data we + want + * Have a flag to log the debug requests (which includes the + unparsed version of events we want). + We should step through and see how this code manages failures, + + For development purposes, both modes of operation are + helpful. Having these is nice for reverse-engineering, + especially new pages. They do inject a lot of noise, though, and + from there, being able to easily ignore these is nice. + */ + function (request) { + // No logger availaber + if (!loEventActive) { + return; + } + //chrome.extension.getBackgroundPage().console.log("Web request url:"+request.url); + var formdata = {}; + let event; + if (request.requestBody) { + formdata = request.requestBody.formData; + } + if (!formdata) { + formdata = {}; + } + if (RAW_DEBUG) { + loEvent.logEvent('raw_http_request', { + 'url': request.url, + 'form_data': formdata + }); + } + + if (this_a_google_docs_save(request)) { + //chrome.extension.getBackgroundPage().console.log("Google Docs bundles "+request.url); + try { + /* We should think through which time stamps we should log. These are all subtly + different: browser event versus request timestamp, as well as user time zone + versus GMT. */ + event = { + 'doc_id': googledocs_id_from_url(request.url), + 'tab_id': googledocs_tab_id_from_url(request.url), + 'url': request.url, + 'bundles': JSON.parse(formdata.bundles), + 'rev': formdata.rev, + 'timestamp': parseInt(request.timeStamp, 10) + }; + logFromServiceWorker(event); + loEvent.logEvent('google_docs_save', event); + } catch (err) { + /* + Oddball events, like text selections. + */ + event = { + 'doc_id': googledocs_id_from_url(request.url), + 'tab_id': googledocs_tab_id_from_url(request.url), + 'url': request.url, + 'formdata': formdata, + 'rev': formdata.rev, + 'timestamp': parseInt(request.timeStamp, 10) + }; + loEvent.logEvent('google_docs_save_extra', event); + } + } else if (this_a_google_docs_bind(request)) { + logFromServiceWorker(request); + } else { + logFromServiceWorker("Not a save or bind: " + request.url); + } + }, + { urls: ["*://docs.google.com/*"] }, + ['requestBody'] +); + +// re-injected scripts when chrome extension is reloaded, upgraded or re-installed +// https://stackoverflow.com/questions/10994324/chrome-extension-content-script-re-injection-after-upgrade-or-install +chrome.runtime.onInstalled.addListener(reinjectContentScripts); +async function reinjectContentScripts() { + for (const contentScript of chrome.runtime.getManifest().content_scripts) { + for (const tab of await chrome.tabs.query({ url: contentScript.matches })) { + // re-inject content script + await chrome.scripting.executeScript({ + target: { tabId: tab.id, allFrames: true }, + files: contentScript.js, + }, function () { + if (!chrome.runtime.lastError) { + console.log('Content script re-injected successfully'); + } + }); + } + } +} diff --git a/extension/writing-process/src/manifest.json b/extension/writing-process/src/manifest.json new file mode 100644 index 000000000..ad7c064a0 --- /dev/null +++ b/extension/writing-process/src/manifest.json @@ -0,0 +1,46 @@ +{ + "name": "__MSG_appName__", + "short_name": "__MSG_appShortName__", + "description": "__MSG_appDescription__", + "homepage_url": "https://github.com/ETS-Next-Gen/writing_observer/tree/master/extension/writing-process", + "incognito": "not_allowed", + "version": "1.0.0.2", + "version_name": "1.0.0.2", + "manifest_version": 3, + "default_locale": "en", + "minimum_chrome_version": "88", + "permissions": [ + "webRequest", + "declarativeNetRequest", + "identity", + "identity.email", + "storage", + "nativeMessaging", + "scripting", + "activeTab", + "tabs" + ], + "icons": { + "48": "assets/lousy-fountain-pen-48.png" + }, + "background": { + "service_worker": "service_worker.js" + }, + "action": { + "default_popup": "pages/settings.html", + "default_icon": { + "48": "assets/lousy-fountain-pen-48.png" + }, + "default_title": "__MSG_appName__" + }, + "content_scripts": [{ + "matches": ["*://docs.google.com/document/*"], + "js": ["3rdparty/sha256.js", "writing_common.js", "writing.js"] + }], + "host_permissions": [ + "*://docs.google.com/document/*" + ], + "options_ui": { + "page": "pages/options.html" + } +} \ No newline at end of file diff --git a/extension/writing-process/src/pages/action.css b/extension/writing-process/src/pages/action.css new file mode 100644 index 000000000..59eb8157e --- /dev/null +++ b/extension/writing-process/src/pages/action.css @@ -0,0 +1,4 @@ +html, body { + width: 400px; +} + diff --git a/extension/writing-process/src/pages/options.html b/extension/writing-process/src/pages/options.html new file mode 100644 index 000000000..3d0538753 --- /dev/null +++ b/extension/writing-process/src/pages/options.html @@ -0,0 +1,42 @@ + + + + + + + + +

Options for debugging

+ +

Server lets us tweak where we send data. User tag lets us + tweak user ID (so we can e.g. pretend to be two users without + multiple Google accounts). Teacher tag lets us tweak who we route + messages to. + +

Server: no value found

+

User tag: no value found

+

Teacher tag: no value found

+
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + diff --git a/extension/writing-process/src/pages/options.js b/extension/writing-process/src/pages/options.js new file mode 100644 index 000000000..184dff3c2 --- /dev/null +++ b/extension/writing-process/src/pages/options.js @@ -0,0 +1,69 @@ +/* + Documentation on how to create an options page + + TODO: Add logging of when options change + */ + +const option_keys = ["teacher_tag", "user_tag", "process_server", "unique_id"]; + +function saveOptions(key) { + /* + Callback when user hits "save" on the options page + + We save to storage. When we're done, we refresh the + text (and the input) to make sure we've saved right and + to show current status. + */ + const value = document.querySelector("input.input-text."+key).value; + let new_setting={}; + new_setting[key] = value; + chrome.storage.sync.set( + new_setting, + (e)=>restoreOptions([key]) + ); +} + +function removeOptions(key) { + /* + Callback when user hits "remove" on the options page + + We just remove they key. + */ + chrome.storage.sync.remove( + key, + (e)=>restoreOptions([key]) + ); +} + +function restoreOptions(keys = option_keys) { + /* + Initialize the options page for the extension. Eventually, we'd + like to also use chrome.storage.managed so that school admins + can set these settings up centrally, without student overrides + */ + chrome.storage.sync.get(keys, function(result){ + for(const key_index in keys) { + const key = keys[key_index]; + console.log(key); + const r=result[key] || "none"; + console.log(r); + document.querySelector(".value-display."+key).innerText = r; + document.querySelector("input."+key).value = r; + } + }); +} + +function initialize() { + for(const key_index in option_keys) { + const key = option_keys[key_index]; + console.log(key); + document.querySelector("button.save-button."+key) + .addEventListener("click", (e) => saveOptions(key)); + document.querySelector("button.remove-button."+key) + .addEventListener("click", (e) => removeOptions(key)); + } + restoreOptions(option_keys); +} + +document.addEventListener('DOMContentLoaded', initialize); + diff --git a/extension/writing-process/src/pages/settings.html b/extension/writing-process/src/pages/settings.html new file mode 100644 index 000000000..a856be7a2 --- /dev/null +++ b/extension/writing-process/src/pages/settings.html @@ -0,0 +1,16 @@ + + + + + + +

Writing Process

+ +

Hi! This is an extension which captures writing process data in + Google Docs. It's part of a research project designed to help your + classrooms work better during COVID19. If you have any feedback or + questions, please don't hesitate to reach out to the researchers or + to talk to your teachers.

+ + + diff --git a/extension/writing-process/src/service_worker.js b/extension/writing-process/src/service_worker.js new file mode 100644 index 000000000..138ba9421 --- /dev/null +++ b/extension/writing-process/src/service_worker.js @@ -0,0 +1,8 @@ +// Combining the two background scripts into one to serve +// as a single service worker script + +try { + importScripts("./writing_common.js", "./background.js"); +} catch (e) { + console.log(e); +} diff --git a/extension/writing-process/src/service_worker_config.js b/extension/writing-process/src/service_worker_config.js new file mode 100644 index 000000000..464af0659 --- /dev/null +++ b/extension/writing-process/src/service_worker_config.js @@ -0,0 +1,7 @@ +// service_worker_config.js +export const CONFIG = { + // Flag for logging `raw_http_request` events + RAW_DEBUG: false, + // Learning Observer websocket connection endpoint + WEBSOCKET_SERVER_URL: "wss://learning-observer.org/wsapi/in/", +}; diff --git a/extension/writing-process/src/writing.js b/extension/writing-process/src/writing.js new file mode 100644 index 000000000..084affc5e --- /dev/null +++ b/extension/writing-process/src/writing.js @@ -0,0 +1,787 @@ +/* + Page script. This is injected into each web page on associated web sites. +*/ + +/* For debugging purposes: we know the extension is active */ +// document.body.style.border = "1px solid blue"; + +import { googledocs_id_from_url, googledocs_tab_id_from_url, treeget } from './writing_common'; +/* + General Utility Functions +*/ + +// Notify the service worker that this content script is active. We also +// provide a hook to let the service worker know when the script is about to +// unload so it can perform any necessary cleanup (for example, shutting down +// loggers when no tabs are active). +if (chrome.runtime?.id !== undefined) { + chrome.runtime.sendMessage({ type: 'content_script_ready' }); + window.addEventListener('beforeunload', () => { + if (chrome.runtime?.id !== undefined) { + chrome.runtime.sendMessage({ type: 'content_script_unloading' }); + } + }); +} + +function log_error(error_string) { + /* + We should send errors to the server, but for now, we + log to the console. + */ + console.trace(error_string); +} + +function log_event(event_type, event) { + /* + We pass an event, annotated with the page document ID and title, + to the background script + */ + // This is a compromise. We'd like to be similar to xAPI / Caliper, both + // of which use the 'object' field with a bunch of verbose stuff. + // + // Verbosity is bad for analytics, but compatibility is good. + // + // This is how Caliper thinks of this: https://www.imsglobal.org/spec/caliper/v1p2#entity + // This is how Tincan/xAPI thinks of this: https://xapi.com/statements-101/ + // + // "Object" is a really bad name. Come on. Seriously? + event["object"] = { + "type": "http://schema.learning-observer.org/writing-observer/", + "title": google_docs_title(), + "id": doc_id(), + "tab_id": tab_id(), + "url": window.location.href, + }; + + event['event'] = event_type; + // We want to track the page status during events. For example, + // Google Docs inserts comments during the document load. + event['readyState'] = document.readyState; + + // uncomment to watch events being logged from the client side with devtools + // console.log(event); + + // Check if the extension runtime still has its context + if (chrome.runtime?.id !== undefined) { + chrome.runtime.sendMessage(event); + } +} + +function doc_id() { + /* + Extract the Google document ID from the window + */ + try { + return googledocs_id_from_url(window.location.href); + } catch(error) { + log_error("Couldn't read document id"); + return null; + } +} + +function tab_id() { + /* + Extract the Google document's current Tab ID from the window + */ + try { + return googledocs_tab_id_from_url(window.location.href); + } catch(error) { + log_error("Couldn't read document's tab id"); + return null; + } +} + + +function this_is_a_google_doc() { + /* + Returns 'true' if we are in a Google Doc + */ + return window.location.href.search("://docs.google.com/") != -1; +} + +function google_docs_title() { + /* + Return the title of a Google Docs document. + + Note this is not guaranteed 100% reliable since Google + may change the page structure. + */ + try { + return document.getElementsByClassName("docs-title-input")[0].value; + } catch(error) { + log_error("Couldn't read document title"); + return null; + } +} + +function google_docs_partial_text() { + /* + Return the *loaded* text of a Google Doc. Note that for long + documents, this may not be the *complete* text since off-screen + pages may be lazy-loaded. The text omits formatting, which is + helpful for many types of analysis + + We want this for redundancy: we'd like to confirm we're correctly + reconstructing text. + */ + try { + return document.getElementsByClassName("kix-page")[0].innerText; + } catch(error) { + log_error("Could not get document text"); + return null; + } +} + +function google_docs_partial_html() { + /* + Return the *loaded* HTML of a Google Doc. Note that for long + documents, this may not be the *complete* HTML, since off-screen + pages may be lazy-loaded. This includes HTML formatting, which + may be helpful, but is incredibly messy. + + I hate Google's HTML. What's wrong with simple, clean, semantic + tags and classes? Why do we need something like this instead: + + + + Seriously, Google? + + And yes, if you download documents from Google, it's a mess like + this too. + */ + return document.getElementsByClassName("kix-page")[0].innerHTML; +} + +function is_string(myVar) { + /* + Utility function to check whether a variable is a string. + We need that because some Google docs graphical object classes + are not strings. + */ + if (typeof myVar === 'string' || myVar instanceof String) { + return true; + } else { + return false; + } +} + +function extractDocsToken() { + /** + * We need the doc token to be able to fetch the document + * history. This token is provided via a + + + + + + + + + Dashboard Debugger + + + +
+ + + + +
+

This is a test page. We can write a JSON key, and monitor a dashboard

+

It is not designed to be robust.

+ + + +
+ +
+ + diff --git a/learning_observer/learning_observer/static/debug.js b/learning_observer/learning_observer/static/debug.js new file mode 100644 index 000000000..1c5b44471 --- /dev/null +++ b/learning_observer/learning_observer/static/debug.js @@ -0,0 +1,12 @@ +d3.select("#query_button").attr("onclick", "query_dashboard()"); + +function query_dashboard() { + console.log("Click"); + dashboard_connection( + JSON.parse(d3.select("#query_string").property("value")), + function(data) { + console.log(data); + d3.select("#query_response").property("value", JSON.stringify(data)); + } + ); +}; diff --git a/learning_observer/learning_observer/static/favicon.ico b/learning_observer/learning_observer/static/favicon.ico new file mode 100644 index 000000000..92fea97e8 Binary files /dev/null and b/learning_observer/learning_observer/static/favicon.ico differ diff --git a/learning_observer/learning_observer/static/liblo.js b/learning_observer/learning_observer/static/liblo.js new file mode 100644 index 000000000..d54bf1765 --- /dev/null +++ b/learning_observer/learning_observer/static/liblo.js @@ -0,0 +1,146 @@ +// +// This is the preloaded Learning Observer library. +// + +// Path management, so that we can have relative URLs + +function lo_modulepath(rel_path) { + // This is used to retrieve URLs of relative + // files in the same git repo. + const path = new URL(document.URL).pathname; + const last_slash = path.lastIndexOf("/"); + const base_path = path.slice(0, last_slash+1); + return base_path + rel_path; +} + +function lo_thirdpartypath(rel_path) { + // This is used to retrieve URLs of external libraries + return "/static/3rd_party/"+rel_path; +} + +function requiremodulelib(lib) { + return lo_modulepath(lib); +} + +function requireexternallib(lib) { + return lo_thirdpartypath(lib) +} + +function requiremoduletext(text) { + return "/static/3rd_party/text.js!"+lo_modulepath(text); +} + +function requiresystemtext(text) { + return "/static/3rd_party/text.js!/static/"+text +} + +function requireconfig() { + return "/static/3rd_party/text.js!/config.json"; +} + + + + +// Helper functions. +// +// + +function rendertime1(t) { + /* + Convert seconds to a time string. + 10 ==> 10 sec + 120 ==> 2:00 + 3600 ==> 1:00:00 + 7601 ==> 2:06:41 + 764450 ==> 8 days + + */ + function str(i) { + if(i<10) { + return "0"+String(i); + } + return String(i) + } + var seconds = Math.floor(t) % 60; + var minutes = Math.floor(t/60) % 60; + var hours = Math.floor(t/3600) % 60; + var days = Math.floor(t/3600/24); + + if ((minutes === 0) && (hours === 0) && (days === 0)) { + return String(seconds) + " sec" // 0-59 seconds + } + if (days>0) { + return String(days) + " days" // >= 1 day + } + if(hours === 0) { + return String(minutes)+":"+str(seconds); // 1 minute - 1 hour + } + return String(hours)+":"+str(minutes)+":"+str(seconds) // 1 - 24 hours +} + +function rendertime2(t) { + /* + Convert seconds to a time string. + + Compact representation. + 10 ==> 10s + 125 ==> 2m + 3600 ==> 1h + 7601 ==> 2h + 764450 ==> 8d + + */ + function str(i) { + if(i<10) { + return "0"+String(i); + } + return String(i) + } + var seconds = Math.floor(t) % 60; + var minutes = Math.floor(t/60) % 60; + var hours = Math.floor(t/3600) % 60; + var days = Math.floor(t/3600/24); + + if(days>0) { + return String(days)+'d'; + } + if(hours>0) { + return String(hours)+'h'; + } + if(minutes>0) { + return String(minutes)+'m'; + } + if(seconds>0) { + return String(seconds)+'s'; + } + return '-'; +} + +// TODO this is copied code from static/common/dashboard.js +// I couldn't get dash to pull in that file specifically, +// but I didn't want to deal with it at that time. +// Guessing that /common is blocked somewhere along the way. +function decode_string_dict(stringdict) { + /* + Decode a string dictionary of the form: + `key1=value1; key2=value2;key3=value3` + This is used both to encode document hashes and for cookies. + + This is inspired by a (buggy) cookie decoder from w3cschools. We + wrote out own since that one starts out with decodeURIComponent, + potentially allowing for injections. + */ + var decoded = {}; + var splitstring = stringdict.split(';'); + for(var i = 0; i + + + + + + \ No newline at end of file diff --git a/learning_observer/learning_observer/static/media/Flag_of_Poland.svg b/learning_observer/learning_observer/static/media/Flag_of_Poland.svg new file mode 100644 index 000000000..b08d02519 --- /dev/null +++ b/learning_observer/learning_observer/static/media/Flag_of_Poland.svg @@ -0,0 +1 @@ + diff --git a/learning_observer/learning_observer/static/media/Flag_of_the_United_States.svg b/learning_observer/learning_observer/static/media/Flag_of_the_United_States.svg new file mode 100644 index 000000000..a11cf5f94 --- /dev/null +++ b/learning_observer/learning_observer/static/media/Flag_of_the_United_States.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/learning_observer/learning_observer/static/media/LICENSE.txt b/learning_observer/learning_observer/static/media/LICENSE.txt new file mode 100644 index 000000000..c3d2d6697 --- /dev/null +++ b/learning_observer/learning_observer/static/media/LICENSE.txt @@ -0,0 +1,11 @@ +ETS_Logo.svg: + +The ETS Logo is a trademark of the Educational Testing Service. For +terms-of-use, see: + +https://www.ets.org/legal/trademarks/owned + +It is not distributed under the same license as the rest of this +system. + +Flags of Poland and the US are SVGs from Wikipedia, and in the public domain diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/LICENSE.TXT b/learning_observer/learning_observer/static/media/hubspot_persona_images/LICENSE.TXT new file mode 100644 index 000000000..6d441a9ee --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/LICENSE.TXT @@ -0,0 +1,67 @@ +These images are from HubSpot's free Make My Persona tool. + +https://www.hubspot.com/make-my-persona?utm_source=mktg-resources + +These have unclear licensing. HubSpot has "Copright (c) 2020 HubSpot +Inc." at the bottom of the page, but no other text there (not even All +Rights Reserved). There is no license file anywhere we could +find. There were many implicit grants in other places e.g. "Create a +buyer persona that your entire company can use to market, sell, and +serve better" which made this use seem okay, so we reached out to +HubSpot to confirm this use was okay. + +HubSpot explicitly confirmed that we were in the clear for UX +prototypes, mockups, and personas. Chat transcript below. + +However, these are NOT distributed under the same license as the rest +of the project. For licensing information, please contact HubSpot +directly. + +My expectation (as of this writing) is that we will NOT use these +avatars beyond mockups, prototypes, and testing. If we go beyond that, +we may revisit. + +The rest of the mockup personas incorporated information from several +such tools. The rest were clearly okay. + +Thank you HubSpot! + + + +Chat with Hubspot support on 5/19/2020 at 3:47pm EST: + +HubBot: + Great, a coach is on their way now. They’ll send a message + when they get here, so I appreciate your patience in the meantime. + +3:40 PM Ali: + Hi Peter, this is Ali from HubSpot Sales. I'm happy to point you in the right direction today. + +3:40 PM + I just had a quick question: you have a bunch of free tools, like a + person generator. If we use them, are we allowed to use the personas + however we like? Or are there licensing restrictions? + + They say nothing, and if it's All Rights Reserved, we're not allowed + to use them for anything. Which sort of makes them pointless. Or I'm + not sure. https://www.hubspot.com/make-my-persona It's a nice tool, + but there's no legal information + + +3:42 PM Ali + You would be creating your buyer persona for your own company, no one + else would see it if you use the generator to create a buyer persona + then there aren't legal restrictions + + +3:43 PM + Okay. Thank you. I'm building an open source educational tool, and I + wanted to share some UX mock-ups and prototypes, including user + personas from the tool (obviously not open-sourcing the personas + themselves). It sounds like that's okay then? + +3:45 PM Ali + Yes that is definitely okay Peter + +3:47 PM + Thank you so much! diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-0.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-0.svg new file mode 100644 index 000000000..e2772e262 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-0.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-1.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-1.svg new file mode 100644 index 000000000..fc1b02974 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-1.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-10.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-10.svg new file mode 100644 index 000000000..15190a5da --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-10.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-11.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-11.svg new file mode 100644 index 000000000..0cf396a51 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-11.svg @@ -0,0 +1,385 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-12.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-12.svg new file mode 100644 index 000000000..76dfcec7e --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-12.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-13.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-13.svg new file mode 100644 index 000000000..f9893bd06 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-13.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-14.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-14.svg new file mode 100644 index 000000000..08598fd7b --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-14.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-2.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-2.svg new file mode 100644 index 000000000..8715d7d87 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-2.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-3.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-3.svg new file mode 100644 index 000000000..25b82a46a --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-3.svg @@ -0,0 +1,403 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-4.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-4.svg new file mode 100644 index 000000000..f984fabc3 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-4.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-5.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-5.svg new file mode 100644 index 000000000..444bc2c37 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-5.svg @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-6.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-6.svg new file mode 100644 index 000000000..bf4955eea --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-6.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-7.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-7.svg new file mode 100644 index 000000000..154df820b --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-7.svg @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-8.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-8.svg new file mode 100644 index 000000000..8e7433ab8 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-8.svg @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-9.svg b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-9.svg new file mode 100644 index 000000000..d438350a7 --- /dev/null +++ b/learning_observer/learning_observer/static/media/hubspot_persona_images/avatar-9.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/learning_observer/learning_observer/static/media/logo-clean.jpg b/learning_observer/learning_observer/static/media/logo-clean.jpg new file mode 100644 index 000000000..145ad8f7b Binary files /dev/null and b/learning_observer/learning_observer/static/media/logo-clean.jpg differ diff --git a/learning_observer/learning_observer/static/media/logo.jpg b/learning_observer/learning_observer/static/media/logo.jpg new file mode 100644 index 000000000..35f6a1290 Binary files /dev/null and b/learning_observer/learning_observer/static/media/logo.jpg differ diff --git a/learning_observer/learning_observer/static/modules/course.html b/learning_observer/learning_observer/static/modules/course.html new file mode 100644 index 000000000..924ad98ee --- /dev/null +++ b/learning_observer/learning_observer/static/modules/course.html @@ -0,0 +1,68 @@ +
+
+
+ {{ name }} +
+
+
+
+ {{{ tools }}} +

+ {{ description_heading }}

+
+
+ +
diff --git a/learning_observer/learning_observer/static/modules/courses.html b/learning_observer/learning_observer/static/modules/courses.html new file mode 100644 index 000000000..7f183437a --- /dev/null +++ b/learning_observer/learning_observer/static/modules/courses.html @@ -0,0 +1,21 @@ +
+

My Courses

+
+ +
+
diff --git a/learning_observer/learning_observer/static/modules/informational.html b/learning_observer/learning_observer/static/modules/informational.html new file mode 100644 index 000000000..85847dd42 --- /dev/null +++ b/learning_observer/learning_observer/static/modules/informational.html @@ -0,0 +1,5 @@ +
+
+ {{{text}}} +
+
diff --git a/learning_observer/learning_observer/static/modules/login.html b/learning_observer/learning_observer/static/modules/login.html new file mode 100644 index 000000000..271e391de --- /dev/null +++ b/learning_observer/learning_observer/static/modules/login.html @@ -0,0 +1,67 @@ + + +
+
+
+

{{ server_name }}

+

{{ front_page_pitch }} +

+
+
+
+
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+

+ + + Contribute + on github

+
+
+
+
+ Learning Observer Logo +
+
+
diff --git a/learning_observer/learning_observer/static/modules/navbar_loggedin.html b/learning_observer/learning_observer/static/modules/navbar_loggedin.html new file mode 100644 index 000000000..949c80fe4 --- /dev/null +++ b/learning_observer/learning_observer/static/modules/navbar_loggedin.html @@ -0,0 +1,17 @@ + diff --git a/learning_observer/learning_observer/static/modules/tool.html b/learning_observer/learning_observer/static/modules/tool.html new file mode 100644 index 000000000..4ebfd143d --- /dev/null +++ b/learning_observer/learning_observer/static/modules/tool.html @@ -0,0 +1,8 @@ +

+ +

diff --git a/learning_observer/learning_observer/static/modules/unauth.md b/learning_observer/learning_observer/static/modules/unauth.md new file mode 100644 index 000000000..7644c259e --- /dev/null +++ b/learning_observer/learning_observer/static/modules/unauth.md @@ -0,0 +1,15 @@ +You do not have an account on this system. + +Your Google email is: **{{ email }}**. + +Your user ID is **{{ user_id }}**. + +If you believe you should have an account, please email the text above +(Google ID and email) to me (Piotr Mitros), and I'll set you up with +an account. If you're authorized, you should have my email already +(but if not, it's pmitros, followed by the @ symbol, followed by +ets.org). + +If you logged in with the wrong account, please [log +out](/auth/logout) and try again. You should use your official school +account. \ No newline at end of file diff --git a/learning_observer/learning_observer/static/ux.css b/learning_observer/learning_observer/static/ux.css new file mode 100644 index 000000000..e5cfbb507 --- /dev/null +++ b/learning_observer/learning_observer/static/ux.css @@ -0,0 +1,7 @@ +.wo-row-tile { + min-height: 350px; +} + +.wo-col-tile { + min-height: 350px; +} diff --git a/learning_observer/learning_observer/static/webapp.html b/learning_observer/learning_observer/static/webapp.html new file mode 100644 index 000000000..9effc9380 --- /dev/null +++ b/learning_observer/learning_observer/static/webapp.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + Writing Analysis + + + + +
+ + + + +
+ +
+ +
+ + + \ No newline at end of file diff --git a/learning_observer/learning_observer/static/webapp.js b/learning_observer/learning_observer/static/webapp.js new file mode 100644 index 000000000..e69082753 --- /dev/null +++ b/learning_observer/learning_observer/static/webapp.js @@ -0,0 +1,275 @@ +function go_home() { + /* + Load the homepage. + */ + window.location.href="/"; +} + +function error(error_message) { + /* + Show an error message. + + TODO: Do this at least somewhat gracefully. + */ + alert("Error: "+error_message); + go_home(); +} + +function ajax(config) +{ + return function(url) { + // Do AJAX calls with error handling + return new Promise(function(resolve, reject) { + config.d3.json(url) + .then(function(data){ + resolve(data); + }) + .catch(function(data){ + reject(data); + }); + }); + } +} + +function initializeBurgerMenu() { + document.querySelectorAll('.navbar-burger').forEach(burger => { + burger.addEventListener('click', () => { + const target = burger.dataset.target; + const targetMenu = document.getElementById(target); + burger.classList.toggle('is-active'); + targetMenu.classList.toggle('is-active'); + }); + }); +} + + + +requirejs( + // TODO: Clean up absolute paths. We hardcoded these for now, due to refactor. + ["/static/3rd_party/text.js!/config.json", + "/static/3rd_party/text.js!/webapi/course_dashboards", // Perhaps this belongs in config.json? + "/static/3rd_party/d3.v5.min.js", + "/static/3rd_party/mustache.min.js", + "/static/3rd_party/showdown.js", + "/static/3rd_party/fontawesome.js", + "/static/3rd_party/text.js!/static/modules/unauth.md", + "/static/3rd_party/text.js!/static/modules/login.html", + "/static/3rd_party/text.js!/static/modules/courses.html", + "/static/3rd_party/text.js!/static/modules/course.html", + "/static/3rd_party/text.js!/static/modules/tool.html", + "/static/3rd_party/text.js!/static/modules/navbar_loggedin.html", + "/static/3rd_party/text.js!/static/modules/informational.html", + "/static/3rd_party/text.js!/auth/userinfo" + ], + function(config, tool_list, d3, mustache, showdown, fontawesome, unauth, login, courses, course, tool, navbar_li, info, auth_info) { + // Parse client configuration. + config = JSON.parse(config); + // console.log(tool_list); + tool_list = JSON.parse(tool_list); + // console.log(tool_list); + // console.log(auth_info); + // console.log(JSON.stringify(auth_info)); + + // Add libraries + config.d3 = d3; + config.ajax = ajax(config); + auth_info = JSON.parse(auth_info); + + // Reload user info + function reload_user_info() { + config.ajax("/auth/userinfo") + .then(function(data) { + auth_info = data; + console.log(auth_info); + console.log(JSON.stringify(auth_info)); + console.log("reloaded user info"); + }); + console.log(auth_info); + } + + + function password_authorize() { + d3.json("/auth/login/password", { + method: 'POST', + headers: { + "Content-type": "application/json; charset=UTF-8" + }, + body: JSON.stringify({ + username: d3.select(".lo-login-username").property("value"), + password: d3.select(".lo-login-password").property("value") + }) + }).then(function(data) { + reload_user_info(); + if (data['status'] === 'authorized') { + load_courses_page(); + } else if (data['status'] === 'unauthorized') { + // TODO: Flash a nice subtle message + alert("Invalid username or password!"); + } + else { + console.log(data); + } + }); + } + + function load_login_page() { + d3.select(".main-page").html(mustache.render(login, config['theme'])); + d3.select(".lo-google-auth").classed("is-hidden", !config['google_oauth']); + d3.select(".lo-http-auth").classed("is-hidden", !config['http_basic_auth']); + d3.select(".lo-password-auth").classed("is-hidden", !config['password_auth']); + d3.select('.lo-google-auth p a').attr('href', function () { + const currentHref = d3.select(this).attr('href'); + if (!currentHref) { return null; } + return currentHref + window.location.search + window.location.hash; + }); + d3.select(".lo-login-button") + .on("click", function() { + password_authorize(); + }); + } + + function load_courses_page() { + /* + Listing of Google Classroom courses + */ + d3.select(".main-page").html(courses); + config.ajax("/webapi/courselist/").then(function(data){ + /* + TODO: We want a function which does this abstracted + our. In essense, we want to call + d3.json_with_auth_and_errors + */ + if(data["error"]!=null) { + if(data["error"]["status"]==="UNAUTHENTICATED") { + load_login_page(); + } + else { + error("Unknown error!"); + } + } else { + let cdg = d3.select(".awd-course-list"); + cdg.selectAll("div.awd-course-card") + .data(data) + .enter() + .append("div") + .html(function(course_json) { + console.log(course_json); + let tools = ""; + for(var i=0; i".format(name=self.name) + + def __eq__(self, other): + if not isinstance(other, EventField): + return False + + return self.event == other.event + + def __lt__(self, other): + if not isinstance(other, EventField): + raise TypeError("< not supported between instances of 'EventField' and other types") + + return self.event < other.event + + +KeyStateType = enum.Enum("KeyStateType", "INTERNAL EXTERNAL") + +# This is a set of fields which we use to index reducers. For example, +# if we'd like to know how many students accessed a specific Google +# Doc, we might create a RESOURCE key (which would receive events for +# all students accessing that resource). If we'd like to keep track of +# a students' work in a particular Google Doc, we'd create a +# STUDENT/RESOURCE key. +# +# At some point, this shouldn't be hardcoded +# +# We'd also like a better way to think of the hierarchy of assignments than ITEM/ASSIGNMENT +KeyFields = [ + "STUDENT", # A single student + "CLASS", # A group of students. Typically, one class roster in Google Classroom + "RESOURCE", # E.g. One Google Doc + # "ASSIGNMENT" # E.g. A collection of Google Docs (e.g. notes, outline, draft) + # TODO we are not 100% sold on the TEACHER / STUDENT split. + "TEACHER" # + # ... # ... and so on. +] + +KeyField = enum.Enum("KeyField", " ".join(KeyFields)) + + +class Scope(frozenset): + ''' + A scope is a set of KeyFields and EventFields. + ''' + pass + + +class ScopeFieldError(Exception): + ''' + Exception used if we e.g. try to add an incorrect type to a scope, have + a mismatched key to a scope, etc. Perhaps this might be a few exceptions + in the future. + ''' + pass diff --git a/learning_observer/learning_observer/stream_analytics/helpers.py b/learning_observer/learning_observer/stream_analytics/helpers.py new file mode 100644 index 000000000..05d5ef1c8 --- /dev/null +++ b/learning_observer/learning_observer/stream_analytics/helpers.py @@ -0,0 +1,353 @@ +''' +Common utility functions for working with analytics modules. + +The goal is to have the modules be pluggable and independent of the +system. For now, our overall system diagram is: + ++---------------+ +| | +-------------+ +| Event Source ---| | Key-Value | +| | | | Store | ++---------------+ | | | ++---------------+ | +-----------+ <------|-- Internal | +| | | | | -------|-> State | +------------+ +------------+ +| Event Source --------|---->| Reducer | | | | | | | +| | | | | | --------|-> External -------->| Aggregator |----> | Dashboard | ++---------------+ | | +-----------+ | State | | | | | ++---------------+ | | | | +------------+ +------------+ +| | | | +-------------+ +| Event Source ----| | +| | | ++---------------+ v + +------------+ + | | + | Archival | + | Repository | + | | + +------------+ + +We create reducers with the `student_event_reducer` decorator. In the +longer term, we'll want to be able to plug together different +aggregators, state types, etc. We'll also want different keys for +reducers (per-student, per-resource, etc.). For now, though, this +works. +''' + +import copy +import functools + +import learning_observer.kvs +from learning_observer.stream_analytics.fields import KeyStateType, KeyField, EventField, Scope + +from learning_observer.log_event import debug_log + +# Not a great place to have this import... things might get circular at +# some point. +import learning_observer.module_loader + + +def fully_qualified_function_name(func): + ''' + Takes a function. Return a fully-qualified string with a name for + that function. E.g.: + + >>> from math import sin + >>> fully_qualified_function_name(math.sin) + 'math.sin' + + This is helpful for then giving unique names to analytics modules. Each module can + be uniquely referenced based on its reduce function. + ''' + return "{module}.{function}".format( + module=func.__module__, + function=func.__qualname__ + ) + + +def make_key_from_json(js): + ''' + This will make a key from a json dictionary + + Note that we ought to do auth / auth upstream of calling this + function. + + E.g. we might pass in: + { + "source": "da_timeline.visualize.handle_event", + "KeyField.STUDENT": "guest-424d691e92afb0ac8aeze585b1d28a49" + } + + And get out: + + 'Internal,da_timeline.visualize.handle_event,STUDENT:guest-424d691e92afb0ac8aeze585b1d28a49' + + This does extensive sanitation, since the JSON typically comes + from a browser + ''' + js = copy.deepcopy(js) + # We want to copy over KeyFields, converting them to `enum`s + # + # This sanitizes them in the process. + key_dict = {} + for key in KeyField: + if str(key) in js: + key_dict[key] = js[str(key)] + del js[str(key)] + # Next, we want to copy over EventFields. + # We have no way to sanitize these, since they're open-ended, except + # to make sure they don't contain magic characters + for key in list(js): + if key.startswith("EventField."): + event = js[key][len("EventField."):] + key_dict[EventField(event)] = js[event] + del js[key] + + stream_module = js['source'] + + key_list = [ + KeyStateType.EXTERNAL.name, + ] + + if KeyField.STUDENT in js: + user_id = js[KeyField.STUDENT] + + aggregator_functions = sum( + [ + a['sources'] + for a in learning_observer.module_loader.course_aggregators().values() + ], + [] + ) + + agg_function = None + for func in aggregator_functions: + if fully_qualified_function_name(func) == js['source']: + agg_function = func + + if agg_function is None: + raise ArgumentError("Invalid function") + + return make_key( + agg_function, + key_dict, + KeyStateType.INTERNAL + ) + + +def make_key(func, key_dict, state_type): + ''' + Create a KVS key. + + It combines: + + * A fully-qualified name for the reducer function + * A dictionary of fields + * Whether the key is internal or external + + Into a unique string + + For example: + >>> make_key( + some_module.reducer, + {h.KeyField.STUDENT: 123}, + h.KeyStateType.INTERNAL + ) + 'Internal,some_module.reducer,STUDENT:123' + ''' + # pylint: disable=isinstance-second-argument-not-valid-type + assert isinstance(state_type, KeyStateType) + assert callable(func) + streammodule = fully_qualified_function_name(func) + # Key starts with whether it is internal versus external state, and what module it comes from + key_list = [ + state_type.name.capitalize(), + streammodule + ] + + # It continues with the fields. These are organized as key-value + # pairs. These need a well-defined order. I'm sure there's a + # logical order here, but for now, we do alphabetical. + # + # We will want to be able to do reduce operations across multiple + # axes. This is where an RDS with multiple indexes might be nice, + # if we can figure out the sharding, etc. Another alternative + # might be to use postgres to organize things (which changes + # rarely), but to keep actual key/value pairs in redis (which + # changes a lot). + for key in sorted(key_dict.keys(), key=lambda x: x.name): + key_list.append("{key}:{value}".format(key=key.name, value=key_dict[key])) + + # And we return this as comma-seperated values + return ",".join(key_list) + + +def kvs_pipeline( + null_state=None, + scope=None, + module_override=None, + qualname_override=None +): + ''' + Closures, anyone? + + There's a bit to unpack here. + + Top-level function. This allows us to configure the decorator (and + returns the decorator). + + * `null_state` tells us the empty state, before any reduce operations have + happened. This can be important for the aggregator. We're documenting the + code before we've written it, so please make sure this works before using. + * `scope` tells us the scope we reduce over. See `fields.Scope` + ''' + if scope is None: + debug_log("TODO: explicitly specify a scope") + debug_log("Defaulting to student scope") + scope = Scope([KeyField.STUDENT]) + + def decorator( + func + ): + ''' + The decorator itself. + + It takes a function which expects an event and an (internal) state from + the KVS, and outputs an internal and an external state. We should + consider removing the concept of an external state. The idea was that + we could make modules with just reducers (where all aggregation, etc. + was handled automatically). This isn't as central to the current + design. + + For interactive development, we allow overriding the `__module__` and + `__qualname__` of the function. This is helpful in places like Jupyer + notebooks, since this is used for setting keys. + + We could, as an alternative, pass these as additional parameters to + `make_key`, and `setattr` just over `wrapper_closure` to avoid side + effects. + ''' + if qualname_override is not None: + setattr(func, '__qualname__', qualname_override) + if module_override is not None: + setattr(func, '__module__', module_override) + + @functools.wraps(func) + async def wrapper_closure(metadata): + ''' + The decorator itself. We create a function that, when called, + creates an event processing pipeline. It keeps a pointer + to the KVS inside of the closure. This way, each pipeline has + its own KVS. This is the level at which we want consistency, + want to allow sharding, etc. If two users are connected, each + will have their own data store connection. + ''' + taskkvs = learning_observer.kvs.KVS() + + async def process_event(event, event_fields={}): + ''' + This is the function which processes events. It calls the event + processor, passes in the event(s) and state. It takes + the internal state and the external state from the + event processor. The internal state goes into the KVS + for use in the next call, while the external state + returns to the dashboard. + + The external state should include everything needed + for the dashboard visualization and exclude anything + large or private. The internal state needs everything + needed to continue reducing the events. + ''' + # TODO: Think through concurrency. + # + # We could put this inside of a transaction, but we + # would lose a few orders of magnitude in performance. + # + # We could keep this outside of a transaction, and handle + # occasional issues. + # + # It's worth noting that: + # + # 1. We have an archival record, and we can replay if there + # are issues + # 2. We keep this open on a per-session basis. The only way + # we might run into concurrency issues is if a student + # is e.g. actively editing on two computers at the same + # time + # 3. If we assume e.g. occasional disconnected operation, as + # on a mobile device, we'll have concurrency problems no + # matter what. In many cases, we should handle this + # explicitly rather than implicitly, for example, with + # conflict-free replicated data type (CRDTs) or explicit + # merge operation + # + # Fun! + # + # But we can think of more ways we might get concurrency + # issues in the future, once we do per-class / per-resource / + # etc. reducers. + # + # * We could funnel these into a common reducer. That'd be easy + # enough and probably the right long-term solution + # * We could have modules explicitly indicate where they need + # thread safety and transactions. That'd be easy enough. + keydict = {} + # Step 1: Handle auth metadata. + if KeyField.STUDENT in scope: + if metadata is not None and 'auth' in metadata: + safe_user_id = metadata['auth']['safe_user_id'] + else: + # In general, this path should NOT be followed. If we + # want guest accounts, each user ought to have a unique + # identifier or cookie assigned on first access. + safe_user_id = '[guest]' + keydict[KeyField.STUDENT] = safe_user_id + + # Step 2: Handle all other metadata. + for field in scope: + # We don't want to override auth fields + if field in keydict: + pass + elif isinstance(field, EventField): + keydict[field] = event_fields.get(field.event, None) + else: + raise Exception("Unknown field", field) + + internal_key = make_key( + func, + keydict, + KeyStateType.INTERNAL + ) + external_key = make_key( + func, + keydict, + KeyStateType.EXTERNAL + ) + + internal_state = await taskkvs[internal_key] + if internal_state is None: + internal_state = copy.deepcopy(null_state) + await taskkvs.set(internal_key, internal_state) + + internal_state, external_state = await func( + event, internal_state + ) + + # We would like to give reducers the option to /not/ write + # on all events + if internal_state is not False: + await taskkvs.set(internal_key, internal_state) + if external_state is not False: + await taskkvs.set(external_key, external_state) + return external_state + return process_event + return wrapper_closure + return decorator + + +# `kvs_pipeline`, in it's current incarnation, is obsolete. +# +# We will now have reducers of multiple types. +# +# We will probably keep `kvs_pipeline` as a generic, and this is part of that +# transition. +student_event_reducer = functools.partial(kvs_pipeline, scope=Scope([KeyField.STUDENT])) diff --git a/learning_observer/learning_observer/stream_analytics/time_on_task.py b/learning_observer/learning_observer/stream_analytics/time_on_task.py new file mode 100644 index 000000000..a31391f88 --- /dev/null +++ b/learning_observer/learning_observer/stream_analytics/time_on_task.py @@ -0,0 +1,103 @@ +''' +Helpers for time-on-task reducers. +''' +import pmss + +pmss.register_field( + name='time_on_task_threshold', + type=pmss.pmsstypes.TYPES.integer, + description='Maximum time to pass before marking a session as over. '\ + 'Should be 60-300 seconds in production, but 5 seconds is nice for '\ + 'debugging in a local deployment.', + default=60 +) +pmss.register_field( + name='binned_time_on_task_bin_size', + type=pmss.pmsstypes.TYPES.integer, + description='How large (in seconds) to make timestamp bins when '\ + 'recording binned time on task.', + default=600 +) + + +def default_time_on_task_state(): + return { + 'saved_ts': None, + 'total_time_on_task': 0 + } + + +def apply_time_on_task(internal_state, current_timestamp, time_delta_threshold): + if internal_state is None: + internal_state = default_time_on_task_state() + last_ts = internal_state['saved_ts'] + internal_state['saved_ts'] = current_timestamp + + if last_ts is None: + last_ts = internal_state['saved_ts'] + if last_ts is not None: + delta_t = min( + time_delta_threshold, + internal_state['saved_ts'] - last_ts + ) + internal_state['total_time_on_task'] += delta_t + return internal_state + + +def default_binned_time_on_task_state(): + return { + 'saved_ts': None, + 'binned_time_on_task': {}, + 'current_bin': None + } + + +def get_time_bin(timestamp, bin_size): + b = (timestamp // bin_size) * bin_size + return int(b) + + +def update_binned_time_on_task(internal_state, current_bin, last_timestamp, delta_time, bin_size): + '''Handle updating the internal state for binned time on task.''' + next_bin = current_bin + bin_size + next_bin_str = str(next_bin) + + current_bin_str = str(current_bin) + if current_bin_str not in internal_state['binned_time_on_task']: + internal_state['binned_time_on_task'][current_bin_str] = 0 + + if last_timestamp + delta_time >= next_bin: + internal_state['binned_time_on_task'][current_bin_str] += next_bin - last_timestamp + if next_bin_str not in internal_state['binned_time_on_task']: + internal_state['binned_time_on_task'][next_bin_str] = 0 + internal_state['binned_time_on_task'][next_bin_str] += last_timestamp + delta_time - next_bin + else: + internal_state['binned_time_on_task'][current_bin_str] += delta_time + + +def apply_binned_time_on_task( + internal_state, + current_timestamp, + time_delta_threshold, + bin_size +): + if internal_state is None: + internal_state = default_binned_time_on_task_state() + last_timestamp = internal_state['saved_ts'] + current_bin = internal_state['current_bin'] + internal_state['saved_ts'] = current_timestamp + + if last_timestamp is None: + last_timestamp = internal_state['saved_ts'] + if current_bin is None: + current_bin = get_time_bin(last_timestamp, bin_size) + + if last_timestamp is not None: + delta_time = min( + time_delta_threshold, + internal_state['saved_ts'] - last_timestamp + ) + update_binned_time_on_task(internal_state, current_bin, last_timestamp, delta_time, bin_size) + + internal_state['current_bin'] = get_time_bin(internal_state['saved_ts'], bin_size) + return internal_state diff --git a/learning_observer/learning_observer/synthetic_student_data.py b/learning_observer/learning_observer/synthetic_student_data.py new file mode 100644 index 000000000..8bec47715 --- /dev/null +++ b/learning_observer/learning_observer/synthetic_student_data.py @@ -0,0 +1,61 @@ +''' +Note that current loremipsum in `pip` is not Python 3 +compatible. If you are getting b'' in your text, the +patch is at: + +`https://github.com/monkeython/loremipsum/issues/10` +''' + +import random + +import numpy +import numpy.random + +import loremipsum +import names + +import learning_observer.util as util + + +def synthetic_student_data(student_id): + ''' + Create fake student data for mock-up UX for one student + ''' + name = names.get_first_name() + essay = "\n".join(loremipsum.get_paragraphs(5)) + return { + 'id': student_id, + 'name': name, + 'email': "{name}@school.district.us".format(name=name), + 'address': "1 Main St", + 'phone': "({pre})-{mid}-{post}".format( + pre=random.randint(200, 999), + mid=random.randint(200, 999), + post=random.randint(1000, 9999)), + 'avatar': "avatar-{number}".format(number=random.randint(0, 14)), + 'ici': random.uniform(100, 1000), + 'essay_length': len(essay), + 'essay': essay, + 'writing_time': random.uniform(5, 60), + 'text_complexity': random.uniform(3, 9), + 'google_doc': "https://docs.google.com/document/d/1YbtJGn7ida2IYNgwCFk3SjhsZ0ztpG5bMzA3WNbVNhU/edit", + 'time_idle': numpy.random.gamma(0.5, scale=5), + 'outline': [{"section": "Problem " + str(i + 1), + "length": random.randint(1, 300)} for i in range(5)], + 'revisions': {} + } + + +def synthetic_data(student_count=20): + ''' + Generate paginated mock student data for `student_count` students. + ''' + data = [ + synthetic_student_data(i) + for i in range(student_count) + ] + return util.paginate(data, 4) + + +if __name__ == '__main__': + print(synthetic_data()) diff --git a/learning_observer/learning_observer/util.py b/learning_observer/learning_observer/util.py new file mode 100644 index 000000000..c3b085129 --- /dev/null +++ b/learning_observer/learning_observer/util.py @@ -0,0 +1,318 @@ +''' +Random helper functions. + +Design invariant: + +* This should not rely on anything in the system. + +We can relax the design invariant, but we should think carefully +before doing so. +''' +import asyncio +import collections +import dash.development.base_component +import datetime +import enum +import hashlib +import math +import numbers +import re +import uuid +from dateutil import parser + +import learning_observer + + +def paginate(data_list, nrows): + ''' + Paginate list `data_list` into `nrows`-item rows. + + This should move into the client + ''' + return [ + data_list[i * nrows:(i + 1) * nrows] + for i in range(math.ceil(len(data_list) / nrows)) + ] + + +def to_safe_filename(name): + ''' + Convert a name to a filename. The filename escapes any non-alphanumeric + characters, so there are no invalid or control characters. + + Can be converted back with `from_filename` + + For example, { would be encoded as -123- since { is character 123 in UTF-8. + ''' + return ''.join( + '-' + str(ord(c)) + '-' if not c.isidentifier() and not c.isalnum() else c + for c in name + ) + + +def from_safe_filename(filename): + ''' + Convert a filename back to a name. + + See `to_filename` for more information. + + Uses `re`, uncompiled, so probably not very fast. Right now, this is used + for testing / debugging, but might be worth optimizing if we ever use it + otherwise. + ''' + return re.sub(r'-(\d+)-', lambda m: chr(int(m.group(1))), filename) + + +def url_pathname(s): + """ + Remove URL and domain from a URL. Return the full remainder of the path. + + Input: https://www.googleapis.com/drive/v3/files + Output: drive/v3/files + + Note that in contrast to the JavaScript version, we don't include the + initial slash. + """ + return s.split('/', 3)[-1] + + +def translate_json_keys(d, translations): + """ + Replace all of the keys in the dictionary with new keys, including + sub-dictionaries. This was written for converting CamelCase from + Google APIs to snake_case. + + Note that this mutates the original data structure + """ + if isinstance(d, list): + for item in d: + translate_json_keys(item, translations) + elif isinstance(d, dict): + for k, v in list(d.items()): + if k in translations: + d[translations[k]] = d.pop(k) + else: + pass # print("UNTRANSLATED KEY: ", k) + + if isinstance(v, dict) or isinstance(v, list): + translate_json_keys(v, translations) + return d + + +def secure_hash(text): + ''' + Our standard hash functions. We can either use either + + * A full hash (e.g. SHA3 512) which should be secure against + intentional attacks (e.g. a well-resourced entity wants to temper + with our data, or if Moore's Law starts up again, a well-resourced + teenager). + + * A short hash (e.g. MD5), which is no longer considered + cryptographically-secure, but is good enough to deter casual + tempering. Most "tempering" comes from bugs, rather than attackers, + so this is very helpful still. MD5 hashes are a bit more manageable + in size. + + For now, we're using full hashes everywhere, but it would probably + make sense to alternate as makes sense. MD5 is 32 characters, while + SHA3_512 is 128 characters (104 if we B32 encode). + ''' + return "SHA512_" + hashlib.sha3_512(text).hexdigest() + + +def insecure_hash(text): + ''' + See `secure_hash` above for documentation + ''' + return "MD5_" + hashlib.md5(text).hexdigest() + + +class MissingType(enum.Enum): + Missing = 'Missing' + + +def get_nested_dict_value(d, key_str=None, default=MissingType.Missing): + """ + Fetch an item from a nested dictionary using `.` to indicate nested keys + + :param d: Dictionary to be searched + :type d: dict + :param key_str: Keys to iterate over + :type key_str: str + :return: Value of nested dictionary + """ + if key_str is None: + key_str = '' + keys = key_str.split('.') + for key in keys: + if isinstance(d, dict) and key in d: + d = d[key] + elif key == '': + d = d + else: + if default == MissingType.Missing: + raise KeyError(f'Key `{key_str}` not found in {d}') + return default + return d + + +def remove_nested_dict_value(d, key_str): + """ + Remove an item from a nested dictionary using `.` to indicate nested keys + """ + keys = key_str.split('.') + for key in keys[:-1]: + if d is not None and key in d: + d = d[key] + else: + raise KeyError(f'Key `{key_str}` not found in {d}') + if keys[-1] in d: + return d.pop(keys[-1]) + else: + raise KeyError(f'Key `{key_str}` not found in {d}') + + +def clean_json(json_object): + ''' + * Deep copy a JSON object + * Convert list-like objects to lists + * Convert dictionary-like objects to dicts + * Convert functions to string representations + ''' + if isinstance(json_object, str): + return str(json_object) + if isinstance(json_object, numbers.Number): + return json_object + if isinstance(json_object, dict): + return {key: clean_json(value) for key, value in json_object.items()} + if isinstance(json_object, list) or isinstance(json_object, tuple): + return [clean_json(i) for i in json_object] + if isinstance(json_object, learning_observer.stream_analytics.fields.Scope): + # We could make a nicer representation.... + return str(json_object) + if callable(json_object): + return str(json_object) + if json_object is None: + return json_object + if str(type(json_object)) == "": + return str(json_object) + if str(type(json_object)) == "": + return str(json_object) + if str(type(json_object)) == "": + return list(json_object) + if isinstance(json_object, dash.development.base_component.Component): + return f"Dash Component {json_object}" + if isinstance(json_object, KeyError): + return str(json_object) + raise ValueError("We don't yet handle this type in clean_json: {} (object: {})".format(type(json_object), json_object)) + + +def timestamp(): + """ + Return a timestamp string in ISO 8601 format + + Returns: + str: The timestamp string. + + The timestamp is in UTC. + """ + return datetime.datetime.utcnow().isoformat() + + +def timeparse(timestamp): + """ + Parse an ISO-8601 datetime string into a datetime.datetime + + Returns: + datetime: datetime object converted from the string timestamp. + + """ + return parser.isoparse(timestamp) + + +def get_seconds_since_epoch(): + ''' + Return a timestamp in the seconds since epoch format + + Returns: + int: seconds since last epoch + ''' + return datetime.datetime.now().timestamp() + + +count = 0 + + +def generate_unique_token(): + '''Update the system counter and return a new unique token. + ''' + global count + count = count + 1 + return f'{count}-{timestamp()}-{str(uuid.uuid4())}' + + +async def ensure_async_generator(it): + '''Take an iterable or single dict item and return it + as an async generator. + ''' + if isinstance(it, dict): + yield it + elif isinstance(it, collections.abc.AsyncIterable): + # If it is already an async iterable, yield from it + async for item in it: + yield item + elif isinstance(it, collections.abc.Iterable): + # If it is a synchronous iterable, iterate over it and yield items + for item in it: + yield item + else: + raise TypeError(f"Object of type {type(it)} is not iterable") + + +async def async_zip(iterator1, iterator2): + '''Zip 2 async generators together. + This functions similar to `zip` + ''' + gen1 = ensure_async_generator(iterator1) + gen2 = ensure_async_generator(iterator2) + try: + while True: + # asyncio.gather finishes when both `anext` items are ready + item1, item2 = await asyncio.gather( + gen1.__anext__(), + gen2.__anext__() + ) + yield item1, item2 + except StopAsyncIteration: + pass + + +async def async_generator_to_list(gen): + '''This is a helper function for converting an async generator + to a list. This is often used when testing pieces of an async + generator pipeline. + ''' + result = [] + async for item in gen: + result.append(item) + return result + + +def get_domain_from_email(email): + '''Helper function to extract the domain from an email address + ''' + if email is None: + return None + if '@' in email: + return email.split('@')[1] + return None + + +# And a test case +if __name__ == '__main__': + assert to_safe_filename('{') == '-123-' + assert from_safe_filename('-123-') == '{' + test_string = "Hello? How are -- you doing? łłł" + assert from_safe_filename(to_safe_filename(test_string)) == test_string + assert url_pathname('https://www.googleapis.com/drive/v3/files') == 'drive/v3/files' diff --git a/learning_observer/learning_observer/utility_handlers.py b/learning_observer/learning_observer/utility_handlers.py new file mode 100644 index 000000000..a51d072c0 --- /dev/null +++ b/learning_observer/learning_observer/utility_handlers.py @@ -0,0 +1,81 @@ +''' +Helpful extra handlers +''' + +import os +import os.path + +import aiohttp +import aiohttp.web + +import pathvalidate + + +# This should be cleaned up. Imports generally. We're mid-refactor... +from learning_observer.log_event import debug_log + + +def static_file_handler(filename): + ''' + Serve a single static file + ''' + async def handler(request): + debug_log(request.headers) + return aiohttp.web.FileResponse(filename) + return handler + + +def json_response_handler(data): + '''Serve data (dict-like) as a json response + ''' + async def handler(request): + return aiohttp.web.json_response(data) + return handler + + +def redirect(new_path): + ''' + Static, fixed redirect to a new location + ''' + async def handler(request): + raise aiohttp.web.HTTPFound(location=new_path) + return handler + + +def static_directory_handler(basepath): + ''' + Serve static files from a directory. + + This could be done directly by nginx on deployment. + + This is very minimal for now: No subdirectories, no gizmos, + nothing fancy. I avoid fancy when we have user input and + filenames. Before adding fancy, I'll want test cases of + aggressive user input. + ''' + + def handler(request): + ''' + We're in a closure, since we want to configure the directory + when we set up the path. + ''' + # Extract the filename from the request + filename = request.match_info['filename'] + # Raise an exception if we get anything nasty + pathvalidate.validate_filename(filename) + # Check that the file exists + full_pathname = os.path.join(basepath, filename) + if not os.path.exists(full_pathname): + raise aiohttp.web.HTTPNotFound() + # And serve pack the file + return aiohttp.web.FileResponse(full_pathname) + return handler + + +def ajax_handler_wrapper(handler_func): + ''' + Wrap a function which returns a JSON object to handle requests + ''' + def handler(request): + return aiohttp.web.json_response(handler_func()) + return handler diff --git a/learning_observer/learning_observer/watchdog_observer.py b/learning_observer/learning_observer/watchdog_observer.py new file mode 100644 index 000000000..010b98b64 --- /dev/null +++ b/learning_observer/learning_observer/watchdog_observer.py @@ -0,0 +1,166 @@ +''' +This is a subsystem designed to restart the system if files changed. + +It has two modes: + + - reimport: It will reimport all modules in the local directory. + - restart: It will hard restart the system + +It currently does not work. We need to make this work with asyncio: + +https://gist.github.com/mivade/f4cb26c282d421a62e8b9a341c7c65f6 + +However, we wanted to commit it since it doesn't break anything, and +we wanted everything to be is in sync. It is behind a feature flag, +and disabled by +''' + +import asyncio +import importlib +import os +import os.path +import sys +import time +import logging +import traceback +import watchdog + +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, LoggingEventHandler + +# TODO fix this +LOCAL_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def reimport_child_modules(paths=[LOCAL_PATH]): + ''' + Reload all modules which are in the given paths. + + This is used when we are running in watchdog mode, and we want to + restart parts of the server when a file changes. + + This does not do a full restart. See: + https://docs.python.org/3/library/importlib.html#importlib.reload + + We should probably be doing a full restart, but we wrote this before + we had a full restart option. Perhaps we should remove this? We'll + decide once we see how useful both options are. + + Args: + paths: A list of paths to search for modules. + + Returns: + A list of modules that were reloaded, a list of modules that + failed to reload, and a list of modules that we skipped (e.g. + system modules). + + If no path is specified, it defaults to the base directory of this file. + ''' + modules = list(sys.modules.values()) + reloaded = [] + failed = [] + + for module in modules: + # Only reload modules that are in the specified paths, + # and only if they are not system modules. + # + # A lot of these checks are probably redundant, but + # better safe than sorry. There is no ideal way to + # determine if a module should be reloaded, so this + # is a bit heuristic. + if not hasattr(module, '__file__'): + continue + if module.__file__ is None: + continue + if not module.__file__.endswith('.py'): + continue + if not any(module.__file__.startswith(path) for path in paths): + continue + if module.__name__.startswith('_'): + continue + if module.__name__ in sys.builtin_module_names: + continue + if not os.path.exists(module.__file__): + continue + if "SourceFileLoader" not in str(module.__loader__): + continue + try: + importlib.reload(module) + print('reloaded %s' % module.__name__) + reloaded.append(module) + except Exception: + print("Failed to reload %s" % module.__name__) + traceback.print_exc() + failed.append(module) + skipped = [m for m in modules if m not in reloaded and m not in failed] + return { + "reloaded": reloaded, + "failed": failed, + "skipped": skipped + } + + +def restart(): + ''' + Restart the system. + ''' + os.execl(sys.executable, sys.executable, *sys.argv) + + +FILETYPES_TO_WATCH = ['yaml', 'py', 'js'] + + +class RestartHandler(FileSystemEventHandler): + ''' + Soft restart the server when a file changes. + + We could even just re-import the one file instead of everything? + ''' + def __init__(self, shutdown, restart, start): + self.shutdown = shutdown + self.restart = restart + self.start = start + + def on_any_event(self, event): + ''' + On any change in the file system, restart the server. + + We should be more selective, looking only at Python files, config file, + and skipping cache files, but for now we'll restart on any change, + since this is helpful for testing this module. + ''' + if (event.is_directory or + event.src_path.split('.')[-1] not in FILETYPES_TO_WATCH or + event.event_type != 'modified'): + return None + print("Reloading server", event) + asyncio.run(self.handle_restart()) + + async def handle_restart(self): + await self.shutdown() + await self.restart() + + +def watchdog(handler=LoggingEventHandler()): + ''' + Set up watchdog mode. This will (eventually) reimport on file changes. + ''' + event_handler = handler + observer = Observer() + print("Watching for changes in:", LOCAL_PATH) + observer.schedule(event_handler, LOCAL_PATH, recursive=True) + observer.start() + return observer + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + observer = watchdog() + try: + while True: + time.sleep(1) + finally: + observer.stop() + observer.join() diff --git a/learning_observer/learning_observer/webapp_helpers.py b/learning_observer/learning_observer/webapp_helpers.py new file mode 100644 index 000000000..4210a0548 --- /dev/null +++ b/learning_observer/learning_observer/webapp_helpers.py @@ -0,0 +1,127 @@ +''' +This file contains assorted middlewares and helpers +''' +import errno +import pmss +import socket + +import aiohttp_cors + +import aiohttp_session +import aiohttp_session.cookie_storage + +import learning_observer.auth +from learning_observer.log_event import debug_log +import learning_observer.settings as settings + + +pmss.register_field( + name='session_secret', + type=pmss.TYPES.passwordtoken, + description='Unique secret key for YOUR deployment to encrypt/decrypt '\ + 'data stored in the session object.', + required=True +) +pmss.register_field( + name='session_max_age', + type=pmss.TYPES.integer, + description='Max age of a session in seconds.', + required=True +) + + +async def request_logger_middleware(request, handler): + ''' + Print all hits. Helpful for debugging. Should eventually go into a + log file. + ''' + debug_log(request) + + +async def add_nocache_middleware(request, response): + ''' + This prevents the browser from caching pages. + + Browsers do wonky things when logging in / out, keeping old pages + around. Caching generally seems like a train wreck for this system. + There's a lot of cleanup we can do to make this more robust, but + for now, this is a good enough solution. + ''' + if '/static/' not in str(request.url): + response.headers['cache-control'] = 'no-cache' + + +def setup_middlewares(app): + ''' + This is a helper function to setup middlewares. + ''' + app.on_response_prepare.append(request_logger_middleware) + # Avoid caching. We should be more specific about what we want to + # cache. + app.on_response_prepare.append(add_nocache_middleware) + app.middlewares.append(learning_observer.auth.auth_middleware) + + +def setup_session_storage(app): + ''' + This is a helper function to setup session storage. + ''' + protocol = settings.pmss_settings.protocol() + cookie_params = {} + if protocol == 'https': + debug_log('Setting cookie parameters for HTTPS') + cookie_params = { + 'domain': settings.pmss_settings.hostname(), + 'secure': True, + 'samesite': 'None' + } + aiohttp_session.setup(app, aiohttp_session.cookie_storage.EncryptedCookieStorage( + learning_observer.auth.fernet_key(settings.pmss_settings.session_secret(types=['aio'])), + max_age=settings.pmss_settings.session_max_age(types=['aio']), + **cookie_params)) + + +def find_open_port(): + """ + Find an open port to run on. + + By default, run on port 8888. If in use, move up ports, until we find + one that is not in use. + + Returns: + int: The open port. + """ + port = 8888 + bound = False + while not bound: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", port)) + bound = True + except socket.error as e: + if e.errno == errno.EADDRINUSE: + bound = False + port = port + 1 + else: + raise + s.close() + return port + + +def setup_cors(app): + ''' + This is a helper function to setup CORS. + + This setup is overly broad. We need this for incoming events + and similar, but we don't want to expose the entire API + through this as we do here. + + TODO: Handle auth / auth more specifically on individual routes. + ''' + cors = aiohttp_cors.setup(app, defaults={ + "*": aiohttp_cors.ResourceOptions( + allow_credentials=True, + expose_headers="*", + allow_headers="*", + ) + }) diff --git a/learning_observer/prototypes/README.md b/learning_observer/prototypes/README.md new file mode 100644 index 000000000..6cd6c490c --- /dev/null +++ b/learning_observer/prototypes/README.md @@ -0,0 +1,15 @@ +Early Prototypes and Draft Code +=============================== + +This is a place for unfinished code... I don't commit most prototypes, +but once something is discussion-worthy or a good starting point, I +sometimes do. + +Prototypes and proofs-of-concept: + +* Help scope out future work +* Help understand capabilities and limitations +* Help us learn +* Are sometimes starting points for implementation + +Code here is of mixed quality, obviously. \ No newline at end of file diff --git a/learning_observer/prototypes/deprecated/orm.py b/learning_observer/prototypes/deprecated/orm.py new file mode 100644 index 000000000..d1b222611 --- /dev/null +++ b/learning_observer/prototypes/deprecated/orm.py @@ -0,0 +1,68 @@ +''' +THIS FILE IS NOT CURRENTLY USED. WE ARE PROTOTYPING. + +Abstraction to access database ''' +import asyncio +import functools + +import json +import yaml + +import asyncpg + +sql_statements = yaml.safe_load(open("init.sql")) + +conn = None + +stored_procedures = {} + + +async def initialize(reset=False): + global conn + print("Connecting to database...") + # Connect to the database + conn = await asyncpg.connect() + if reset: + await conn.execute(sql_statements['reset']) + + # Set up tables and stored procedures, if they don't exist. + await conn.execute(sql_statements['init']) + + # Set up stored procedures + for stored_procedure in sql_statements['stored_procedures']: + stored_procedures[stored_procedure] = await conn.prepare( + sql_statements['stored_procedures'][stored_procedure] + ) + print("Connected...") + +asyncio.get_event_loop().run_until_complete(initialize()) + + +# TODO: This should be done with a decorator, rather than cut-and-paste +def fetch_events(username, docstring): + return stored_procedures['fetch_events'].cursor(username, docstring) + + +async def insert_event(username, docstring, event): + rv = await stored_procedures['insert_event'].fetchval( + username, docstring, event + ) + return rv + + +if __name__ == '__main__': + async def test(): + print(await insert_event("pmitros", "doc", json.dumps({ + "ty": "ts", + "si": 5, + "ei": 7, + "ibi": 2, + "s": "hel", + "f": "lo" + }))) + async with conn.transaction(): + cursor = fetch_events("pmitros", "doc") + async for record in cursor: + print(record) + + asyncio.get_event_loop().run_until_complete(test()) diff --git a/learning_observer/prototypes/google_docs/README.md b/learning_observer/prototypes/google_docs/README.md new file mode 100644 index 000000000..e572fcfc7 --- /dev/null +++ b/learning_observer/prototypes/google_docs/README.md @@ -0,0 +1,44 @@ +Google Docs APIs +================ + +These are experiments with the Google Docs and Google Drive APIs. On +the whole, the APIs are developer-friendly and easy-to-use. They run +into a few brick walls for our use-case. Upsides: + +* We can grab ground truth documents via the Google Drive and Google + Docs APIs, at least assuming the document is shared with the teacher + (which may or may not be the case), or by the student. +* We can grab [comments](Link https://developers.google.com/drive/api/v3/reference/comments), + [Revisions](https://developers.google.com/drive/api/v3/reference/revisions) (although not + with the same granularity as our extension), + [comment replies](https://developers.google.com/resources/api-libraries/documentation/drive/v3/python/latest/drive_v3.replies.html), + and [suggested revisions](https://developers.google.com/docs/api/how-tos/suggestions) +* There is a poorly-document API which appears to monitor for changes (https://developers.google.com/drive/api/v3/reference/channels) +* Some of the APIs include [indexes](https://developers.google.com/docs/api/how-tos/overview) +* We can get a lot more through Vault, but I'm not sure schools would + grant us that kind of access. It's also tough to test too, since it + requires a Google Workspace account of the right type. + +The major constraints are: + +* Google's permissions and auth system, which isn't really designed + for automation or monitoring. They're designed to grant short-term, + expiring access, although it looks like Google recently added + [service accounts](https://github.com/googleapis/google-api-python-client/blob/master/docs/oauth-server.md) + which may address this issue. +* They're not designed for realtime use (e.g. monitoring writing + processes) + +The APIs couldn't replace our pipeline, but would be a helpful +supplement. + +Note that this code would need to be rewritten for *Writing Observer*, +since the [client +library](https://github.com/googleapis/google-api-python-client/blob/master/docs/README.md) +we're using is not asynchronous, and would lead to performance issues. + +We also (in the current version) do no pagination; this is just to +understand the types of data returned. + +To get started, you will need a `credentials.json` from Google's API +console, set up for a desktop application. \ No newline at end of file diff --git a/learning_observer/prototypes/google_docs/google_apis.py b/learning_observer/prototypes/google_docs/google_apis.py new file mode 100644 index 000000000..4368c4c4c --- /dev/null +++ b/learning_observer/prototypes/google_docs/google_apis.py @@ -0,0 +1,113 @@ +# TODO/HACK/Unfinished: +# +# * We do *not* handle pagination in this prototype. + +import argparse +import os.path + +import json + +from googleapiclient.discovery import build +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials + + +# If modifying these scopes, delete the file token.json. +SCOPES = [ + 'https://www.googleapis.com/auth/documents.readonly', + 'https://www.googleapis.com/auth/drive.metadata.readonly', + 'https://www.googleapis.com/auth/drive.readonly' +] + + +def list_files(creds): + service = build('drive', 'v3', credentials=creds) + + # Call the Drive v3 API + results = service.files().list( + pageSize=10, fields="nextPageToken, files(id, name)").execute() + items = results.get('files', []) + print(items) + + +def document(creds, document_id): + service = build('docs', 'v1', credentials=creds) + + # This is an optional keyword parameter we should play with + # later. + suggestion_modes = [ + "DEFAULT_FOR_CURRENT_ACCESS", + "SUGGESTIONS_INLINE", + "PREVIEW_SUGGESTIONS_ACCEPTED", + "PREVIEW_WITHOUT_SUGGESTIONS" + ] + + SUGGESTION_MODE = suggestion_modes[0] + + document = service.documents().get( + documentId=document_id + ).execute() + print('The title of the document is: {}'.format(document.get('title'))) + return document + + +def document_revisions(creds, document_id): + service = build('drive', 'v3', credentials=creds) + r = service.revisions() + return r.list(fileId=document_id).execute() + + +def document_comments(creds, document_id): + service = build('drive', 'v3', credentials=creds) + return service.comments().list( + fileId=document_id, + fields="*", + includeDeleted=True + ).execute() + + +def authenticate(): + creds = None + # The file token.json stores the user's access and refresh tokens, and is + # created automatically when the authorization flow completes for the first + # time. + if os.path.exists('token.json'): + creds = Credentials.from_authorized_user_file('token.json', SCOPES) + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if False and creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + 'credentials.json', SCOPES) + creds = flow.run_local_server(port=0) + # Save the credentials for the next run + with open('token.json', 'w') as token: + token.write(creds.to_json()) + return creds + + +def main(document_id): + """Shows basic usage of the Docs API. + Prints the title of a sample document. + """ + creds = authenticate() + print(list_files(creds)) + print("Document:") + print(document(creds, document_id)) + with open("doc.json", "w") as fp: + fp.write(json.dumps(document(creds, document_id), indent=2)) + print("Document revisions:") + print(document_revisions(creds, document_id)) + print("Document comments:") + print(document_comments(creds, document_id)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + "document_id", help="Google document ID. Usually 44 characters long" + ) + args = parser.parse_args() + main(args.document_id) diff --git a/learning_observer/prototypes/google_docs/requirements.txt b/learning_observer/prototypes/google_docs/requirements.txt new file mode 100644 index 000000000..b572a3930 --- /dev/null +++ b/learning_observer/prototypes/google_docs/requirements.txt @@ -0,0 +1,3 @@ +google-api-python-client +google-auth-httplib2 +google-auth-oauthlib diff --git a/learning_observer/prototypes/local_reducer.ipynb b/learning_observer/prototypes/local_reducer.ipynb new file mode 100644 index 000000000..b1b79b2b2 --- /dev/null +++ b/learning_observer/prototypes/local_reducer.ipynb @@ -0,0 +1,247 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0bacc725-4463-48fe-9731-83c4e40515aa", + "metadata": {}, + "source": [ + "# Running Reducers Locally\n", + "\n", + "This document serves as a walkthrough to setting up a reducer and running it locally." + ] + }, + { + "cell_type": "markdown", + "id": "6c78e246-6158-437f-9510-e4d23596e69d", + "metadata": {}, + "source": [ + "## Initial Setup\n", + "\n", + "The Learning Observer platform offers an offline mode that will initialize all the necessary settings and modules." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d6c55db5-55fd-4c41-9af3-844200209967", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARN:: Unrecognized Minty URL detected: https://cdn.jsdelivr.net/npm/bootswatch@5.2.3/dist/minty/bootstrap.min.css\n", + "You will need to update dash bootstrap components hash value.\n", + "\n", + "WARN:: Unrecognized Minty URL detected: https://cdn.jsdelivr.net/npm/bootswatch@5.2.3/dist/minty/bootstrap.min.css\n", + "You will need to update dash bootstrap components hash value.\n", + "\n", + "WARN:: Unrecognized Minty URL detected: https://cdn.jsdelivr.net/npm/bootswatch@5.2.3/dist/minty/bootstrap.min.css\n", + "You will need to update dash bootstrap components hash value.\n", + "\n" + ] + } + ], + "source": [ + "import learning_observer.offline\n", + "learning_observer.offline.init()" + ] + }, + { + "cell_type": "markdown", + "id": "3f8b9ac0-a916-43c6-9423-7e8df3be1c83", + "metadata": {}, + "source": [ + "## Creating the Reducer\n", + "\n", + "To create turn a function into a reducer, we need to wrap it in the `kvs_pipeline` decorator. This decorator handles setting the appropriate items in the KVS when the reducer is ran. Note that reducer functions are expected to take in an `event` and a `state` parameter. The function should output the `Internal` and `External` state.\n", + "\n", + "Additionally, we need to register our new reducer with the Learning Observer platform. When we want to query the communication protocol for data from this reducer, the system looks for the `id` of our reducer in the registered reducers." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "44ef1583-5086-4460-8c02-65b076cd9473", + "metadata": {}, + "outputs": [], + "source": [ + "from learning_observer.stream_analytics.helpers import kvs_pipeline, KeyField, Scope\n", + "import learning_observer.module_loader\n", + "\n", + "# create reducer function\n", + "@kvs_pipeline(scope=Scope([KeyField.STUDENT]), module_override='testing')\n", + "async def event_counter(event, state):\n", + " if state is None:\n", + " state = {}\n", + " state['event_count'] = state.get('event_count', 0) + 1\n", + " return state, state\n", + "\n", + "# define specific information and register reducer\n", + "reducer = {\n", + " 'context': 'local.testing',\n", + " 'function': event_counter,\n", + " 'scope': Scope([KeyField.STUDENT]),\n", + " 'default': {'event_count': 0},\n", + " 'module': 'testing',\n", + " 'id': 'test-event-reducer'\n", + "}\n", + "reducers = learning_observer.module_loader.add_reducer(reducer, 'test-event-reducer')" + ] + }, + { + "cell_type": "markdown", + "id": "004b224a-a12e-4ea2-8984-04c1b531a407", + "metadata": {}, + "source": [ + "## Running the Reducer over Data\n", + "\n", + "Learning Observer's offline mode allows for processing event files through reducers. First, we define which files we want ran. Then, we process them through a specific reducer (the `pipeline` parameter`)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b72f9c3a-21b2-4cf8-9e50-3bf43e599116", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(1214, 'localhost.testcase', 'Tester')" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "input_path = os.path.join(os.getcwd(), 'learning_observer', 'learning_observer', 'logs', 'sample01.log')\n", + "await learning_observer.offline.process_file(file_path=input_path, source=\"localhost.testcase\", pipeline=event_counter, userid='Tester')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3334198b-800c-43df-975b-7dcb0f40317f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Keys:\n", + "['Internal,testing.event_counter,STUDENT:Tester', 'External,testing.event_counter,STUDENT:Tester']\n" + ] + }, + { + "data": { + "text/plain": [ + "{'event_count': 1214}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# check to see if our reducer ran correctly\n", + "kvs = learning_observer.kvs.KVS()\n", + "print(\"Keys:\")\n", + "keys = await kvs.keys()\n", + "print(keys)\n", + "await kvs['Internal,testing.event_counter,STUDENT:Tester']\n" + ] + }, + { + "cell_type": "markdown", + "id": "29f13776-79a9-4482-9509-3d24edb91c6b", + "metadata": {}, + "source": [ + "## Query Reducer\n", + "\n", + "To properly query the results of a reducer in the communication protocol, we need to create and execute an execution DAG." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "3a8c0a35-61f4-45fe-82b7-38bb73d04373", + "metadata": {}, + "outputs": [], + "source": [ + "import learning_observer.communication_protocol.query as q\n", + "course_roster = q.call('learning_observer.courseroster')\n", + "EXECUTION_DAG = {\n", + " \"execution_dag\": {\n", + " \"roster\": course_roster(runtime=q.parameter(\"runtime\"), course_id=q.parameter(\"course_id\", required=True)),\n", + " 'event_count': q.select(q.keys('test-event-reducer', STUDENTS=q.variable(\"roster\"), STUDENTS_path='user_id'), fields={'event_count': 'event_count'}),\n", + " },\n", + " \"exports\": {\n", + " 'event_count': {\n", + " 'returns': 'event_count'\n", + " }\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "d7106acd-b678-4590-894d-1e19bcaa08a8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'event_count': [{'event_count': 1214}]}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import learning_observer.communication_protocol.integration\n", + "import learning_observer.runtime\n", + "func = learning_observer.communication_protocol.integration.prepare_dag_execution(EXECUTION_DAG, ['event_count'])\n", + "await func(course_id=12345, runtime=learning_observer.runtime.Runtime(None))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "253b9d4c-0134-4368-83f8-2514cd37bba6", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/learning_observer/prototypes/proxy.py b/learning_observer/prototypes/proxy.py new file mode 100644 index 000000000..bc41a939f --- /dev/null +++ b/learning_observer/prototypes/proxy.py @@ -0,0 +1,123 @@ +''' +This is a PROTOTYPE proxy request handler. It is designed to be used with +aiohttp. The goal is to be able to connnect to Jupyter Notebook servers, and +relay the requests to them. + +This currently works 90% with Jupyter Notebook servers running on localhost, +but runs into issues with API requests. That's probably some CSRF issue, or +similar. + +We could also try to monitor at the ZMQ level, and relay the requests to +the notebook server. +''' + +from ast import Not +import asyncio +from datetime import datetime +import re +import multidict + +from aiohttp import web +import aiohttp + +BASE_URL = "http://localhost:8889" + + +async def proxy( + base_url=BASE_URL, + source_port=8080, + target_port=8889, +): + async def proxy_handler(request): + ''' + Relay HTTP requests from 8080 to 8889 + + This is the main handler for the proxy. + ''' + print(request) + target_url = base_url + request.path + + cookies = request.cookies + headers = multidict.CIMultiDict(request.headers) + if "referer" in headers: + old_referer = headers['referer'] + headers.popall("referer") + headers['referer'] = old_referer.replace( + str(source_port), str(target_port) + ) + async with aiohttp.ClientSession() as client: + if request.method == "POST": + post_data = await request.post() + print("PD", post_data) + resp = await client.post( + target_url, + data=post_data, + cookies=cookies, + headers=headers + ) + elif request.method == "GET": + resp = await client.get( + target_url, + cookies=cookies, + headers=headers + ) + elif request.method == "PUT": + put_data = await request.post() + resp = await client.put( + target_url, + data=put_data, + cookies=cookies, + headers=headers + ) + else: + raise NotImplementedError( + "Unsupported method: " + request.method + ) + data = await resp.read() + + if resp.status == 200: + data = await resp.read() + return web.Response( + body=data, + status=resp.status, + headers=resp.headers + ) + elif resp.status == 301: + return web.HTTPFound(resp.headers['Location']) + elif resp.status == 302: + return web.HTTPFound(resp.headers['Location']) + elif resp.status == 304: + return web.Response(status=resp.status) + elif resp.status == 401: + return web.HTTPUnauthorized() + elif resp.status == 404: + return web.HTTPNotFound() + elif resp.status == 403: + print(resp) + print(data) + return web.HTTPForbidden() + else: + print("Error:", resp.status) + return web.HTTPInternalServerError() + return proxy_handler + + +async def init_app(): + ''' + This is the main entry point for testing the proxy. + + It creates a proxy server and runs it in a loop. This is useful for + testing the proxy without the full system. + ''' + app = web.Application() + p = await proxy() + app.router.add_get('/{path:.*}', p) + app.router.add_post('/{path:.*}', p) + app.router.add_put('/{path:.*}', p) + return app + + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + app = loop.run_until_complete(init_app()) + web.run_app(app) diff --git a/learning_observer/prototypes/selenium_gdocs_automation/README.md b/learning_observer/prototypes/selenium_gdocs_automation/README.md new file mode 100644 index 000000000..6b8eb7424 --- /dev/null +++ b/learning_observer/prototypes/selenium_gdocs_automation/README.md @@ -0,0 +1,21 @@ +Front-end test infrastructure +============================= + +It'd be great if we could do front-end testing. This is a prototype of +using Google Docs with [Selenium](https://www.selenium.dev/). + +Conclusions: + +1. Google plays a game of cat-and-mouse to prevent front-end + automation. It's annoying. I presume this is to stop some kind of + fraud. One might think Google would have better ways to shut + down fraud, but in this case, Google chose to externalize costs + onto customers. +2. People figure out work-arounds and Google shuts them down +3. The code, as committed, works right now, by using + [undetected-chromedriver](https://pypi.org/project/undetected-chromedriver/). +4. But it's possible it will stop tomorrow. + +Given the current size of the development team and the risk profile, I +decided not to throw more time into this right now. Joining the Google +cat-and-mouse game might make sense if/when the project expands. diff --git a/learning_observer/prototypes/selenium_gdocs_automation/selenium_gdoc.py b/learning_observer/prototypes/selenium_gdocs_automation/selenium_gdoc.py new file mode 100644 index 000000000..a89ac3165 --- /dev/null +++ b/learning_observer/prototypes/selenium_gdocs_automation/selenium_gdoc.py @@ -0,0 +1,110 @@ +''' +This is a script to log into Google Docs, and eventually do a +little bit of typing. + +This should not be used with your main Google account. + +For this to work, you will need to enable "Less secure app access." + +And then it still won't work... It's cat-and-mouse + +https://sqa.stackexchange.com/questions/42307/trying-to-login-to-gmail-with-selenium-but-this-browser-or-app-may-not-be-secur +https://stackoverflow.com/questions/60117232/selenium-google-login-block +https://stackoverflow.com/questions/57602974/gmail-is-blocking-login-via-automation-selenium + +undetected_chromedriver seems to work right now, but might stop tomorrow. +''' + +import os +import random +import sys +import time + +import undetected_chromedriver.v2 as uc +from selenium.webdriver.common.keys import Keys + +# I haven't validated this URL, and it should NOT be used in production unless +# it's confirmed to be a Google thing. I think it is, but I'm not sure. + +PLAYGROUND_OAUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth/" \ + "oauthchooseaccount?redirect_uri=https%3A%2F%2Fdevelopers.google.com%" \ + "2Foauthplayground&prompt=consent&response_type=code&" \ + "client_id=407408718192.apps.googleusercontent.com&scope=email&" \ + "access_type=offline&flowName=GeneralOAuthFlow" + +chrome_options = uc.ChromeOptions() + +chrome_options.add_argument("--disable-extensions") +chrome_options.add_argument("--disable-popup-blocking") +chrome_options.add_argument("--profile-directory=Default") +chrome_options.add_argument("--ignore-certificate-errors") +chrome_options.add_argument("--disable-plugins-discovery") +chrome_options.add_argument("--incognito") +chrome_options.add_argument("user_agent=DN") +driver = uc.Chrome(options=chrome_options) + +driver.delete_all_cookies() + +driver.get(PLAYGROUND_OAUTH_URL) + +USERNAME_XPATH = "/html/body/div[1]/div[1]/div[2]/div/div[2]/div/div/div[2]" \ + "/div/div[1]/div/form/span/section/div/div/div[1]/div/div[1]/div/div[1]" \ + "/input" + +PASSWORD_XPATH = "/html/body/div[1]/div[1]/div[2]/div/div[2]/div/div/div[2]/" \ + "div/div[1]/div/form/span/section/div/div/div[1]/div/div[1]/div/div[1]/input" + +print("Username: ") +driver.find_element_by_xpath(USERNAME_XPATH).send_keys(input()) +driver.find_element_by_xpath(PASSWORD_XPATH).send_keys(Keys.RETURN) + + +# Old (non-working) version: + +# import selenium.webdriver +# import time +# from selenium.webdriver.chrome.options import Options +# from selenium_stealth import stealth + +# chrome_options = Options() +# chrome_options.add_argument('--disable-useAutomationExtension') +# chrome_options.add_argument("--disable-popup-blocking") +# chrome_options.add_argument("--profile-directory=Default") +# chrome_options.add_argument("--disable-plugins-discovery") +# chrome_options.add_argument("--disable-web-security") +# chrome_options.add_argument("--allopw-running-insecure-content") +# chrome_options.add_argument("--incognito") +# chrome_options.add_argument("user_agent=DN") +# chrome_options.add_experimental_option("excludeSwitches", +# ["enable-automation"]) +# chrome_options.add_experimental_option('useAutomationExtension', False) + +# driver = selenium.webdriver.Chrome(options=chrome_options) +# stealth(driver, +# languages=["en-US", "en"], +# vendor="Google Inc.", +# platform="Win32", +# webgl_vendor="Intel Inc.", +# renderer="Intel Iris OpenGL Engine", +# fix_hairline=True, +# ) + +# def documentready(): +# return driver.execute_script('return document.readyState;') == 'complete' + +# driver.get(PLAYGROUND_OAUTH_URL) + +# while not documentready(): +# time.sleep(0.1) + +# time.sleep(1) + +# ets = driver.find_elements_by_css_selector("input") +# et = [e for e in ets if e.get_attribute("aria-label") == 'Email or phone'][0] +# et.send_keys() + +# btns = driver.find_elements_by_css_selector("button") +# btn = [b for b in btns if b.text == 'Next'][0] +# btn.click() + +# #driver.get("http://docs.google.com") diff --git a/learning_observer/prototypes/user_data_store.py b/learning_observer/prototypes/user_data_store.py new file mode 100644 index 000000000..b23edb0f1 --- /dev/null +++ b/learning_observer/prototypes/user_data_store.py @@ -0,0 +1,163 @@ +''' +Abstraction to access database + +We need to store a few types of data: + +1) An archival store of process data usable for: + - Posthoc analysis + - Error recovery + - Debugging +We expect to potentially receive multiple events per +second per student, so this needs to be able to handle +moderately high volume, high-velocity data. The JSON +format has a lot of redundancy, be design, and compresses +well. + +2) Working memory: + - Storing state as we stream process events + - This does not have to be reliable IF we have clean + mechanisms for recovering from the archival store + - We may have the same user connected to multiple machines + (e.g. when editing the same document on two computers), + so we probably cannot rely on async+in-memory. But we can + rely on high-speed in-memory like redis or memcached. + - We likely do need at least snapshots of some kind in + non-volatile memory, so we don't have to replay everythin + when we restart. + +There are open questions as to what granularity state should live +at (e.g. per-student, per-teacher, pre-resource, etc.), and +appropriate abstractions. + +3) Typical operational information: + +- Users table, with logins +- Probably some list of documents we're operating on +- Some way to map students to classes, so we know who ought to + receive data for which student. + +This naturally fits into a traditional SQL database, like postgresql +or sqlite. + +By design, we want to support at least two modes of operation: + +1) Small-scale (e.g. development / debugging), with no external + dependencies. Working on this project should not require spinning + up an army of cloud machines, servers, and microservices. Here, we + can e.g. store "large" process data in sqlite, or query static files + on disk. + +2) Scalable (e.g. deployment), where we can swap out local stores for + larger-scale stores requiring either serious dev-ops or serious + cloud $$$. + +We'd like to be able to go between the two smoothly (e.g. run all but one +service locally). + +Python asynchronous database support is limited. A few options: + +https://github.com/python-gino/gino +https://www.encode.io/databases/ (and https://github.com/encode/orm) + +As well as database-specific options such as: + +https://pypi.org/project/aiosqlite/ +https://github.com/aio-libs/aiopg + +We decided to try databases due to support for both sqlite and postgresql. +''' +import asyncio +import functools + +import json +import yaml + +import asyncpg +from databases import Database +import sqlalchemy + + +async def initialize(reset=False): + pass + + +async def set_resource_state(username, resource): + pass + + +async def get_resource_state(username, resource): + pass + + +async def fetch_events(username, resource): + ''' + Grab all the events for a particular user / resource + + `resource` is typically `googledocs://docstring` + ''' + pass + + +async def insert_event(username, resource, event): + ''' + Store an event in the database + ''' + pass + + +async def get_class(username, class_id=None): + ''' + Return all the students in a teacher's class. + + Teachers can have multiple classes. + ''' + pass + + +async def get_recipients(username): + ''' + Return all the teachers who should be notified of events for user + `username` + ''' + pass + + +# database = Database('sqlite:///example.db') +# await database.connect() + +metadata = sqlalchemy.MetaData() + +users = sqlalchemy.Table( + "users", + metadata, + sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column("username", sqlalchemy.String(length=100)), + # Should we have a roles table instead? + sqlalchemy.Column("is_student", sqlalchemy.Boolean()), + sqlalchemy.Column("is_teacher", sqlalchemy.Boolean()), + +) +schools = sqlalchemy.Table( + "schools", + metadata, + sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column("name", sqlalchemy.String(length=100)), +) + +classes = sqlalchemy.Table( + "classes", + metadata, + sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column("name", sqlalchemy.String(length=100)), +) + +class_students = sqlalchemy.Table( + "class_student", + metadata, + sqlalchemy.Column("student_id", sqlalchemy.Integer, sqlalchemy.ForeignKey("users.id")), + sqlalchemy.Column("class_id", sqlalchemy.Integer, sqlalchemy.ForeignKey("class.id")), +) + + +# engine = sqlalchemy.create_engine(str(database.url)) +# metadata.create_all(engine) diff --git a/learning_observer/pyproject.toml b/learning_observer/pyproject.toml new file mode 100644 index 000000000..7b959378a --- /dev/null +++ b/learning_observer/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/learning_observer/reducer_testing.ipynb b/learning_observer/reducer_testing.ipynb new file mode 100644 index 000000000..f857602c7 --- /dev/null +++ b/learning_observer/reducer_testing.ipynb @@ -0,0 +1,224 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "4a7dd2a2-0337-49de-aad1-4f4acc3857ae", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a custom reducer\n", + "\n", + "# import helpers to define reducer scope\n", + "from learning_observer.stream_analytics.helpers import kvs_pipeline, KeyField, Scope\n", + "\n", + "@kvs_pipeline(scope=Scope([KeyField.STUDENT]), module_override='testing')\n", + "async def event_counter(event, state):\n", + " '''This is a simple reducer to count the total\n", + " events for a given scope.\n", + " '''\n", + " if state is None:\n", + " state = {}\n", + " state['event_count'] = state.get('event_count', 0) - 1\n", + " return state, state" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "169ee920-e84a-489c-8a47-eeadfbb84ba2", + "metadata": {}, + "outputs": [], + "source": [ + "# Implement reducer into system with our h\n", + "ID = 'event_counter'\n", + "module = 'example_mod'\n", + "\n", + "import learning_observer.interactive_development\n", + "reducer = learning_observer.interactive_development.construct_reducer(ID, event_counter, module=module, default={'event_count': 0})\n", + "await learning_observer.interactive_development.hot_load_reducer(reducer, reload=True, migration_function=learning_observer.interactive_development.DROP_DATA)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0a1ca8fe-5ccc-41c6-8391-5e8b9c66cabc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({, }), 'default': {'saved_ts': 0}, 'module': , 'id': 'writing_observer.time_on_task'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({, }), 'default': {'text': ''}, 'module': , 'id': 'writing_observer.reconstruct'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {}, 'module': , 'id': 'writing_observer.event_count'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'docs': []}, 'module': , 'id': 'writing_observer.document_list'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'document_id': ''}, 'module': , 'id': 'writing_observer.last_document'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'tags': {}}, 'module': , 'id': 'writing_observer.document_tagging'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'timestamps': {}}, 'module': , 'id': 'writing_observer.document_access_timestamps'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'event_count': 0}, 'module': 'example_mod', 'id': 'event_counter'}]\n" + ] + } + ], + "source": [ + "import learning_observer.module_loader \n", + "print(learning_observer.module_loader.reducers())" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e70bd437-cbb5-4848-a48e-57c68cf96527", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "211" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import learning_observer.kvs\n", + "\n", + "kvs = learning_observer.kvs.KVS()\n", + "len(await kvs.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "77b3cd68-839e-46f7-b93b-b44aac5d5276", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Create a dashboard to connect to the reducer you just wrote\n", + "# This dashboard creates a graph for \"Total events over time\"\n", + "import dash\n", + "from dash import Dash, html, dcc, callback, Output, Input, State, clientside_callback, Patch\n", + "import time\n", + "import json\n", + "import lo_dash_react_components as lodrc\n", + "import pandas as pd\n", + "import plotly.graph_objects as go\n", + "\n", + "app = Dash(__name__)\n", + "\n", + "fig = go.Figure(data=go.Scatter(\n", + " x=pd.Series(dtype=object), y=pd.Series(dtype=object)\n", + "))\n", + "\n", + "# create app layout\n", + "app.layout = html.Div([\n", + " html.H4('Graph of event count'),\n", + " dcc.Graph(id='graph', figure=fig),\n", + " html.H4('Incoming data.'),\n", + " lodrc.LOConnection(id='ws', url='ws://localhost:9999/wsapi/communication_protocol')\n", + "])\n", + "\n", + "# Receive message from websocket and update graph\n", + "clientside_callback(\n", + " '''function(msg) {\n", + " if (!msg) {\n", + " return window.dash_clientside.no_update;\n", + " }\n", + " // extract data from message\n", + " const data = JSON.parse(msg.data);\n", + " console.log(data);\n", + " const students = data.test.event_count;\n", + " if (students === undefined) { return window.dash_clientside.no_update; }\n", + " if (students.length === 0) {\n", + " return window.dash_clientside.no_update;\n", + " }\n", + " // prep data for dcc.Graph.extendData\n", + " const studentIndex = 0;\n", + " const x = [Date.now() / 1000];\n", + " const y = [students[studentIndex].event_count];\n", + " return [\n", + " { x: [x], y: [y] },\n", + " [0]\n", + " ];\n", + " }''',\n", + " Output('graph', 'extendData'),\n", + " Input('ws', 'message')\n", + ")\n", + " \n", + "# Send connection information on the websocket when the connectedj\n", + "# NOTE that this uses an f''' (triple quote) string.\n", + "# Any curly braces need to be doubled up because of this.\n", + "clientside_callback(\n", + " f'''function(state) {{\n", + " if (state === undefined) {{\n", + " return window.dash_clientside.no_update;\n", + " }}\n", + " if (state.readyState === 1) {{\n", + " return JSON.stringify({{\"test\": {{\"execution_dag\": \"{module}\", \"target_exports\": [\"event_count\"], \"kwargs\": {{\"course_id\": 12345}}}}}});\n", + " }}\n", + " }}''',\n", + " Output('ws', 'send'),\n", + " Input('ws', 'state')\n", + ")\n", + "\n", + "# `jupyter_mode='inline'` will run the dashboard below\n", + "# `supress_callback_exceptions=True` will prevent dash\n", + "# from warning you about callbacks with missing IDS.\n", + "# These callbacks are from other dashboards.\n", + "app.run_server(jupyter_mode='inline', suppress_callback_exceptions=True)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4449c151-2119-432c-85d8-6c6a8cf64457", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Learning Observer Kernel", + "language": "python", + "name": "learning_observer_kernel" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/learning_observer/setup.cfg b/learning_observer/setup.cfg new file mode 100644 index 000000000..50a723bb2 --- /dev/null +++ b/learning_observer/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +name = Learning Observer +description = Learning Observer Core Package +url = http://www.ets.org +author_email = pmitros@ets.org +author = Piotr Mitros +version = file:VERSION + +[options] +packages = learning_observer + +[options.entry_points] +lo_modules = + lo_core = learning_observer.module +console_scripts = + learning_observer = learning_observer.run:run \ No newline at end of file diff --git a/learning_observer/setup.py b/learning_observer/setup.py new file mode 100644 index 000000000..28dd9e631 --- /dev/null +++ b/learning_observer/setup.py @@ -0,0 +1,29 @@ +''' +Install script. Everything is handled in setup.cfg + +To set up locally for development, run `python setup.py develop`, in a +virtualenv, preferably. +''' + +from setuptools import setup, find_packages +import os + +my_path = os.path.dirname(os.path.realpath(__file__)) +parent_path = os.path.abspath(os.path.join(my_path, os.pardir)) +req_path = os.path.join(parent_path, 'requirements.txt') +wo_path = os.path.join(parent_path, 'wo_requirements.txt') + + +def clean_requirements(filename): + file_path = os.path.join(parent_path, filename) + requirements = [s.strip() for s in open(file_path).readlines() if len(s) > 1] + return requirements + +setup( + install_requires=clean_requirements(req_path), + extras_require={ + "wo": clean_requirements(wo_path), + }, + packages=find_packages(), + package_data={'': ['static/**/*', 'static_data/*.template', 'creds.yaml.example', 'communication_protocol/schema.json']} +) diff --git a/learning_observer/test.sh b/learning_observer/test.sh new file mode 100755 index 000000000..11568d9c6 --- /dev/null +++ b/learning_observer/test.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# learning_observer/test.sh +echo "=================================================" +echo "Running tests for Learning Observer" +echo "=================================================" + +# Modify the commands below to fit your testing needs +echo "Running doctests" +# Learning Observer expects a `creds.yaml` file to be +# present. This command creates one if it does not +# already exist. +if [ ! -f "creds.yaml" ]; then + cp learning_observer/creds.yaml.workshop creds.yaml +fi + +# Including the `learning_observer/` path will ignore +# our `util` and `prototype` directories. We ignore +# the `main.py` file as it expects us to pass command +# line arguments which interferes with PyTest's +# `--doctest-modules` flag. +pytest --doctest-modules learning_observer/ --ignore=learning_observer/main.py diff --git a/list-unused-css.js b/list-unused-css.js new file mode 100644 index 000000000..05de19705 --- /dev/null +++ b/list-unused-css.js @@ -0,0 +1,77 @@ +const fs = require('fs') +const core = require('@actions/core') +const glob = require('glob') +const postcss = require('postcss') +const postcssScss = require('postcss-scss') + +const ingoreFiles = [ + './**/node_modules/**', './**/deps/**', './**/build/**', + './**/3rd_party/**', '.extension/**' +] + +const inputFiles = [ + ...glob.sync('./**/*.html', { ignore: ingoreFiles }), + ...glob.sync('./**/*.js', { ignore: ingoreFiles }), + ...glob.sync('./**/*.py', { ignore: ingoreFiles }) +] +const cssFiles = [ + ...glob.sync('./**/*.css', { ignore: ingoreFiles }), + ...glob.sync('./**/*.scss', { ignore: ingoreFiles }) +] + +const usedClasses = new Set() + +inputFiles.forEach((file) => { + const content = fs.readFileSync(file, 'utf8') + const classRegex = /(?:class(?:Name)(?:_name)?=["']|[\s.])([\w\s-]+)["'\s;]/g + let match + + while ((match = classRegex.exec(content)) !== null) { + const classes = match[1].split(' ').filter((cls) => cls.trim()) + classes.forEach((cls) => usedClasses.add(cls)) + } +}) +let totalUnusedClasses = 0 +let currentFile = null + +const listUnusedCSS = () => { + let unusedCount = 0 + return { + postcssPlugin: 'list-unused-css', + Rule (rule) { + if (rule.selector.startsWith('.')) { + const className = rule.selector.slice(1) + if (!usedClasses.has(className)) { + if (unusedCount === 0) { + console.log(`==========\nFile: ${currentFile}`) + } + console.log(`Unused class: ${className}`) + unusedCount++ + } + } + }, + OnceExit () { + console.log(`Total unused classes in this file: ${unusedCount}`) + totalUnusedClasses += unusedCount + } + } +} +listUnusedCSS.postcss = true +const unusedClassPromises = cssFiles.map((file) => { + const css = fs.readFileSync(file, 'utf8') + const syntax = file.endsWith('.scss') ? postcssScss : undefined + currentFile = file + + return postcss([listUnusedCSS()]) + .process(css, { from: file, syntax }) +}) + +Promise.all(unusedClassPromises) + .then(() => { + if (totalUnusedClasses > 0) { + core.setFailed(`Total unused classes found: ${totalUnusedClasses}`) + } + }) + .catch((error) => { + core.setFailed(error.message) + }) diff --git a/modules/ccss/README.md b/modules/ccss/README.md new file mode 100644 index 000000000..c60fa301b --- /dev/null +++ b/modules/ccss/README.md @@ -0,0 +1,24 @@ +# Common Core State Standards for Python + +This is a small package which allows the use of Common Core State Standards from Python. + + import ccss + ccss.standards + ccss.standards.math() + ccss.standards.math().grade(5) + ccss.standards.ela().subdomain('CCRA') + ccss.standards.ela().subdomain('LF').grade([5,6]) + +These will all return dictionary-like objects mapping CCSS tags to their text. Queries can be changed in arbitrary order. + +It's possible to see options available. For example: + + ccss.standards.grades() + ccss.standards.subdomains() + ccss.standards.subdomain('CCRA').grades() + +You should be mindful of [licensing issues with Common Core](ccss_public_license). This code is open-source. The standards are not. + +The text is also scraped, and there are occasional bugs. We are missing a few tags, and a few have partial text. Feel free to submit a PR to fix it! + +This package is in development. If you use it in your project, we recommend pinning versions, as the API may change (but it's very usable in the current version, and we don't anticipate specific reasons to upgrade just because a newer version exists). \ No newline at end of file diff --git a/modules/ccss/ccss/__init__.py b/modules/ccss/ccss/__init__.py new file mode 100644 index 000000000..846e4fb29 --- /dev/null +++ b/modules/ccss/ccss/__init__.py @@ -0,0 +1,2 @@ +from ccss import settings +from ccss import ELA, MATH diff --git a/modules/ccss/ccss/ccss.json b/modules/ccss/ccss/ccss.json new file mode 100644 index 000000000..5cfdcd5fe --- /dev/null +++ b/modules/ccss/ccss/ccss.json @@ -0,0 +1,1526 @@ +{ + "CCSS.ELA-Literacy.CCRA.R.1": "Read closely to determine what the text says explicitly and to make logical inferences from it; cite specific textual evidence when writing or speaking to support conclusions drawn from the text.", + "CCSS.ELA-Literacy.CCRA.R.2": "Determine central ideas or themes of a text and analyze their development; summarize the key supporting details and ideas.", + "CCSS.ELA-Literacy.CCRA.R.3": "Analyze how and why individuals, events, or ideas develop and interact over the course of a text.", + "CCSS.ELA-Literacy.CCRA.R.4": "Interpret words and phrases as they are used in a text, including determining technical, connotative, and figurative meanings, and analyze how specific word choices shape meaning or tone.", + "CCSS.ELA-Literacy.CCRA.R.5": "Analyze the structure of texts, including how specific sentences, paragraphs, and larger portions of the text (e.g., a section, chapter, scene, or stanza) relate to each other and the whole.", + "CCSS.ELA-Literacy.CCRA.R.6": "Assess how point of view or purpose shapes the content and style of a text.", + "CCSS.ELA-Literacy.CCRA.R.7": "Integrate and evaluate content presented in diverse media and formats, including visually and quantitatively, as well as in words.", + "CCSS.ELA-Literacy.CCRA.R.8": "Delineate and evaluate the argument and specific claims in a text, including the validity of the reasoning as well as the relevance and sufficiency of the evidence.", + "CCSS.ELA-Literacy.CCRA.R.9": "Analyze how two or more texts address similar themes or topics in order to build knowledge or to compare the approaches the authors take.", + "CCSS.ELA-Literacy.CCRA.R.10": "Read and comprehend complex literary and informational texts independently and proficiently.", + "CCSS.ELA-Literacy.CCRA.W.1": "Write arguments to support claims in an analysis of substantive topics or texts using valid reasoning and relevant and sufficient evidence.", + "CCSS.ELA-Literacy.CCRA.W.2": "Write informative/explanatory texts to examine and convey complex ideas and information clearly and accurately through the effective selection, organization, and analysis of content.", + "CCSS.ELA-Literacy.CCRA.W.3": "Write narratives to develop real or imagined experiences or events using effective technique, well-chosen details and well-structured event sequences.", + "CCSS.ELA-Literacy.CCRA.W.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience.", + "CCSS.ELA-Literacy.CCRA.W.5": "Develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach.", + "CCSS.ELA-Literacy.CCRA.W.6": "Use technology, including the Internet, to produce and publish writing and to interact and collaborate with others.", + "CCSS.ELA-Literacy.CCRA.W.7": "Conduct short as well as more sustained research projects based on focused questions, demonstrating understanding of the subject under investigation.", + "CCSS.ELA-Literacy.CCRA.W.8": "Gather relevant information from multiple print and digital sources, assess the credibility and accuracy of each source, and integrate the information while avoiding plagiarism.", + "CCSS.ELA-Literacy.CCRA.W.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.CCRA.W.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.CCRA.SL.1": "Prepare for and participate effectively in a range of conversations and collaborations with diverse partners, building on others' ideas and expressing their own clearly and persuasively.", + "CCSS.ELA-Literacy.CCRA.SL.2": "Integrate and evaluate information presented in diverse media and formats, including visually, quantitatively, and orally.", + "CCSS.ELA-Literacy.CCRA.SL.3": "Evaluate a speaker's point of view, reasoning, and use of evidence and rhetoric.", + "CCSS.ELA-Literacy.CCRA.SL.4": "Present information, findings, and supporting evidence such that listeners can follow the line of reasoning and the organization, development, and style are appropriate to task, purpose, and audience.", + "CCSS.ELA-Literacy.CCRA.SL.5": "Make strategic use of digital media and visual displays of data to express information and enhance understanding of presentations.", + "CCSS.ELA-Literacy.CCRA.SL.6": "Adapt speech to a variety of contexts and communicative tasks, demonstrating command of formal English when indicated or appropriate.", + "CCSS.ELA-Literacy.CCRA.L.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.CCRA.L.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.CCRA.L.3": "Apply knowledge of language to understand how language functions in different contexts, to make effective choices for meaning or style, and to comprehend more fully when reading or listening.", + "CCSS.ELA-Literacy.CCRA.L.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases by using context clues, analyzing meaningful word parts, and consulting general and specialized reference materials, as appropriate.", + "CCSS.ELA-Literacy.CCRA.L.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.CCRA.L.6": "Acquire and use accurately a range of general academic and domain-specific words and phrases sufficient for reading, writing, speaking, and listening at the college and career readiness level; demonstrate independence in gathering vocabulary knowledge when encountering an unknown term important to comprehension or expression.", + "CCSS.ELA-Literacy.RL.K.1": "With prompting and support, ask and answer questions about key details in a text.", + "CCSS.ELA-Literacy.RL.K.2": "With prompting and support, retell familiar stories, including key details.", + "CCSS.ELA-Literacy.RL.K.3": "With prompting and support, identify characters, settings, and major events in a story.", + "CCSS.ELA-Literacy.RL.K.4": "Ask and answer questions about unknown words in a text.", + "CCSS.ELA-Literacy.RL.K.5": "Recognize common types of texts (e.g., storybooks, poems).", + "CCSS.ELA-Literacy.RL.K.6": "With prompting and support, name the author and illustrator of a story and define the role of each in telling the story.", + "CCSS.ELA-Literacy.RL.K.7": "With prompting and support, describe the relationship between illustrations and the story in which they appear (e.g., what moment in a story an illustration depicts).", + "CCSS.ELA-Literacy.RL.K.8": "(RL.K.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.K.9": "With prompting and support, compare and contrast the adventures and experiences of characters in familiar stories.", + "CCSS.ELA-Literacy.RL.K.10": "Actively engage in group reading activities with purpose and understanding.", + "CCSS.ELA-Literacy.RL.1.1": "Ask and answer questions about key details in a text.", + "CCSS.ELA-Literacy.RL.1.2": "Retell stories, including key details, and demonstrate understanding of their central message or lesson.", + "CCSS.ELA-Literacy.RL.1.3": "Describe characters, settings, and major events in a story, using key details.", + "CCSS.ELA-Literacy.RL.1.4": "Identify words and phrases in stories or poems that suggest feelings or appeal to the senses.", + "CCSS.ELA-Literacy.RL.1.5": "Explain major differences between books that tell stories and books that give information, drawing on a wide reading of a range of text types.", + "CCSS.ELA-Literacy.RL.1.6": "Identify who is telling the story at various points in a text.", + "CCSS.ELA-Literacy.RL.1.7": "Use illustrations and details in a story to describe its characters, setting, or events.", + "CCSS.ELA-Literacy.RL.1.8": "(RL.1.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.1.9": "Compare and contrast the adventures and experiences of characters in stories.", + "CCSS.ELA-Literacy.RL.1.10": "With prompting and support, read prose and poetry of appropriate complexity for grade 1.", + "CCSS.ELA-Literacy.RL.2.1": "Ask and answer such questions as", + "CCSS.ELA-Literacy.RL.2.2": "Recount stories, including fables and folktales from diverse cultures, and determine their central message, lesson, or moral.", + "CCSS.ELA-Literacy.RL.2.3": "Describe how characters in a story respond to major events and challenges.", + "CCSS.ELA-Literacy.RL.2.4": "Describe how words and phrases (e.g., regular beats, alliteration, rhymes, repeated lines) supply rhythm and meaning in a story, poem, or song.", + "CCSS.ELA-Literacy.RL.2.5": "Describe the overall structure of a story, including describing how the beginning introduces the story and the ending concludes the action.", + "CCSS.ELA-Literacy.RL.2.6": "Acknowledge differences in the points of view of characters, including by speaking in a different voice for each character when reading dialogue aloud.", + "CCSS.ELA-Literacy.RL.2.7": "Use information gained from the illustrations and words in a print or digital text to demonstrate understanding of its characters, setting, or plot.", + "CCSS.ELA-Literacy.RL.2.8": "(RL.2.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.2.9": "Compare and contrast two or more versions of the same story (e.g., Cinderella stories) by different authors or from different cultures.", + "CCSS.ELA-Literacy.RL.2.10": "By the end of the year, read and comprehend literature, including stories and poetry, in the grades 2-3 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RL.3.1": "Ask and answer questions to demonstrate understanding of a text, referring explicitly to the text as the basis for the answers.", + "CCSS.ELA-Literacy.RL.3.2": "Recount stories, including fables, folktales, and myths from diverse cultures; determine the central message, lesson, or moral and explain how it is conveyed through key details in the text.", + "CCSS.ELA-Literacy.RL.3.3": "Describe characters in a story (e.g., their traits, motivations, or feelings) and explain how their actions contribute to the sequence of events", + "CCSS.ELA-Literacy.RL.3.4": "Determine the meaning of words and phrases as they are used in a text, distinguishing literal from nonliteral language.", + "CCSS.ELA-Literacy.RL.3.5": "Refer to parts of stories, dramas, and poems when writing or speaking about a text, using terms such as chapter, scene, and stanza; describe how each successive part builds on earlier sections.", + "CCSS.ELA-Literacy.RL.3.6": "Distinguish their own point of view from that of the narrator or those of the characters.", + "CCSS.ELA-Literacy.RL.3.7": "Explain how specific aspects of a text's illustrations contribute to what is conveyed by the words in a story (e.g., create mood, emphasize aspects of a character or setting)", + "CCSS.ELA-Literacy.RL.3.8": "(RL.3.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.3.9": "Compare and contrast the themes, settings, and plots of stories written by the same author about the same or similar characters (e.g., in books from a series)", + "CCSS.ELA-Literacy.RL.3.10": "By the end of the year, read and comprehend literature, including stories, dramas, and poetry, at the high end of the grades 2-3 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RL.4.1": "Refer to details and examples in a text when explaining what the text says explicitly and when drawing inferences from the text.", + "CCSS.ELA-Literacy.RL.4.2": "Determine a theme of a story, drama, or poem from details in the text; summarize the text.", + "CCSS.ELA-Literacy.RL.4.3": "Describe in depth a character, setting, or event in a story or drama, drawing on specific details in the text (e.g., a character's thoughts, words, or actions).", + "CCSS.ELA-Literacy.RL.4.4": "Determine the meaning of words and phrases as they are used in a text, including those that allude to significant characters found in mythology (e.g., Herculean).", + "CCSS.ELA-Literacy.RL.4.5": "Explain major differences between poems, drama, and prose, and refer to the structural elements of poems (e.g., verse, rhythm, meter) and drama (e.g., casts of characters, settings, descriptions, dialogue, stage directions) when writing or speaking about a text.", + "CCSS.ELA-Literacy.RL.4.6": "Compare and contrast the point of view from which different stories are narrated, including the difference between first- and third-person narrations.", + "CCSS.ELA-Literacy.RL.4.7": "Make connections between the text of a story or drama and a visual or oral presentation of the text, identifying where each version reflects specific descriptions and directions in the text.", + "CCSS.ELA-Literacy.RL.4.8": "(RL.4.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.4.9": "Compare and contrast the treatment of similar themes and topics (e.g., opposition of good and evil) and patterns of events (e.g., the quest) in stories, myths, and traditional literature from different cultures.", + "CCSS.ELA-Literacy.RL.4.10": "By the end of the year, read and comprehend literature, including stories, dramas, and poetry, in the grades 4-5 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RL.5.1": "Quote accurately from a text when explaining what the text says explicitly and when drawing inferences from the text.", + "CCSS.ELA-Literacy.RL.5.2": "Determine a theme of a story, drama, or poem from details in the text, including how characters in a story or drama respond to challenges or how the speaker in a poem reflects upon a topic; summarize the text.", + "CCSS.ELA-Literacy.RL.5.3": "Compare and contrast two or more characters, settings, or events in a story or drama, drawing on specific details in the text (e.g., how characters interact).", + "CCSS.ELA-Literacy.RL.5.4": "Determine the meaning of words and phrases as they are used in a text, including figurative language such as metaphors and similes.", + "CCSS.ELA-Literacy.RL.5.5": "Explain how a series of chapters, scenes, or stanzas fits together to provide the overall structure of a particular story, drama, or poem.", + "CCSS.ELA-Literacy.RL.5.6": "Describe how a narrator's or speaker's point of view influences how events are described.", + "CCSS.ELA-Literacy.RL.5.7": "Analyze how visual and multimedia elements contribute to the meaning, tone, or beauty of a text (e.g., graphic novel, multimedia presentation of fiction, folktale, myth, poem).", + "CCSS.ELA-Literacy.RL.5.8": "(RL.5.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.5.9": "Compare and contrast stories in the same genre (e.g., mysteries and adventure stories) on their approaches to similar themes and topics.", + "CCSS.ELA-Literacy.RL.5.10": "By the end of the year, read and comprehend literature, including stories, dramas, and poetry, at the high end of the grades 4-5 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RL.6.1": "Cite textual evidence to support analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RL.6.2": "Determine a theme or central idea of a text and how it is conveyed through particular details; provide a summary of the text distinct from personal opinions or judgments.", + "CCSS.ELA-Literacy.RL.6.3": "Describe how a particular story's or drama's plot unfolds in a series of episodes as well as how the characters respond or change as the plot moves toward a resolution.", + "CCSS.ELA-Literacy.RL.6.4": "Determine the meaning of words and phrases as they are used in a text, including figurative and connotative meanings; analyze the impact of a specific word choice on meaning and tone", + "CCSS.ELA-Literacy.RL.6.5": "Analyze how a particular sentence, chapter, scene, or stanza fits into the overall structure of a text and contributes to the development of the theme, setting, or plot.", + "CCSS.ELA-Literacy.RL.6.6": "Explain how an author develops the point of view of the narrator or speaker in a text.", + "CCSS.ELA-Literacy.RL.6.7": "Compare and contrast the experience of reading a story, drama, or poem to listening to or viewing an audio, video, or live version of the text, including contrasting what they \"see\" and \"hear\" when reading the text to what they perceive when they listen or watch.", + "CCSS.ELA-Literacy.RL.6.8": "(RL.6.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.6.9": "Compare and contrast texts in different forms or genres (e.g., stories and poems; historical novels and fantasy stories) in terms of their approaches to similar themes and topics.", + "CCSS.ELA-Literacy.RL.6.10": "By the end of the year, read and comprehend literature, including stories, dramas, and poems, in the grades 6-8 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RL.7.1": "Cite several pieces of textual evidence to support analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RL.7.2": "Determine a theme or central idea of a text and analyze its development over the course of the text; provide an objective summary of the text.", + "CCSS.ELA-Literacy.RL.7.3": "Analyze how particular elements of a story or drama interact (e.g., how setting shapes the characters or plot).", + "CCSS.ELA-Literacy.RL.7.4": "Determine the meaning of words and phrases as they are used in a text, including figurative and connotative meanings; analyze the impact of rhymes and other repetitions of sounds (e.g., alliteration) on a specific verse or stanza of a poem or section of a story or drama.", + "CCSS.ELA-Literacy.RL.7.5": "Analyze how a drama's or poem's form or structure (e.g., soliloquy, sonnet) contributes to its meaning", + "CCSS.ELA-Literacy.RL.7.6": "Analyze how an author develops and contrasts the points of view of different characters or narrators in a text.", + "CCSS.ELA-Literacy.RL.7.7": "Compare and contrast a written story, drama, or poem to its audio, filmed, staged, or multimedia version, analyzing the effects of techniques unique to each medium (e.g., lighting, sound, color, or camera focus and angles in a film).", + "CCSS.ELA-Literacy.RL.7.8": "(RL.7.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.7.9": "Compare and contrast a fictional portrayal of a time, place, or character and a historical account of the same period as a means of understanding how authors of fiction use or alter history.", + "CCSS.ELA-Literacy.RL.7.10": "By the end of the year, read and comprehend literature, including stories, dramas, and poems, in the grades 6-8 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RL.8.1": "Cite the textual evidence that most strongly supports an analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RL.8.2": "Determine a theme or central idea of a text and analyze its development over the course of the text, including its relationship to the characters, setting, and plot; provide an objective summary of the text.", + "CCSS.ELA-Literacy.RL.8.3": "Analyze how particular lines of dialogue or incidents in a story or drama propel the action, reveal aspects of a character, or provoke a decision.", + "CCSS.ELA-Literacy.RL.8.4": "Determine the meaning of words and phrases as they are used in a text, including figurative and connotative meanings; analyze the impact of specific word choices on meaning and tone, including analogies or allusions to other texts.", + "CCSS.ELA-Literacy.RL.8.5": "Compare and contrast the structure of two or more texts and analyze how the differing structure of each text contributes to its meaning and style.", + "CCSS.ELA-Literacy.RL.8.6": "Analyze how differences in the points of view of the characters and the audience or reader (e.g., created through the use of dramatic irony) create such effects as suspense or humor.", + "CCSS.ELA-Literacy.RL.8.7": "Analyze the extent to which a filmed or live production of a story or drama stays faithful to or departs from the text or script, evaluating the choices made by the director or actors.", + "CCSS.ELA-Literacy.RL.8.8": "(RL.8.8 not applicable to literature)", + "CCSS.ELA-Literacy.RL.8.9": "Analyze how a modern work of fiction draws on themes, patterns of events, or character types from myths, traditional stories, or religious works such as the Bible, including describing how the material is rendered new.", + "CCSS.ELA-Literacy.RL.8.10": "By the end of the year, read and comprehend literature, including stories, dramas, and poems, at the high end of grades 6-8 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RI.K.1": "With prompting and support, ask and answer questions about key details in a text.", + "CCSS.ELA-Literacy.RI.K.2": "With prompting and support, identify the main topic and retell key details of a text.", + "CCSS.ELA-Literacy.RI.K.3": "With prompting and support, describe the connection between two individuals, events, ideas, or pieces of information in a text.", + "CCSS.ELA-Literacy.RI.K.4": "With prompting and support, ask and answer questions about unknown words in a text.", + "CCSS.ELA-Literacy.RI.K.5": "Identify the front cover, back cover, and title page of a book.", + "CCSS.ELA-Literacy.RI.K.6": "Name the author and illustrator of a text and define the role of each in presenting the ideas or information in a text.", + "CCSS.ELA-Literacy.RI.K.7": "With prompting and support, describe the relationship between illustrations and the text in which they appear (e.g., what person, place, thing, or idea in the text an illustration depicts).", + "CCSS.ELA-Literacy.RI.K.8": "With prompting and support, identify the reasons an author gives to support points in a text.", + "CCSS.ELA-Literacy.RI.K.9": "With prompting and support, identify basic similarities in and differences between two texts on the same topic (e.g., in illustrations, descriptions, or procedures).", + "CCSS.ELA-Literacy.RI.K.10": "Actively engage in group reading activities with purpose and understanding.", + "CCSS.ELA-Literacy.RI.1.1": "Ask and answer questions about key details in a text.", + "CCSS.ELA-Literacy.RI.1.2": "Identify the main topic and retell key details of a text.", + "CCSS.ELA-Literacy.RI.1.3": "Describe the connection between two individuals, events, ideas, or pieces of information in a text.", + "CCSS.ELA-Literacy.RI.1.4": "Ask and answer questions to help determine or clarify the meaning of words and phrases in a text.", + "CCSS.ELA-Literacy.RI.1.5": "Know and use various text features (e.g., headings, tables of contents, glossaries, electronic menus, icons) to locate key facts or information in a text.", + "CCSS.ELA-Literacy.RI.1.6": "Distinguish between information provided by pictures or other illustrations and information provided by the words in a text.", + "CCSS.ELA-Literacy.RI.1.7": "Use the illustrations and details in a text to describe its key ideas.", + "CCSS.ELA-Literacy.RI.1.8": "Identify the reasons an author gives to support points in a text.", + "CCSS.ELA-Literacy.RI.1.9": "Identify basic similarities in and differences between two texts on the same topic (e.g., in illustrations, descriptions, or procedures).", + "CCSS.ELA-Literacy.RI.1.10": "With prompting and support, read informational texts appropriately complex for grade 1.", + "CCSS.ELA-Literacy.RI.2.1": "Ask and answer such questions as", + "CCSS.ELA-Literacy.RI.2.2": "Identify the main topic of a multiparagraph text as well as the focus of specific paragraphs within the text.", + "CCSS.ELA-Literacy.RI.2.3": "Describe the connection between a series of historical events, scientific ideas or concepts, or steps in technical procedures in a text.", + "CCSS.ELA-Literacy.RI.2.4": "Determine the meaning of words and phrases in a text relevant to a", + "CCSS.ELA-Literacy.RI.2.5": "Know and use various text features (e.g., captions, bold print, subheadings, glossaries, indexes, electronic menus, icons) to locate key facts or information in a text efficiently.", + "CCSS.ELA-Literacy.RI.2.6": "Identify the main purpose of a text, including what the author wants to answer, explain, or describe.", + "CCSS.ELA-Literacy.RI.2.7": "Explain how specific images (e.g., a diagram showing how a machine works) contribute to and clarify a text.", + "CCSS.ELA-Literacy.RI.2.8": "Describe how reasons support specific points the author makes in a text.", + "CCSS.ELA-Literacy.RI.2.9": "Compare and contrast the most important points presented by two texts on the same topic.", + "CCSS.ELA-Literacy.RI.2.10": "By the end of year, read and comprehend informational texts, including history/social studies, science, and technical texts, in the grades 2-3 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RI.3.1": "Ask and answer questions to demonstrate understanding of a text, referring explicitly to the text as the basis for the answers.", + "CCSS.ELA-Literacy.RI.3.2": "Determine the main idea of a text; recount the key details and explain how they support the main idea.", + "CCSS.ELA-Literacy.RI.3.3": "Describe the relationship between a series of historical events, scientific ideas or concepts, or steps in technical procedures in a text, using language that pertains to time, sequence, and cause/effect.", + "CCSS.ELA-Literacy.RI.3.4": "Determine the meaning of general academic and domain-specific words and phrases in a text relevant to a", + "CCSS.ELA-Literacy.RI.3.5": "Use text features and search tools (e.g., key words, sidebars, hyperlinks) to locate information relevant to a given topic efficiently.", + "CCSS.ELA-Literacy.RI.3.6": "Distinguish their own point of view from that of the author of a text.", + "CCSS.ELA-Literacy.RI.3.7": "Use information gained from illustrations (e.g., maps, photographs) and the words in a text to demonstrate understanding of the text (e.g., where, when, why, and how key events occur).", + "CCSS.ELA-Literacy.RI.3.8": "Describe the logical connection between particular sentences and paragraphs in a text (e.g., comparison, cause/effect, first/second/third in a sequence).", + "CCSS.ELA-Literacy.RI.3.9": "Compare and contrast the most important points and key details presented in two texts on the same topic.", + "CCSS.ELA-Literacy.RI.3.10": "By the end of the year, read and comprehend informational texts, including history/social studies, science, and technical texts, at the high end of the grades 2-3 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RI.4.1": "Refer to details and examples in a text when explaining what the text says explicitly and when drawing inferences from the text.", + "CCSS.ELA-Literacy.RI.4.2": "Determine the main idea of a text and explain how it is supported by key details; summarize the text.", + "CCSS.ELA-Literacy.RI.4.3": "Explain events, procedures, ideas, or concepts in a historical, scientific, or technical text, including what happened and why, based on specific information in the text.", + "CCSS.ELA-Literacy.RI.4.4": "Determine the meaning of general academic and domain-specific words or phrases in a text relevant to a", + "CCSS.ELA-Literacy.RI.4.5": "Describe the overall structure (e.g., chronology, comparison, cause/effect, problem/solution) of events, ideas, concepts, or information in a text or part of a text.", + "CCSS.ELA-Literacy.RI.4.6": "Compare and contrast a firsthand and secondhand account of the same event or topic; describe the differences in focus and the information provided.", + "CCSS.ELA-Literacy.RI.4.7": "Interpret information presented visually, orally, or quantitatively (e.g., in charts, graphs, diagrams, time lines, animations, or interactive elements on Web pages) and explain how the information contributes to an understanding of the text in which it appears.", + "CCSS.ELA-Literacy.RI.4.8": "Explain how an author uses reasons and evidence to support particular points in a text.", + "CCSS.ELA-Literacy.RI.4.9": "Integrate information from two texts on the same topic in order to write or speak about the subject knowledgeably.", + "CCSS.ELA-Literacy.RI.4.10": "By the end of year, read and comprehend informational texts, including history/social studies, science, and technical texts, in the grades 4-5 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RI.5.1": "Quote accurately from a text when explaining what the text says explicitly and when drawing inferences from the text.", + "CCSS.ELA-Literacy.RI.5.2": "Determine two or more main ideas of a text and explain how they are supported by key details; summarize the text.", + "CCSS.ELA-Literacy.RI.5.3": "Explain the relationships or interactions between two or more individuals, events, ideas, or concepts in a historical, scientific, or technical text based on specific information in the text.", + "CCSS.ELA-Literacy.RI.5.4": "Determine the meaning of general academic and domain-specific words and phrases in a text relevant to a", + "CCSS.ELA-Literacy.RI.5.5": "Compare and contrast the overall structure (e.g., chronology, comparison, cause/effect, problem/solution) of events, ideas, concepts, or information in two or more texts.", + "CCSS.ELA-Literacy.RI.5.6": "Analyze multiple accounts of the same event or topic, noting important similarities and differences in the point of view they represent.", + "CCSS.ELA-Literacy.RI.5.7": "Draw on information from multiple print or digital sources, demonstrating the ability to locate an answer to a question quickly or to solve a problem efficiently.", + "CCSS.ELA-Literacy.RI.5.8": "Explain how an author uses reasons and evidence to support particular points in a text, identifying which reasons and evidence support which point(s).", + "CCSS.ELA-Literacy.RI.5.9": "Integrate information from several texts on the same topic in order to write or speak about the subject knowledgeably.", + "CCSS.ELA-Literacy.RI.5.10": "By the end of the year, read and comprehend informational texts, including history/social studies, science, and technical texts, at the high end of the grades 4-5 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RI.6.1": "Cite textual evidence to support analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RI.6.2": "Determine a central idea of a text and how it is conveyed through particular details; provide a summary of the text distinct from personal opinions or judgments.", + "CCSS.ELA-Literacy.RI.6.3": "Analyze in detail how a key individual, event, or idea is introduced, illustrated, and elaborated in a text (e.g., through examples or anecdotes).", + "CCSS.ELA-Literacy.RI.6.4": "Determine the meaning of words and phrases as they are used in a text, including figurative, connotative, and technical meanings.", + "CCSS.ELA-Literacy.RI.6.5": "Analyze how a particular sentence, paragraph, chapter, or section fits into the overall structure of a text and contributes to the development of the ideas.", + "CCSS.ELA-Literacy.RI.6.6": "Determine an author's point of view or purpose in a text and explain how it is conveyed in the text.", + "CCSS.ELA-Literacy.RI.6.7": "Integrate information presented in different media or formats (e.g., visually, quantitatively) as well as in words to develop a coherent understanding of a topic or issue.", + "CCSS.ELA-Literacy.RI.6.8": "Trace and evaluate the argument and specific claims in a text, distinguishing claims that are supported by reasons and evidence from claims that are not.", + "CCSS.ELA-Literacy.RI.6.9": "Compare and contrast one author's presentation of events with that of another (e.g., a memoir written by and a biography on the same person).", + "CCSS.ELA-Literacy.RI.6.10": "By the end of the year, read and comprehend literary nonfiction in the grades 6-8 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RI.7.1": "Cite several pieces of textual evidence to support analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RI.7.2": "Determine two or more central ideas in a text and analyze their development over the course of the text; provide an objective summary of the text.", + "CCSS.ELA-Literacy.RI.7.3": "Analyze the interactions between individuals, events, and ideas in a text (e.g., how ideas influence individuals or events, or how individuals influence ideas or events).", + "CCSS.ELA-Literacy.RI.7.4": "Determine the meaning of words and phrases as they are used in a text, including figurative, connotative, and technical meanings; analyze the impact of a specific word choice on meaning and tone.", + "CCSS.ELA-Literacy.RI.7.5": "Analyze the structure an author uses to organize a text, including how the major sections contribute to the whole and to the development of the ideas.", + "CCSS.ELA-Literacy.RI.7.6": "Determine an author's point of view or purpose in a text and analyze how the author distinguishes his or her position from that of others.", + "CCSS.ELA-Literacy.RI.7.7": "Compare and contrast a text to an audio, video, or multimedia version of the text, analyzing each medium's portrayal of the subject (e.g., how the delivery of a speech affects the impact of the words).", + "CCSS.ELA-Literacy.RI.7.8": "Trace and evaluate the argument and specific claims in a text, assessing whether the reasoning is sound and the evidence is relevant and sufficient to support the claims.", + "CCSS.ELA-Literacy.RI.7.9": "Analyze how two or more authors writing about the same topic shape their presentations of key information by emphasizing different evidence or advancing different interpretations of facts.", + "CCSS.ELA-Literacy.RI.7.10": "By the end of the year, read and comprehend literary nonfiction in the grades 6-8 text complexity band proficiently, with scaffolding as needed at the high end of the range.", + "CCSS.ELA-Literacy.RI.8.1": "Cite the textual evidence that most strongly supports an analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RI.8.2": "Determine a central idea of a text and analyze its development over the course of the text, including its relationship to supporting ideas; provide an objective summary of the text.", + "CCSS.ELA-Literacy.RI.8.3": "Analyze how a text makes connections among and distinctions between individuals, ideas, or events (e.g., through comparisons, analogies, or categories).", + "CCSS.ELA-Literacy.RI.8.4": "Determine the meaning of words and phrases as they are used in a text, including figurative, connotative, and technical meanings; analyze the impact of specific word choices on meaning and tone, including analogies or allusions to other texts.", + "CCSS.ELA-Literacy.RI.8.5": "Analyze in detail the structure of a specific paragraph in a text, including the role of particular sentences in developing and refining a key concept.", + "CCSS.ELA-Literacy.RI.8.6": "Determine an author's point of view or purpose in a text and analyze how the author acknowledges and responds to conflicting evidence or viewpoints.", + "CCSS.ELA-Literacy.RI.8.7": "Evaluate the advantages and disadvantages of using different mediums (e.g., print or digital text, video, multimedia) to present a particular topic or idea.", + "CCSS.ELA-Literacy.RI.8.8": "Delineate and evaluate the argument and specific claims in a text, assessing whether the reasoning is sound and the evidence is relevant and sufficient; recognize when irrelevant evidence is introduced.", + "CCSS.ELA-Literacy.RI.8.9": "Analyze a case in which two or more texts provide conflicting information on the same topic and identify where the texts disagree on matters of fact or interpretation.", + "CCSS.ELA-Literacy.RI.8.10": "By the end of the year, read and comprehend literary nonfiction at the high end of the grades 6-8 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RI.9-10.1": "Cite strong and thorough textual evidence to support analysis of what the text says explicitly as well as inferences drawn from the text.", + "CCSS.ELA-Literacy.RI.9-10.2": "Determine a central idea of a text and analyze its development over the course of the text, including how it emerges and is shaped and refined by specific details; provide an objective summary of the text.", + "CCSS.ELA-Literacy.RI.9-10.3": "Analyze how the author unfolds an analysis or series of ideas or events, including the order in which the points are made, how they are introduced and developed, and the connections that are drawn between them.", + "CCSS.ELA-Literacy.RI.9-10.4": "Determine the meaning of words and phrases as they are used in a text, including figurative, connotative, and technical meanings; analyze the cumulative impact of specific word choices on meaning and tone (e.g., how the language of a court opinion differs from that of a newspaper).", + "CCSS.ELA-Literacy.RI.9-10.5": "Analyze in detail how an author's ideas or claims are developed and refined by particular sentences, paragraphs, or larger portions of a text (e.g., a section or chapter).", + "CCSS.ELA-Literacy.RI.9-10.6": "Determine an author's point of view or purpose in a text and analyze how an author uses rhetoric to advance that point of view or purpose.", + "CCSS.ELA-Literacy.RI.9-10.7": "Analyze various accounts of a subject told in different mediums (e.g., a person's life story in both print and multimedia), determining which details are emphasized in each account.", + "CCSS.ELA-Literacy.RI.9-10.8": "Delineate and evaluate the argument and specific claims in a text, assessing whether the reasoning is valid and the evidence is relevant and sufficient; identify false statements and fallacious reasoning.", + "CCSS.ELA-Literacy.RI.9-10.9": "Analyze seminal U.S. documents of historical and literary significance \u00c2\u00a0(e.g., Washington's Farewell Address, the Gettysburg Address, Roosevelt's Four Freedoms speech, King's \"Letter from Birmingham Jail\"), including how they address related themes and concepts.", + "CCSS.ELA-Literacy.RI.9-10.10": "By the end of grade 9, read and comprehend literary nonfiction in the grades 9-10 text complexity band proficiently, with scaffolding as needed at the high end of the range.\nBy the end of grade 10, read and comprehend literary nonfiction at the high end of the grades 9-10 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RF.K.1": "Demonstrate understanding of the organization and basic features of print.", + "CCSS.ELA-Literacy.RF.K.2": "Demonstrate understanding of spoken words, syllables, and sounds (phonemes).", + "CCSS.ELA-Literacy.RF.K.3": "Know and apply grade-level phonics and word analysis skills in decoding words.", + "CCSS.ELA-Literacy.RF.K.4": "Read emergent-reader texts with purpose and understanding.", + "CCSS.ELA-Literacy.RF.K.1.a": "Follow words from left to right, top to bottom, and page by page.", + "CCSS.ELA-Literacy.RF.K.1.b": "Recognize that spoken words are represented in written language by specific sequences of letters.", + "CCSS.ELA-Literacy.RF.K.1.c": "Understand that words are separated by spaces in print.", + "CCSS.ELA-Literacy.RF.K.1.d": "Recognize and name all upper- and lowercase letters of the alphabet.", + "CCSS.ELA-Literacy.RF.K.2.a": "Recognize and produce rhyming words.", + "CCSS.ELA-Literacy.RF.K.2.b": "Count, pronounce, blend, and segment syllables in spoken words.", + "CCSS.ELA-Literacy.RF.K.2.c": "Blend and segment onsets and rimes of single-syllable spoken words.", + "CCSS.ELA-Literacy.RF.K.2.d": "Isolate and pronounce the initial, medial vowel, and final sounds (phonemes) in three-phoneme (consonant-vowel-consonant, or CVC) words.", + "CCSS.ELA-Literacy.RF.K.2.e": "Add or substitute individual sounds (phonemes) in simple, one-syllable words to make new words.", + "CCSS.ELA-Literacy.RF.K.3.a": "Demonstrate basic knowledge of one-to-one letter-sound correspondences by producing the primary sound or many of the most frequent sounds for each consonant.", + "CCSS.ELA-Literacy.RF.K.3.b": "Associate the long and short sounds with the common spellings (graphemes) for the five major vowels.", + "CCSS.ELA-Literacy.RF.K.3.c": "Read common high-frequency words by sight (e.g.,", + "CCSS.ELA-Literacy.RF.K.3.d": "Distinguish between similarly spelled words by identifying the sounds of the letters that differ.", + "CCSS.ELA-Literacy.RF.1.1": "Demonstrate understanding of the organization and basic features of print.", + "CCSS.ELA-Literacy.RF.1.2": "Demonstrate understanding of spoken words, syllables, and sounds (phonemes).", + "CCSS.ELA-Literacy.RF.1.3": "Know and apply grade-level phonics and word analysis skills in decoding words.", + "CCSS.ELA-Literacy.RF.1.4": "Read with sufficient accuracy and fluency to support comprehension.", + "CCSS.ELA-Literacy.RF.1.1.a": "Recognize the distinguishing features of a sentence (e.g., first word, capitalization, ending punctuation).", + "CCSS.ELA-Literacy.RF.1.2.a": "Distinguish long from short vowel sounds in spoken single-syllable words.", + "CCSS.ELA-Literacy.RF.1.2.b": "Orally produce single-syllable words by blending sounds (phonemes), including consonant blends.", + "CCSS.ELA-Literacy.RF.1.2.c": "Isolate and pronounce initial, medial vowel, and final sounds (phonemes) in spoken single-syllable words.", + "CCSS.ELA-Literacy.RF.1.2.d": "Segment spoken single-syllable words into their complete sequence of individual sounds (phonemes).", + "CCSS.ELA-Literacy.RF.1.3.a": "Know the spelling-sound correspondences for common consonant digraphs.", + "CCSS.ELA-Literacy.RF.1.3.b": "Decode regularly spelled one-syllable words.", + "CCSS.ELA-Literacy.RF.1.3.c": "Know final -e and common vowel team conventions for representing long vowel sounds.", + "CCSS.ELA-Literacy.RF.1.3.d": "Use knowledge that every syllable must have a vowel sound to determine the number of syllables in a printed word.", + "CCSS.ELA-Literacy.RF.1.3.e": "Decode two-syllable words following basic patterns by breaking the words into syllables.", + "CCSS.ELA-Literacy.RF.1.3.f": "Read words with inflectional endings.", + "CCSS.ELA-Literacy.RF.1.3.g": "Recognize and read grade-appropriate irregularly spelled words.", + "CCSS.ELA-Literacy.RF.1.4.a": "Read grade-level text with purpose and understanding.", + "CCSS.ELA-Literacy.RF.1.4.b": "Read grade-level text orally with accuracy, appropriate rate, and expression on successive readings.", + "CCSS.ELA-Literacy.RF.1.4.c": "Use context to confirm or self-correct word recognition and understanding, rereading as necessary.", + "CCSS.ELA-Literacy.RF.2.3": "Know and apply grade-level phonics and word analysis skills in decoding words.", + "CCSS.ELA-Literacy.RF.2.4": "Read with sufficient accuracy and fluency to support comprehension.", + "CCSS.ELA-Literacy.RF.2.3.a": "Distinguish long and short vowels when reading regularly spelled one-syllable words.", + "CCSS.ELA-Literacy.RF.2.3.b": "Know spelling-sound correspondences for additional common vowel teams.", + "CCSS.ELA-Literacy.RF.2.3.c": "Decode regularly spelled two-syllable words with long vowels.", + "CCSS.ELA-Literacy.RF.2.3.d": "Decode words with common prefixes and suffixes.", + "CCSS.ELA-Literacy.RF.2.3.e": "Identify words with inconsistent but common spelling-sound correspondences.", + "CCSS.ELA-Literacy.RF.2.3.f": "Recognize and read grade-appropriate irregularly spelled words.", + "CCSS.ELA-Literacy.RF.2.4.a": "Read grade-level text with purpose and understanding.", + "CCSS.ELA-Literacy.RF.2.4.b": "Read grade-level text orally with accuracy, appropriate rate, and expression on successive readings.", + "CCSS.ELA-Literacy.RF.2.4.c": "Use context to confirm or self-correct word recognition and understanding, rereading as necessary.", + "CCSS.ELA-Literacy.RF.3.3": "Know and apply grade-level phonics and word analysis skills in decoding words.", + "CCSS.ELA-Literacy.RF.3.4": "Read with sufficient accuracy and fluency to support comprehension.", + "CCSS.ELA-Literacy.RF.3.3.a": "Identify and know the meaning of the most common prefixes and derivational suffixes.", + "CCSS.ELA-Literacy.RF.3.3.b": "Decode words with common Latin suffixes.", + "CCSS.ELA-Literacy.RF.3.3.c": "Decode multisyllable words.", + "CCSS.ELA-Literacy.RF.3.3.d": "Read grade-appropriate irregularly spelled words.", + "CCSS.ELA-Literacy.RF.3.4.a": "Read grade-level text with purpose and understanding.", + "CCSS.ELA-Literacy.RF.3.4.b": "Read grade-level prose and poetry orally with accuracy, appropriate rate, and expression on successive readings.", + "CCSS.ELA-Literacy.RF.3.4.c": "Use context to confirm or self-correct word recognition and understanding, rereading as necessary.", + "CCSS.ELA-Literacy.RF.4.3": "Know and apply grade-level phonics and word analysis skills in decoding words.", + "CCSS.ELA-Literacy.RF.4.4": "Read with sufficient accuracy and fluency to support comprehension.", + "CCSS.ELA-Literacy.RF.4.3.a": "Use combined knowledge of all letter-sound correspondences, syllabication patterns, and morphology (e.g., roots and affixes) to read accurately unfamiliar multisyllabic words in context and out of context.", + "CCSS.ELA-Literacy.RF.4.4.a": "Read grade-level text with purpose and understanding.", + "CCSS.ELA-Literacy.RF.4.4.b": "Read grade-level prose and poetry orally with accuracy, appropriate rate, and expression on successive readings.", + "CCSS.ELA-Literacy.RF.4.4.c": "Use context to confirm or self-correct word recognition and understanding, rereading as necessary.", + "CCSS.ELA-Literacy.RF.5.3": "Know and apply grade-level phonics and word analysis skills in decoding words.", + "CCSS.ELA-Literacy.RF.5.4": "Read with sufficient accuracy and fluency to support comprehension.", + "CCSS.ELA-Literacy.RF.5.3.a": "Use combined knowledge of all letter-sound correspondences, syllabication patterns, and morphology (e.g., roots and affixes) to read accurately unfamiliar multisyllabic words in context and out of context.", + "CCSS.ELA-Literacy.RF.5.4.a": "Read grade-level text with purpose and understanding.", + "CCSS.ELA-Literacy.RF.5.4.b": "Read grade-level prose and poetry orally with accuracy, appropriate rate, and expression on successive readings.", + "CCSS.ELA-Literacy.RF.5.4.c": "Use context to confirm or self-correct word recognition and understanding, rereading as necessary.", + "CCSS.ELA-Literacy.W.K.1": "Use a combination of drawing, dictating, and writing to compose opinion pieces in which they tell a reader the topic or the name of the book they are writing about and state an opinion or preference about the topic or book (e.g.,", + "CCSS.ELA-Literacy.W.K.2": "Use a combination of drawing, dictating, and writing to compose informative/explanatory texts in which they name what they are writing about and supply some information about the topic.", + "CCSS.ELA-Literacy.W.K.3": "Use a combination of drawing, dictating, and writing to narrate a single event or several loosely linked events, tell about the events in the order in which they occurred, and provide a reaction to what happened.", + "CCSS.ELA-Literacy.W.K.4": "(W.K.4 begins in grade 3)", + "CCSS.ELA-Literacy.W.K.5": "With guidance and support from adults, respond to questions and suggestions from peers and add details to strengthen writing as needed.", + "CCSS.ELA-Literacy.W.K.6": "With guidance and support from adults, explore a variety of digital tools to produce and publish writing, including in collaboration with peers.", + "CCSS.ELA-Literacy.W.K.7": "Participate in shared research and writing projects (e.g., explore a number of books by a favorite author and express opinions about them).", + "CCSS.ELA-Literacy.W.K.8": "With guidance and support from adults, recall information from experiences or gather information from provided sources to answer a question.", + "CCSS.ELA-Literacy.W.K.9": "(W.K.9 begins in grade 4)", + "CCSS.ELA-Literacy.W.K.10": "(W.K.10 begins in grade 3)", + "CCSS.ELA-Literacy.W.1.1": "Write opinion pieces in which they introduce the topic or name the book they are writing about, state an opinion, supply a reason for the opinion, and provide some sense of closure.", + "CCSS.ELA-Literacy.W.1.2": "Write informative/explanatory texts in which they name a topic, supply some facts about the topic, and provide some sense of closure.", + "CCSS.ELA-Literacy.W.1.3": "Write narratives in which they recount two or more appropriately sequenced events, include some details regarding what happened, use temporal words to signal event order, and provide some sense of closure.", + "CCSS.ELA-Literacy.W.1.4": "(W.1.4 begins in grade 3)", + "CCSS.ELA-Literacy.W.1.5": "With guidance and support from adults, focus on a topic, respond to questions and suggestions from peers, and add details to strengthen writing as needed.", + "CCSS.ELA-Literacy.W.1.6": "With guidance and support from adults, use a variety of digital tools to produce and publish writing, including in collaboration with peers.", + "CCSS.ELA-Literacy.W.1.7": "Participate in shared research and writing projects (e.g., explore a number of \"how-to\" books on a given topic and use them to write a sequence of instructions).", + "CCSS.ELA-Literacy.W.1.8": "With guidance and support from adults, recall information from experiences or gather information from provided sources to answer a question.", + "CCSS.ELA-Literacy.W.1.9": "(W.1.9 begins in grade 4)", + "CCSS.ELA-Literacy.W.1.10": "(W.1.10 begins in grade 3)", + "CCSS.ELA-Literacy.W.2.1": "Write opinion pieces in which they introduce the topic or book they are writing about, state an opinion, supply reasons that support the opinion, use linking words (e.g.,", + "CCSS.ELA-Literacy.W.2.2": "Write informative/explanatory texts in which they introduce a topic, use facts and definitions to develop points, and provide a concluding statement or section.", + "CCSS.ELA-Literacy.W.2.3": "Write narratives in which they recount a well-elaborated event or short sequence of events, include details to describe actions, thoughts, and feelings, use temporal words to signal event order, and provide a sense of closure.", + "CCSS.ELA-Literacy.W.2.4": "(W.2.4 begins in grade 3)", + "CCSS.ELA-Literacy.W.2.5": "With guidance and support from adults and peers, focus on a topic and strengthen writing as needed by revising and editing.", + "CCSS.ELA-Literacy.W.2.6": "With guidance and support from adults, use a variety of digital tools to produce and publish writing, including in collaboration with peers.", + "CCSS.ELA-Literacy.W.2.7": "Participate in shared research and writing projects (e.g., read a number of books on a single topic to produce a report; record science observations).", + "CCSS.ELA-Literacy.W.2.8": "Recall information from experiences or gather information from provided sources to answer a question.", + "CCSS.ELA-Literacy.W.2.9": "(W.2.9 begins in grade 4)", + "CCSS.ELA-Literacy.W.2.10": "(W.2.10 begins in grade 3)", + "CCSS.ELA-Literacy.W.3.1": "Write opinion pieces on topics or texts, supporting a point of view with reasons.", + "CCSS.ELA-Literacy.W.3.2": "Write informative/explanatory texts to examine a topic and convey ideas and information clearly.", + "CCSS.ELA-Literacy.W.3.3": "Write narratives to develop real or imagined experiences or events using effective technique, descriptive details, and clear event sequences.", + "CCSS.ELA-Literacy.W.3.4": "With guidance and support from adults, produce writing in which the development and organization are appropriate to task and purpose. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.3.5": "With guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, and editing. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grade 3", + "CCSS.ELA-Literacy.W.3.6": "With guidance and support from adults, use technology to produce and publish writing (using keyboarding skills) as well as to interact and collaborate with others.", + "CCSS.ELA-Literacy.W.3.7": "Conduct short research projects that build knowledge about a topic.", + "CCSS.ELA-Literacy.W.3.8": "Recall information from experiences or gather information from print and digital sources; take brief notes on sources and sort evidence into provided categories.", + "CCSS.ELA-Literacy.W.3.9": "(W.3.9 begins in grade 4)", + "CCSS.ELA-Literacy.W.3.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.3.1.a": "Introduce the topic or text they are writing about, state an opinion, and create an organizational structure that lists reasons.", + "CCSS.ELA-Literacy.W.3.1.b": "Provide reasons that support the opinion.", + "CCSS.ELA-Literacy.W.3.1.c": "Use linking words and phrases (e.g.,", + "CCSS.ELA-Literacy.W.3.1.d": "Provide a concluding statement or section.", + "CCSS.ELA-Literacy.W.3.2.a": "Introduce a topic and group related information together; include illustrations when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.3.2.b": "Develop the topic with facts, definitions, and details.", + "CCSS.ELA-Literacy.W.3.2.c": "Use linking words and phrases (e.g.,", + "CCSS.ELA-Literacy.W.3.2.d": "Provide a concluding statement or section.", + "CCSS.ELA-Literacy.W.3.3.a": "Establish a situation and introduce a narrator and/or characters; organize an event sequence that unfolds naturally.", + "CCSS.ELA-Literacy.W.3.3.b": "Use dialogue and descriptions of actions, thoughts, and feelings to develop experiences and events or show the response of characters to situations.", + "CCSS.ELA-Literacy.W.3.3.c": "Use temporal words and phrases to signal event order.", + "CCSS.ELA-Literacy.W.3.3.d": "Provide a sense of closure.", + "CCSS.ELA-Literacy.W.4.1": "Write opinion pieces on topics or texts, supporting a point of view with reasons and information.", + "CCSS.ELA-Literacy.W.4.2": "Write informative/explanatory texts to examine a topic and convey ideas and information clearly.", + "CCSS.ELA-Literacy.W.4.3": "Write narratives to develop real or imagined experiences or events using effective technique, descriptive details, and clear event sequences.", + "CCSS.ELA-Literacy.W.4.4": "Produce clear and coherent writing in which the development and organization are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.4.5": "With guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, and editing. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grade 4", + "CCSS.ELA-Literacy.W.4.6": "With some guidance and support from adults, use technology, including the Internet, to produce and publish writing as well as to interact and collaborate with others; demonstrate sufficient command of keyboarding skills to type a minimum of one page in a single sitting.", + "CCSS.ELA-Literacy.W.4.7": "Conduct short research projects that build knowledge through investigation of different aspects of a topic.", + "CCSS.ELA-Literacy.W.4.8": "Recall relevant information from experiences or gather relevant information from print and digital sources; take notes and categorize information, and provide a list of sources.", + "CCSS.ELA-Literacy.W.4.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.4.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.4.1.a": "Introduce a topic or text clearly, state an opinion, and create an organizational structure in which related ideas are grouped to support the writer's purpose.", + "CCSS.ELA-Literacy.W.4.1.b": "Provide reasons that are supported by facts and details.", + "CCSS.ELA-Literacy.W.4.1.c": "Link opinion and reasons using words and phrases (e.g.,", + "CCSS.ELA-Literacy.W.4.1.d": "Provide a concluding statement or section related to the opinion presented.", + "CCSS.ELA-Literacy.W.4.2.a": "Introduce a topic clearly and group related information in paragraphs and sections; include formatting (e.g., headings), illustrations, and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.4.2.b": "Develop the topic with facts, definitions, concrete details, quotations, or other information and examples related to the topic.", + "CCSS.ELA-Literacy.W.4.2.c": "Link ideas within categories of information using words and phrases (e.g.,", + "CCSS.ELA-Literacy.W.4.2.d": "Use precise language and domain-specific vocabulary to inform about or explain the topic.", + "CCSS.ELA-Literacy.W.4.2.e": "Provide a concluding statement or section related to the information or explanation presented.", + "CCSS.ELA-Literacy.W.4.3.a": "Orient the reader by establishing a situation and introducing a narrator and/or characters; organize an event sequence that unfolds naturally.", + "CCSS.ELA-Literacy.W.4.3.b": "Use dialogue and description to develop experiences and events or show the responses of characters to situations.", + "CCSS.ELA-Literacy.W.4.3.c": "Use a variety of transitional words and phrases to manage the sequence of events.", + "CCSS.ELA-Literacy.W.4.3.d": "Use concrete words and phrases and sensory details to convey experiences and events precisely.", + "CCSS.ELA-Literacy.W.4.3.e": "Provide a conclusion that follows from the narrated experiences or events.", + "CCSS.ELA-Literacy.W.4.9.a": "Apply", + "CCSS.ELA-Literacy.W.4.9.b": "Apply", + "CCSS.ELA-Literacy.W.5.1": "Write opinion pieces on topics or texts, supporting a point of view with reasons and information.", + "CCSS.ELA-Literacy.W.5.2": "Write informative/explanatory texts to examine a topic and convey ideas and information clearly.", + "CCSS.ELA-Literacy.W.5.3": "Write narratives to develop real or imagined experiences or events using effective technique, descriptive details, and clear event sequences.", + "CCSS.ELA-Literacy.W.5.4": "Produce clear and coherent writing in which the development and organization are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.5.5": "With guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grade 5", + "CCSS.ELA-Literacy.W.5.6": "With some guidance and support from adults, use technology, including the Internet, to produce and publish writing as well as to interact and collaborate with others; demonstrate sufficient command of keyboarding skills to type a minimum of two pages in a single sitting.", + "CCSS.ELA-Literacy.W.5.7": "Conduct short research projects that use several sources to build knowledge through investigation of different aspects of a topic.", + "CCSS.ELA-Literacy.W.5.8": "Recall relevant information from experiences or gather relevant information from print and digital sources; summarize or paraphrase information in notes and finished work, and provide a list of sources.", + "CCSS.ELA-Literacy.W.5.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.5.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.5.1.a": "Introduce a topic or text clearly, state an opinion, and create an organizational structure in which ideas are logically grouped to support the writer's purpose.", + "CCSS.ELA-Literacy.W.5.1.b": "Provide logically ordered reasons that are supported by facts and details.", + "CCSS.ELA-Literacy.W.5.1.c": "Link opinion and reasons using words, phrases, and clauses (e.g.,", + "CCSS.ELA-Literacy.W.5.1.d": "Provide a concluding statement or section related to the opinion presented.", + "CCSS.ELA-Literacy.W.5.2.a": "Introduce a topic clearly, provide a general observation and focus, and group related information logically; include formatting (e.g., headings), illustrations, and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.5.2.b": "Develop the topic with facts, definitions, concrete details, quotations, or other information and examples related to the topic.", + "CCSS.ELA-Literacy.W.5.2.c": "Link ideas within and across categories of information using words, phrases, and clauses (e.g.,", + "CCSS.ELA-Literacy.W.5.2.d": "Use precise language and domain-specific vocabulary to inform about or explain the topic.", + "CCSS.ELA-Literacy.W.5.2.e": "Provide a concluding statement or section related to the information or explanation presented.", + "CCSS.ELA-Literacy.W.5.3.a": "Orient the reader by establishing a situation and introducing a narrator and/or characters; organize an event sequence that unfolds naturally.", + "CCSS.ELA-Literacy.W.5.3.b": "Use narrative techniques, such as dialogue, description, and pacing, to develop experiences and events or show the responses of characters to situations.", + "CCSS.ELA-Literacy.W.5.3.c": "Use a variety of transitional words, phrases, and clauses to manage the sequence of events.", + "CCSS.ELA-Literacy.W.5.3.d": "Use concrete words and phrases and sensory details to convey experiences and events precisely.", + "CCSS.ELA-Literacy.W.5.3.e": "Provide a conclusion that follows from the narrated experiences or events.", + "CCSS.ELA-Literacy.W.5.9.a": "Apply", + "CCSS.ELA-Literacy.W.5.9.b": "Apply", + "CCSS.ELA-Literacy.W.6.1": "Write arguments to support claims with clear reasons and relevant evidence.", + "CCSS.ELA-Literacy.W.6.2": "Write informative/explanatory texts to examine a topic and convey ideas, concepts, and information through the selection, organization, and analysis of relevant content.", + "CCSS.ELA-Literacy.W.6.3": "Write narratives to develop real or imagined experiences or events using effective technique, relevant descriptive details, and well-structured event sequences.", + "CCSS.ELA-Literacy.W.6.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.6.5": "With some guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grade 6", + "CCSS.ELA-Literacy.W.6.6": "Use technology, including the Internet, to produce and publish writing as well as to interact and collaborate with others; demonstrate sufficient command of keyboarding skills to type a minimum of three pages in a single sitting.", + "CCSS.ELA-Literacy.W.6.7": "Conduct short research projects to answer a question, drawing on several sources and refocusing the inquiry when appropriate.", + "CCSS.ELA-Literacy.W.6.8": "Gather relevant information from multiple print and digital sources; assess the credibility of each source; and quote or paraphrase the data and conclusions of others while avoiding plagiarism and providing basic bibliographic information for sources.", + "CCSS.ELA-Literacy.W.6.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.6.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.6.1.a": "Introduce claim(s) and organize the reasons and evidence clearly.", + "CCSS.ELA-Literacy.W.6.1.b": "Support claim(s) with clear reasons and relevant evidence, using credible sources and demonstrating an understanding of the topic or text.", + "CCSS.ELA-Literacy.W.6.1.c": "Use words, phrases, and clauses to clarify the relationships among claim(s) and reasons.", + "CCSS.ELA-Literacy.W.6.1.d": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.W.6.1.e": "Provide a concluding statement or section that follows from the argument presented.", + "CCSS.ELA-Literacy.W.6.2.a": "Introduce a topic; organize ideas, concepts, and information, using strategies such as definition, classification, comparison/contrast, and cause/effect; include formatting (e.g., headings), graphics (e.g., charts, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.6.2.b": "Develop the topic with relevant facts, definitions, concrete details, quotations, or other information and examples.", + "CCSS.ELA-Literacy.W.6.2.c": "Use appropriate transitions to clarify the relationships among ideas and concepts.", + "CCSS.ELA-Literacy.W.6.2.d": "Use precise language and domain-specific vocabulary to inform about or explain the topic.", + "CCSS.ELA-Literacy.W.6.2.e": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.W.6.2.f": "Provide a concluding statement or section that follows from the information or explanation presented.", + "CCSS.ELA-Literacy.W.6.3.a": "Engage and orient the reader by establishing a context and introducing a narrator and/or characters; organize an event sequence that unfolds naturally and logically.", + "CCSS.ELA-Literacy.W.6.3.b": "Use narrative techniques, such as dialogue, pacing, and description, to develop experiences, events, and/or characters.", + "CCSS.ELA-Literacy.W.6.3.c": "Use a variety of transition words, phrases, and clauses to convey sequence and signal shifts from one time frame or setting to another.", + "CCSS.ELA-Literacy.W.6.3.d": "Use precise words and phrases, relevant descriptive details, and sensory language to convey experiences and events.", + "CCSS.ELA-Literacy.W.6.3.e": "Provide a conclusion that follows from the narrated experiences or events.", + "CCSS.ELA-Literacy.W.6.9.a": "Apply", + "CCSS.ELA-Literacy.W.6.9.b": "Apply", + "CCSS.ELA-Literacy.W.7.1": "Write arguments to support claims with clear reasons and relevant evidence.", + "CCSS.ELA-Literacy.W.7.2": "Write informative/explanatory texts to examine a topic and convey ideas, concepts, and information through the selection, organization, and analysis of relevant content.", + "CCSS.ELA-Literacy.W.7.3": "Write narratives to develop real or imagined experiences or events using effective technique, relevant descriptive details, and well-structured event sequences.", + "CCSS.ELA-Literacy.W.7.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.7.5": "With some guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on how well purpose and audience have been addressed. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grade 7", + "CCSS.ELA-Literacy.W.7.6": "Use technology, including the Internet, to produce and publish writing and link to and cite sources as well as to interact and collaborate with others, including linking to and citing sources.", + "CCSS.ELA-Literacy.W.7.7": "Conduct short research projects to answer a question, drawing on several sources and generating additional related, focused questions for further research and investigation.", + "CCSS.ELA-Literacy.W.7.8": "Gather relevant information from multiple print and digital sources, using search terms effectively; assess the credibility and accuracy of each source; and quote or paraphrase the data and conclusions of others while avoiding plagiarism and following a standard format for citation.", + "CCSS.ELA-Literacy.W.7.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.7.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.7.1.a": "Introduce claim(s), acknowledge alternate or opposing claims, and organize the reasons and evidence logically.", + "CCSS.ELA-Literacy.W.7.1.b": "Support claim(s) with logical reasoning and relevant evidence, using accurate, credible sources and demonstrating an understanding of the topic or text.", + "CCSS.ELA-Literacy.W.7.1.c": "Use words, phrases, and clauses to create cohesion and clarify the relationships among claim(s), reasons, and evidence.", + "CCSS.ELA-Literacy.W.7.1.d": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.W.7.1.e": "Provide a concluding statement or section that follows from and supports the argument presented.", + "CCSS.ELA-Literacy.W.7.2.a": "Introduce a topic clearly, previewing what is to follow; organize ideas, concepts, and information, using strategies such as definition, classification, comparison/contrast, and cause/effect; include formatting (e.g., headings), graphics (e.g., charts, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.7.2.b": "Develop the topic with relevant facts, definitions, concrete details, quotations, or other information and examples.", + "CCSS.ELA-Literacy.W.7.2.c": "Use appropriate transitions to create cohesion and clarify the relationships among ideas and concepts.", + "CCSS.ELA-Literacy.W.7.2.d": "Use precise language and domain-specific vocabulary to inform about or explain the topic.", + "CCSS.ELA-Literacy.W.7.2.e": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.W.7.2.f": "Provide a concluding statement or section that follows from and supports the information or explanation presented.", + "CCSS.ELA-Literacy.W.7.3.a": "Engage and orient the reader by establishing a context and point of view and introducing a narrator and/or characters; organize an event sequence that unfolds naturally and logically.", + "CCSS.ELA-Literacy.W.7.3.b": "Use narrative techniques, such as dialogue, pacing, and description, to develop experiences, events, and/or characters.", + "CCSS.ELA-Literacy.W.7.3.c": "Use a variety of transition words, phrases, and clauses to convey sequence and signal shifts from one time frame or setting to another.", + "CCSS.ELA-Literacy.W.7.3.d": "Use precise words and phrases, relevant descriptive details, and sensory language to capture the action and convey experiences and events.", + "CCSS.ELA-Literacy.W.7.3.e": "Provide a conclusion that follows from and reflects on the narrated experiences or events.", + "CCSS.ELA-Literacy.W.7.9.a": "Apply", + "CCSS.ELA-Literacy.W.7.9.b": "Apply", + "CCSS.ELA-Literacy.W.8.1": "Write arguments to support claims with clear reasons and relevant evidence", + "CCSS.ELA-Literacy.W.8.2": "Write informative/explanatory texts to examine a topic and convey ideas, concepts, and information through the selection, organization, and analysis of relevant content.", + "CCSS.ELA-Literacy.W.8.3": "Write narratives to develop real or imagined experiences or events using effective technique, relevant descriptive details, and well-structured event sequences.", + "CCSS.ELA-Literacy.W.8.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.8.5": "With some guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on how well purpose and audience have been addressed. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grade 8", + "CCSS.ELA-Literacy.W.8.6": "Use technology, including the Internet, to produce and publish writing and present the relationships between information and ideas efficiently as well as to interact and collaborate with others.", + "CCSS.ELA-Literacy.W.8.7": "Conduct short research projects to answer a question (including a self-generated question), drawing on several sources and generating additional related, focused questions that allow for multiple avenues of exploration.", + "CCSS.ELA-Literacy.W.8.8": "Gather relevant information from multiple print and digital sources, using search terms effectively; assess the credibility and accuracy of each source; and quote or paraphrase the data and conclusions of others while avoiding plagiarism and following a standard format for citation.", + "CCSS.ELA-Literacy.W.8.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.8.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.8.1.a": "Introduce claim(s), acknowledge and distinguish the claim(s) from alternate or opposing claims, and organize the reasons and evidence logically.", + "CCSS.ELA-Literacy.W.8.1.b": "Support claim(s) with logical reasoning and relevant evidence, using accurate, credible sources and demonstrating an understanding of the topic or text.", + "CCSS.ELA-Literacy.W.8.1.c": "Use words, phrases, and clauses to create cohesion and clarify the relationships among claim(s), counterclaims, reasons, and evidence.", + "CCSS.ELA-Literacy.W.8.1.d": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.W.8.1.e": "Provide a concluding statement or section that follows from and supports the argument presented.", + "CCSS.ELA-Literacy.W.8.2.a": "Introduce a topic clearly, previewing what is to follow; organize ideas, concepts, and information into broader categories; include formatting (e.g., headings), graphics (e.g., charts, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.8.2.b": "Develop the topic with relevant, well-chosen facts, definitions, concrete details, quotations, or other information and examples.", + "CCSS.ELA-Literacy.W.8.2.c": "Use appropriate and varied transitions to create cohesion and clarify the relationships among ideas and concepts.", + "CCSS.ELA-Literacy.W.8.2.d": "Use precise language and domain-specific vocabulary to inform about or explain the topic.", + "CCSS.ELA-Literacy.W.8.2.e": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.W.8.2.f": "Provide a concluding statement or section that follows from and supports the information or explanation presented.", + "CCSS.ELA-Literacy.W.8.3.a": "Engage and orient the reader by establishing a context and point of view and introducing a narrator and/or characters; organize an event sequence that unfolds naturally and logically.", + "CCSS.ELA-Literacy.W.8.3.b": "Use narrative techniques, such as dialogue, pacing, description, and reflection, to develop experiences, events, and/or characters.", + "CCSS.ELA-Literacy.W.8.3.c": "Use a variety of transition words, phrases, and clauses to convey sequence, signal shifts from one time frame or setting to another, and show the relationships among experiences and events.", + "CCSS.ELA-Literacy.W.8.3.d": "Use precise words and phrases, relevant descriptive details, and sensory language to capture the action and convey experiences and events.", + "CCSS.ELA-Literacy.W.8.3.e": "Provide a conclusion that follows from and reflects on the narrated experiences or events.", + "CCSS.ELA-Literacy.W.8.9.a": "Apply", + "CCSS.ELA-Literacy.W.8.9.b": "Apply", + "CCSS.ELA-Literacy.W.9-10.1": "Write arguments to support claims in an analysis of substantive topics or texts, using valid reasoning and relevant and sufficient evidence.", + "CCSS.ELA-Literacy.W.9-10.2": "Write informative/explanatory texts to examine and convey complex ideas, concepts, and information clearly and accurately through the effective selection, organization, and analysis of content.", + "CCSS.ELA-Literacy.W.9-10.3": "Write narratives to develop real or imagined experiences or events using effective technique, well-chosen details, and well-structured event sequences.", + "CCSS.ELA-Literacy.W.9-10.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.9-10.5": "Develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on addressing what is most significant for a specific purpose and audience. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grades 9-10", + "CCSS.ELA-Literacy.W.9-10.6": "Use technology, including the Internet, to produce, publish, and update individual or shared writing products, taking advantage of technology's capacity to link to other information and to display information flexibly and dynamically.", + "CCSS.ELA-Literacy.W.9-10.7": "Conduct short as well as more sustained research projects to answer a question (including a self-generated question) or solve a problem; narrow or broaden the inquiry when appropriate; synthesize multiple sources on the subject, demonstrating understanding of the subject under investigation.", + "CCSS.ELA-Literacy.W.9-10.8": "Gather relevant information from multiple authoritative print and digital sources, using advanced searches effectively; assess the usefulness of each source in answering the research question; integrate information into the text selectively to maintain the flow of ideas, avoiding plagiarism and following a standard format for citation.", + "CCSS.ELA-Literacy.W.9-10.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.9-10.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.9-10.1.a": "Introduce precise claim(s), distinguish the claim(s) from alternate or opposing claims, and create an organization that establishes clear relationships among claim(s), counterclaims, reasons, and evidence.", + "CCSS.ELA-Literacy.W.9-10.1.b": "Develop claim(s) and counterclaims fairly, supplying evidence for each while pointing out the strengths and limitations of both in a manner that anticipates the audience's knowledge level and concerns.", + "CCSS.ELA-Literacy.W.9-10.1.c": "Use words, phrases, and clauses to link the major sections of the text, create cohesion, and clarify the relationships between claim(s) and reasons, between reasons and evidence, and between claim(s) and counterclaims.", + "CCSS.ELA-Literacy.W.9-10.1.d": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.W.9-10.1.e": "Provide a concluding statement or section that follows from and supports the argument presented.", + "CCSS.ELA-Literacy.W.9-10.2.a": "Introduce a topic; organize complex ideas, concepts, and information to make important connections and distinctions; include formatting (e.g., headings), graphics (e.g., figures, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.9-10.2.b": "Develop the topic with well-chosen, relevant, and sufficient facts, extended definitions, concrete details, quotations, or other information and examples appropriate to the audience's knowledge of the topic.", + "CCSS.ELA-Literacy.W.9-10.2.c": "Use appropriate and varied transitions to link the major sections of the text, create cohesion, and clarify the relationships among complex ideas and concepts.", + "CCSS.ELA-Literacy.W.9-10.2.d": "Use precise language and domain-specific vocabulary to manage the complexity of the topic.", + "CCSS.ELA-Literacy.W.9-10.2.e": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.W.9-10.2.f": "Provide a concluding statement or section that follows from and supports the information or explanation presented (e.g., articulating implications or the significance of the topic).", + "CCSS.ELA-Literacy.W.9-10.3.a": "Engage and orient the reader by setting out a problem, situation, or observation, establishing one or multiple point(s) of view, and introducing a narrator and/or characters; create a smooth progression of experiences or events.", + "CCSS.ELA-Literacy.W.9-10.3.b": "Use narrative techniques, such as dialogue, pacing, description, reflection, and multiple plot lines, to develop experiences, events, and/or characters.", + "CCSS.ELA-Literacy.W.9-10.3.c": "Use a variety of techniques to sequence events so that they build on one another to create a coherent whole.", + "CCSS.ELA-Literacy.W.9-10.3.d": "Use precise words and phrases, telling details, and sensory language to convey a vivid picture of the experiences, events, setting, and/or characters.", + "CCSS.ELA-Literacy.W.9-10.3.e": "Provide a conclusion that follows from and reflects on what is experienced, observed, or resolved over the course of the narrative.", + "CCSS.ELA-Literacy.W.9-10.9.a": "Apply", + "CCSS.ELA-Literacy.W.9-10.9.b": "Apply", + "CCSS.ELA-Literacy.W.11-12.1": "Write arguments to support claims in an analysis of substantive topics or texts, using valid reasoning and relevant and sufficient evidence.", + "CCSS.ELA-Literacy.W.11-12.2": "Write informative/explanatory texts to examine and convey complex ideas, concepts, and information clearly and accurately through the effective selection, organization, and analysis of content.", + "CCSS.ELA-Literacy.W.11-12.3": "Write narratives to develop real or imagined experiences or events using effective technique, well-chosen details, and well-structured event sequences.", + "CCSS.ELA-Literacy.W.11-12.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience. (Grade-specific expectations for writing types are defined in standards 1-3 above.)", + "CCSS.ELA-Literacy.W.11-12.5": "Develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on addressing what is most significant for a specific purpose and audience. (Editing for conventions should demonstrate command of Language standards 1-3 up to and including grades 11-12", + "CCSS.ELA-Literacy.W.11-12.6": "Use technology, including the Internet, to produce, publish, and update individual or shared writing products in response to ongoing feedback, including new arguments or information.", + "CCSS.ELA-Literacy.W.11-12.7": "Conduct short as well as more sustained research projects to answer a question (including a self-generated question) or solve a problem; narrow or broaden the inquiry when appropriate; synthesize multiple sources on the subject, demonstrating understanding of the subject under investigation.", + "CCSS.ELA-Literacy.W.11-12.8": "Gather relevant information from multiple authoritative print and digital sources, using advanced searches effectively; assess the strengths and limitations of each source in terms of the task, purpose, and audience; integrate information into the text selectively to maintain the flow of ideas, avoiding plagiarism and overreliance on any one source and following a standard format for citation.", + "CCSS.ELA-Literacy.W.11-12.9": "Draw evidence from literary or informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.W.11-12.10": "Write routinely over extended time frames (time for research, reflection, and revision) and shorter time frames (a single sitting or a day or two) for a range of tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.W.11-12.1.a": "Introduce precise, knowledgeable claim(s), establish the significance of the claim(s), distinguish the claim(s) from alternate or opposing claims, and create an organization that logically sequences claim(s), counterclaims, reasons, and evidence.", + "CCSS.ELA-Literacy.W.11-12.1.b": "Develop claim(s) and counterclaims fairly and thoroughly, supplying the most relevant evidence for each while pointing out the strengths and limitations of both in a manner that anticipates the audience's knowledge level, concerns, values, and possible biases.", + "CCSS.ELA-Literacy.W.11-12.1.c": "Use words, phrases, and clauses as well as varied syntax to link the major sections of the text, create cohesion, and clarify the relationships between claim(s) and reasons, between reasons and evidence, and between claim(s) and counterclaims.", + "CCSS.ELA-Literacy.W.11-12.1.d": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.W.11-12.1.e": "Provide a concluding statement or section that follows from and supports the argument presented.", + "CCSS.ELA-Literacy.W.11-12.2.a": "Introduce a topic; organize complex ideas, concepts, and information so that each new element builds on that which precedes it to create a unified whole; include formatting (e.g., headings), graphics (e.g., figures, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.W.11-12.2.b": "Develop the topic thoroughly by selecting the most significant and relevant facts, extended definitions, concrete details, quotations, or other information and examples appropriate to the audience's knowledge of the topic.", + "CCSS.ELA-Literacy.W.11-12.2.c": "Use appropriate and varied transitions and syntax to link the major sections of the text, create cohesion, and clarify the relationships among complex ideas and concepts.", + "CCSS.ELA-Literacy.W.11-12.2.d": "Use precise language, domain-specific vocabulary, and techniques such as metaphor, simile, and analogy to manage the complexity of the topic.", + "CCSS.ELA-Literacy.W.11-12.2.e": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.W.11-12.2.f": "Provide a concluding statement or section that follows from and supports the information or explanation presented (e.g., articulating implications or the significance of the topic).", + "CCSS.ELA-Literacy.W.11-12.3.a": "Engage and orient the reader by setting out a problem, situation, or observation and its significance, establishing one or multiple point(s) of view, and introducing a narrator and/or characters; create a smooth progression of experiences or events.", + "CCSS.ELA-Literacy.W.11-12.3.b": "Use narrative techniques, such as dialogue, pacing, description, reflection, and multiple plot lines, to develop experiences, events, and/or characters.", + "CCSS.ELA-Literacy.W.11-12.3.c": "Use a variety of techniques to sequence events so that they build on one another to create a coherent whole and build toward a particular tone and outcome (e.g., a sense of mystery, suspense, growth, or resolution).", + "CCSS.ELA-Literacy.W.11-12.3.d": "Use precise words and phrases, telling details, and sensory language to convey a vivid picture of the experiences, events, setting, and/or characters.", + "CCSS.ELA-Literacy.W.11-12.3.e": "Provide a conclusion that follows from and reflects on what is experienced, observed, or resolved over the course of the narrative.", + "CCSS.ELA-Literacy.W.11-12.9.a": "Apply", + "CCSS.ELA-Literacy.W.11-12.9.b": "Apply", + "CCSS.ELA-Literacy.SL.K.1": "Participate in collaborative conversations with diverse partners about", + "CCSS.ELA-Literacy.SL.K.2": "Confirm understanding of a text read aloud or information presented orally or through other media by asking and answering questions about key details and requesting clarification if something is not understood.", + "CCSS.ELA-Literacy.SL.K.3": "Ask and answer questions in order to seek help, get information, or clarify something that is not understood.", + "CCSS.ELA-Literacy.SL.K.4": "Describe familiar people, places, things, and events and, with prompting and support, provide additional detail.", + "CCSS.ELA-Literacy.SL.K.5": "Add drawings or other visual displays to descriptions as desired to provide additional detail.", + "CCSS.ELA-Literacy.SL.K.6": "Speak audibly and express thoughts, feelings, and ideas clearly.", + "CCSS.ELA-Literacy.SL.K.1.a": "Follow agreed-upon rules for discussions (e.g., listening to others and taking turns speaking about the topics and texts under discussion).", + "CCSS.ELA-Literacy.SL.K.1.b": "Continue a conversation through multiple exchanges.", + "CCSS.ELA-Literacy.SL.1.1": "Participate in collaborative conversations with diverse partners about", + "CCSS.ELA-Literacy.SL.1.2": "Ask and answer questions about key details in a text read aloud or information presented orally or through other media.", + "CCSS.ELA-Literacy.SL.1.3": "Ask and answer questions about what a speaker says in order to gather additional information or clarify something that is not understood.", + "CCSS.ELA-Literacy.SL.1.4": "Describe people, places, things, and events with relevant details, expressing ideas and feelings clearly.", + "CCSS.ELA-Literacy.SL.1.5": "Add drawings or other visual displays to descriptions when appropriate to clarify ideas, thoughts, and feelings.", + "CCSS.ELA-Literacy.SL.1.6": "Produce complete sentences when appropriate to task and situation. (See grade 1 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.1.1.a": "Follow agreed-upon rules for discussions (e.g., listening to others with care, speaking one at a time about the topics and texts under discussion).", + "CCSS.ELA-Literacy.SL.1.1.b": "Build on others' talk in conversations by responding to the comments of others through multiple exchanges.", + "CCSS.ELA-Literacy.SL.1.1.c": "Ask questions to clear up any confusion about the topics and texts under discussion.", + "CCSS.ELA-Literacy.SL.2.1": "Participate in collaborative conversations with diverse partners about", + "CCSS.ELA-Literacy.SL.2.2": "Recount or describe key ideas or details from a text read aloud or information presented orally or through other media.", + "CCSS.ELA-Literacy.SL.2.3": "Ask and answer questions about what a speaker says in order to clarify comprehension, gather additional information, or deepen understanding of a topic or issue.", + "CCSS.ELA-Literacy.SL.2.4": "Tell a story or recount an experience with appropriate facts and relevant, descriptive details, speaking audibly in coherent sentences.", + "CCSS.ELA-Literacy.SL.2.5": "Create audio recordings of stories or poems; add drawings or other visual displays to stories or recounts of experiences when appropriate to clarify ideas, thoughts, and feelings.", + "CCSS.ELA-Literacy.SL.2.6": "Produce complete sentences when appropriate to task and situation in order to provide requested detail or clarification. (See grade 2 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.2.1.a": "Follow agreed-upon rules for discussions (e.g., gaining the floor in respectful ways, listening to others with care, speaking one at a time about the topics and texts under discussion).", + "CCSS.ELA-Literacy.SL.2.1.b": "Build on others' talk in conversations by linking their comments to the remarks of others.", + "CCSS.ELA-Literacy.SL.2.1.c": "Ask for clarification and further explanation as needed about the topics and texts under discussion.", + "CCSS.ELA-Literacy.SL.3.1": "Engage effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on", + "CCSS.ELA-Literacy.SL.3.2": "Determine the main ideas and supporting details of a text read aloud or information presented in diverse media and formats, including visually, quantitatively, and orally.", + "CCSS.ELA-Literacy.SL.3.3": "Ask and answer questions about information from a speaker, offering appropriate elaboration and detail.", + "CCSS.ELA-Literacy.SL.3.4": "Report on a topic or text, tell a story, or recount an experience with appropriate facts and relevant, descriptive details, speaking clearly at an understandable pace.", + "CCSS.ELA-Literacy.SL.3.5": "Create engaging audio recordings of stories or poems that demonstrate fluid reading at an understandable pace; add visual displays when appropriate to emphasize or enhance certain facts or details.", + "CCSS.ELA-Literacy.SL.3.6": "Speak in complete sentences when appropriate to task and situation in order to provide requested detail or clarification. (See grade 3 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.3.1.a": "Come to discussions prepared, having read or studied required material; explicitly draw on that preparation and other information known about the topic to explore ideas under discussion.", + "CCSS.ELA-Literacy.SL.3.1.b": "Follow agreed-upon rules for discussions (e.g., gaining the floor in respectful ways, listening to others with care, speaking one at a time about the topics and texts under discussion).", + "CCSS.ELA-Literacy.SL.3.1.c": "Ask questions to check understanding of information presented, stay on topic, and link their comments to the remarks of others.", + "CCSS.ELA-Literacy.SL.3.1.d": "Explain their own ideas and understanding in light of the discussion.", + "CCSS.ELA-Literacy.SL.4.1": "Engage effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on", + "CCSS.ELA-Literacy.SL.4.2": "Paraphrase portions of a text read aloud or information presented in diverse media and formats, including visually, quantitatively, and orally.", + "CCSS.ELA-Literacy.SL.4.3": "Identify the reasons and evidence a speaker provides to support particular points.", + "CCSS.ELA-Literacy.SL.4.4": "Report on a topic or text, tell a story, or recount an experience in an organized manner, using appropriate facts and relevant, descriptive details to support main ideas or themes; speak clearly at an understandable pace.", + "CCSS.ELA-Literacy.SL.4.5": "Add audio recordings and visual displays to presentations when appropriate to enhance the development of main ideas or themes.", + "CCSS.ELA-Literacy.SL.4.6": "Differentiate between contexts that call for formal English (e.g., presenting ideas) and situations where informal discourse is appropriate (e.g., small-group discussion); use formal English when appropriate to task and situation. (See grade 4 Language standards 1", + "CCSS.ELA-Literacy.SL.4.1.a": "Come to discussions prepared, having read or studied required material; explicitly draw on that preparation and other information known about the topic to explore ideas under discussion.", + "CCSS.ELA-Literacy.SL.4.1.b": "Follow agreed-upon rules for discussions and carry out assigned roles.", + "CCSS.ELA-Literacy.SL.4.1.c": "Pose and respond to specific questions to clarify or follow up on information, and make comments that contribute to the discussion and link to the remarks of others.", + "CCSS.ELA-Literacy.SL.4.1.d": "Review the key ideas expressed and explain their own ideas and understanding in light of the discussion.", + "CCSS.ELA-Literacy.SL.5.1": "Engage effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on", + "CCSS.ELA-Literacy.SL.5.2": "Summarize a written text read aloud or information presented in diverse media and formats, including visually, quantitatively, and orally.", + "CCSS.ELA-Literacy.SL.5.3": "Summarize the points a speaker makes and explain how each claim is supported by reasons and evidence.", + "CCSS.ELA-Literacy.SL.5.4": "Report on a topic or text or present an opinion, sequencing ideas logically and using appropriate facts and relevant, descriptive details to support main ideas or themes; speak clearly at an understandable pace.", + "CCSS.ELA-Literacy.SL.5.5": "Include multimedia components (e.g., graphics, sound) and visual displays in presentations when appropriate to enhance the development of main ideas or themes.", + "CCSS.ELA-Literacy.SL.5.6": "Adapt speech to a variety of contexts and tasks, using formal English when appropriate to task and situation. (See grade 5 Language standards 1 and\n3", + "CCSS.ELA-Literacy.SL.5.1.a": "Come to discussions prepared, having read or studied required material; explicitly draw on that preparation and other information known about the topic to explore ideas under discussion.", + "CCSS.ELA-Literacy.SL.5.1.b": "Follow agreed-upon rules for discussions and carry out assigned roles.", + "CCSS.ELA-Literacy.SL.5.1.c": "Pose and respond to specific questions by making comments that contribute to the discussion and elaborate on the remarks of others.", + "CCSS.ELA-Literacy.SL.5.1.d": "Review the key ideas expressed and draw conclusions in light of information and knowledge gained from the discussions.", + "CCSS.ELA-Literacy.SL.6.1": "Engage effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on grade 6 topics, texts, and issues, building on others' ideas and expressing their own clearly.", + "CCSS.ELA-Literacy.SL.6.2": "Interpret information presented in diverse media and formats (e.g., visually, quantitatively, orally) and explain how it contributes to a topic, text, or issue under study.", + "CCSS.ELA-Literacy.SL.6.3": "Delineate a speaker's argument and specific claims, distinguishing claims that are supported by reasons and evidence from claims that are not.", + "CCSS.ELA-Literacy.SL.6.4": "Present claims and findings, sequencing ideas logically and using pertinent descriptions, facts, and details to accentuate main ideas or themes; use appropriate eye contact, adequate volume, and clear pronunciation.", + "CCSS.ELA-Literacy.SL.6.5": "Include multimedia components (e.g., graphics, images, music, sound) and visual displays in presentations to clarify information.", + "CCSS.ELA-Literacy.SL.6.6": "Adapt speech to a variety of contexts and tasks, demonstrating command of formal English when indicated or appropriate. (See grade 6 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.6.1.a": "Come to discussions prepared, having read or studied required material; explicitly draw on that preparation by referring to evidence on the topic, text, or issue to probe and reflect on ideas under discussion.", + "CCSS.ELA-Literacy.SL.6.1.b": "Follow rules for collegial discussions, set specific goals and deadlines, and define individual roles as needed.", + "CCSS.ELA-Literacy.SL.6.1.c": "Pose and respond to specific questions with elaboration and detail by making comments that contribute to the topic, text, or issue under discussion.", + "CCSS.ELA-Literacy.SL.6.1.d": "Review the key ideas expressed and demonstrate understanding of multiple perspectives through reflection and paraphrasing.", + "CCSS.ELA-Literacy.SL.7.1": "Engage effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on grade 7 topics, texts, and issues, building on others' ideas and expressing their own clearly.", + "CCSS.ELA-Literacy.SL.7.2": "Analyze the main ideas and supporting details presented in diverse media and formats (e.g., visually, quantitatively, orally) and explain how the ideas clarify a topic, text, or issue under study.", + "CCSS.ELA-Literacy.SL.7.3": "Delineate a speaker's argument and specific claims, evaluating the soundness of the reasoning and the relevance and sufficiency of the evidence.", + "CCSS.ELA-Literacy.SL.7.4": "Present claims and findings, emphasizing salient points in a focused, coherent manner with pertinent descriptions, facts, details, and examples; use appropriate eye contact, adequate volume, and clear pronunciation.", + "CCSS.ELA-Literacy.SL.7.5": "Include multimedia components and visual displays in presentations to clarify claims and findings and emphasize salient points.", + "CCSS.ELA-Literacy.SL.7.6": "Adapt speech to a variety of contexts and tasks, demonstrating command of formal English when indicated or appropriate. (See grade 7 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.7.1.a": "Come to discussions prepared, having read or researched material under study; explicitly draw on that preparation by referring to evidence on the topic, text, or issue to probe and reflect on ideas under discussion.", + "CCSS.ELA-Literacy.SL.7.1.b": "Follow rules for collegial discussions, track progress toward specific goals and deadlines, and define individual roles as needed.", + "CCSS.ELA-Literacy.SL.7.1.c": "Pose questions that elicit elaboration and respond to others' questions and comments with relevant observations and ideas that bring the discussion back on topic as needed.", + "CCSS.ELA-Literacy.SL.7.1.d": "Acknowledge new information expressed by others and, when warranted, modify their own views.", + "CCSS.ELA-Literacy.SL.8.1": "Engage effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on grade 8 topics, texts, and issues, building on others' ideas and expressing their own clearly.", + "CCSS.ELA-Literacy.SL.8.2": "Analyze the purpose of information presented in diverse media and formats (e.g., visually, quantitatively, orally) and evaluate the motives (e.g., social, commercial, political) behind its presentation.", + "CCSS.ELA-Literacy.SL.8.3": "Delineate a speaker's argument and specific claims, evaluating the soundness of the reasoning and relevance and sufficiency of the evidence and identifying when irrelevant evidence is introduced.", + "CCSS.ELA-Literacy.SL.8.4": "Present claims and findings, emphasizing salient points in a focused, coherent manner with relevant evidence, sound valid reasoning, and well-chosen details; use appropriate eye contact, adequate volume, and clear pronunciation.", + "CCSS.ELA-Literacy.SL.8.5": "Integrate multimedia and visual displays into presentations to clarify information, strengthen claims and evidence, and add interest.", + "CCSS.ELA-Literacy.SL.8.6": "Adapt speech to a variety of contexts and tasks, demonstrating command of formal English when indicated or appropriate. (See grade 8 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.8.1.a": "Come to discussions prepared, having read or researched material under study; explicitly draw on that preparation by referring to evidence on the topic, text, or issue to probe and reflect on ideas under discussion.", + "CCSS.ELA-Literacy.SL.8.1.b": "Follow rules for collegial discussions and decision-making, track progress toward specific goals and deadlines, and define individual roles as needed.", + "CCSS.ELA-Literacy.SL.8.1.c": "Pose questions that connect the ideas of several speakers and respond to others' questions and comments with relevant evidence, observations, and ideas.", + "CCSS.ELA-Literacy.SL.8.1.d": "Acknowledge new information expressed by others, and, when warranted, qualify or justify their own views in light of the evidence presented.", + "CCSS.ELA-Literacy.SL.9-10.1": "Initiate and participate effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on grades 9-10 topics, texts, and issues, building on others' ideas and expressing their own clearly and persuasively.", + "CCSS.ELA-Literacy.SL.9-10.2": "Integrate multiple sources of information presented in diverse media or formats (e.g., visually, quantitatively, orally) evaluating the credibility and accuracy of each source.", + "CCSS.ELA-Literacy.SL.9-10.3": "Evaluate a speaker's point of view, reasoning, and use of evidence and rhetoric, identifying any fallacious reasoning or exaggerated or distorted evidence.", + "CCSS.ELA-Literacy.SL.9-10.4": "Present information, findings, and supporting evidence clearly, concisely, and logically such that listeners can follow the line of reasoning and the organization, development, substance, and style are appropriate to purpose, audience, and task.", + "CCSS.ELA-Literacy.SL.9-10.5": "Make strategic use of digital media (e.g., textual, graphical, audio, visual, and interactive elements) in presentations to enhance understanding of findings, reasoning, and evidence and to add interest.", + "CCSS.ELA-Literacy.SL.9-10.6": "Adapt speech to a variety of contexts and tasks, demonstrating command of formal English when indicated or appropriate. (See grades 9-10 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.9-10.1.a": "Come to discussions prepared, having read and researched material under study; explicitly draw on that preparation by referring to evidence from texts and other research on the topic or issue to stimulate a thoughtful, well-reasoned exchange of ideas.", + "CCSS.ELA-Literacy.SL.9-10.1.b": "Work with peers to set rules for collegial discussions and decision-making (e.g., informal consensus, taking votes on key issues, presentation of alternate views), clear goals and deadlines, and individual roles as needed.", + "CCSS.ELA-Literacy.SL.9-10.1.c": "Propel conversations by posing and responding to questions that relate the current discussion to broader themes or larger ideas; actively incorporate others into the discussion; and clarify, verify, or challenge ideas and conclusions.", + "CCSS.ELA-Literacy.SL.9-10.1.d": "Respond thoughtfully to diverse perspectives, summarize points of agreement and disagreement, and, when warranted, qualify or justify their own views and understanding and make new connections in light of the evidence and reasoning presented.", + "CCSS.ELA-Literacy.SL.11-12.1": "Initiate and participate effectively in a range of collaborative discussions (one-on-one, in groups, and teacher-led) with diverse partners on grades 11-12 topics, texts, and issues, building on others' ideas and expressing their own clearly and persuasively.", + "CCSS.ELA-Literacy.SL.11-12.2": "Integrate multiple sources of information presented in diverse formats and media (e.g., visually, quantitatively, orally) in order to make informed decisions and solve problems, evaluating the credibility and accuracy of each source and noting any discrepancies among the data.", + "CCSS.ELA-Literacy.SL.11-12.3": "Evaluate a speaker's point of view, reasoning, and use of evidence and rhetoric, assessing the stance, premises, links among ideas, word choice, points of emphasis, and tone used.", + "CCSS.ELA-Literacy.SL.11-12.4": "Present information, findings, and supporting evidence, conveying a clear and distinct perspective, such that listeners can follow the line of reasoning, alternative or opposing perspectives are addressed, and the organization, development, substance, and style are appropriate to purpose, audience, and a range of formal and informal tasks.", + "CCSS.ELA-Literacy.SL.11-12.5": "Make strategic use of digital media (e.g., textual, graphical, audio, visual, and interactive elements) in presentations to enhance understanding of findings, reasoning, and evidence and to add interest.", + "CCSS.ELA-Literacy.SL.11-12.6": "Adapt speech to a variety of contexts and tasks, demonstrating a command of formal English when indicated or appropriate. (See grades 11-12 Language standards 1 and 3", + "CCSS.ELA-Literacy.SL.11-12.1.a": "Come to discussions prepared, having read and researched material under study; explicitly draw on that preparation by referring to evidence from texts and other research on the topic or issue to stimulate a thoughtful, well-reasoned exchange of ideas.", + "CCSS.ELA-Literacy.SL.11-12.1.b": "Work with peers to promote civil, democratic discussions and decision-making, set clear goals and deadlines, and establish individual roles as needed.", + "CCSS.ELA-Literacy.SL.11-12.1.c": "Propel conversations by posing and responding to questions that probe reasoning and evidence; ensure a hearing for a full range of positions on a topic or issue; clarify, verify, or challenge ideas and conclusions; and promote divergent and creative perspectives.", + "CCSS.ELA-Literacy.SL.11-12.1.d": "Respond thoughtfully to diverse perspectives; synthesize comments, claims, and evidence made on all sides of an issue; resolve contradictions when possible; and determine what additional information or research is required to deepen the investigation or complete the task.", + "CCSS.ELA-Literacy.L.K.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.K.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.K.3": "(L.K.3 begins in grade 2)", + "CCSS.ELA-Literacy.L.K.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on kindergarten reading and content.", + "CCSS.ELA-Literacy.L.K.5": "With guidance and support from adults, explore word relationships and nuances in word meanings.", + "CCSS.ELA-Literacy.L.K.6": "Use words and phrases acquired through conversations, reading and being read to, and responding to texts.", + "CCSS.ELA-Literacy.L.K.1.a": "Print many upper- and lowercase letters.", + "CCSS.ELA-Literacy.L.K.1.b": "Use frequently occurring nouns and verbs.", + "CCSS.ELA-Literacy.L.K.1.c": "Form regular plural nouns orally by adding /s/ or /es/ (e.g.,", + "CCSS.ELA-Literacy.L.K.1.d": "Understand and use question words (interrogatives) (e.g.,", + "CCSS.ELA-Literacy.L.K.1.e": "Use the most frequently occurring prepositions (e.g.,", + "CCSS.ELA-Literacy.L.K.1.f": "Produce and expand complete sentences in shared language activities.", + "CCSS.ELA-Literacy.L.K.2.a": "Capitalize the first word in a sentence and the pronoun", + "CCSS.ELA-Literacy.L.K.2.b": "Recognize and name end punctuation.", + "CCSS.ELA-Literacy.L.K.2.c": "Write a letter or letters for most consonant and short-vowel sounds (phonemes).", + "CCSS.ELA-Literacy.L.K.2.d": "Spell simple words phonetically, drawing on knowledge of sound-letter relationships.", + "CCSS.ELA-Literacy.L.K.4.a": "Identify new meanings for familiar words and apply them accurately (e.g., knowing", + "CCSS.ELA-Literacy.L.K.4.b": "Use the most frequently occurring inflections and affixes (e.g.,", + "CCSS.ELA-Literacy.L.K.5.a": "Sort common objects into categories (e.g., shapes, foods) to gain a sense of the concepts the categories represent.", + "CCSS.ELA-Literacy.L.K.5.b": "Demonstrate understanding of frequently occurring verbs and adjectives by relating them to their opposites (antonyms).", + "CCSS.ELA-Literacy.L.K.5.c": "Identify real-life connections between words and their use (e.g., note places at school that are colorful).", + "CCSS.ELA-Literacy.L.K.5.d": "Distinguish shades of meaning among verbs describing the same general action (e.g.,", + "CCSS.ELA-Literacy.L.1.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.1.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.1.3": "(L.1.3 begins in grade 2)", + "CCSS.ELA-Literacy.L.1.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on", + "CCSS.ELA-Literacy.L.1.5": "With guidance and support from adults, demonstrate understanding of word relationships and nuances in word meanings.", + "CCSS.ELA-Literacy.L.1.6": "Use words and phrases acquired through conversations, reading and being read to, and responding to texts, including using frequently occurring conjunctions to signal simple relationships (e.g.,", + "CCSS.ELA-Literacy.L.1.1.a": "Print all upper- and lowercase letters.", + "CCSS.ELA-Literacy.L.1.1.b": "Use common, proper, and possessive nouns.", + "CCSS.ELA-Literacy.L.1.1.c": "Use singular and plural nouns with matching verbs in basic sentences (e.g., He hops; We hop).", + "CCSS.ELA-Literacy.L.1.1.d": "Use personal, possessive, and indefinite pronouns (e.g., I, me, my; they, them, their, anyone, everything).", + "CCSS.ELA-Literacy.L.1.1.e": "Use verbs to convey a sense of past, present, and future (e.g., Yesterday I walked home; Today I walk home; Tomorrow I will walk home).", + "CCSS.ELA-Literacy.L.1.1.f": "Use frequently occurring adjectives.", + "CCSS.ELA-Literacy.L.1.1.g": "Use frequently occurring conjunctions (e.g.,", + "CCSS.ELA-Literacy.L.1.1.h": "Use determiners (e.g., articles, demonstratives).", + "CCSS.ELA-Literacy.L.1.1.i": "Use frequently occurring prepositions (e.g.,", + "CCSS.ELA-Literacy.L.1.1.j": "Produce and expand complete simple and compound declarative, interrogative, imperative, and exclamatory sentences in response to prompts.", + "CCSS.ELA-Literacy.L.1.2.a": "Capitalize dates and names of people.", + "CCSS.ELA-Literacy.L.1.2.b": "Use end punctuation for sentences.", + "CCSS.ELA-Literacy.L.1.2.c": "Use commas in dates and to separate single words in a series.", + "CCSS.ELA-Literacy.L.1.2.d": "Use conventional spelling for words with common spelling patterns and for frequently occurring irregular words.", + "CCSS.ELA-Literacy.L.1.2.e": "Spell untaught words phonetically, drawing on phonemic awareness and spelling conventions.", + "CCSS.ELA-Literacy.L.1.4.a": "Use sentence-level context as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.1.4.b": "Use frequently occurring affixes as a clue to the meaning of a word.", + "CCSS.ELA-Literacy.L.1.4.c": "Identify frequently occurring root words (e.g.,", + "CCSS.ELA-Literacy.L.1.5.a": "Sort words into categories (e.g., colors, clothing) to gain a sense of the concepts the categories represent.", + "CCSS.ELA-Literacy.L.1.5.b": "Define words by category and by one or more key attributes (e.g., a", + "CCSS.ELA-Literacy.L.1.5.c": "Identify real-life connections between words and their use (e.g., note places at home that are", + "CCSS.ELA-Literacy.L.1.5.d": "Distinguish shades of meaning among verbs differing in manner (e.g.,", + "CCSS.ELA-Literacy.L.2.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.2.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.2.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.2.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on grade 2 reading and content, choosing flexibly from an array of strategies.", + "CCSS.ELA-Literacy.L.2.5": "Demonstrate understanding of word relationships and nuances in word meanings.", + "CCSS.ELA-Literacy.L.2.6": "Use words and phrases acquired through conversations, reading and being read to, and responding to texts, including using adjectives and adverbs to describe (e.g.,", + "CCSS.ELA-Literacy.L.2.1.a": "Use collective nouns (e.g.,", + "CCSS.ELA-Literacy.L.2.1.b": "Form and use frequently occurring irregular plural nouns (e.g.,", + "CCSS.ELA-Literacy.L.2.1.c": "Use reflexive pronouns (e.g.,", + "CCSS.ELA-Literacy.L.2.1.d": "Form and use the past tense of frequently occurring irregular verbs (e.g.,", + "CCSS.ELA-Literacy.L.2.1.e": "Use adjectives and adverbs, and choose between them depending on what is to be modified.", + "CCSS.ELA-Literacy.L.2.1.f": "Produce, expand, and rearrange complete simple and compound sentences (e.g.,", + "CCSS.ELA-Literacy.L.2.2.a": "Capitalize holidays, product names, and geographic names.", + "CCSS.ELA-Literacy.L.2.2.b": "Use commas in greetings and closings of letters.", + "CCSS.ELA-Literacy.L.2.2.c": "Use an apostrophe to form contractions and frequently occurring possessives.", + "CCSS.ELA-Literacy.L.2.2.d": "Generalize learned spelling patterns when writing words (e.g.,", + "CCSS.ELA-Literacy.L.2.2.e": "Consult reference materials, including beginning dictionaries, as needed to check and correct spellings.", + "CCSS.ELA-Literacy.L.2.3.a": "Compare formal and informal uses of English", + "CCSS.ELA-Literacy.L.2.4.a": "Use sentence-level context as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.2.4.b": "Determine the meaning of the new word formed when a known prefix is added to a known word (e.g.,", + "CCSS.ELA-Literacy.L.2.4.c": "Use a known root word as a clue to the meaning of an unknown word with the same root (e.g.,", + "CCSS.ELA-Literacy.L.2.4.d": "Use knowledge of the meaning of individual words to predict the meaning of compound words (e.g.,", + "CCSS.ELA-Literacy.L.2.4.e": "Use glossaries and beginning dictionaries, both print and digital, to determine or clarify the meaning of words and phrases.", + "CCSS.ELA-Literacy.L.2.5.a": "Identify real-life connections between words and their use (e.g.,", + "CCSS.ELA-Literacy.L.2.5.b": "Distinguish shades of meaning among closely related verbs (e.g.,", + "CCSS.ELA-Literacy.L.3.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.3.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.3.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.3.4": "Determine or clarify the meaning of unknown and multiple-meaning word and phrases based on grade 3 reading and content, choosing flexibly from a range of strategies.", + "CCSS.ELA-Literacy.L.3.5": "Demonstrate understanding of figurative language, word relationships and nuances in word meanings.", + "CCSS.ELA-Literacy.L.3.6": "Acquire and use accurately grade-appropriate conversational, general academic, and domain-specific words and phrases, including those that signal spatial and temporal relationships (e.g.,", + "CCSS.ELA-Literacy.L.3.1.a": "Explain the function of nouns, pronouns, verbs, adjectives, and adverbs in general and their functions in particular sentences.", + "CCSS.ELA-Literacy.L.3.1.b": "Form and use regular and irregular plural nouns.", + "CCSS.ELA-Literacy.L.3.1.c": "Use abstract nouns (e.g.,", + "CCSS.ELA-Literacy.L.3.1.d": "Form and use regular and irregular verbs.", + "CCSS.ELA-Literacy.L.3.1.e": "Form and use the simple (e.g.,", + "CCSS.ELA-Literacy.L.3.1.f": "Ensure subject-verb and pronoun-antecedent agreement.*", + "CCSS.ELA-Literacy.L.3.1.g": "Form and use comparative and superlative adjectives and adverbs, and choose between them depending on what is to be modified.", + "CCSS.ELA-Literacy.L.3.1.h": "Use coordinating and subordinating conjunctions.", + "CCSS.ELA-Literacy.L.3.1.i": "Produce simple, compound, and complex sentences.", + "CCSS.ELA-Literacy.L.3.2.a": "Capitalize appropriate words in titles.", + "CCSS.ELA-Literacy.L.3.2.b": "Use commas in addresses.", + "CCSS.ELA-Literacy.L.3.2.c": "Use commas and quotation marks in dialogue.", + "CCSS.ELA-Literacy.L.3.2.d": "Form and use possessives.", + "CCSS.ELA-Literacy.L.3.2.e": "Use conventional spelling for high-frequency and other studied words and for adding suffixes to base words (e.g.,", + "CCSS.ELA-Literacy.L.3.2.f": "Use spelling patterns and generalizations (e.g.,", + "CCSS.ELA-Literacy.L.3.2.g": "Consult reference materials, including beginning dictionaries, as needed to check and correct spellings.", + "CCSS.ELA-Literacy.L.3.3.a": "Choose words and phrases for effect.*", + "CCSS.ELA-Literacy.L.3.3.b": "Recognize and observe differences between the conventions of spoken and written standard English.", + "CCSS.ELA-Literacy.L.3.4.a": "Use sentence-level context as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.3.4.b": "Determine the meaning of the new word formed when a known affix is added to a known word (e.g.,", + "CCSS.ELA-Literacy.L.3.4.c": "Use a known root word as a clue to the meaning of an unknown word with the same root (e.g.,", + "CCSS.ELA-Literacy.L.3.4.d": "Use glossaries or beginning dictionaries, both print and digital, to determine or clarify the precise meaning of key words and phrases.", + "CCSS.ELA-Literacy.L.3.5.a": "Distinguish the literal and nonliteral meanings of words and phrases in context (e.g.,", + "CCSS.ELA-Literacy.L.3.5.b": "Identify real-life connections between words and their use (e.g., describe people who are", + "CCSS.ELA-Literacy.L.3.5.c": "Distinguish shades of meaning among related words that describe states of mind or degrees of certainty (e.g.,", + "CCSS.ELA-Literacy.L.4.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.4.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.4.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.4.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on grade 4 reading and content, choosing flexibly from a range of strategies.", + "CCSS.ELA-Literacy.L.4.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.4.6": "Acquire and use accurately grade-appropriate general academic and domain-specific words and phrases, including those that signal precise actions, emotions, or states of being (e.g., quizzed, whined, stammered) and that are basic to a particular topic (e.g.,", + "CCSS.ELA-Literacy.L.4.1.a": "Use relative pronouns (", + "CCSS.ELA-Literacy.L.4.1.b": "Form and use the progressive (e.g.,", + "CCSS.ELA-Literacy.L.4.1.c": "Use modal auxiliaries (e.g.,", + "CCSS.ELA-Literacy.L.4.1.d": "Order adjectives within sentences according to conventional patterns (e.g.,", + "CCSS.ELA-Literacy.L.4.1.e": "Form and use prepositional phrases.", + "CCSS.ELA-Literacy.L.4.1.f": "Produce complete sentences, recognizing and correcting inappropriate fragments and run-ons.*", + "CCSS.ELA-Literacy.L.4.1.g": "Correctly use frequently confused words (e.g.,", + "CCSS.ELA-Literacy.L.4.2.a": "Use correct capitalization.", + "CCSS.ELA-Literacy.L.4.2.b": "Use commas and quotation marks to mark direct speech and quotations from a text.", + "CCSS.ELA-Literacy.L.4.2.c": "Use a comma before a coordinating conjunction in a compound sentence.", + "CCSS.ELA-Literacy.L.4.2.d": "Spell grade-appropriate words correctly, consulting references as needed.", + "CCSS.ELA-Literacy.L.4.3.a": "Choose words and phrases to convey ideas precisely.*", + "CCSS.ELA-Literacy.L.4.3.b": "Choose punctuation for effect.*", + "CCSS.ELA-Literacy.L.4.3.c": "Differentiate between contexts that call for formal English (e.g., presenting ideas) and situations where informal discourse is appropriate (e.g., small-group discussion).", + "CCSS.ELA-Literacy.L.4.4.a": "Use context (e.g., definitions, examples, or restatements in text) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.4.4.b": "Use common, grade-appropriate Greek and Latin affixes and roots as clues to the meaning of a word (e.g.,", + "CCSS.ELA-Literacy.L.4.4.c": "Consult reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation and determine or clarify the precise meaning of key words and phrases.", + "CCSS.ELA-Literacy.L.4.5.a": "Explain the meaning of simple similes and metaphors (e.g.,", + "CCSS.ELA-Literacy.L.4.5.b": "Recognize and explain the meaning of common idioms, adages, and proverbs.", + "CCSS.ELA-Literacy.L.4.5.c": "Demonstrate understanding of words by relating them to their opposites (antonyms) and to words with similar but not identical meanings (synonyms).", + "CCSS.ELA-Literacy.L.5.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.5.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.5.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.5.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on grade 5 reading and content, choosing flexibly from a range of strategies.", + "CCSS.ELA-Literacy.L.5.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.5.6": "Acquire and use accurately grade-appropriate general academic and domain-specific words and phrases, including those that signal contrast, addition, and other logical relationships (e.g.,", + "CCSS.ELA-Literacy.L.5.1.a": "Explain the function of conjunctions, prepositions, and interjections in general and their function in particular sentences.", + "CCSS.ELA-Literacy.L.5.1.b": "Form and use the perfect (e.g.,", + "CCSS.ELA-Literacy.L.5.1.c": "Use verb tense to convey various times, sequences, states, and conditions.", + "CCSS.ELA-Literacy.L.5.1.d": "Recognize and correct inappropriate shifts in verb tense.*", + "CCSS.ELA-Literacy.L.5.1.e": "Use correlative conjunctions (e.g.,", + "CCSS.ELA-Literacy.L.5.2.a": "Use punctuation to separate items in a series.*", + "CCSS.ELA-Literacy.L.5.2.b": "Use a comma to separate an introductory element from the rest of the sentence.", + "CCSS.ELA-Literacy.L.5.2.c": "Use a comma to set off the words", + "CCSS.ELA-Literacy.L.5.2.d": "Use underlining, quotation marks, or italics to indicate titles of works.", + "CCSS.ELA-Literacy.L.5.2.e": "Spell grade-appropriate words correctly, consulting references as needed.", + "CCSS.ELA-Literacy.L.5.3.a": "Expand, combine, and reduce sentences for meaning, reader/listener interest, and style.", + "CCSS.ELA-Literacy.L.5.3.b": "Compare and contrast the varieties of English (e.g.,", + "CCSS.ELA-Literacy.L.5.4.a": "Use context (e.g., cause/effect relationships and comparisons in text) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.5.4.b": "Use common, grade-appropriate Greek and Latin affixes and roots as clues to the meaning of a word (e.g.,", + "CCSS.ELA-Literacy.L.5.4.c": "Consult reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation and determine or clarify the precise meaning of key words and phrases.", + "CCSS.ELA-Literacy.L.5.5.a": "Interpret figurative language, including similes and metaphors, in context.", + "CCSS.ELA-Literacy.L.5.5.b": "Recognize and explain the meaning of common idioms, adages, and proverbs.", + "CCSS.ELA-Literacy.L.5.5.c": "Use the relationship between particular words (e.g., synonyms, antonyms, homographs) to better understand each of the words.", + "CCSS.ELA-Literacy.L.6.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.6.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.6.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.6.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on grade 6 reading and content, choosing flexibly from a range of strategies.", + "CCSS.ELA-Literacy.L.6.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.6.6": "Acquire and use accurately grade-appropriate general academic and domain-specific words and phrases; gather vocabulary knowledge when considering a word or phrase important to comprehension or expression.", + "CCSS.ELA-Literacy.L.6.1.a": "Ensure that pronouns are in the proper case (subjective, objective, possessive).", + "CCSS.ELA-Literacy.L.6.1.b": "Use intensive pronouns (e.g.,", + "CCSS.ELA-Literacy.L.6.1.c": "Recognize and correct inappropriate shifts in pronoun number and person.*", + "CCSS.ELA-Literacy.L.6.1.d": "Recognize and correct vague pronouns (i.e., ones with unclear or ambiguous antecedents).*", + "CCSS.ELA-Literacy.L.6.1.e": "Recognize variations from standard English in their own and others' writing and speaking, and identify and use strategies to improve expression in conventional language.*", + "CCSS.ELA-Literacy.L.6.2.a": "Use punctuation (commas, parentheses, dashes) to set off nonrestrictive/parenthetical elements.*", + "CCSS.ELA-Literacy.L.6.2.b": "Spell correctly.", + "CCSS.ELA-Literacy.L.6.3.a": "Vary sentence patterns for meaning, reader/listener interest, and style.*", + "CCSS.ELA-Literacy.L.6.3.b": "Maintain consistency in style and tone.*", + "CCSS.ELA-Literacy.L.6.4.a": "Use context (e.g., the overall meaning of a sentence or paragraph; a word's position or function in a sentence) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.6.4.b": "Use common, grade-appropriate Greek or Latin affixes and roots as clues to the meaning of a word (e.g.,", + "CCSS.ELA-Literacy.L.6.4.c": "Consult reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation of a word or determine or clarify its precise meaning or its part of speech.", + "CCSS.ELA-Literacy.L.6.4.d": "Verify the preliminary determination of the meaning of a word or phrase (e.g., by checking the inferred meaning in context or in a dictionary).", + "CCSS.ELA-Literacy.L.6.5.a": "Interpret figures of speech (e.g., personification) in context.", + "CCSS.ELA-Literacy.L.6.5.b": "Use the relationship between particular words (e.g., cause/effect, part/whole, item/category) to better understand each of the words.", + "CCSS.ELA-Literacy.L.6.5.c": "Distinguish among the connotations (associations) of words with similar denotations (definitions) (e.g.,", + "CCSS.ELA-Literacy.L.7.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.7.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.7.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.7.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on", + "CCSS.ELA-Literacy.L.7.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.7.6": "Acquire and use accurately grade-appropriate general academic and domain-specific words and phrases; gather vocabulary knowledge when considering a word or phrase important to comprehension or expression.", + "CCSS.ELA-Literacy.L.7.1.a": "Explain the function of phrases and clauses in general and their function in specific sentences.", + "CCSS.ELA-Literacy.L.7.1.b": "Choose among simple, compound, complex, and compound-complex sentences to signal differing relationships among ideas.", + "CCSS.ELA-Literacy.L.7.1.c": "Place phrases and clauses within a sentence, recognizing and correcting misplaced and dangling modifiers.*", + "CCSS.ELA-Literacy.L.7.2.a": "Use a comma to separate coordinate adjectives (e.g.,", + "CCSS.ELA-Literacy.L.7.2.b": "Spell correctly.", + "CCSS.ELA-Literacy.L.7.3.a": "Choose language that expresses ideas precisely and concisely, recognizing and eliminating wordiness and redundancy.*", + "CCSS.ELA-Literacy.L.7.4.a": "Use context (e.g., the overall meaning of a sentence or paragraph; a word's position or function in a sentence) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.7.4.b": "Use common, grade-appropriate Greek or Latin affixes and roots as clues to the meaning of a word (e.g.,", + "CCSS.ELA-Literacy.L.7.4.c": "Consult general and specialized reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation of a word or determine or clarify its precise meaning or its part of speech.", + "CCSS.ELA-Literacy.L.7.4.d": "Verify the preliminary determination of the meaning of a word or phrase (e.g., by checking the inferred meaning in context or in a dictionary).", + "CCSS.ELA-Literacy.L.7.5.a": "Interpret figures of speech (e.g., literary, biblical, and mythological allusions) in context.", + "CCSS.ELA-Literacy.L.7.5.b": "Use the relationship between particular words (e.g., synonym/antonym, analogy) to better understand each of the words.", + "CCSS.ELA-Literacy.L.7.5.c": "Distinguish among the connotations (associations) of words with similar denotations (definitions) (e.g.,", + "CCSS.ELA-Literacy.L.8.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.8.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.8.3": "Use knowledge of language and its conventions when writing, speaking, reading, or listening.", + "CCSS.ELA-Literacy.L.8.4": "Determine or clarify the meaning of unknown and multiple-meaning words or phrases based on", + "CCSS.ELA-Literacy.L.8.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.8.6": "Acquire and use accurately grade-appropriate general academic and domain-specific words and phrases; gather vocabulary knowledge when considering a word or phrase important to comprehension or expression.", + "CCSS.ELA-Literacy.L.8.1.a": "Explain the function of verbals (gerunds, participles, infinitives) in general and their function in particular sentences.", + "CCSS.ELA-Literacy.L.8.1.b": "Form and use verbs in the active and passive voice.", + "CCSS.ELA-Literacy.L.8.1.c": "Form and use verbs in the indicative, imperative, interrogative, conditional, and subjunctive mood.", + "CCSS.ELA-Literacy.L.8.1.d": "Recognize and correct inappropriate shifts in verb voice and mood.*", + "CCSS.ELA-Literacy.L.8.2.a": "Use punctuation (comma, ellipsis, dash) to indicate a pause or break.", + "CCSS.ELA-Literacy.L.8.2.b": "Use an ellipsis to indicate an omission.", + "CCSS.ELA-Literacy.L.8.2.c": "Spell correctly.", + "CCSS.ELA-Literacy.L.8.3.a": "Use verbs in the active and passive voice and in the conditional and subjunctive mood to achieve particular effects (e.g., emphasizing the actor or the action; expressing uncertainty or describing a state contrary to fact).", + "CCSS.ELA-Literacy.L.8.4.a": "Use context (e.g., the overall meaning of a sentence or paragraph; a word's position or function in a sentence) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.8.4.b": "Use common, grade-appropriate Greek or Latin affixes and roots as clues to the meaning of a word (e.g.,", + "CCSS.ELA-Literacy.L.8.4.c": "Consult general and specialized reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation of a word or determine or clarify its precise meaning or its part of speech.", + "CCSS.ELA-Literacy.L.8.4.d": "Verify the preliminary determination of the meaning of a word or phrase (e.g., by checking the inferred meaning in context or in a dictionary).", + "CCSS.ELA-Literacy.L.8.5.a": "Interpret figures of speech (e.g. verbal irony, puns) in context.", + "CCSS.ELA-Literacy.L.8.5.b": "Use the relationship between particular words to better understand each of the words.", + "CCSS.ELA-Literacy.L.8.5.c": "Distinguish among the connotations (associations) of words with similar denotations (definitions) (e.g.,", + "CCSS.ELA-Literacy.L.9-10.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.9-10.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.9-10.3": "Apply knowledge of language to understand how language functions in different contexts, to make effective choices for meaning or style, and to comprehend more fully when reading or listening.", + "CCSS.ELA-Literacy.L.9-10.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on", + "CCSS.ELA-Literacy.L.9-10.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.9-10.6": "Acquire and use accurately general academic and domain-specific words and phrases, sufficient for reading, writing, speaking, and listening at the college and career readiness level; demonstrate independence in gathering vocabulary knowledge when considering a word or phrase important to comprehension or expression.", + "CCSS.ELA-Literacy.L.9-10.1.a": "Use parallel structure.*", + "CCSS.ELA-Literacy.L.9-10.1.b": "Use various types of phrases (noun, verb, adjectival, adverbial, participial, prepositional, absolute) and clauses (independent, dependent; noun, relative, adverbial) to convey specific meanings and add variety and interest to writing or presentations.", + "CCSS.ELA-Literacy.L.9-10.2.a": "Use a semicolon (and perhaps a conjunctive adverb) to link two or more closely related independent clauses.", + "CCSS.ELA-Literacy.L.9-10.2.b": "Use a colon to introduce a list or quotation.", + "CCSS.ELA-Literacy.L.9-10.2.c": "Spell correctly.", + "CCSS.ELA-Literacy.L.9-10.3.a": "Write and edit work so that it conforms to the guidelines in a style manual (e.g.,", + "CCSS.ELA-Literacy.L.9-10.4.a": "Use context (e.g., the overall meaning of a sentence, paragraph, or text; a word's position or function in a sentence) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.9-10.4.b": "Identify and correctly use patterns of word changes that indicate different meanings or parts of speech (e.g.,", + "CCSS.ELA-Literacy.L.9-10.4.c": "Consult general and specialized reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation of a word or determine or clarify its precise meaning, its part of speech, or its etymology.", + "CCSS.ELA-Literacy.L.9-10.4.d": "Verify the preliminary determination of the meaning of a word or phrase (e.g., by checking the inferred meaning in context or in a dictionary).", + "CCSS.ELA-Literacy.L.9-10.5.a": "Interpret figures of speech (e.g., euphemism, oxymoron) in context and analyze their role in the text.", + "CCSS.ELA-Literacy.L.9-10.5.b": "Analyze nuances in the meaning of words with similar denotations.", + "CCSS.ELA-Literacy.L.11-12.1": "Demonstrate command of the conventions of standard English grammar and usage when writing or speaking.", + "CCSS.ELA-Literacy.L.11-12.2": "Demonstrate command of the conventions of standard English capitalization, punctuation, and spelling when writing.", + "CCSS.ELA-Literacy.L.11-12.3": "Apply knowledge of language to understand how language functions in different contexts, to make effective choices for meaning or style, and to comprehend more fully when reading or listening.", + "CCSS.ELA-Literacy.L.11-12.4": "Determine or clarify the meaning of unknown and multiple-meaning words and phrases based on", + "CCSS.ELA-Literacy.L.11-12.5": "Demonstrate understanding of figurative language, word relationships, and nuances in word meanings.", + "CCSS.ELA-Literacy.L.11-12.6": "Acquire and use accurately general academic and domain-specific words and phrases, sufficient for reading, writing, speaking, and listening at the college and career readiness level; demonstrate independence in gathering vocabulary knowledge when considering a word or phrase important to comprehension or expression.", + "CCSS.ELA-Literacy.L.11-12.1.a": "Apply the understanding that usage is a matter of convention, can change over time, and is sometimes contested.", + "CCSS.ELA-Literacy.L.11-12.1.b": "Resolve issues of complex or contested usage, consulting references (e.g.,", + "CCSS.ELA-Literacy.L.11-12.2.a": "Observe hyphenation conventions.", + "CCSS.ELA-Literacy.L.11-12.2.b": "Spell correctly.", + "CCSS.ELA-Literacy.L.11-12.3.a": "Vary syntax for effect, consulting references (e.g., Tufte's", + "CCSS.ELA-Literacy.L.11-12.4.a": "Use context (e.g., the overall meaning of a sentence, paragraph, or text; a word's position or function in a sentence) as a clue to the meaning of a word or phrase.", + "CCSS.ELA-Literacy.L.11-12.4.b": "Identify and correctly use patterns of word changes that indicate different meanings or parts of speech (e.g.,", + "CCSS.ELA-Literacy.L.11-12.4.c": "Consult general and specialized reference materials (e.g., dictionaries, glossaries, thesauruses), both print and digital, to find the pronunciation of a word or determine or clarify its precise meaning, its part of speech, its etymology, or its standard usage.", + "CCSS.ELA-Literacy.L.11-12.4.d": "Verify the preliminary determination of the meaning of a word or phrase (e.g., by checking the inferred meaning in context or in a dictionary).", + "CCSS.ELA-Literacy.L.11-12.5.a": "Interpret figures of speech (e.g., hyperbole, paradox) in context and analyze their role in the text.", + "CCSS.ELA-Literacy.L.11-12.5.b": "Analyze nuances in the meaning of words with similar denotations.", + "CCSS.ELA-Literacy.RH.6-8.1": "Cite specific textual evidence to support analysis of primary and secondary sources.", + "CCSS.ELA-Literacy.RH.6-8.2": "Determine the central ideas or information of a primary or secondary source; provide an accurate summary of the source distinct from prior knowledge or opinions.", + "CCSS.ELA-Literacy.RH.6-8.3": "Identify key steps in a text's description of a process related to history/social studies (e.g., how a bill becomes law, how interest rates are raised or lowered).", + "CCSS.ELA-Literacy.RH.6-8.4": "Determine the meaning of words and phrases as they are used in a text, including vocabulary specific to domains related to history/social studies.", + "CCSS.ELA-Literacy.RH.6-8.5": "Describe how a text presents information (e.g., sequentially, comparatively, causally).", + "CCSS.ELA-Literacy.RH.6-8.6": "Identify aspects of a text that reveal an author's point of view or purpose (e.g., loaded language, inclusion or avoidance of particular facts).", + "CCSS.ELA-Literacy.RH.6-8.7": "Integrate visual information (e.g., in charts, graphs, photographs, videos, or maps) with other information in print and digital texts.", + "CCSS.ELA-Literacy.RH.6-8.8": "Distinguish among fact, opinion, and reasoned judgment in a text.", + "CCSS.ELA-Literacy.RH.6-8.9": "Analyze the relationship between a primary and secondary source on the same topic.", + "CCSS.ELA-Literacy.RH.6-8.10": "By the end of grade 8, read and comprehend history/social studies texts in the grades 6-8 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RH.9-10.1": "Cite specific textual evidence to support analysis of primary and secondary sources, attending to such features as the date and origin of the information.", + "CCSS.ELA-Literacy.RH.9-10.2": "Determine the central ideas or information of a primary or secondary source; provide an accurate summary of how key events or ideas develop over the course of the text.", + "CCSS.ELA-Literacy.RH.9-10.3": "Analyze in detail a series of events described in a text; determine whether earlier events caused later ones or simply preceded them.", + "CCSS.ELA-Literacy.RH.9-10.4": "Determine the meaning of words and phrases as they are used in a text, including vocabulary describing political, social, or economic aspects of history/social science.", + "CCSS.ELA-Literacy.RH.9-10.5": "Analyze how a text uses structure to emphasize key points or advance an explanation or analysis.", + "CCSS.ELA-Literacy.RH.9-10.6": "Compare the point of view of two or more authors for how they treat the same or similar topics, including which details they include and emphasize in their respective accounts.", + "CCSS.ELA-Literacy.RH.9-10.7": "Integrate quantitative or technical analysis (e.g., charts, research data) with qualitative analysis in print or digital text.", + "CCSS.ELA-Literacy.RH.9-10.8": "Assess the extent to which the reasoning and evidence in a text support the author's claims.", + "CCSS.ELA-Literacy.RH.9-10.9": "Compare and contrast treatments of the same topic in several primary and secondary sources.", + "CCSS.ELA-Literacy.RH.9-10.10": "By the end of grade 10, read and comprehend history/social studies texts in the grades 9-10 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RH.11-12.1": "Cite specific textual evidence to support analysis of primary and secondary sources, connecting insights gained from specific details to an understanding of the text as a whole.", + "CCSS.ELA-Literacy.RH.11-12.2": "Determine the central ideas or information of a primary or secondary source; provide an accurate summary that makes clear the relationships among the key details and ideas.", + "CCSS.ELA-Literacy.RH.11-12.3": "Evaluate various explanations for actions or events and determine which explanation best accords with textual evidence, acknowledging where the text leaves matters uncertain.", + "CCSS.ELA-Literacy.RH.11-12.4": "Determine the meaning of words and phrases as they are used in a text, including analyzing how an author uses and refines the meaning of a key term over the course of a text (e.g., how Madison defines", + "CCSS.ELA-Literacy.RH.11-12.5": "Analyze in detail how a complex primary source is structured, including how key sentences, paragraphs, and larger portions of the text contribute to the whole.", + "CCSS.ELA-Literacy.RH.11-12.6": "Evaluate authors' differing points of view on the same historical event or issue by assessing the authors' claims, reasoning, and evidence.", + "CCSS.ELA-Literacy.RH.11-12.7": "Integrate and evaluate multiple sources of information presented in diverse formats and media (e.g., visually, quantitatively, as well as in words) in order to address a question or solve a problem.", + "CCSS.ELA-Literacy.RH.11-12.8": "Evaluate an author's premises, claims, and evidence by corroborating or challenging them with other information.", + "CCSS.ELA-Literacy.RH.11-12.9": "Integrate information from diverse sources, both primary and secondary, into a coherent understanding of an idea or event, noting discrepancies among sources.", + "CCSS.ELA-Literacy.RH.11-12.10": "By the end of grade 12, read and comprehend history/social studies texts in the grades 11-CCR text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RST.6-8.1": "Cite specific textual evidence to support analysis of science and technical texts.", + "CCSS.ELA-Literacy.RST.6-8.2": "Determine the central ideas or conclusions of a text; provide an accurate summary of the text distinct from prior knowledge or opinions.", + "CCSS.ELA-Literacy.RST.6-8.3": "Follow precisely a multistep procedure when carrying out experiments, taking measurements, or performing technical tasks.", + "CCSS.ELA-Literacy.RST.6-8.4": "Determine the meaning of symbols, key terms, and other domain-specific words and phrases as they are used in a specific scientific or technical context relevant to", + "CCSS.ELA-Literacy.RST.6-8.5": "Analyze the structure an author uses to organize a text, including how the major sections contribute to the whole and to an understanding of the topic.", + "CCSS.ELA-Literacy.RST.6-8.6": "Analyze the author's purpose in providing an explanation, describing a procedure, or discussing an experiment in a text.", + "CCSS.ELA-Literacy.RST.6-8.7": "Integrate quantitative or technical information expressed in words in a text with a version of that information expressed visually (e.g., in a flowchart, diagram, model, graph, or table).", + "CCSS.ELA-Literacy.RST.6-8.8": "Distinguish among facts, reasoned judgment based on research findings, and speculation in a text.", + "CCSS.ELA-Literacy.RST.6-8.9": "Compare and contrast the information gained from experiments, simulations, video, or multimedia sources with that gained from reading a text on the same topic.", + "CCSS.ELA-Literacy.RST.6-8.10": "By the end of grade 8, read and comprehend science/technical texts in the grades 6-8 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RST.9-10.1": "Cite specific textual evidence to support analysis of science and technical texts, attending to the precise details of explanations or descriptions.", + "CCSS.ELA-Literacy.RST.9-10.2": "Determine the central ideas or conclusions of a text; trace the text's explanation or depiction of a complex process, phenomenon, or concept; provide an accurate summary of the text.", + "CCSS.ELA-Literacy.RST.9-10.3": "Follow precisely a complex multistep procedure when carrying out experiments, taking measurements, or performing technical tasks, attending to special cases or exceptions defined in the text.", + "CCSS.ELA-Literacy.RST.9-10.4": "Determine the meaning of symbols, key terms, and other domain-specific words and phrases as they are used in a specific scientific or technical context relevant to", + "CCSS.ELA-Literacy.RST.9-10.5": "Analyze the structure of the relationships among concepts in a text, including relationships among key terms (e.g.,", + "CCSS.ELA-Literacy.RST.9-10.6": "Analyze the author's purpose in providing an explanation, describing a procedure, or discussing an experiment in a text, defining the question the author seeks to address.", + "CCSS.ELA-Literacy.RST.9-10.7": "Translate quantitative or technical information expressed in words in a text into visual form (e.g., a table or chart) and translate information expressed visually or mathematically (e.g., in an equation) into words.", + "CCSS.ELA-Literacy.RST.9-10.8": "Assess the extent to which the reasoning and evidence in a text support the author's claim or a recommendation for solving a scientific or technical problem.", + "CCSS.ELA-Literacy.RST.9-10.9": "Compare and contrast findings presented in a text to those from other sources (including their own experiments), noting when the findings support or contradict previous explanations or accounts.", + "CCSS.ELA-Literacy.RST.9-10.10": "By the end of grade 10, read and comprehend science/technical texts in the grades 9-10 text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.RST.11-12.1": "Cite specific textual evidence to support analysis of science and technical texts, attending to important distinctions the author makes and to any gaps or inconsistencies in the account.", + "CCSS.ELA-Literacy.RST.11-12.2": "Determine the central ideas or conclusions of a text; summarize complex concepts, processes, or information presented in a text by paraphrasing them in simpler but still accurate terms.", + "CCSS.ELA-Literacy.RST.11-12.3": "Follow precisely a complex multistep procedure when carrying out experiments, taking measurements, or performing technical tasks; analyze the specific results based on explanations in the text.", + "CCSS.ELA-Literacy.RST.11-12.4": "Determine the meaning of symbols, key terms, and other domain-specific words and phrases as they are used in a specific scientific or technical context relevant to", + "CCSS.ELA-Literacy.RST.11-12.5": "Analyze how the text structures information or ideas into categories or hierarchies, demonstrating understanding of the information or ideas.", + "CCSS.ELA-Literacy.RST.11-12.6": "Analyze the author's purpose in providing an explanation, describing a procedure, or discussing an experiment in a text, identifying important issues that remain unresolved.", + "CCSS.ELA-Literacy.RST.11-12.7": "Integrate and evaluate multiple sources of information presented in diverse formats and media (e.g., quantitative data, video, multimedia) in order to address a question or solve a problem.", + "CCSS.ELA-Literacy.RST.11-12.8": "Evaluate the hypotheses, data, analysis, and conclusions in a science or technical text, verifying the data when possible and corroborating or challenging conclusions with other sources of information.", + "CCSS.ELA-Literacy.RST.11-12.9": "Synthesize information from a range of sources (e.g., texts, experiments, simulations) into a coherent understanding of a process, phenomenon, or concept, resolving conflicting information when possible.", + "CCSS.ELA-Literacy.RST.11-12.10": "By the end of grade 12, read and comprehend science/technical texts in the grades 11-CCR text complexity band independently and proficiently.", + "CCSS.ELA-Literacy.WHST.6-8.1": "Write arguments focused on", + "CCSS.ELA-Literacy.WHST.6-8.2": "Write informative/explanatory texts, including the narration of historical events, scientific procedures/ experiments, or technical processes.", + "CCSS.ELA-Literacy.WHST.6-8.3": "(See note; not applicable as a separate requirement)", + "CCSS.ELA-Literacy.WHST.6-8.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience.", + "CCSS.ELA-Literacy.WHST.6-8.5": "With some guidance and support from peers and adults, develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on how well purpose and audience have been addressed.", + "CCSS.ELA-Literacy.WHST.6-8.6": "Use technology, including the Internet, to produce and publish writing and present the relationships between information and ideas clearly and efficiently.", + "CCSS.ELA-Literacy.WHST.6-8.7": "Conduct short research projects to answer a question (including a self-generated question), drawing on several sources and generating additional related, focused questions that allow for multiple avenues of exploration.", + "CCSS.ELA-Literacy.WHST.6-8.8": "Gather relevant information from multiple print and digital sources, using search terms effectively; assess the credibility and accuracy of each source; and quote or paraphrase the data and conclusions of others while avoiding plagiarism and following a standard format for citation.", + "CCSS.ELA-Literacy.WHST.6-8.9": "Draw evidence from informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.WHST.6-8.10": "Write routinely over extended time frames (time for reflection and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.WHST.6-8.1.a": "Introduce claim(s) about a topic or issue, acknowledge and distinguish the claim(s) from alternate or opposing claims, and organize the reasons and evidence logically.", + "CCSS.ELA-Literacy.WHST.6-8.1.b": "Support claim(s) with logical reasoning and relevant, accurate data and evidence that demonstrate an understanding of the topic or text, using credible sources.", + "CCSS.ELA-Literacy.WHST.6-8.1.c": "Use words, phrases, and clauses to create cohesion and clarify the relationships among claim(s), counterclaims, reasons, and evidence.", + "CCSS.ELA-Literacy.WHST.6-8.1.d": "Establish and maintain a formal style.", + "CCSS.ELA-Literacy.WHST.6-8.1.e": "Provide a concluding statement or section that follows from and supports the argument presented.", + "CCSS.ELA-Literacy.WHST.6-8.2.a": "Introduce a topic clearly, previewing what is to follow; organize ideas, concepts, and information into broader categories as appropriate to achieving purpose; include formatting (e.g., headings), graphics (e.g., charts, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.WHST.6-8.2.b": "Develop the topic with relevant, well-chosen facts, definitions, concrete details, quotations, or other information and examples.", + "CCSS.ELA-Literacy.WHST.6-8.2.c": "Use appropriate and varied transitions to create cohesion and clarify the relationships among ideas and concepts.", + "CCSS.ELA-Literacy.WHST.6-8.2.d": "Use precise language and domain-specific vocabulary to inform about or explain the topic.", + "CCSS.ELA-Literacy.WHST.6-8.2.e": "Establish and maintain a formal style and objective tone.", + "CCSS.ELA-Literacy.WHST.6-8.2.f": "Provide a concluding statement or section that follows from and supports the information or explanation presented.", + "CCSS.ELA-Literacy.WHST.9-10.1": "Write arguments focused on", + "CCSS.ELA-Literacy.WHST.9-10.2": "Write informative/explanatory texts, including the narration of historical events, scientific procedures/ experiments, or technical processes.", + "CCSS.ELA-Literacy.WHST.9-10.3": "(See note; not applicable as a separate requirement)", + "CCSS.ELA-Literacy.WHST.9-10.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience.", + "CCSS.ELA-Literacy.WHST.9-10.5": "Develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on addressing what is most significant for a specific purpose and audience.", + "CCSS.ELA-Literacy.WHST.9-10.6": "Use technology, including the Internet, to produce, publish, and update individual or shared writing products, taking advantage of technology's capacity to link to other information and to display information flexibly and dynamically.", + "CCSS.ELA-Literacy.WHST.9-10.7": "Conduct short as well as more sustained research projects to answer a question (including a self-generated question) or solve a problem; narrow or broaden the inquiry when appropriate; synthesize multiple sources on the subject, demonstrating understanding of the subject under investigation.", + "CCSS.ELA-Literacy.WHST.9-10.8": "Gather relevant information from multiple authoritative print and digital sources, using advanced searches effectively; assess the usefulness of each source in answering the research question; integrate information into the text selectively to maintain the flow of ideas, avoiding plagiarism and following a standard format for citation.", + "CCSS.ELA-Literacy.WHST.9-10.9": "Draw evidence from informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.WHST.9-10.10": "Write routinely over extended time frames (time for reflection and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.WHST.9-10.1.a": "Introduce precise claim(s), distinguish the claim(s) from alternate or opposing claims, and create an organization that establishes clear relationships among the claim(s), counterclaims, reasons, and evidence.", + "CCSS.ELA-Literacy.WHST.9-10.1.b": "Develop claim(s) and counterclaims fairly, supplying data and evidence for each while pointing out the strengths and limitations of both claim(s) and counterclaims in a discipline-appropriate form and in a manner that anticipates the audience's knowledge level and concerns.", + "CCSS.ELA-Literacy.WHST.9-10.1.c": "Use words, phrases, and clauses to link the major sections of the text, create cohesion, and clarify the relationships between claim(s) and reasons, between reasons and evidence, and between claim(s) and counterclaims.", + "CCSS.ELA-Literacy.WHST.9-10.1.d": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.WHST.9-10.1.e": "Provide a concluding statement or section that follows from or supports the argument presented.", + "CCSS.ELA-Literacy.WHST.9-10.2.a": "Introduce a topic and organize ideas, concepts, and information to make important connections and distinctions; include formatting (e.g., headings), graphics (e.g., figures, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.WHST.9-10.2.b": "Develop the topic with well-chosen, relevant, and sufficient facts, extended definitions, concrete details, quotations, or other information and examples appropriate to the audience's knowledge of the topic.", + "CCSS.ELA-Literacy.WHST.9-10.2.c": "Use varied transitions and sentence structures to link the major sections of the text, create cohesion, and clarify the relationships among ideas and concepts.", + "CCSS.ELA-Literacy.WHST.9-10.2.d": "Use precise language and domain-specific vocabulary to manage the complexity of the topic and convey a style appropriate to the discipline and context as well as to the expertise of likely readers.", + "CCSS.ELA-Literacy.WHST.9-10.2.e": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.WHST.9-10.2.f": "Provide a concluding statement or section that follows from and supports the information or explanation presented (e.g., articulating implications or the significance of the topic).", + "CCSS.ELA-Literacy.WHST.11-12.1": "Write arguments focused on", + "CCSS.ELA-Literacy.WHST.11-12.2": "Write informative/explanatory texts, including the narration of historical events, scientific procedures/experiments, or technical processes.", + "CCSS.ELA-Literacy.WHST.11-12.3": "(See note; not applicable as a separate requirement)", + "CCSS.ELA-Literacy.WHST.11-12.4": "Produce clear and coherent writing in which the development, organization, and style are appropriate to task, purpose, and audience.", + "CCSS.ELA-Literacy.WHST.11-12.5": "Develop and strengthen writing as needed by planning, revising, editing, rewriting, or trying a new approach, focusing on addressing what is most significant for a specific purpose and audience.", + "CCSS.ELA-Literacy.WHST.11-12.6": "Use technology, including the Internet, to produce, publish, and update individual or shared writing products in response to ongoing feedback, including new arguments or information.", + "CCSS.ELA-Literacy.WHST.11-12.7": "Conduct short as well as more sustained research projects to answer a question (including a self-generated question) or solve a problem; narrow or broaden the inquiry when appropriate; synthesize multiple sources on the subject, demonstrating understanding of the subject under investigation.", + "CCSS.ELA-Literacy.WHST.11-12.8": "Gather relevant information from multiple authoritative print and digital sources, using advanced searches effectively; assess the strengths and limitations of each source in terms of the specific task, purpose, and audience; integrate information into the text selectively to maintain the flow of ideas, avoiding plagiarism and overreliance on any one source and following a standard format for citation.", + "CCSS.ELA-Literacy.WHST.11-12.9": "Draw evidence from informational texts to support analysis, reflection, and research.", + "CCSS.ELA-Literacy.WHST.11-12.10": "Write routinely over extended time frames (time for reflection and revision) and shorter time frames (a single sitting or a day or two) for a range of discipline-specific tasks, purposes, and audiences.", + "CCSS.ELA-Literacy.WHST.11-12.1.a": "Introduce precise, knowledgeable claim(s), establish the significance of the claim(s), distinguish the claim(s) from alternate or opposing claims, and create an organization that logically sequences the claim(s), counterclaims, reasons, and evidence.", + "CCSS.ELA-Literacy.WHST.11-12.1.b": "Develop claim(s) and counterclaims fairly and thoroughly, supplying the most relevant data and evidence for each while pointing out the strengths and limitations of both claim(s) and counterclaims in a discipline-appropriate form that anticipates the audience's knowledge level, concerns, values, and possible biases.", + "CCSS.ELA-Literacy.WHST.11-12.1.c": "Use words, phrases, and clauses as well as varied syntax to link the major sections of the text, create cohesion, and clarify the relationships between claim(s) and reasons, between reasons and evidence, and between claim(s) and counterclaims.", + "CCSS.ELA-Literacy.WHST.11-12.1.d": "Establish and maintain a formal style and objective tone while attending to the norms and conventions of the discipline in which they are writing.", + "CCSS.ELA-Literacy.WHST.11-12.1.e": "Provide a concluding statement or section that follows from or supports the argument presented.", + "CCSS.ELA-Literacy.WHST.11-12.2.a": "Introduce a topic and organize complex ideas, concepts, and information so that each new element builds on that which precedes it to create a unified whole; include formatting (e.g., headings), graphics (e.g., figures, tables), and multimedia when useful to aiding comprehension.", + "CCSS.ELA-Literacy.WHST.11-12.2.b": "Develop the topic thoroughly by selecting the most significant and relevant facts, extended definitions, concrete details, quotations, or other information and examples appropriate to the audience's knowledge of the topic.", + "CCSS.ELA-Literacy.WHST.11-12.2.c": "Use varied transitions and sentence structures to link the major sections of the text, create cohesion, and clarify the relationships among complex ideas and concepts.", + "CCSS.ELA-Literacy.WHST.11-12.2.d": "Use precise language, domain-specific vocabulary and techniques such as metaphor, simile, and analogy to manage the complexity of the topic; convey a knowledgeable stance in a style that responds to the discipline and context as well as to the expertise of likely readers.", + "CCSS.ELA-Literacy.WHST.11-12.2.e": "Provide a concluding statement or section that follows from and supports the information or explanation provided (e.g., articulating implications or the significance of the topic).", + "CCSS.Math.Content.K.CC.A.1": "Count to 100 by ones and by tens.", + "CCSS.Math.Content.K.CC.A.2": "Count forward beginning from a given number within the known sequence (instead of having to begin at 1).", + "CCSS.Math.Content.K.CC.A.3": "Write numbers from 0 to 20. Represent a number of objects with a written numeral 0-20 (with 0 representing a count of no objects).", + "CCSS.Math.Content.K.CC.B.4": "Understand the relationship between numbers and quantities; connect counting to cardinality.", + "CCSS.Math.Content.K.CC.B.5": "Count to answer \"how many?\" questions about as many as 20 things arranged in a line, a rectangular array, or a circle, or as many as 10 things in a scattered configuration; given a number from 1-20, count out that many objects.", + "CCSS.Math.Content.K.CC.C.6": "Identify whether the number of objects in one group is greater than, less than, or equal to the number of objects in another group, e.g., by using matching and counting strategies.", + "CCSS.Math.Content.K.CC.C.7": "Compare two numbers between 1 and 10 presented as written numerals.", + "CCSS.Math.Content.K.CC.B.4.a": "When counting objects, say the number names in the standard order, pairing each object with one and only one number name and each number name with one and only one object.", + "CCSS.Math.Content.K.CC.B.4.b": "Understand that the last number name said tells the number of objects counted. The number of objects is the same regardless of their arrangement or the order in which they were counted.", + "CCSS.Math.Content.K.CC.B.4.c": "Understand that each successive number name refers to a quantity that is one larger.", + "CCSS.Math.Content.K.OA.A.1": "Represent addition and subtraction with objects, fingers, mental images, drawings", + "CCSS.Math.Content.K.OA.A.2": "Solve addition and subtraction word problems, and add and subtract within 10, e.g., by using objects or drawings to represent the problem.", + "CCSS.Math.Content.K.OA.A.3": "Decompose numbers less than or equal to 10 into pairs in more than one way, e.g., by using objects or drawings, and record each decomposition by a drawing or equation (e.g., 5 = 2 + 3 and 5 = 4 + 1).", + "CCSS.Math.Content.K.OA.A.4": "For any number from 1 to 9, find the number that makes 10 when added to the given number, e.g., by using objects or drawings, and record the answer with a drawing or equation.", + "CCSS.Math.Content.K.OA.A.5": "Fluently add and subtract within 5.", + "CCSS.Math.Content.K.NBT.A.1": "Compose and decompose numbers from 11 to 19 into ten ones and some further ones, e.g., by using objects or drawings, and record each composition or decomposition by a drawing or equation (such as 18 = 10 + 8); understand that these numbers are composed of ten ones and one, two, three, four, five, six, seven, eight, or nine ones.", + "CCSS.Math.Content.K.MD.A.1": "Describe measurable attributes of objects, such as length or weight. Describe several measurable attributes of a single object.", + "CCSS.Math.Content.K.MD.A.2": "Directly compare two objects with a measurable attribute in common, to see which object has \"more of\"/\"less of\" the attribute, and describe the difference.", + "CCSS.Math.Content.K.MD.B.3": "Classify objects into given categories; count the numbers of objects in each category and sort the categories by count.", + "CCSS.Math.Content.K.G.A.1": "Describe objects in the environment using names of shapes, and describe the relative positions of these objects using terms such as", + "CCSS.Math.Content.K.G.A.2": "Correctly name shapes regardless of their orientations or overall size.", + "CCSS.Math.Content.K.G.A.3": "Identify shapes as two-dimensional (lying in a plane, \"flat\") or three-dimensional (\"solid\").", + "CCSS.Math.Content.K.G.B.4": "Analyze and compare two- and three-dimensional shapes, in different sizes and orientations, using informal language to describe their similarities, differences, parts (e.g., number of sides and vertices/\"corners\") and other attributes (e.g., having sides of equal length).", + "CCSS.Math.Content.K.G.B.5": "Model shapes in the world by building shapes from components (e.g., sticks and clay balls) and drawing shapes.", + "CCSS.Math.Content.K.G.B.6": "Compose simple shapes to form larger shapes.", + "CCSS.Math.Content.1.OA.A.1": "Use addition and subtraction within 20 to solve word problems involving situations of adding to, taking from, putting together, taking apart, and comparing, with unknowns in all positions, e.g., by using objects, drawings, and equations with a symbol for the unknown number to represent the problem.", + "CCSS.Math.Content.1.OA.A.2": "Solve word problems that call for addition of three whole numbers whose sum is less than or equal to 20, e.g., by using objects, drawings, and equations with a symbol for the unknown number to represent the problem.", + "CCSS.Math.Content.1.OA.B.3": "Apply properties of operations as strategies to add and subtract.", + "CCSS.Math.Content.1.OA.B.4": "Understand subtraction as an unknown-addend problem.", + "CCSS.Math.Content.1.OA.C.5": "Relate counting to addition and subtraction (e.g., by counting on 2 to add 2).", + "CCSS.Math.Content.1.OA.C.6": "Add and subtract within 20, demonstrating fluency for addition and subtraction within 10. Use strategies such as counting on; making ten (e.g., 8 + 6 = 8 + 2 + 4 = 10 + 4 = 14); decomposing a number leading to a ten (e.g., 13 - 4 = 13 - 3 - 1 = 10 - 1 = 9); using the relationship between addition and subtraction (e.g., knowing that 8 + 4 = 12, one knows 12 - 8 = 4); and creating equivalent but easier or known sums (e.g., adding 6 + 7 by creating the known equivalent 6 + 6 + 1 = 12 + 1 = 13).", + "CCSS.Math.Content.1.OA.D.7": "Understand the meaning of the equal sign, and determine if equations involving addition and subtraction are true or false. For example, which of the following equations are true and which are false? 6 = 6, 7 = 8 - 1, 5 + 2 = 2 + 5, 4 + 1 = 5 + 2.", + "CCSS.Math.Content.1.OA.D.8": "Determine the unknown whole number in an addition or subtraction equation relating three whole numbers.", + "CCSS.Math.Content.1.NBT.A.1": "Count to 120, starting at any number less than 120. In this range, read and write numerals and represent a number of objects with a written numeral.", + "CCSS.Math.Content.1.NBT.B.2": "Understand that the two digits of a two-digit number represent amounts of tens and ones. Understand the following as special cases:", + "CCSS.Math.Content.1.NBT.B.3": "Compare two two-digit numbers based on meanings of the tens and ones digits, recording the results of comparisons with the symbols >, =, and <.", + "CCSS.Math.Content.1.NBT.C.4": "Add within 100, including adding a two-digit number and a one-digit number, and adding a two-digit number and a multiple of 10, using concrete models or drawings and strategies based on place value, properties of operations, and/or the relationship between addition and subtraction; relate the strategy to a written method and explain the reasoning used. Understand that in adding two-digit numbers, one adds tens and tens, ones and ones; and sometimes it is necessary to compose a ten.", + "CCSS.Math.Content.1.NBT.C.5": "Given a two-digit number, mentally find 10 more or 10 less than the number, without having to count; explain the reasoning used.", + "CCSS.Math.Content.1.NBT.C.6": "Subtract multiples of 10 in the range 10-90 from multiples of 10 in the range 10-90 (positive or zero differences), using concrete models or drawings and strategies based on place value, properties of operations, and/or the relationship between addition and subtraction; relate the strategy to a written method and explain the reasoning used.", + "CCSS.Math.Content.1.NBT.B.2.a": "10 can be thought of as a bundle of ten ones \u2014 called a \"ten.\"", + "CCSS.Math.Content.1.NBT.B.2.b": "The numbers from 11 to 19 are composed of a ten and one, two, three, four, five, six, seven, eight, or nine ones.", + "CCSS.Math.Content.1.NBT.B.2.c": "The numbers 10, 20, 30, 40, 50, 60, 70, 80, 90 refer to one, two, three, four, five, six, seven, eight, or nine tens (and 0 ones).", + "CCSS.Math.Content.1.MD.A.1": "Order three objects by length; compare the lengths of two objects indirectly by using a third object.", + "CCSS.Math.Content.1.MD.A.2": "Express the length of an object as a whole number of length units, by laying multiple copies of a shorter object (the length unit) end to end; understand that the length measurement of an object is the number of same-size length units that span it with no gaps or overlaps.", + "CCSS.Math.Content.1.MD.B.3": "Tell and write time in hours and half-hours using analog and digital clocks.", + "CCSS.Math.Content.1.MD.C.4": "Organize, represent, and interpret data with up to three categories; ask and answer questions about the total number of data points, how many in each category, and how many more or less are in one category than in another.", + "CCSS.Math.Content.1.G.A.1": "Distinguish between defining attributes (e.g., triangles are closed and three-sided) versus non-defining attributes (e.g., color, orientation, overall size); build and draw shapes to possess defining attributes.", + "CCSS.Math.Content.1.G.A.2": "Compose two-dimensional shapes (rectangles, squares, trapezoids, triangles, half-circles, and quarter-circles) or three-dimensional shapes (cubes, right rectangular prisms, right circular cones, and right circular cylinders) to create a composite shape, and compose new shapes from the composite shape.", + "CCSS.Math.Content.1.G.A.3": "Partition circles and rectangles into two and four equal shares, describe the shares using the words", + "CCSS.Math.Content.2.OA.A.1": "Use addition and subtraction within 100 to solve one- and two-step word problems involving situations of adding to, taking from, putting together, taking apart, and comparing, with unknowns in all positions, e.g., by using drawings and equations with a symbol for the unknown number to represent the problem.", + "CCSS.Math.Content.2.OA.B.2": "Fluently add and subtract within 20 using mental strategies.", + "CCSS.Math.Content.2.OA.C.3": "Determine whether a group of objects (up to 20) has an odd or even number of members, e.g., by pairing objects or counting them by 2s; write an equation to express an even number as a sum of two equal addends.", + "CCSS.Math.Content.2.OA.C.4": "Use addition to find the total number of objects arranged in rectangular arrays with up to 5 rows and up to 5 columns; write an equation to express the total as a sum of equal addends.", + "CCSS.Math.Content.2.NBT.A.1": "Understand that the three digits of a three-digit number represent amounts of hundreds, tens, and ones; e.g., 706 equals 7 hundreds, 0 tens, and 6 ones. Understand the following as special cases:", + "CCSS.Math.Content.2.NBT.A.2": "Count within 1000; skip-count by 5s, 10s, and 100s.", + "CCSS.Math.Content.2.NBT.A.3": "Read and write numbers to 1000 using base-ten numerals, number names, and expanded form.", + "CCSS.Math.Content.2.NBT.A.4": "Compare two three-digit numbers based on meanings of the hundreds, tens, and ones digits, using >, =, and < symbols to record the results of comparisons.", + "CCSS.Math.Content.2.NBT.B.5": "Fluently add and subtract within 100 using strategies based on place value, properties of operations, and/or the relationship between addition and subtraction.", + "CCSS.Math.Content.2.NBT.B.6": "Add up to four two-digit numbers using strategies based on place value and properties of operations.", + "CCSS.Math.Content.2.NBT.B.7": "Add and subtract within 1000, using concrete models or drawings and strategies based on place value, properties of operations, and/or the relationship between addition and subtraction; relate the strategy to a written method. Understand that in adding or subtracting three-digit numbers, one adds or subtracts hundreds and hundreds, tens and tens, ones and ones; and sometimes it is necessary to compose or decompose tens or hundreds.", + "CCSS.Math.Content.2.NBT.B.8": "Mentally add 10 or 100 to a given number 100-900, and mentally subtract 10 or 100 from a given number 100-900.", + "CCSS.Math.Content.2.NBT.B.9": "Explain why addition and subtraction strategies work, using place value and the properties of operations.", + "CCSS.Math.Content.2.NBT.A.1.a": "100 can be thought of as a bundle of ten tens \u2014 called a \"hundred.\"", + "CCSS.Math.Content.2.NBT.A.1.b": "The numbers 100, 200, 300, 400, 500, 600, 700, 800, 900 refer to one, two, three, four, five, six, seven, eight, or nine hundreds (and 0 tens and 0 ones).", + "CCSS.Math.Content.2.MD.A.1": "Measure the length of an object by selecting and using appropriate tools such as rulers, yardsticks, meter sticks, and measuring tapes.", + "CCSS.Math.Content.2.MD.A.2": "Measure the length of an object twice, using length units of different lengths for the two measurements; describe how the two measurements relate to the size of the unit chosen.", + "CCSS.Math.Content.2.MD.A.3": "Estimate lengths using units of inches, feet, centimeters, and meters.", + "CCSS.Math.Content.2.MD.A.4": "Measure to determine how much longer one object is than another, expressing the length difference in terms of a standard length unit.", + "CCSS.Math.Content.2.MD.B.5": "Use addition and subtraction within 100 to solve word problems involving lengths that are given in the same units, e.g., by using drawings (such as drawings of rulers) and equations with a symbol for the unknown number to represent the problem.", + "CCSS.Math.Content.2.MD.B.6": "Represent whole numbers as lengths from 0 on a number line diagram with equally spaced points corresponding to the numbers 0, 1, 2, ..., and represent whole-number sums and differences within 100 on a number line diagram.", + "CCSS.Math.Content.2.MD.C.7": "Tell and write time from analog and digital clocks to the nearest five minutes, using a.m. and p.m.", + "CCSS.Math.Content.2.MD.C.8": "Solve word problems involving dollar bills, quarters, dimes, nickels, and pennies, using $ and \u00a2 symbols appropriately. Example: If you have 2 dimes and 3 pennies, how many cents do you have?", + "CCSS.Math.Content.2.MD.D.9": "Generate measurement data by measuring lengths of several objects to the nearest whole unit, or by making repeated measurements of the same object. Show the measurements by making a line plot, where the horizontal scale is marked off in whole-number units.", + "CCSS.Math.Content.2.MD.D.10": "Draw a picture graph and a bar graph (with single-unit scale) to represent a data set with up to four categories. Solve simple put-together, take-apart, and compare problems", + "CCSS.Math.Content.2.G.A.1": "Recognize and draw shapes having specified attributes, such as a given number of angles or a given number of equal faces.", + "CCSS.Math.Content.2.G.A.2": "Partition a rectangle into rows and columns of same-size squares and count to find the total number of them.", + "CCSS.Math.Content.2.G.A.3": "Partition circles and rectangles into two, three, or four equal shares, describe the shares using the words halves, thirds, half of, a third of, etc., and describe the whole as two halves, three thirds, four fourths. Recognize that equal shares of identical wholes need not have the same shape.", + "CCSS.Math.Content.3.OA.A.1": "Interpret products of whole numbers, e.g., interpret 5 \u00d7 7 as the total number of objects in 5 groups of 7 objects each.", + "CCSS.Math.Content.3.OA.A.2": "Interpret whole-number quotients of whole numbers, e.g., interpret 56 \u00f7 8 as the number of objects in each share when 56 objects are partitioned equally into 8 shares, or as a number of shares when 56 objects are partitioned into equal shares of 8 objects each.", + "CCSS.Math.Content.3.OA.A.3": "Use multiplication and division within 100 to solve word problems in situations involving equal groups, arrays, and measurement quantities, e.g., by using drawings and equations with a symbol for the unknown number to represent the problem.", + "CCSS.Math.Content.3.OA.A.4": "Determine the unknown whole number in a multiplication or division equation relating three whole numbers.", + "CCSS.Math.Content.3.OA.B.5": "Apply properties of operations as strategies to multiply and divide.", + "CCSS.Math.Content.3.OA.B.6": "Understand division as an unknown-factor problem.", + "CCSS.Math.Content.3.OA.C.7": "Fluently multiply and divide within 100, using strategies such as the relationship between multiplication and division (e.g., knowing that 8 \u00d7 5 = 40, one knows 40 \u00f7 5 = 8) or properties of operations. By the end of Grade 3, know from memory all products of two one-digit numbers.", + "CCSS.Math.Content.3.OA.D.8": "Solve two-step word problems using the four operations. Represent these problems using equations with a letter standing for the unknown quantity. Assess the reasonableness of answers using mental computation and estimation strategies including rounding.", + "CCSS.Math.Content.3.OA.D.9": "Identify arithmetic patterns (including patterns in the addition table or multiplication table), and explain them using properties of operations.", + "CCSS.Math.Content.3.NBT.A.1": "Use place value understanding to round whole numbers to the nearest 10 or 100.", + "CCSS.Math.Content.3.NBT.A.2": "Fluently add and subtract within 1000 using strategies and algorithms based on place value, properties of operations, and/or the relationship between addition and subtraction.", + "CCSS.Math.Content.3.NBT.A.3": "Multiply one-digit whole numbers by multiples of 10 in the range 10-90 (e.g., 9 \u00d7 80, 5 \u00d7 60) using strategies based on place value and properties of operations.", + "CCSS.Math.Content.3.NF.A.1": "Understand a fraction 1/", + "CCSS.Math.Content.3.NF.A.2": "Understand a fraction as a number on the number line; represent fractions on a number line diagram.", + "CCSS.Math.Content.3.NF.A.3": "Explain equivalence of fractions in special cases, and compare fractions by reasoning about their size.", + "CCSS.Math.Content.3.NF.A.2.a": "Represent a fraction 1/", + "CCSS.Math.Content.3.NF.A.2.b": "Represent a fraction", + "CCSS.Math.Content.3.NF.A.3.a": "Understand two fractions as equivalent (equal) if they are the same size, or the same point on a number line.", + "CCSS.Math.Content.3.NF.A.3.b": "Recognize and generate simple equivalent fractions, e.g., 1/2 = 2/4, 4/6 = 2/3. Explain why the fractions are equivalent, e.g., by using a visual fraction model.", + "CCSS.Math.Content.3.NF.A.3.c": "Express whole numbers as fractions, and recognize fractions that are equivalent to whole numbers.", + "CCSS.Math.Content.3.NF.A.3.d": "Compare two fractions with the same numerator or the same denominator by reasoning about their size. Recognize that comparisons are valid only when the two fractions refer to the same whole. Record the results of comparisons with the symbols >, =, or <, and justify the conclusions, e.g., by using a visual fraction model.", + "CCSS.Math.Content.3.MD.A.1": "Tell and write time to the nearest minute and measure time intervals in minutes. Solve word problems involving addition and subtraction of time intervals in minutes, e.g., by representing the problem on a number line diagram.", + "CCSS.Math.Content.3.MD.A.2": "Measure and estimate liquid volumes and masses of objects using standard units of grams (g), kilograms (kg), and liters (l).", + "CCSS.Math.Content.3.MD.B.3": "Draw a scaled picture graph and a scaled bar graph to represent a data set with several categories. Solve one- and two-step \"how many more\" and \"how many less\" problems using information presented in scaled bar graphs.", + "CCSS.Math.Content.3.MD.B.4": "Generate measurement data by measuring lengths using rulers marked with halves and fourths of an inch. Show the data by making a line plot, where the horizontal scale is marked off in appropriate units\u2014 whole numbers, halves, or quarters.", + "CCSS.Math.Content.3.MD.C.5": "Recognize area as an attribute of plane figures and understand concepts of area measurement.", + "CCSS.Math.Content.3.MD.C.6": "Measure areas by counting unit squares (square cm, square m, square in, square ft, and improvised units).", + "CCSS.Math.Content.3.MD.C.7": "Relate area to the operations of multiplication and addition.", + "CCSS.Math.Content.3.MD.D.8": "Solve real world and mathematical problems involving perimeters of polygons, including finding the perimeter given the side lengths, finding an unknown side length, and exhibiting rectangles with the same perimeter and different areas or with the same area and different perimeters.", + "CCSS.Math.Content.3.MD.C.5.a": "A square with side length 1 unit, called \"a unit square,\" is said to have \"one square unit\" of area, and can be used to measure area.", + "CCSS.Math.Content.3.MD.C.5.b": "A plane figure which can be covered without gaps or overlaps by", + "CCSS.Math.Content.3.MD.C.7.a": "Find the area of a rectangle with whole-number side lengths by tiling it, and show that the area is the same as would be found by multiplying the side lengths.", + "CCSS.Math.Content.3.MD.C.7.b": "Multiply side lengths to find areas of rectangles with whole-number side lengths in the context of solving real world and mathematical problems, and represent whole-number products as rectangular areas in mathematical reasoning.", + "CCSS.Math.Content.3.MD.C.7.c": "Use tiling to show in a concrete case that the area of a rectangle with whole-number side lengths", + "CCSS.Math.Content.3.MD.C.7.d": "Recognize area as additive. Find areas of rectilinear figures by decomposing them into non-overlapping rectangles and adding the areas of the non-overlapping parts, applying this technique to solve real world problems.", + "CCSS.Math.Content.3.G.A.1": "Understand that shapes in different categories (e.g., rhombuses, rectangles, and others) may share attributes (e.g., having four sides), and that the shared attributes can define a larger category (e.g., quadrilaterals). Recognize rhombuses, rectangles, and squares as examples of quadrilaterals, and draw examples of quadrilaterals that do not belong to any of these subcategories.", + "CCSS.Math.Content.3.G.A.2": "Partition shapes into parts with equal areas. Express the area of each part as a unit fraction of the whole.", + "CCSS.Math.Content.4.OA.A.1": "Interpret a multiplication equation as a comparison, e.g., interpret 35 = 5 \u00d7 7 as a statement that 35 is 5 times as many as 7 and 7 times as many as 5. Represent verbal statements of multiplicative comparisons as multiplication equations.", + "CCSS.Math.Content.4.OA.A.2": "Multiply or divide to solve word problems involving multiplicative comparison, e.g., by using drawings and equations with a symbol for the unknown number to represent the problem, distinguishing multiplicative comparison from additive comparison.", + "CCSS.Math.Content.4.OA.A.3": "Solve multistep word problems posed with whole numbers and having whole-number answers using the four operations, including problems in which remainders must be interpreted. Represent these problems using equations with a letter standing for the unknown quantity. Assess the reasonableness of answers using mental computation and estimation strategies including rounding.", + "CCSS.Math.Content.4.OA.B.4": "Find all factor pairs for a whole number in the range 1-100. Recognize that a whole number is a multiple of each of its factors. Determine whether a given whole number in the range 1-100 is a multiple of a given one-digit number. Determine whether a given whole number in the range 1-100 is prime or composite.", + "CCSS.Math.Content.4.OA.C.5": "Generate a number or shape pattern that follows a given rule. Identify apparent features of the pattern that were not explicit in the rule itself.", + "CCSS.Math.Content.4.NBT.A.1": "Recognize that in a multi-digit whole number, a digit in one place represents ten times what it represents in the place to its right.", + "CCSS.Math.Content.4.NBT.A.2": "Read and write multi-digit whole numbers using base-ten numerals, number names, and expanded form. Compare two multi-digit numbers based on meanings of the digits in each place, using >, =, and < symbols to record the results of comparisons.", + "CCSS.Math.Content.4.NBT.A.3": "Use place value understanding to round multi-digit whole numbers to any place.", + "CCSS.Math.Content.4.NBT.B.4": "Fluently add and subtract multi-digit whole numbers using the standard algorithm.", + "CCSS.Math.Content.4.NBT.B.5": "Multiply a whole number of up to four digits by a one-digit whole number, and multiply two two-digit numbers, using strategies based on place value and the properties of operations. Illustrate and explain the calculation by using equations, rectangular arrays, and/or area models.", + "CCSS.Math.Content.4.NBT.B.6": "Find whole-number quotients and remainders with up to four-digit dividends and one-digit divisors, using strategies based on place value, the properties of operations, and/or the relationship between multiplication and division. Illustrate and explain the calculation by using equations, rectangular arrays, and/or area models.", + "CCSS.Math.Content.4.NF.A.1": "Explain why a fraction", + "CCSS.Math.Content.4.NF.A.2": "Compare two fractions with different numerators and different denominators, e.g., by creating common denominators or numerators, or by comparing to a benchmark fraction such as 1/2. Recognize that comparisons are valid only when the two fractions refer to the same whole. Record the results of comparisons with symbols >, =, or <, and justify the conclusions, e.g., by using a visual fraction model.", + "CCSS.Math.Content.4.NF.B.3": "Understand a fraction", + "CCSS.Math.Content.4.NF.B.4": "Apply and extend previous understandings of multiplication to multiply a fraction by a whole number.", + "CCSS.Math.Content.4.NF.C.5": "Express a fraction with denominator 10 as an equivalent fraction with denominator 100, and use this technique to add two fractions with respective denominators 10 and 100.", + "CCSS.Math.Content.4.NF.C.6": "Use decimal notation for fractions with denominators 10 or 100.", + "CCSS.Math.Content.4.NF.C.7": "Compare two decimals to hundredths by reasoning about their size. Recognize that comparisons are valid only when the two decimals refer to the same whole. Record the results of comparisons with the symbols >, =, or <, and justify the conclusions, e.g., by using a visual model.", + "CCSS.Math.Content.4.NF.B.3.a": "Understand addition and subtraction of fractions as joining and separating parts referring to the same whole.", + "CCSS.Math.Content.4.NF.B.3.b": "Decompose a fraction into a sum of fractions with the same denominator in more than one way, recording each decomposition by an equation. Justify decompositions, e.g., by using a visual fraction model.", + "CCSS.Math.Content.4.NF.B.3.c": "Add and subtract mixed numbers with like denominators, e.g., by replacing each mixed number with an equivalent fraction, and/or by using properties of operations and the relationship between addition and subtraction.", + "CCSS.Math.Content.4.NF.B.3.d": "Solve word problems involving addition and subtraction of fractions referring to the same whole and having like denominators, e.g., by using visual fraction models and equations to represent the problem.", + "CCSS.Math.Content.4.NF.B.4.a": "Understand a fraction", + "CCSS.Math.Content.4.NF.B.4.b": "Understand a multiple of a/b as a multiple of 1/b, and use this understanding to multiply a fraction by a whole number.", + "CCSS.Math.Content.4.NF.B.4.c": "Solve word problems involving multiplication of a fraction by a whole number, e.g., by using visual fraction models and equations to represent the problem.", + "CCSS.Math.Content.4.MD.A.1": "Know relative sizes of measurement units within one system of units including km, m, cm; kg, g; lb, oz.; l, ml; hr, min, sec. Within a single system of measurement, express measurements in a larger unit in terms of a smaller unit. Record measurement equivalents in a two-column table.", + "CCSS.Math.Content.4.MD.A.2": "Use the four operations to solve word problems involving distances, intervals of time, liquid volumes, masses of objects, and money, including problems involving simple fractions or decimals, and problems that require expressing measurements given in a larger unit in terms of a smaller unit. Represent measurement quantities using diagrams such as number line diagrams that feature a measurement scale.", + "CCSS.Math.Content.4.MD.A.3": "Apply the area and perimeter formulas for rectangles in real world and mathematical problems.", + "CCSS.Math.Content.4.MD.B.4": "Make a line plot to display a data set of measurements in fractions of a unit (1/2, 1/4, 1/8). Solve problems involving addition and subtraction of fractions by using information presented in line plots.", + "CCSS.Math.Content.4.MD.C.5": "Recognize angles as geometric shapes that are formed wherever two rays share a common endpoint, and understand concepts of angle measurement:", + "CCSS.Math.Content.4.MD.C.6": "Measure angles in whole-number degrees using a protractor. Sketch angles of specified measure.", + "CCSS.Math.Content.4.MD.C.7": "Recognize angle measure as additive. When an angle is decomposed into non-overlapping parts, the angle measure of the whole is the sum of the angle measures of the parts. Solve addition and subtraction problems to find unknown angles on a diagram in real world and mathematical problems, e.g., by using an equation with a symbol for the unknown angle measure.", + "CCSS.Math.Content.4.MD.C.5.a": "An angle is measured with reference to a circle with its center at the common endpoint of the rays, by considering the fraction of the circular arc between the points where the two rays intersect the circle. An angle that turns through 1/360 of a circle is called a \"one-degree angle,\" and can be used to measure angles.", + "CCSS.Math.Content.4.MD.C.5.b": "An angle that turns through", + "CCSS.Math.Content.4.G.A.1": "Draw points, lines, line segments, rays, angles (right, acute, obtuse), and perpendicular and parallel lines. Identify these in two-dimensional figures.", + "CCSS.Math.Content.4.G.A.2": "Classify two-dimensional figures based on the presence or absence of parallel or perpendicular lines, or the presence or absence of angles of a specified size. Recognize right triangles as a category, and identify right triangles.", + "CCSS.Math.Content.4.G.A.3": "Recognize a line of symmetry for a two-dimensional figure as a line across the figure such that the figure can be folded along the line into matching parts. Identify line-symmetric figures and draw lines of symmetry.", + "CCSS.Math.Content.5.OA.A.1": "Use parentheses, brackets, or braces in numerical expressions, and evaluate expressions with these symbols.", + "CCSS.Math.Content.5.OA.A.2": "Write simple expressions that record calculations with numbers, and interpret numerical expressions without evaluating them.", + "CCSS.Math.Content.5.OA.B.3": "Generate two numerical patterns using two given rules. Identify apparent relationships between corresponding terms. Form ordered pairs consisting of corresponding terms from the two patterns, and graph the ordered pairs on a coordinate plane.", + "CCSS.Math.Content.5.NBT.A.1": "Recognize that in a multi-digit number, a digit in one place represents 10 times as much as it represents in the place to its right and 1/10 of what it represents in the place to its left.", + "CCSS.Math.Content.5.NBT.A.2": "Explain patterns in the number of zeros of the product when multiplying a number by powers of 10, and explain patterns in the placement of the decimal point when a decimal is multiplied or divided by a power of 10. Use whole-number exponents to denote powers of 10.", + "CCSS.Math.Content.5.NBT.A.3": "Read, write, and compare decimals to thousandths.", + "CCSS.Math.Content.5.NBT.A.4": "Use place value understanding to round decimals to any place.", + "CCSS.Math.Content.5.NBT.B.5": "Fluently multiply multi-digit whole numbers using the standard algorithm.", + "CCSS.Math.Content.5.NBT.B.6": "Find whole-number quotients of whole numbers with up to four-digit dividends and two-digit divisors, using strategies based on place value, the properties of operations, and/or the relationship between multiplication and division. Illustrate and explain the calculation by using equations, rectangular arrays, and/or area models.", + "CCSS.Math.Content.5.NBT.B.7": "Add, subtract, multiply, and divide decimals to hundredths, using concrete models or drawings and strategies based on place value, properties of operations, and/or the relationship between addition and subtraction; relate the strategy to a written method and explain the reasoning used.", + "CCSS.Math.Content.5.NBT.A.3.a": "Read and write decimals to thousandths using base-ten numerals, number names, and expanded form, e.g., 347.392 = 3 \u00d7 100 + 4 \u00d7 10 + 7 \u00d7 1 + 3 \u00d7 (1/10) + 9 \u00d7 (1/100) + 2 \u00d7 (1/1000).", + "CCSS.Math.Content.5.NBT.A.3.b": "Compare two decimals to thousandths based on meanings of the digits in each place, using >, =, and < symbols to record the results of comparisons.", + "CCSS.Math.Content.5.NF.A.1": "Add and subtract fractions with unlike denominators (including mixed numbers) by replacing given fractions with equivalent fractions in such a way as to produce an equivalent sum or difference of fractions with like denominators.", + "CCSS.Math.Content.5.NF.A.2": "Solve word problems involving addition and subtraction of fractions referring to the same whole, including cases of unlike denominators, e.g., by using visual fraction models or equations to represent the problem. Use benchmark fractions and number sense of fractions to estimate mentally and assess the reasonableness of answers.", + "CCSS.Math.Content.5.NF.B.3": "Interpret a fraction as division of the numerator by the denominator (", + "CCSS.Math.Content.5.NF.B.4": "Apply and extend previous understandings of multiplication to multiply a fraction or whole number by a fraction.", + "CCSS.Math.Content.5.NF.B.5": "Interpret multiplication as scaling (resizing), by:", + "CCSS.Math.Content.5.NF.B.6": "Solve real world problems involving multiplication of fractions and mixed numbers, e.g., by using visual fraction models or equations to represent the problem.", + "CCSS.Math.Content.5.NF.B.7": "Apply and extend previous understandings of division to divide unit fractions by whole numbers and whole numbers by unit fractions.", + "CCSS.Math.Content.5.NF.B.4.a": "Interpret the product (", + "CCSS.Math.Content.5.NF.B.4.b": "Find the area of a rectangle with fractional side lengths by tiling it with unit squares of the appropriate unit fraction side lengths, and show that the area is the same as would be found by multiplying the side lengths. Multiply fractional side lengths to find areas of rectangles, and represent fraction products as rectangular areas.", + "CCSS.Math.Content.5.NF.B.5.a": "Comparing the size of a product to the size of one factor on the basis of the size of the other factor, without performing the indicated multiplication.", + "CCSS.Math.Content.5.NF.B.5.b": "Explaining why multiplying a given number by a fraction greater than 1 results in a product greater than the given number (recognizing multiplication by whole numbers greater than 1 as a familiar case); explaining why multiplying a given number by a fraction less than 1 results in a product smaller than the given number; and relating the principle of fraction equivalence", + "CCSS.Math.Content.5.NF.B.7.a": "Interpret division of a unit fraction by a non-zero whole number, and compute such quotients.", + "CCSS.Math.Content.5.NF.B.7.b": "Interpret division of a whole number by a unit fraction, and compute such quotients.", + "CCSS.Math.Content.5.NF.B.7.c": "Solve real world problems involving division of unit fractions by non-zero whole numbers and division of whole numbers by unit fractions, e.g., by using visual fraction models and equations to represent the problem.", + "CCSS.Math.Content.5.MD.A.1": "Convert among different-sized standard measurement units within a given measurement system (e.g., convert 5 cm to 0.05 m), and use these conversions in solving multi-step, real world problems.", + "CCSS.Math.Content.5.MD.B.2": "Make a line plot to display a data set of measurements in fractions of a unit (1/2, 1/4, 1/8). Use operations on fractions for this grade to solve problems involving information presented in line plots.", + "CCSS.Math.Content.5.MD.C.3": "Recognize volume as an attribute of solid figures and understand concepts of volume measurement.", + "CCSS.Math.Content.5.MD.C.4": "Measure volumes by counting unit cubes, using cubic cm, cubic in, cubic ft, and improvised units.", + "CCSS.Math.Content.5.MD.C.5": "Relate volume to the operations of multiplication and addition and solve real world and mathematical problems involving volume.", + "CCSS.Math.Content.5.MD.C.3.a": "A cube with side length 1 unit, called a \"unit cube,\" is said to have \"one cubic unit\" of volume, and can be used to measure volume.", + "CCSS.Math.Content.5.MD.C.3.b": "A solid figure which can be packed without gaps or overlaps using", + "CCSS.Math.Content.5.MD.C.5.a": "Find the volume of a right rectangular prism with whole-number side lengths by packing it with unit cubes, and show that the volume is the same as would be found by multiplying the edge lengths, equivalently by multiplying the height by the area of the base. Represent threefold whole-number products as volumes, e.g., to represent the associative property of multiplication.", + "CCSS.Math.Content.5.MD.C.5.b": "Apply the formulas", + "CCSS.Math.Content.5.MD.C.5.c": "Recognize volume as additive. Find volumes of solid figures composed of two non-overlapping right rectangular prisms by adding the volumes of the non-overlapping parts, applying this technique to solve real world problems.", + "CCSS.Math.Content.5.G.A.1": "Use a pair of perpendicular number lines, called axes, to define a coordinate system, with the intersection of the lines (the origin) arranged to coincide with the 0 on each line and a given point in the plane located by using an ordered pair of numbers, called its coordinates. Understand that the first number indicates how far to travel from the origin in the direction of one axis, and the second number indicates how far to travel in the direction of the second axis, with the convention that the names of the two axes and the coordinates correspond (e.g.,", + "CCSS.Math.Content.5.G.A.2": "Represent real world and mathematical problems by graphing points in the first quadrant of the coordinate plane, and interpret coordinate values of points in the context of the situation.", + "CCSS.Math.Content.5.G.B.3": "Understand that attributes belonging to a category of two-dimensional figures also belong to all subcategories of that category. For example, all rectangles have four right angles and squares are rectangles, so all squares have four right angles.", + "CCSS.Math.Content.5.G.B.4": "Classify two-dimensional figures in a hierarchy based on properties.", + "CCSS.Math.Content.6.RP.A.1": "Understand the concept of a ratio and use ratio language to describe a ratio relationship between two quantities.", + "CCSS.Math.Content.6.RP.A.2": "Understand the concept of a unit rate a/b associated with a ratio a:b with b \u2260 0, and use rate language in the context of a ratio relationship.", + "CCSS.Math.Content.6.RP.A.3": "Use ratio and rate reasoning to solve real-world and mathematical problems, e.g., by reasoning about tables of equivalent ratios, tape diagrams, double number line diagrams, or equations.", + "CCSS.Math.Content.6.RP.A.3.a": "Make tables of equivalent ratios relating quantities with whole-number measurements, find missing values in the tables, and plot the pairs of values on the coordinate plane. Use tables to compare ratios.", + "CCSS.Math.Content.6.RP.A.3.b": "Solve unit rate problems including those involving unit pricing and constant speed.", + "CCSS.Math.Content.6.RP.A.3.c": "Find a percent of a quantity as a rate per 100 (e.g., 30% of a quantity means 30/100 times the quantity); solve problems involving finding the whole, given a part and the percent.", + "CCSS.Math.Content.6.RP.A.3.d": "Use ratio reasoning to convert measurement units; manipulate and transform units appropriately when multiplying or dividing quantities.", + "CCSS.Math.Content.6.NS.A.1": "Interpret and compute quotients of fractions, and solve word problems involving division of fractions by fractions, e.g., by using visual fraction models and equations to represent the problem.", + "CCSS.Math.Content.6.NS.B.2": "Fluently divide multi-digit numbers using the standard algorithm.", + "CCSS.Math.Content.6.NS.B.3": "Fluently add, subtract, multiply, and divide multi-digit decimals using the standard algorithm for each operation.", + "CCSS.Math.Content.6.NS.B.4": "Find the greatest common factor of two whole numbers less than or equal to 100 and the least common multiple of two whole numbers less than or equal to 12. Use the distributive property to express a sum of two whole numbers 1-100 with a common factor as a multiple of a sum of two whole numbers with no common factor.", + "CCSS.Math.Content.6.NS.C.5": "Understand that positive and negative numbers are used together to describe quantities having opposite directions or values (e.g., temperature above/below zero, elevation above/below sea level, credits/debits, positive/negative electric charge); use positive and negative numbers to represent quantities in real-world contexts, explaining the meaning of 0 in each situation.", + "CCSS.Math.Content.6.NS.C.6": "Understand a rational number as a point on the number line. Extend number line diagrams and coordinate axes familiar from previous grades to represent points on the line and in the plane with negative number coordinates.", + "CCSS.Math.Content.6.NS.C.7": "Understand ordering and absolute value of rational numbers.", + "CCSS.Math.Content.6.NS.C.8": "Solve real-world and mathematical problems by graphing points in all four quadrants of the coordinate plane. Include use of coordinates and absolute value to find distances between points with the same first coordinate or the same second coordinate.", + "CCSS.Math.Content.6.NS.C.6.a": "Recognize opposite signs of numbers as indicating locations on opposite sides of 0 on the number line; recognize that the opposite of the opposite of a number is the number itself, e.g., -(-3) = 3, and that 0 is its own opposite.", + "CCSS.Math.Content.6.NS.C.6.b": "Understand signs of numbers in ordered pairs as indicating locations in quadrants of the coordinate plane; recognize that when two ordered pairs differ only by signs, the locations of the points are related by reflections across one or both axes.", + "CCSS.Math.Content.6.NS.C.6.c": "Find and position integers and other rational numbers on a horizontal or vertical number line diagram; find and position pairs of integers and other rational numbers on a coordinate plane.", + "CCSS.Math.Content.6.NS.C.7.a": "Interpret statements of inequality as statements about the relative position of two numbers on a number line diagram.", + "CCSS.Math.Content.6.NS.C.7.b": "Write, interpret, and explain statements of order for rational numbers in real-world contexts.", + "CCSS.Math.Content.6.NS.C.7.c": "Understand the absolute value of a rational number as its distance from 0 on the number line; interpret absolute value as magnitude for a positive or negative quantity in a real-world situation.", + "CCSS.Math.Content.6.NS.C.7.d": "Distinguish comparisons of absolute value from statements about order.", + "CCSS.Math.Content.6.EE.A.1": "Write and evaluate numerical expressions involving whole-number exponents.", + "CCSS.Math.Content.6.EE.A.2": "Write, read, and evaluate expressions in which letters stand for numbers.", + "CCSS.Math.Content.6.EE.A.3": "Apply the properties of operations to generate equivalent expressions.", + "CCSS.Math.Content.6.EE.A.4": "Identify when two expressions are equivalent (i.e., when the two expressions name the same number regardless of which value is substituted into them).", + "CCSS.Math.Content.6.EE.B.5": "Understand solving an equation or inequality as a process of answering a question: which values from a specified set, if any, make the equation or inequality true? Use substitution to determine whether a given number in a specified set makes an equation or inequality true.", + "CCSS.Math.Content.6.EE.B.6": "Use variables to represent numbers and write expressions when solving a real-world or mathematical problem; understand that a variable can represent an unknown number, or, depending on the purpose at hand, any number in a specified set.", + "CCSS.Math.Content.6.EE.B.7": "Solve real-world and mathematical problems by writing and solving equations of the form", + "CCSS.Math.Content.6.EE.B.8": "Write an inequality of the form", + "CCSS.Math.Content.6.EE.C.9": "Use variables to represent two quantities in a real-world problem that change in relationship to one another; write an equation to express one quantity, thought of as the dependent variable, in terms of the other quantity, thought of as the independent variable. Analyze the relationship between the dependent and independent variables using graphs and tables, and relate these to the equation. For example, in a problem involving motion at constant speed, list and graph ordered pairs of distances and times, and write the equation d = 65t to represent the relationship between distance and time.", + "CCSS.Math.Content.6.EE.A.2.a": "Write expressions that record operations with numbers and with letters standing for numbers.", + "CCSS.Math.Content.6.EE.A.2.b": "Identify parts of an expression using mathematical terms (sum, term, product, factor, quotient, coefficient); view one or more parts of an expression as a single entity.", + "CCSS.Math.Content.6.EE.A.2.c": "Evaluate expressions at specific values of their variables. Include expressions that arise from formulas used in real-world problems. Perform arithmetic operations, including those involving whole-number exponents, in the conventional order when there are no parentheses to specify a particular order (Order of Operations).", + "CCSS.Math.Content.6.G.A.1": "Find the area of right triangles, other triangles, special quadrilaterals, and polygons by composing into rectangles or decomposing into triangles and other shapes; apply these techniques in the context of solving real-world and mathematical problems.", + "CCSS.Math.Content.6.G.A.2": "Find the volume of a right rectangular prism with fractional edge lengths by packing it with unit cubes of the appropriate unit fraction edge lengths, and show that the volume is the same as would be found by multiplying the edge lengths of the prism. Apply the formulas", + "CCSS.Math.Content.6.G.A.3": "Draw polygons in the coordinate plane given coordinates for the vertices; use coordinates to find the length of a side joining points with the same first coordinate or the same second coordinate. Apply these techniques in the context of solving real-world and mathematical problems.", + "CCSS.Math.Content.6.G.A.4": "Represent three-dimensional figures using nets made up of rectangles and triangles, and use the nets to find the surface area of these figures. Apply these techniques in the context of solving real-world and mathematical problems.", + "CCSS.Math.Content.6.SP.A.1": "Recognize a statistical question as one that anticipates variability in the data related to the question and accounts for it in the answers.", + "CCSS.Math.Content.6.SP.A.2": "Understand that a set of data collected to answer a statistical question has a distribution which can be described by its center, spread, and overall shape.", + "CCSS.Math.Content.6.SP.A.3": "Recognize that a measure of center for a numerical data set summarizes all of its values with a single number, while a measure of variation describes how its values vary with a single number.", + "CCSS.Math.Content.6.SP.B.4": "Display numerical data in plots on a number line, including dot plots, histograms, and box plots.", + "CCSS.Math.Content.6.SP.B.5": "Summarize numerical data sets in relation to their context, such as by:", + "CCSS.Math.Content.6.SP.B.5.a": "Reporting the number of observations.", + "CCSS.Math.Content.6.SP.B.5.b": "Describing the nature of the attribute under investigation, including how it was measured and its units of measurement.", + "CCSS.Math.Content.6.SP.B.5.c": "Giving quantitative measures of center (median and/or mean) and variability (interquartile range and/or mean absolute deviation), as well as describing any overall pattern and any striking deviations from the overall pattern with reference to the context in which the data were gathered.", + "CCSS.Math.Content.6.SP.B.5.d": "Relating the choice of measures of center and variability to the shape of the data distribution and the context in which the data were gathered.", + "CCSS.Math.Content.7.RP.A.1": "Compute unit rates associated with ratios of fractions, including ratios of lengths, areas and other quantities measured in like or different units.", + "CCSS.Math.Content.7.RP.A.2": "Recognize and represent proportional relationships between quantities.", + "CCSS.Math.Content.7.RP.A.3": "Use proportional relationships to solve multistep ratio and percent problems. Examples: simple interest, tax, markups and markdowns, gratuities and commissions, fees, percent increase and decrease, percent error.", + "CCSS.Math.Content.7.RP.A.2.a": "Decide whether two quantities are in a proportional relationship, e.g., by testing for equivalent ratios in a table or graphing on a coordinate plane and observing whether the graph is a straight line through the origin.", + "CCSS.Math.Content.7.RP.A.2.b": "Identify the constant of proportionality (unit rate) in tables, graphs, equations, diagrams, and verbal descriptions of proportional relationships.", + "CCSS.Math.Content.7.RP.A.2.c": "Represent proportional relationships by equations.", + "CCSS.Math.Content.7.RP.A.2.d": "Explain what a point (", + "CCSS.Math.Content.7.NS.A.1": "Apply and extend previous understandings of addition and subtraction to add and subtract rational numbers; represent addition and subtraction on a horizontal or vertical number line diagram.", + "CCSS.Math.Content.7.NS.A.2": "Apply and extend previous understandings of multiplication and division and of fractions to multiply and divide rational numbers.", + "CCSS.Math.Content.7.NS.A.3": "Solve real-world and mathematical problems involving the four operations with rational numbers.", + "CCSS.Math.Content.7.NS.A.1.a": "Describe situations in which opposite quantities combine to make 0.", + "CCSS.Math.Content.7.NS.A.1.b": "Understand", + "CCSS.Math.Content.7.NS.A.1.c": "Understand subtraction of rational numbers as adding the additive inverse,", + "CCSS.Math.Content.7.NS.A.1.d": "Apply properties of operations as strategies to add and subtract rational numbers.", + "CCSS.Math.Content.7.NS.A.2.a": "Understand that multiplication is extended from fractions to rational numbers by requiring that operations continue to satisfy the properties of operations, particularly the distributive property, leading to products such as (-1)(-1) = 1 and the rules for multiplying signed numbers. Interpret products of rational numbers by describing real-world contexts.", + "CCSS.Math.Content.7.NS.A.2.b": "Understand that integers can be divided, provided that the divisor is not zero, and every quotient of integers (with non-zero divisor) is a rational number. If", + "CCSS.Math.Content.7.NS.A.2.c": "Apply properties of operations as strategies to multiply and divide rational numbers.", + "CCSS.Math.Content.7.NS.A.2.d": "Convert a rational number to a decimal using long division; know that the decimal form of a rational number terminates in 0s or eventually repeats.", + "CCSS.Math.Content.7.EE.A.1": "Apply properties of operations as strategies to add, subtract, factor, and expand linear expressions with rational coefficients.", + "CCSS.Math.Content.7.EE.A.2": "Understand that rewriting an expression in different forms in a problem context can shed light on the problem and how the quantities in it are related.", + "CCSS.Math.Content.7.EE.B.3": "Solve multi-step real-life and mathematical problems posed with positive and negative rational numbers in any form (whole numbers, fractions, and decimals), using tools strategically. Apply properties of operations to calculate with numbers in any form; convert between forms as appropriate; and assess the reasonableness of answers using mental computation and estimation strategies.", + "CCSS.Math.Content.7.EE.B.4": "Use variables to represent quantities in a real-world or mathematical problem, and construct simple equations and inequalities to solve problems by reasoning about the quantities.", + "CCSS.Math.Content.7.EE.B.4.a": "Solve word problems leading to equations of the form", + "CCSS.Math.Content.7.EE.B.4.b": "Solve word problems leading to inequalities of the form", + "CCSS.Math.Content.7.G.A.1": "Solve problems involving scale drawings of geometric figures, including computing actual lengths and areas from a scale drawing and reproducing a scale drawing at a different scale.", + "CCSS.Math.Content.7.G.A.2": "Draw (freehand, with ruler and protractor, and with technology) geometric shapes with given conditions. Focus on constructing triangles from three measures of angles or sides, noticing when the conditions determine a unique triangle, more than one triangle, or no triangle.", + "CCSS.Math.Content.7.G.A.3": "Describe the two-dimensional figures that result from slicing three-dimensional figures, as in plane sections of right rectangular prisms and right rectangular pyramids.", + "CCSS.Math.Content.7.G.B.4": "Know the formulas for the area and circumference of a circle and use them to solve problems; give an informal derivation of the relationship between the circumference and area of a circle.", + "CCSS.Math.Content.7.G.B.5": "Use facts about supplementary, complementary, vertical, and adjacent angles in a multi-step problem to write and solve simple equations for an unknown angle in a figure.", + "CCSS.Math.Content.7.G.B.6": "Solve real-world and mathematical problems involving area, volume and surface area of two- and three-dimensional objects composed of triangles, quadrilaterals, polygons, cubes, and right prisms.", + "CCSS.Math.Content.7.SP.A.1": "Understand that statistics can be used to gain information about a population by examining a sample of the population; generalizations about a population from a sample are valid only if the sample is representative of that population. Understand that random sampling tends to produce representative samples and support valid inferences.", + "CCSS.Math.Content.7.SP.A.2": "Use data from a random sample to draw inferences about a population with an unknown characteristic of interest. Generate multiple samples (or simulated samples) of the same size to gauge the variation in estimates or predictions.", + "CCSS.Math.Content.7.SP.B.3": "Informally assess the degree of visual overlap of two numerical data distributions with similar variabilities, measuring the difference between the centers by expressing it as a multiple of a measure of variability.", + "CCSS.Math.Content.7.SP.B.4": "Use measures of center and measures of variability for numerical data from random samples to draw informal comparative inferences about two populations.", + "CCSS.Math.Content.7.SP.C.5": "Understand that the probability of a chance event is a number between 0 and 1 that expresses the likelihood of the event occurring. Larger numbers indicate greater likelihood. A probability near 0 indicates an unlikely event, a probability around 1/2 indicates an event that is neither unlikely nor likely, and a probability near 1 indicates a likely event.", + "CCSS.Math.Content.7.SP.C.6": "Approximate the probability of a chance event by collecting data on the chance process that produces it and observing its long-run relative frequency, and predict the approximate relative frequency given the probability.", + "CCSS.Math.Content.7.SP.C.7": "Develop a probability model and use it to find probabilities of events. Compare probabilities from a model to observed frequencies; if the agreement is not good, explain possible sources of the discrepancy.", + "CCSS.Math.Content.7.SP.C.8": "Find probabilities of compound events using organized lists, tables, tree diagrams, and simulation.", + "CCSS.Math.Content.7.SP.C.7.a": "Develop a uniform probability model by assigning equal probability to all outcomes, and use the model to determine probabilities of events.", + "CCSS.Math.Content.7.SP.C.7.b": "Develop a probability model (which may not be uniform) by observing frequencies in data generated from a chance process.", + "CCSS.Math.Content.7.SP.C.8.a": "Understand that, just as with simple events, the probability of a compound event is the fraction of outcomes in the sample space for which the compound event occurs.", + "CCSS.Math.Content.7.SP.C.8.b": "Represent sample spaces for compound events using methods such as organized lists, tables and tree diagrams. For an event described in everyday language (e.g., \"rolling double sixes\"), identify the outcomes in the sample space which compose the event.", + "CCSS.Math.Content.7.SP.C.8.c": "Design and use a simulation to generate frequencies for compound events.", + "CCSS.Math.Content.8.NS.A.1": "Know that numbers that are not rational are called irrational. Understand informally that every number has a decimal expansion; for rational numbers show that the decimal expansion repeats eventually, and convert a decimal expansion which repeats eventually into a rational number.", + "CCSS.Math.Content.8.NS.A.2": "Use rational approximations of irrational numbers to compare the size of irrational numbers, locate them approximately on a number line diagram, and estimate the value of expressions (e.g., \u03c0", + "CCSS.Math.Content.8.EE.A.1": "Know and apply the properties of integer exponents to generate equivalent numerical expressions. For example, 3", + "CCSS.Math.Content.8.EE.A.2": "Use square root and cube root symbols to represent solutions to equations of the form", + "CCSS.Math.Content.8.EE.A.3": "Use numbers expressed in the form of a single digit times an integer power of 10 to estimate very large or very small quantities, and to express how many times as much one is than the other.", + "CCSS.Math.Content.8.EE.A.4": "Perform operations with numbers expressed in scientific notation, including problems where both decimal and scientific notation are used. Use scientific notation and choose units of appropriate size for measurements of very large or very small quantities (e.g., use millimeters per year for seafloor spreading). Interpret scientific notation that has been generated by technology", + "CCSS.Math.Content.8.EE.B.5": "Graph proportional relationships, interpreting the unit rate as the slope of the graph. Compare two different proportional relationships represented in different ways. For example, compare a distance-time graph to a distance-time equation to determine which of two moving objects has greater speed.", + "CCSS.Math.Content.8.EE.B.6": "Use similar triangles to explain why the slope m is the same between any two distinct points on a non-vertical line in the coordinate plane; derive the equation y = mx for a line through the origin and the equation", + "CCSS.Math.Content.8.EE.C.7": "Solve linear equations in one variable.", + "CCSS.Math.Content.8.EE.C.8": "Analyze and solve pairs of simultaneous linear equations.", + "CCSS.Math.Content.8.EE.C.7.a": "Give examples of linear equations in one variable with one solution, infinitely many solutions, or no solutions. Show which of these possibilities is the case by successively transforming the given equation into simpler forms, until an equivalent equation of the form", + "CCSS.Math.Content.8.EE.C.7.b": "Solve linear equations with rational number coefficients, including equations whose solutions require expanding expressions using the distributive property and collecting like terms.", + "CCSS.Math.Content.8.EE.C.8.a": "Understand that solutions to a system of two linear equations in two variables correspond to points of intersection of their graphs, because points of intersection satisfy both equations simultaneously.", + "CCSS.Math.Content.8.EE.C.8.b": "Solve systems of two linear equations in two variables algebraically, and estimate solutions by graphing the equations. Solve simple cases by inspection.", + "CCSS.Math.Content.8.EE.C.8.c": "Solve real-world and mathematical problems leading to two linear equations in two variables.", + "CCSS.Math.Content.8.F.A.1": "Understand that a function is a rule that assigns to each input exactly one output. The graph of a function is the set of ordered pairs consisting of an input and the corresponding output.", + "CCSS.Math.Content.8.F.A.2": "Compare properties of two functions each represented in a different way (algebraically, graphically, numerically in tables, or by verbal descriptions).", + "CCSS.Math.Content.8.F.A.3": "Interpret the equation", + "CCSS.Math.Content.8.F.B.4": "Construct a function to model a linear relationship between two quantities. Determine the rate of change\u00a0 and initial value of the function from a description of a relationship or from two (", + "CCSS.Math.Content.8.F.B.5": "Describe qualitatively the functional relationship between two quantities by analyzing a graph (e.g., where the function is increasing or decreasing, linear or nonlinear). Sketch a graph that exhibits the qualitative features of a function that has been described verbally.", + "CCSS.Math.Content.8.G.A.1": "Verify experimentally the properties of rotations, reflections, and translations:", + "CCSS.Math.Content.8.G.A.2": "Understand that a two-dimensional figure is congruent to another if the second can be obtained from the first by a sequence of rotations, reflections, and translations; given two congruent figures, describe a sequence that exhibits the congruence between them.", + "CCSS.Math.Content.8.G.A.3": "Describe the effect of dilations, translations, rotations, and reflections on two-dimensional figures using coordinates.", + "CCSS.Math.Content.8.G.A.4": "Understand that a two-dimensional figure is similar to another if the second can be obtained from the first by a sequence of rotations, reflections, translations, and dilations; given two similar two-dimensional figures, describe a sequence that exhibits the similarity between them.", + "CCSS.Math.Content.8.G.A.5": "Use informal arguments to establish facts about the angle sum and exterior angle of triangles, about the angles created when parallel lines are cut by a transversal, and the angle-angle criterion for similarity of triangles.", + "CCSS.Math.Content.8.G.B.6": "Explain a proof of the Pythagorean Theorem and its converse.", + "CCSS.Math.Content.8.G.B.7": "Apply the Pythagorean Theorem to determine unknown side lengths in right triangles in real-world and mathematical problems in two and three dimensions.", + "CCSS.Math.Content.8.G.B.8": "Apply the Pythagorean Theorem to find the distance between two points in a coordinate system.", + "CCSS.Math.Content.8.G.C.9": "Know the formulas for the volumes of cones, cylinders, and spheres and use them to solve real-world and mathematical problems.", + "CCSS.Math.Content.8.G.A.1.a": "Lines are taken to lines, and line segments to line segments of the same length.", + "CCSS.Math.Content.8.G.A.1.b": "Angles are taken to angles of the same measure.", + "CCSS.Math.Content.8.G.A.1.c": "Parallel lines are taken to parallel lines.", + "CCSS.Math.Content.8.SP.A.1": "Construct and interpret scatter plots for bivariate measurement data to investigate patterns of association between two quantities. Describe patterns such as clustering, outliers, positive or negative association, linear association, and nonlinear association.", + "CCSS.Math.Content.8.SP.A.2": "Know that straight lines are widely used to model relationships between two quantitative variables. For scatter plots that suggest a linear association, informally fit a straight line, and informally assess the model fit by judging the closeness of the data points to the line.", + "CCSS.Math.Content.8.SP.A.3": "Use the equation of a linear model to solve problems in the context of bivariate measurement data, interpreting the slope and intercept.", + "CCSS.Math.Content.8.SP.A.4": "Understand that patterns of association can also be seen in bivariate categorical data by displaying frequencies and relative frequencies in a two-way table. Construct and interpret a two-way table summarizing data on two categorical variables collected from the same subjects. Use relative frequencies calculated for rows or columns to describe possible association between the two variables.", + "CCSS.Math.Content.HSN.RN.A.1": "Explain how the definition of the meaning of rational exponents follows from extending the properties of integer exponents to those values, allowing for a notation for radicals in terms of rational exponents.", + "CCSS.Math.Content.HSN.RN.A.2": "Rewrite expressions involving radicals and rational exponents using the properties of exponents.", + "CCSS.Math.Content.HSN.RN.B.3": "Explain why the sum or product of two rational numbers is rational; that the sum of a rational number and an irrational number is irrational; and that the product of a nonzero rational number and an irrational number is irrational.", + "CCSS.Math.Content.HSN.Q.A.1": "Use units as a way to understand problems and to guide the solution of multi-step problems; choose and interpret units consistently in formulas; choose and interpret the scale and the origin in graphs and data displays.", + "CCSS.Math.Content.HSN.Q.A.2": "Define appropriate quantities for the purpose of descriptive modeling.", + "CCSS.Math.Content.HSN.Q.A.3": "Choose a level of accuracy appropriate to limitations on measurement when reporting quantities.", + "CCSS.Math.Content.HSN.CN.A.1": "Know there is a complex number", + "CCSS.Math.Content.HSN.CN.A.2": "Use the relation", + "CCSS.Math.Content.HSN.CN.A.3": "(+) Find the conjugate of a complex number; use conjugates to find moduli and quotients of complex numbers.", + "CCSS.Math.Content.HSN.CN.B.4": "(+) Represent complex numbers on the complex plane in rectangular and polar form (including real and imaginary numbers), and explain why the rectangular and polar forms of a given complex number represent the same number.", + "CCSS.Math.Content.HSN.CN.B.5": "(+) Represent addition, subtraction, multiplication, and conjugation of complex numbers geometrically on the complex plane; use properties of this representation for computation.", + "CCSS.Math.Content.HSN.CN.B.6": "(+) Calculate the distance between numbers in the complex plane as the modulus of the difference, and the midpoint of a segment as the average of the numbers at its endpoints.", + "CCSS.Math.Content.HSN.CN.C.7": "Solve quadratic equations with real coefficients that have complex solutions.", + "CCSS.Math.Content.HSN.CN.C.8": "(+) Extend polynomial identities to the complex numbers.", + "CCSS.Math.Content.HSN.CN.C.9": "(+) Know the Fundamental Theorem of Algebra; show that it is true for quadratic polynomials.", + "CCSS.Math.Content.HSN.VM.A.1": "(+) Recognize vector quantities as having both magnitude and direction. Represent vector quantities by directed line segments, and use appropriate symbols for vectors and their magnitudes (e.g.,", + "CCSS.Math.Content.HSN.VM.A.2": "(+) Find the components of a vector by subtracting the coordinates of an initial point from the coordinates of a terminal point.", + "CCSS.Math.Content.HSN.VM.A.3": "(+) Solve problems involving velocity and other quantities that can be represented by vectors.", + "CCSS.Math.Content.HSN.VM.B.4": "(+) Add and subtract vectors.", + "CCSS.Math.Content.HSN.VM.B.5": "(+) Multiply a vector by a scalar.", + "CCSS.Math.Content.HSN.VM.C.6": "(+) Use matrices to represent and manipulate data, e.g., to represent payoffs or incidence relationships in a network.", + "CCSS.Math.Content.HSN.VM.C.7": "(+) Multiply matrices by scalars to produce new matrices, e.g., as when all of the payoffs in a game are doubled.", + "CCSS.Math.Content.HSN.VM.C.8": "(+) Add, subtract, and multiply matrices of appropriate dimensions.", + "CCSS.Math.Content.HSN.VM.C.9": "(+) Understand that, unlike multiplication of numbers, matrix multiplication for square matrices is not a commutative operation, but still satisfies the associative and distributive properties.", + "CCSS.Math.Content.HSN.VM.C.10": "(+) Understand that the zero and identity matrices play a role in matrix addition and multiplication similar to the role of 0 and 1 in the real numbers. The determinant of a square matrix is nonzero if and only if the matrix has a multiplicative inverse.", + "CCSS.Math.Content.HSN.VM.C.11": "(+) Multiply a vector (regarded as a matrix with one column) by a matrix of suitable dimensions to produce another vector. Work with matrices as transformations of vectors.", + "CCSS.Math.Content.HSN.VM.C.12": "(+) Work with 2 \u00d7 2 matrices as a transformations of the plane, and interpret the absolute value of the determinant in terms of area.", + "CCSS.Math.Content.HSN.VM.B.4.a": "Add vectors end-to-end, component-wise, and by the parallelogram rule. Understand that the magnitude of a sum of two vectors is typically not the sum of the magnitudes.", + "CCSS.Math.Content.HSN.VM.B.4.b": "Given two vectors in magnitude and direction form, determine the magnitude and direction of their sum.", + "CCSS.Math.Content.HSN.VM.B.4.c": "Understand vector subtraction", + "CCSS.Math.Content.HSN.VM.B.5.a": "Represent scalar multiplication graphically by scaling vectors and possibly reversing their direction; perform scalar multiplication component-wise, e.g., as", + "CCSS.Math.Content.HSN.VM.B.5.b": "Compute the magnitude of a scalar multiple", + "CCSS.Math.Content.HSA.SSE.A.1": "Interpret expressions that represent a quantity in terms of its context.", + "CCSS.Math.Content.HSA.SSE.A.2": "Use the structure of an expression to identify ways to rewrite it.", + "CCSS.Math.Content.HSA.SSE.B.3": "Choose and produce an equivalent form of an expression to reveal and explain properties of the quantity represented by the expression.", + "CCSS.Math.Content.HSA.SSE.B.4": "Derive the formula for the sum of a finite geometric series (when the common ratio is not 1), and use the formula to solve problems.", + "CCSS.Math.Content.HSA.SSE.A.1.a": "Interpret parts of an expression, such as terms, factors, and coefficients.", + "CCSS.Math.Content.HSA.SSE.A.1.b": "Interpret complicated expressions by viewing one or more of their parts as a single entity.", + "CCSS.Math.Content.HSA.SSE.B.3.a": "Factor a quadratic expression to reveal the zeros of the function it defines.", + "CCSS.Math.Content.HSA.SSE.B.3.b": "Complete the square in a quadratic expression to reveal the maximum or minimum value of the function it defines.", + "CCSS.Math.Content.HSA.SSE.B.3.c": "Use the properties of exponents to transform expressions for exponential functions.", + "CCSS.Math.Content.HSA.APR.A.1": "Understand that polynomials form a system analogous to the integers, namely, they are closed under the operations of addition, subtraction, and multiplication; add, subtract, and multiply polynomials.", + "CCSS.Math.Content.HSA.APR.B.2": "Know and apply the Remainder Theorem: For a polynomial", + "CCSS.Math.Content.HSA.APR.B.3": "Identify zeros of polynomials when suitable factorizations are available, and use the zeros to construct a rough graph of the function defined by the polynomial.", + "CCSS.Math.Content.HSA.APR.C.4": "Prove polynomial identities and use them to describe numerical relationships.", + "CCSS.Math.Content.HSA.APR.C.5": "(+) Know and apply the Binomial Theorem for the expansion of (", + "CCSS.Math.Content.HSA.APR.D.6": "Rewrite simple rational expressions in different forms; write", + "CCSS.Math.Content.HSA.APR.D.7": "(+) Understand that rational expressions form a system analogous to the rational numbers, closed under addition, subtraction, multiplication, and division by a nonzero rational expression; add, subtract, multiply, and divide rational expressions.", + "CCSS.Math.Content.HSA.CED.A.1": "Create equations and inequalities in one variable and use them to solve problems.", + "CCSS.Math.Content.HSA.CED.A.2": "Create equations in two or more variables to represent relationships between quantities; graph equations on coordinate axes with labels and scales.", + "CCSS.Math.Content.HSA.CED.A.3": "Represent constraints by equations or inequalities, and by systems of equations and/or inequalities, and interpret solutions as viable or nonviable options in a modeling context.", + "CCSS.Math.Content.HSA.CED.A.4": "Rearrange formulas to highlight a quantity of interest, using the same reasoning as in solving equations.", + "CCSS.Math.Content.HSA.REI.A.1": "Explain each step in solving a simple equation as following from the equality of numbers asserted at the previous step, starting from the assumption that the original equation has a solution. Construct a viable argument to justify a solution method.", + "CCSS.Math.Content.HSA.REI.A.2": "Solve simple rational and radical equations in one variable, and give examples showing how extraneous solutions may arise.", + "CCSS.Math.Content.HSA.REI.B.3": "Solve linear equations and inequalities in one variable, including equations with coefficients represented by letters.", + "CCSS.Math.Content.HSA.REI.B.4": "Solve quadratic equations in one variable.", + "CCSS.Math.Content.HSA.REI.C.5": "Prove that, given a system of two equations in two variables, replacing one equation by the sum of that equation and a multiple of the other produces a system with the same solutions.", + "CCSS.Math.Content.HSA.REI.C.6": "Solve systems of linear equations exactly and approximately (e.g., with graphs), focusing on pairs of linear equations in two variables.", + "CCSS.Math.Content.HSA.REI.C.7": "Solve a simple system consisting of a linear equation and a quadratic equation in two variables algebraically and graphically. For example, find the points of intersection between the line", + "CCSS.Math.Content.HSA.REI.C.8": "(+) Represent a system of linear equations as a single matrix equation in a vector variable.", + "CCSS.Math.Content.HSA.REI.C.9": "(+) Find the inverse of a matrix if it exists and use it to solve systems of linear equations (using technology for matrices of dimension 3 \u00d7 3 or greater).", + "CCSS.Math.Content.HSA.REI.D.10": "Understand that the graph of an equation in two variables is the set of all its solutions plotted in the coordinate plane, often forming a curve (which could be a line).", + "CCSS.Math.Content.HSA.REI.D.11": "Explain why the", + "CCSS.Math.Content.HSA.REI.D.12": "Graph the solutions to a linear inequality in two variables as a half-plane (excluding the boundary in the case of a strict inequality), and graph the solution set to a system of linear inequalities in two variables as the intersection of the corresponding half-planes.", + "CCSS.Math.Content.HSA.REI.B.4.a": "Use the method of completing the square to transform any quadratic equation in", + "CCSS.Math.Content.HSA.REI.B.4.b": "Solve quadratic equations by inspection (e.g., for", + "CCSS.Math.Content.HSF.IF.A.1": "Understand that a function from one set (called the domain) to another set (called the range) assigns to each element of the domain exactly one element of the range. If", + "CCSS.Math.Content.HSF.IF.A.2": "Use function notation, evaluate functions for inputs in their domains, and interpret statements that use function notation in terms of a context.", + "CCSS.Math.Content.HSF.IF.A.3": "Recognize that sequences are functions, sometimes defined recursively, whose domain is a subset of the integers.", + "CCSS.Math.Content.HSF.IF.B.4": "For a function that models a relationship between two quantities, interpret key features of graphs and tables in terms of the quantities, and sketch graphs showing key features given a verbal description of the relationship.", + "CCSS.Math.Content.HSF.IF.B.5": "Relate the domain of a function to its graph and, where applicable, to the quantitative relationship it describes.", + "CCSS.Math.Content.HSF.IF.B.6": "Calculate and interpret the average rate of change of a function (presented symbolically or as a table) over a specified interval. Estimate the rate of change from a graph.", + "CCSS.Math.Content.HSF.IF.C.7": "Graph functions expressed symbolically and show key features of the graph, by hand in simple cases and using technology for more complicated cases.", + "CCSS.Math.Content.HSF.IF.C.8": "Write a function defined by an expression in different but equivalent forms to reveal and explain different properties of the function.", + "CCSS.Math.Content.HSF.IF.C.9": "Compare properties of two functions each represented in a different way (algebraically, graphically, numerically in tables, or by verbal descriptions).", + "CCSS.Math.Content.HSF.IF.C.7.a": "Graph linear and quadratic functions and show intercepts, maxima, and minima.", + "CCSS.Math.Content.HSF.IF.C.7.b": "Graph square root, cube root, and piecewise-defined functions, including step functions and absolute value functions.", + "CCSS.Math.Content.HSF.IF.C.7.c": "Graph polynomial functions, identifying zeros when suitable factorizations are available, and showing end behavior.", + "CCSS.Math.Content.HSF.IF.C.7.d": "(+) Graph rational functions, identifying zeros and asymptotes when suitable factorizations are available, and showing end behavior.", + "CCSS.Math.Content.HSF.IF.C.7.e": "Graph exponential and logarithmic functions, showing intercepts and end behavior, and trigonometric functions, showing period, midline, and amplitude.", + "CCSS.Math.Content.HSF.IF.C.8.a": "Use the process of factoring and completing the square in a quadratic function to show zeros, extreme values, and symmetry of the graph, and interpret these in terms of a context.", + "CCSS.Math.Content.HSF.IF.C.8.b": "Use the properties of exponents to interpret expressions for exponential functions. For example, identify percent rate of change in functions such as y = (1.02)\u00e1\u00b5\u0097, y = (0.97)\u00e1\u00b5\u0097, y = (1.01)12\u00e1\u00b5\u0097, y = (1.2)\u00e1\u00b5\u0097/10, and classify them as representing exponential growth or decay.", + "CCSS.Math.Content.HSF.BF.A.1": "Write a function that describes a relationship between two quantities.", + "CCSS.Math.Content.HSF.BF.A.2": "Write arithmetic and geometric sequences both recursively and with an explicit formula, use them to model situations, and translate between the two forms.", + "CCSS.Math.Content.HSF.BF.B.3": "Identify the effect on the graph of replacing", + "CCSS.Math.Content.HSF.BF.B.4": "Find inverse functions.", + "CCSS.Math.Content.HSF.BF.B.5": "(+) Understand the inverse relationship between exponents and logarithms and use this relationship to solve problems involving logarithms and exponents.", + "CCSS.Math.Content.HSF.BF.A.1.a": "Determine an explicit expression, a recursive process, or steps for calculation from a context.", + "CCSS.Math.Content.HSF.BF.A.1.b": "Combine standard function types using arithmetic operations.", + "CCSS.Math.Content.HSF.BF.A.1.c": "(+) Compose functions.", + "CCSS.Math.Content.HSF.BF.B.4.a": "Solve an equation of the form f(x) = c for a simple function f that has an inverse and write an expression for the inverse.", + "CCSS.Math.Content.HSF.BF.B.4.b": "(+) Verify by composition that one function is the inverse of another.", + "CCSS.Math.Content.HSF.BF.B.4.c": "(+) Read values of an inverse function from a graph or a table, given that the function has an inverse.", + "CCSS.Math.Content.HSF.BF.B.4.d": "(+) Produce an invertible function from a non-invertible function by restricting the domain.", + "CCSS.Math.Content.HSF.LE.A.1": "Distinguish between situations that can be modeled with linear functions and with exponential functions.", + "CCSS.Math.Content.HSF.LE.A.2": "Construct linear and exponential functions, including arithmetic and geometric sequences, given a graph, a description of a relationship, or two input-output pairs (include reading these from a table).", + "CCSS.Math.Content.HSF.LE.A.3": "Observe using graphs and tables that a quantity increasing exponentially eventually exceeds a quantity increasing linearly, quadratically, or (more generally) as a polynomial function.", + "CCSS.Math.Content.HSF.LE.A.4": "For exponential models, express as a logarithm the solution to", + "CCSS.Math.Content.HSF.LE.B.5": "Interpret the parameters in a linear or exponential function in terms of a context.", + "CCSS.Math.Content.HSF.LE.A.1.a": "Prove that linear functions grow by equal differences over equal intervals, and that exponential functions grow by equal factors over equal intervals.", + "CCSS.Math.Content.HSF.LE.A.1.b": "Recognize situations in which one quantity changes at a constant rate per unit interval relative to another.", + "CCSS.Math.Content.HSF.LE.A.1.c": "Recognize situations in which a quantity grows or decays by a constant percent rate per unit interval relative to another.", + "CCSS.Math.Content.HSF.TF.A.1": "Understand radian measure of an angle as the length of the arc on the unit circle subtended by the angle.", + "CCSS.Math.Content.HSF.TF.A.2": "Explain how the unit circle in the coordinate plane enables the extension of trigonometric functions to all real numbers, interpreted as radian measures of angles traversed counterclockwise around the unit circle.", + "CCSS.Math.Content.HSF.TF.A.3": "(+) Use special triangles to determine geometrically the values of sine, cosine, tangent for \u03c0/3, \u03c0/4 and \u03c0/6, and use the unit circle to express the values of sine, cosine, and tangent for", + "CCSS.Math.Content.HSF.TF.A.4": "(+) Use the unit circle to explain symmetry (odd and even) and periodicity of trigonometric functions.", + "CCSS.Math.Content.HSF.TF.B.5": "Choose trigonometric functions to model periodic phenomena with specified amplitude, frequency, and midline.", + "CCSS.Math.Content.HSF.TF.B.6": "(+) Understand that restricting a trigonometric function to a domain on which it is always increasing or always decreasing allows its inverse to be constructed.", + "CCSS.Math.Content.HSF.TF.B.7": "(+) Use inverse functions to solve trigonometric equations that arise in modeling contexts; evaluate the solutions using technology, and interpret them in terms of the context.", + "CCSS.Math.Content.HSF.TF.C.8": "Prove the Pythagorean identity sin", + "CCSS.Math.Content.HSF.TF.C.9": "(+) Prove the addition and subtraction formulas for sine, cosine, and tangent and use them to solve problems.", + "CCSS.Math.Content.HSG.CO.A.1": "Know precise definitions of angle, circle, perpendicular line, parallel line, and line segment, based on the undefined notions of point, line, distance along a line, and distance around a circular arc.", + "CCSS.Math.Content.HSG.CO.A.2": "Represent transformations in the plane using, e.g., transparencies and geometry software; describe transformations as functions that take points in the plane as inputs and give other points as outputs. Compare transformations that preserve distance and angle to those that do not (e.g., translation versus horizontal stretch).", + "CCSS.Math.Content.HSG.CO.A.3": "Given a rectangle, parallelogram, trapezoid, or regular polygon, describe the rotations and reflections that carry it onto itself.", + "CCSS.Math.Content.HSG.CO.A.4": "Develop definitions of rotations, reflections, and translations in terms of angles, circles, perpendicular lines, parallel lines, and line segments.", + "CCSS.Math.Content.HSG.CO.A.5": "Given a geometric figure and a rotation, reflection, or translation, draw the transformed figure using, e.g., graph paper, tracing paper, or geometry software. Specify a sequence of transformations that will carry a given figure onto another.", + "CCSS.Math.Content.HSG.CO.B.6": "Use geometric descriptions of rigid motions to transform figures and to predict the effect of a given rigid motion on a given figure; given two figures, use the definition of congruence in terms of rigid motions to decide if they are congruent.", + "CCSS.Math.Content.HSG.CO.B.7": "Use the definition of congruence in terms of rigid motions to show that two triangles are congruent if and only if corresponding pairs of sides and corresponding pairs of angles are congruent.", + "CCSS.Math.Content.HSG.CO.B.8": "Explain how the criteria for triangle congruence (ASA, SAS, and SSS) follow from the definition of congruence in terms of rigid motions.", + "CCSS.Math.Content.HSG.CO.C.9": "Prove theorems about lines and angles.", + "CCSS.Math.Content.HSG.CO.C.10": "Prove theorems about triangles.", + "CCSS.Math.Content.HSG.CO.C.11": "Prove theorems about parallelograms.", + "CCSS.Math.Content.HSG.CO.D.12": "Make formal geometric constructions with a variety of tools and methods (compass and straightedge, string, reflective devices, paper folding, dynamic geometric software, etc.).", + "CCSS.Math.Content.HSG.CO.D.13": "Construct an equilateral triangle, a square, and a regular hexagon inscribed in a circle.", + "CCSS.Math.Content.HSG.SRT.A.1": "Verify experimentally the properties of dilations given by a center and a scale factor:", + "CCSS.Math.Content.HSG.SRT.A.2": "Given two figures, use the definition of similarity in terms of similarity transformations to decide if they are similar; explain using similarity transformations the meaning of similarity for triangles as the equality of all corresponding pairs of angles and the proportionality of all corresponding pairs of sides.", + "CCSS.Math.Content.HSG.SRT.A.3": "Use the properties of similarity transformations to establish the AA criterion for two triangles to be similar.", + "CCSS.Math.Content.HSG.SRT.B.4": "Prove theorems about triangles.", + "CCSS.Math.Content.HSG.SRT.B.5": "Use congruence and similarity criteria for triangles to solve problems and to prove relationships in geometric figures.", + "CCSS.Math.Content.HSG.SRT.C.6": "Understand that by similarity, side ratios in right triangles are properties of the angles in the triangle, leading to definitions of trigonometric ratios for acute angles.", + "CCSS.Math.Content.HSG.SRT.C.7": "Explain and use the relationship between the sine and cosine of complementary angles.", + "CCSS.Math.Content.HSG.SRT.C.8": "Use trigonometric ratios and the Pythagorean Theorem to solve right triangles in applied problems.", + "CCSS.Math.Content.HSG.SRT.D.9": "(+) Derive the formula", + "CCSS.Math.Content.HSG.SRT.D.10": "(+) Prove the Laws of Sines and Cosines and use them to solve problems.", + "CCSS.Math.Content.HSG.SRT.D.11": "(+) Understand and apply the Law of Sines and the Law of Cosines to find unknown measurements in right and non-right triangles (e.g., surveying problems, resultant forces).", + "CCSS.Math.Content.HSG.SRT.A.1.a": "A dilation takes a line not passing through the center of the dilation to a parallel line, and leaves a line passing through the center unchanged.", + "CCSS.Math.Content.HSG.SRT.A.1.b": "The dilation of a line segment is longer or shorter in the ratio given by the scale factor.", + "CCSS.Math.Content.HSG.C.A.1": "Prove that all circles are similar.", + "CCSS.Math.Content.HSG.C.A.2": "Identify and describe relationships among inscribed angles, radii, and chords.", + "CCSS.Math.Content.HSG.C.A.3": "Construct the inscribed and circumscribed circles of a triangle, and prove properties of angles for a quadrilateral inscribed in a circle.", + "CCSS.Math.Content.HSG.C.A.4": "(+) Construct a tangent line from a point outside a given circle to the circle.", + "CCSS.Math.Content.HSG.C.B.5": "Derive using similarity the fact that the length of the arc intercepted by an angle is proportional to the radius, and define the radian measure of the angle as the constant of proportionality; derive the formula for the area of a sector.", + "CCSS.Math.Content.HSG.GPE.A.1": "Derive the equation of a circle of given center and radius using the Pythagorean Theorem; complete the square to find the center and radius of a circle given by an equation.", + "CCSS.Math.Content.HSG.GPE.A.2": "Derive the equation of a parabola given a focus and directrix.", + "CCSS.Math.Content.HSG.GPE.A.3": "(+) Derive the equations of ellipses and hyperbolas given the foci, using the fact that the sum or difference of distances from the foci is constant.", + "CCSS.Math.Content.HSG.GPE.B.4": "Use coordinates to prove simple geometric theorems algebraically.", + "CCSS.Math.Content.HSG.GPE.B.5": "Prove the slope criteria for parallel and perpendicular lines and use them to solve geometric problems (e.g., find the equation of a line parallel or perpendicular to a given line that passes through a given point).", + "CCSS.Math.Content.HSG.GPE.B.6": "Find the point on a directed line segment between two given points that partitions the segment in a given ratio.", + "CCSS.Math.Content.HSG.GPE.B.7": "Use coordinates to compute perimeters of polygons and areas of triangles and rectangles, e.g., using the distance formula.", + "CCSS.Math.Content.HSG.GMD.A.1": "Give an informal argument for the formulas for the circumference of a circle, area of a circle, volume of a cylinder, pyramid, and cone.", + "CCSS.Math.Content.HSG.GMD.A.2": "(+) Give an informal argument using Cavalieri's principle for the formulas for the volume of a sphere and other solid figures.", + "CCSS.Math.Content.HSG.GMD.A.3": "Use volume formulas for cylinders, pyramids, cones, and spheres to solve problems.", + "CCSS.Math.Content.HSG.GMD.B.4": "Identify the shapes of two-dimensional cross-sections of three-dimensional objects, and identify three-dimensional objects generated by rotations of two-dimensional objects.", + "CCSS.Math.Content.HSG.MG.A.1": "Use geometric shapes, their measures, and their properties to describe objects (e.g., modeling a tree trunk or a human torso as a cylinder).", + "CCSS.Math.Content.HSG.MG.A.2": "Apply concepts of density based on area and volume in modeling situations (e.g., persons per square mile, BTUs per cubic foot).", + "CCSS.Math.Content.HSG.MG.A.3": "Apply geometric methods to solve design problems (e.g., designing an object or structure to satisfy physical constraints or minimize cost; working with typographic grid systems based on ratios).", + "CCSS.Math.Content.HSS.ID.A.1": "Represent data with plots on the real number line (dot plots, histograms, and box plots).", + "CCSS.Math.Content.HSS.ID.A.2": "Use statistics appropriate to the shape of the data distribution to compare center (median, mean) and spread (interquartile range, standard deviation) of two or more different data sets.", + "CCSS.Math.Content.HSS.ID.A.3": "Interpret differences in shape, center, and spread in the context of the data sets, accounting for possible effects of extreme data points (outliers).", + "CCSS.Math.Content.HSS.ID.A.4": "Use the mean and standard deviation of a data set to fit it to a normal distribution and to estimate population percentages. Recognize that there are data sets for which such a procedure is not appropriate. Use calculators, spreadsheets, and tables to estimate areas under the normal curve.", + "CCSS.Math.Content.HSS.ID.B.5": "Summarize categorical data for two categories in two-way frequency tables. Interpret relative frequencies in the context of the data (including joint, marginal, and conditional relative frequencies). Recognize possible associations and trends in the data.", + "CCSS.Math.Content.HSS.ID.B.6": "Represent data on two quantitative variables on a scatter plot, and describe how the variables are related.", + "CCSS.Math.Content.HSS.ID.C.7": "Interpret the slope (rate of change) and the intercept (constant term) of a linear model in the context of the data.", + "CCSS.Math.Content.HSS.ID.C.8": "Compute (using technology) and interpret the correlation coefficient of a linear fit.", + "CCSS.Math.Content.HSS.ID.C.9": "Distinguish between correlation and causation.", + "CCSS.Math.Content.HSS.ID.B.6.a": "Fit a function to the data; use functions fitted to data to solve problems in the context of the data. Use given functions or choose a function suggested by the context. Emphasize linear, quadratic, and exponential models.", + "CCSS.Math.Content.HSS.ID.B.6.b": "Informally assess the fit of a function by plotting and analyzing residuals.", + "CCSS.Math.Content.HSS.ID.B.6.c": "Fit a linear function for a scatter plot that suggests a linear association.", + "CCSS.Math.Content.HSS.IC.A.1": "Understand statistics as a process for making inferences about population parameters based on a random sample from that population.", + "CCSS.Math.Content.HSS.IC.A.2": "Decide if a specified model is consistent with results from a given data-generating process, e.g., using simulation.", + "CCSS.Math.Content.HSS.IC.B.3": "Recognize the purposes of and differences among sample surveys, experiments, and observational studies; explain how randomization relates to each.", + "CCSS.Math.Content.HSS.IC.B.4": "Use data from a sample survey to estimate a population mean or proportion; develop a margin of error through the use of simulation models for random sampling.", + "CCSS.Math.Content.HSS.IC.B.5": "Use data from a randomized experiment to compare two treatments; use simulations to decide if differences between parameters are significant.", + "CCSS.Math.Content.HSS.IC.B.6": "Evaluate reports based on data.", + "CCSS.Math.Content.HSS.CP.A.1": "Describe events as subsets of a sample space (the set of outcomes) using characteristics (or categories) of the outcomes, or as unions, intersections, or complements of other events (\"or,\" \"and,\" \"not\").", + "CCSS.Math.Content.HSS.CP.A.2": "Understand that two events", + "CCSS.Math.Content.HSS.CP.A.3": "Understand the conditional probability of", + "CCSS.Math.Content.HSS.CP.A.4": "Construct and interpret two-way frequency tables of data when two categories are associated with each object being classified. Use the two-way table as a sample space to decide if events are independent and to approximate conditional probabilities.", + "CCSS.Math.Content.HSS.CP.A.5": "Recognize and explain the concepts of conditional probability and independence in everyday language and everyday situations.", + "CCSS.Math.Content.HSS.CP.B.6": "Find the conditional probability of", + "CCSS.Math.Content.HSS.CP.B.7": "Apply the Addition Rule, P(A or B) = P(A) + P(B) - P(A and B), and interpret the answer in terms of the model.", + "CCSS.Math.Content.HSS.CP.B.8": "(+) Apply the general Multiplication Rule in a uniform probability model, P(A and B) = P(A)P(B|A) = P(B)P(A|B), and interpret the answer in terms of the model.", + "CCSS.Math.Content.HSS.CP.B.9": "(+) Use permutations and combinations to compute probabilities of compound events and solve problems.", + "CCSS.Math.Content.HSS.MD.A.1": "(+) Define a random variable for a quantity of interest by assigning a numerical value to each event in a sample space; graph the corresponding probability distribution using the same graphical displays as for data distributions.", + "CCSS.Math.Content.HSS.MD.A.2": "(+) Calculate the expected value of a random variable; interpret it as the mean of the probability distribution.", + "CCSS.Math.Content.HSS.MD.A.3": "(+) Develop a probability distribution for a random variable defined for a sample space in which theoretical probabilities can be calculated; find the expected value.", + "CCSS.Math.Content.HSS.MD.A.4": "(+) Develop a probability distribution for a random variable defined for a sample space in which probabilities are assigned empirically; find the expected value.", + "CCSS.Math.Content.HSS.MD.B.5": "(+) Weigh the possible outcomes of a decision by assigning probabilities to payoff values and finding expected values.", + "CCSS.Math.Content.HSS.MD.B.6": "(+) Use probabilities to make fair decisions (e.g., drawing by lots, using a random number generator).", + "CCSS.Math.Content.HSS.MD.B.7": "(+) Analyze decisions and strategies using probability concepts (e.g., product testing, medical testing, pulling a hockey goalie at the end of a game).", + "CCSS.Math.Content.HSS.MD.B.5.a": "Find the expected payoff for a game of chance.", + "CCSS.Math.Content.HSS.MD.B.5.b": "Evaluate and compare strategies on the basis of expected values." +} \ No newline at end of file diff --git a/modules/ccss/ccss/ccss.py b/modules/ccss/ccss/ccss.py new file mode 100644 index 000000000..f86b7a738 --- /dev/null +++ b/modules/ccss/ccss/ccss.py @@ -0,0 +1,88 @@ +''' +This is a simple interface to navigate Common Core State Standards. +''' + +from pkg_resources import resource_filename +import json + +ELA = 'ELA-Literacy' +MATH = 'Math' + + +class Standard: + def __init__(self, standard_str): + self.standard_str = standard_str.replace("Math.Content", "Math") + parts = self.standard_str.split('.') + self.subject = parts[1] + if self.subject == ELA: + self.subdomain = parts[2] + self.grade = parts[3] + self.id = ".".join(parts[4:]) + elif self.subject == MATH: + self.subdomain = parts[3] + self.grade = parts[2] + self.id = ".".join(parts[4:]) + else: + raise AttributeError("Unknown subject") + + def __str__(self): + return self.standard_str + + +class Standards(dict): + def query(self, func): + return Standards( + { + key: value + for key, value in self.items() + if func(Standard(key)) + } + ) + + def math(self): + # Return a new Standards object with just math items + return self.query(lambda key: key.subject == MATH) + + def ela(self): + # Return a new Standards object with just ELA items + return self.query(lambda key: key.subject == ELA) + + def subdomain(self, subdomains): + # Handle lists or individual values + if not isinstance(subdomains, list): + subdomains = [subdomains] + # Return a new Standards object with specified subdomain items + return self.query(lambda key: key.subdomain in subdomains) + + def id(self, ids): + # Handle lists or individual values + if not isinstance(ids, list): + ids = [ids] + # Return a new Standards object with specified id items + return self.query(lambda key: key.id in ids) + + def grade(self, grade_levels): + # Handle lists or individual values + if not isinstance(grade_levels, list): + grade_levels = [grade_levels] + + # Handle integers + grade_levels = list(map(str, grade_levels)) + return self.query(lambda key: key.grade in grade_levels) + + def subdomains(self): + all_subdomains = {Standard(key).subdomain for key in self} + return sorted(all_subdomains) + + def ids(self): + all_ids = {Standard(key).id for key in self} + return sorted(all_ids) + + def grades(self): + all_grades = {Standard(key).grade for key in self} + return sorted(all_grades) + + +json_file_path = resource_filename(__name__, 'ccss.json') + +standards = Standards(json.load(open(json_file_path))) diff --git a/modules/ccss/ccss/download.py b/modules/ccss/ccss/download.py new file mode 100644 index 000000000..3894b4d61 --- /dev/null +++ b/modules/ccss/ccss/download.py @@ -0,0 +1,70 @@ +'''This is a script which downloads CCSS standards. + +This script is a one-off, since it will break if the page layout ever +changes. It was half-generated by GPT. The core of this package are +the JSON files extracted, and the scripts to make use of them. +''' + +from bs4 import BeautifulSoup +import requests +import json + +# Fetch the webpage +subjects = ["ELA-Literacy", "Math"] + + +def fetch_urls(url): + response = requests.get(url) + soup = BeautifulSoup(response.text, 'html.parser') + + nav = soup.find('div', {'id': 'sidebar'}) + all_urls = [a['href'] for a in nav.find_all('a')] + return [ + u.replace('../', '') + for u in all_urls + if 'pdf' not in u and 'http' not in u + ] + + +def fetch_standards(url): + response = requests.get(url) + + # Parse the HTML with BeautifulSoup + soup = BeautifulSoup(response.text, 'html.parser') + + # Find the standards and sub-standards + standards = soup.find_all('div', {'class': 'standard'}) + standards_json = {} + for standard in standards: + identifier = standard.find('a', {'class': 'identifier'}).text + description = standard.find('br').next_sibling + standards_json[identifier] = description.strip() + + # Find the substandards + substandards = soup.find_all('div', {'class': 'substandard'}) + substandards_json = {} + for substandard in substandards: + identifier = substandard.find('a', {'class': 'identifier'}).text + description = substandard.find('br').next_sibling + substandards_json[identifier] = description.strip() + + # Output the JSON document + return standards_json, substandards_json + + +all_standards = {} + +for subject in subjects: + base_url = f"https://www.thecorestandards.org/{subject}" + for u in fetch_urls(base_url): + try: + standards, substandards = fetch_standards( + f"https://www.thecorestandards.org/{u}") + all_standards.update(standards) + all_standards.update(substandards) + except Exception: + print(f"Skipping {u}") + +print(json.dumps(all_standards, indent=2)) + +json.dump(all_standards, open("ccss.json", "w"), indent=2) diff --git a/modules/ccss/ccss/st_search.py b/modules/ccss/ccss/st_search.py new file mode 100644 index 000000000..0caa7863e --- /dev/null +++ b/modules/ccss/ccss/st_search.py @@ -0,0 +1,62 @@ +''' +This implements a search for a particular standard using a +relatively small, fast neural network. + +Load time is a little bit annoying (3-4 seconds), but queries +run quickly. + +It can be used directly, or to return a set of all possible +results, with the final results then pruned by an LLM + +Perhaps this ought to be teased out into its own library. This should +definitely not be placed in anything imported in __init__.py or +ccss.py, since most uses of ccss probably won't use this, and it adds +to startup time. +''' + +import json + +from sentence_transformers import SentenceTransformer, util +import torch + +# Load the model. This is a relatively small model (80MB) +model = SentenceTransformer('all-MiniLM-L6-v2') + +standard_keys, standard_texts = zip(*json.load(open("ccss.json")).items()) + +# Encode all standard_texts to get their embeddings +embeddings = model.encode(standard_texts, convert_to_tensor=True) + + +def search(query, *args, max_result_count=5): + ''' + Fast (imperfect) semantic similarity search. + + Fast enough to work in realtime (e.g. for autocomplete) + + From best to worst. + + `max_result_count` can be set to `None` to return all standards + items + ''' + # Encode the query + query_embedding = model.encode(query, convert_to_tensor=True) + + # Use cosine similarity to find the most similar + # standard_texts to the query + cos_similarities = util.pytorch_cos_sim(query_embedding, embeddings)[0] + + # Get the index of the most similar sentence + top_match_index = torch.argmax(cos_similarities).item() + top_match_indices = cos_similarities.argsort(descending=True) + if max_result_count: + top_match_indices = top_match_indices[:max_result_count] + + result_standard_texts = [standard_texts[i] for i in top_match_indices] + result_standard_keys = [standard_keys[i] for i in top_match_indices] + return zip(result_standard_keys, result_standard_texts) + + +if __name__ == '__main__': + for key, text in search("division", max_result_count=10): + print(f"{key}: {text}") diff --git a/modules/ccss/ccss/test.py b/modules/ccss/ccss/test.py new file mode 100644 index 000000000..5e4d5c8fd --- /dev/null +++ b/modules/ccss/ccss/test.py @@ -0,0 +1,50 @@ +''' +For now, this just runs all the code paths and checks return +types. We should do something more robust later. +''' + +import unittest + +import ccss + + +class TestStandards(unittest.TestCase): + def test_math(self): + math_standards = ccss.standards.math() + self.assertIsInstance(math_standards, ccss.Standards) + + def test_ela(self): + ela_standards = ccss.standards.ela() + self.assertIsInstance(ela_standards, ccss.Standards) + + def test_subdomain_with_list(self): + sub_standards = ccss.standards.subdomain(['Math']) + self.assertIsInstance(sub_standards, ccss.Standards) + + def test_subdomain_with_str(self): + sub_standards = ccss.standards.subdomain('Math') + self.assertIsInstance(sub_standards, ccss.Standards) + + def test_id_with_list(self): + sub_standards = ccss.standards.id(['1', '2']) + self.assertIsInstance(sub_standards, ccss.Standards) + + def test_id_with_str(self): + sub_standards = ccss.standards.id('1') + self.assertIsInstance(sub_standards, ccss.Standards) + + def test_grade_with_list(self): + sub_standards = ccss.standards.grade(['1', '2']) + self.assertIsInstance(sub_standards, ccss.Standards) + + def test_grade_with_str(self): + sub_standards = ccss.standards.grade('1') + self.assertIsInstance(sub_standards, ccss.Standards) + + def test_grade_with_int(self): + sub_standards = ccss.standards.grade(1) + self.assertIsInstance(sub_standards, ccss.Standards) + + +if __name__ == '__main__': + unittest.main() diff --git a/modules/ccss/ccss_public_license.md b/modules/ccss/ccss_public_license.md new file mode 100644 index 000000000..125dbd5d8 --- /dev/null +++ b/modules/ccss/ccss_public_license.md @@ -0,0 +1,103 @@ +# Common Core License Disclaimer + +If you are using this package, you are also presumably using the Common Core State Standards as well. For reference, a copy of the license text from: + + https://www.thecorestandards.org/public-license/ + +This was downloaded 3/30/2024. + +Note that this text applies to the original Common Core standards (not the Python code / Python package we developed, which is open-source). We include this so that you can evaluate whether or not it is appropriate for your use, and the extent to which it applies. + +I'll provide a little bit of background. CCSS made the unfortunate decision not to use a standard license but draft a custom one. This places a lot of uses in legally-murky territory unless you have proper legal advice for what is and isn't okay. The license text, while pleasantly short, repeats mistakes of past licenses, and more critically, isn't well-understood (like a vetted license). + +Note that I am not a laywer, and disclaimer this has not been reviewed by lawyers. Any of this may be incorrect. It is not here as legal advice, but rather so that you can understand why it might be important to talk to a lawyer before adopting CCSS for your own curriculum or technology system, and give the background which might be helpful to hold that conversation. + +## CCSS "Public License" issues +=============================== + +Broad use of CCSS standards is permitted under the so-called ["Public License"](https://www.thecorestandards.org/public-license/), also included below for reference. This license has several problems, the dominant of which it carries a use restriction (namely that it only permits "purposes that support the Common Core State Standards Initiative"). + +This might not seem like a big deal to a lay reader, but it has several severe problems: + +* **Compatibility with OERs**. Most OER, free software, and open-source licenses do not permit the addition of use restrictions. This does not permit uses of CCSS-derivative works in, for example, a CC-BY-SA curricular resource, and the license [non-free](https://en.wikipedia.org/wiki/The_Free_Software_Definition). + +* **Poor legal text** The phrasing here is ill-defined. If you're making a piece of curriculum which diverges from Common Core, is that supporting the initiative? At what point does innovating diverge enough that you're in violation? Legally-ambiguous license text is problematic because to defend yourself in court, you're looking at hundreds of thousands of dollars in legal fees. + +** **Discouraging progress** Common Core is an excellent starting point to start developing curriculum and innovating from. Ideally, progress would feed back into Common Core. This is discouraged by this license. + +The above is based on hard-learned lessons from similar licenses in other projects. CC-SA-NC has some of the same bugs as this license text, for example, but CC-SA-NC has been examined by armies of lawyers, and we at least understand how those play out. Here, you're on _terra incognita_. + +Generally, these sorts of legal minefields tend to crop up a lot in custom licenses, as CCSS chose to use. That's why most projects use standard, well-understood licenses. I could write a diatribe about how this license text seems to confuse contract law and copyright law, but I would leave that to a proper lawyer. + +I was disappointed that CCSS created a custom, ill-understood license which may expose you to legal liability if you adopt CCSS. + +Why this might not apply +======================== + +Licenses often try to do things not really kosher with copyright law, and this license does smell fishy. A lawyer should review for whether / when a license is required. Places to look: + +* Under section 105 of the Copyright Act of 1976, works created by the federal government are not entitled to domestic copyright protection under U.S. law and are therefore in the public domain. I have no idea how this relates to an initiative sponsored by the National Governors Association and the Council of Chief State School Officers. It is odd that a tax-funded initative would not be open, though. +* There's a complex set of law about when license requirements kick in. To be specific, copyright applies to specific actions such as copying and ditribution. Many anticipated uses might not touch on those. +* Data cannot be copyrighted, but creative works can. In a case like this one, we are extracting data from a creative work. The data includes descriptive text, to a non-lawyer, it is clear as mud where the line is drawn. +* Most uses of CCSS are probably not significant enough to constitute a derivative work, under fair use. +* Specifically, cases like *Google LLC v. Oracle America, Inc.* suggest uses like this package might fall entirely under the fair use exemption. + +The problem here is that even if the above arguments are right, adopting CCSS likely requires legal review, which is thousands of dollars of lawyer time. If CCSSO disagrees with your lawyer and sues, proving that in court requires hundreds of thousands or millions of dollars. Most educational organizations don't have that. + +At some point, hopefully, NGA/CCSSO should fix its license, since excluding CCSS from use in most OERs was probably not the intended effect of adopting this license, and is likely to discourage broad use of CCSS. + +Indeed, I suspect it has as much uptake as it does simply because a lawyer never did such a review, and I'm unaware of CCSSO suing anyone yet. + +*** + +## Actual Text of the CCSS "Public License" + +### Public License + +#### Introduction + +THE COMMON CORE STATE STANDARDS ARE PROVIDED UNDER THE TERMS OF THIS PUBLIC LICENSE. THE COMMON CORE STATE STANDARDS ARE PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE COMMON CORE STATE STANDARDS OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +ANY PERSON WHO EXERCISES ANY RIGHTS TO THE COMMON CORE STATE STANDARDS THEREBY ACCEPTS AND AGREES TO BE BOUND BY THE TERMS OF THIS LICENSE. THE RIGHTS CONTAINED HEREIN ARE GRANTED IN CONSIDERATION OF ACCEPTANCE OF SUCH TERMS AND CONDITIONS. + +#### License Grant + +The NGA Center for Best Practices (NGA Center) and the Council of Chief State School Officers (CCSSO) hereby grant a limited, non-exclusive, royalty-free license to copy, publish, distribute, and display the Common Core State Standards for purposes that support the Common Core State Standards Initiative. These uses may involve the Common Core State Standards as a whole or selected excerpts or portions. + +#### Attribution; Copyright Notice + +NGA Center/CCSSO shall be acknowledged as the sole owners and developers of the Common Core State Standards, and no claims to the contrary shall be made. + +Any publication or public display shall include the following notice: “© Copyright 2010. National Governors Association Center for Best Practices and Council of Chief State School Officers. All rights reserved.” + +States and territories of the United States as well as the District of Columbia that have adopted the Common Core State Standards in whole are exempt from this provision of the License. + +#### Material Beyond the Scope of the Public License + +This License extends to the Common Core State Standards only and not to the examples. A number of the examples are comprised of materials that are not subject to copyright, such as due to being in the public domain, and others required NGA Center and CCSSO to obtain permission for their use from a third party copyright holder. + +With respect to copyrighted works provided by the Penguin Group (USA) Inc., duplication, distribution, emailing, copying, or printing is allowed only of the work as a whole. + +McGraw-Hill makes no representations or warranties as to the accuracy of any information contained in the McGraw-Hill Material, including any warranties of merchantability or fitness for a particular purpose. In no event shall McGraw-Hill have any liability to any party for special, incidental, tort, or consequential damages arising out of or in connection with the McGraw-Hill Material, even if McGraw-Hill has been advised of the possibility of such damages. + +#### Representations, Warranties and Disclaimer + +THE COMMON CORE STATE STANDARDS ARE PROVIDED AS-IS AND WITH ALL FAULTS, AND NGA CENTER/CCSSO MAKE NO REPRESENTATIONS OR WARRANTIES OF ANY KIND, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. + +#### Limitation on Liability + +UNDER NO CIRCUMSTANCES SHALL NGA CENTER OR CCSSO, INDIVIDUALLY OR JOINTLY, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, CONSEQUENTIAL, OR PUNITIVE DAMAGES HOWEVER CAUSED AND ON ANY LEGAL THEORY OF LIABILITY, WHETHER FOR CONTRACT, TORT, STRICT LIABILITY, OR A COMBINATION THEREOF (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE COMMON CORE STATE STANDARDS, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH RISK AND POTENTIAL DAMAGE. WITHOUT LIMITING THE FOREGOING, LICENSEE WAIVES THE RIGHT TO SEEK LEGAL REDRESS AGAINST, AND RELEASES FROM ALL LIABILITY AND COVENANTS NOT TO SUE, NGA CENTER AND CCSSO. + +#### Termination + +This License and the rights granted hereunder will terminate automatically as to a licensee upon any breach by that licensee of the terms of this License. + +NGA Center and CCSSO reserve the right to release the Common Core State Standards under different license terms or to stop distributing the Common Core State Standards at any time; provided, however that any such election will not serve to withdraw this License with respect to any person utilizing the Common Core State Standards pursuant to this License. + +#### Miscellaneous + +This License shall be construed in accordance with the laws of the District of Columbia, without regard to conflicts principles, and as applicable, US federal law. A court of competent jurisdiction in Washington, DC shall be the exclusive forum for the resolution of any disputes regarding this License, and consent to the personal and subject matter jurisdiction, and venue, of such court is irrevocably given. + +If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. + +No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by authorized representatives of NGA Center and CCSSO. diff --git a/modules/ccss/setup.py b/modules/ccss/setup.py new file mode 100644 index 000000000..15baf16be --- /dev/null +++ b/modules/ccss/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup, find_packages + +setup( + name='ccss', + version='0.1', + packages=find_packages(), +) diff --git a/modules/language_tool/languagetool.py b/modules/language_tool/languagetool.py new file mode 100644 index 000000000..1eafcf45b --- /dev/null +++ b/modules/language_tool/languagetool.py @@ -0,0 +1,52 @@ +''' +A thin, async wrapper to languagetool +''' + +import asyncio +import inspect + +import aiohttp + +session = None + + +async def check(language, text): + ''' + Takes a language (e.g. `en-US`), and a text. + + Returns a JSON object of the LanguageTool spell / grammar + check + ''' + + global session + if session is None: + session = aiohttp.ClientSession() + if inspect.iscoroutinefunction(session): + session = await session + + query = { + 'language': language, + 'text': text + } + resp = await session.post( + 'http://localhost:8081/v2/check', + data=query + ) + + return await resp.json() + + +async def main(): + ''' + A simple test case, and demo of syntax + ''' + en = await check('en-US', 'This is a tset of the emergecny...') + print(en['matches']) + pl = await check('pl', 'Sprawdzamy awarje, ale nie ma...') + print(en['matches']) + await session.close() + + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/modules/lo_action_summary/MANIFEST.in b/modules/lo_action_summary/MANIFEST.in new file mode 100644 index 000000000..21a23cfb1 --- /dev/null +++ b/modules/lo_action_summary/MANIFEST.in @@ -0,0 +1 @@ +include lo_action_summary/assets/* diff --git a/modules/lo_action_summary/README.md b/modules/lo_action_summary/README.md new file mode 100644 index 000000000..4f8ed44b2 --- /dev/null +++ b/modules/lo_action_summary/README.md @@ -0,0 +1,3 @@ +# Learning Observer Action Summary + +This module allows users to view a student's history of events for a specific reducer context. diff --git a/modules/lo_action_summary/lo_action_summary/__init__.py b/modules/lo_action_summary/lo_action_summary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_action_summary/lo_action_summary/assets/scripts.js b/modules/lo_action_summary/lo_action_summary/assets/scripts.js new file mode 100644 index 000000000..b55ec34e7 --- /dev/null +++ b/modules/lo_action_summary/lo_action_summary/assets/scripts.js @@ -0,0 +1,105 @@ +/** + * Javascript callbacks to be used with the LO Example dashboard + */ + +// Initialize the `dash_clientside` object if it doesn't exist +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +window.dash_clientside.lo_action_summary = { + /** + * Send updated queries to the communication protocol. + * @param {object} wsReadyState LOConnection status object + * @param {string} urlHash query string from hash for determining course id + * @returns stringified json object that is sent to the communication protocl + */ + sendToLOConnection: async function (wsReadyState, urlHash) { + if (wsReadyState === undefined) { + return window.dash_clientside.no_update; + } + if (wsReadyState.readyState === 1) { + if (urlHash.length === 0) { return window.dash_clientside.no_update; } + const decodedParams = decode_string_dict(urlHash.slice(1)); + if (!decodedParams.course_id) { return window.dash_clientside.no_update; } + + if ('student_id' in decodedParams) { + decodedParams.student_id = [{ user_id: decodedParams.student_id }]; + } else { + decodedParams.student_id = []; + } + + const outgoingMessage = { + lo_action_summary_query: { + execution_dag: 'lo_action_summary', + target_exports: ['roster', 'action_summary'], + kwargs: decodedParams + } + }; + return JSON.stringify(outgoingMessage); + } + return window.dash_clientside.no_update; + }, + + /** + * Process a message from LOConnection + * @param {object} incomingMessage object received from LOConnection + * @returns parsed data to local storage + */ + receiveWSMessage: async function (incomingMessage) { + // TODO the naming here is broken serverside. Notice above we + // called the target export `student_event_history_export`, i.e. the named + // export. Below, we need to call `lo_action_summary_join_roster`, i.e. the name + // of the node. This ought to be cleaned up in the communication protocl. + const parsedMessage = JSON.parse(incomingMessage.data); + const messageData = parsedMessage.lo_action_summary_query; + if (messageData.error !== undefined) { + console.error('Error received from server', messageData.error); + return {}; + } + return messageData; + }, + + /** + * Build the student UI components based on the stored websocket data + * @param {*} wsStorageData information stored in the websocket store + * @returns Dash object to be displayed on page + */ + populateStudentRadioItems: function (wsStorageData) { + if (!wsStorageData) { + return window.dash_clientside.no_update; + } + const roster = wsStorageData.roster || []; + let options = []; + for (const student of roster) { + const studentOption = { + value: student.user_id, + label: { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [{ + namespace: 'lo_dash_react_components', + props: { + profile: student.profile, + className: 'student-name-tag d-inline-block', + includeName: true, + id: `${student.user_id}-activity-img` + }, + type: 'LONameTag' + }] + } + } + }; + options = options.concat(studentOption); + } + return options; + }, + + updateHashWithSelectedStudent: function (selected) { + if (selected == null) { return window.dash_clientside.no_update; } + const params = decode_string_dict(window.location.hash.slice(1)); + return `#course_id=${params.course_id};student_id=${selected}`; + } + +}; diff --git a/modules/lo_action_summary/lo_action_summary/dash_dashboard.py b/modules/lo_action_summary/lo_action_summary/dash_dashboard.py new file mode 100644 index 000000000..0c6a02237 --- /dev/null +++ b/modules/lo_action_summary/lo_action_summary/dash_dashboard.py @@ -0,0 +1,96 @@ +''' +''' +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + + +_prefix = 'lo-action-summary' +_namespace = 'lo_action_summary' +_websocket = f'{_prefix}-websocket' +_websocket_storage = f'{_prefix}-websocket-store' +_student_list = f'{_prefix}-student-list' +_student_output = f'{_prefix}-student-output' + + +def layout(): + ''' + Function to define the page's layout. + ''' + page_layout = html.Div(children=[ + html.H1(children='Learning Observer Action Summary'), + dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionStatusAIO(aio_id=_websocket)), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), + ]), + dcc.Store(id=_websocket_storage), + html.H2('Students'), + dbc.RadioItems(id=_student_list, inline=True), + html.H2('Action Summary'), + html.Div(id=_student_output) + ]) + return page_layout + +# Send the initial state based on the url hash to LO. +# If this is not included, nothing will be returned from +# the communication protocol. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), + Output(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash') +) + +# Handle receiving a message from the websocket. +# This step will parse the message and update the +# local storage accordingly. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='receiveWSMessage'), + Output(_websocket_storage, 'data'), + Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'message'), + prevent_initial_call=True +) + +# Build the UI based on what we've received from the +# communicaton protocol +# This clientside callback and the serverside callback below are +# the same +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='populateStudentRadioItems'), + Output(_student_list, 'options'), + Input(_websocket_storage, 'data'), +) + +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateHashWithSelectedStudent'), + Output('_pages_location', 'hash'), + Input(_student_list, 'value') +) + + +def create_markdown_text(events): + markdown_str = '```\n' + for e in events: + markdown_str += f'{e}\n' + markdown_str += '```' + return markdown_str + + +@callback( + Output(_student_output, 'children'), + Input(_websocket_storage, 'data'), +) +def populate_output(data): + if not data: + return 'No student selected' + # there should only be 1 + student_summaries = data.get('single_action_summary', []) + if len(student_summaries) == 0: + return 'No student selected' + + events = student_summaries[0]['events'] + if len(events) > 0: + return html.Div([ + dcc.Markdown(create_markdown_text(events)) + ]) + return 'No events found.' diff --git a/modules/lo_action_summary/lo_action_summary/module.py b/modules/lo_action_summary/lo_action_summary/module.py new file mode 100644 index 000000000..07a88ec38 --- /dev/null +++ b/modules/lo_action_summary/lo_action_summary/module.py @@ -0,0 +1,90 @@ +''' +Learning Observer Action Summary + +This module allows users to view a student's history of events for a specific reducer context. +''' +import learning_observer.downloads as d +import learning_observer.communication_protocol.query as q +from learning_observer.dash_integration import thirdparty_url, static_url +from learning_observer.stream_analytics.helpers import KeyField, Scope + +import lo_action_summary.reducers +import lo_action_summary.dash_dashboard + +# Name for the module +NAME = 'Learning Observer Action Summary' + +course_roster = q.call('learning_observer.courseroster') + +EXECUTION_DAG = { + 'execution_dag': { + 'roster': course_roster(runtime=q.parameter('runtime'), course_id=q.parameter('course_id', required=True)), + 'single_action_summary': q.select(q.keys('lo_action_summary.student_event_history', STUDENTS=q.parameter('student_id', required=True), STUDENTS_path='user_id'), fields=q.SelectFields.All), + }, + 'exports': { + 'roster': { + 'returns': 'roster', + 'parameters': ['course_id'], + }, + 'action_summary': { + 'returns': 'single_action_summary', + 'parameters': ['course_id', 'student_id'], + } + } +} + +# TODO we want the event history for both SBA and DA +# reducer_context = 'org.ets.sba' +reducer_context = 'org.ets.da' +REDUCERS = [ + { + 'context': reducer_context, + 'scope': Scope([KeyField.STUDENT]), + 'function': lo_action_summary.reducers.student_event_history, + 'default': {'events': []} + } +] + +''' +Define pages created with Dash. +''' +DASH_PAGES = [ + { + 'MODULE': lo_action_summary.dash_dashboard, + 'LAYOUT': lo_action_summary.dash_dashboard.layout, + 'ASSETS': 'assets', + 'TITLE': 'Learning Observer Action Summary', + 'DESCRIPTION': "This module allows users to view a student's history of events for a specific reducer context.", + 'SUBPATH': 'lo-action-summary', + 'CSS': [ + thirdparty_url('css/fontawesome_all.css') + ], + 'SCRIPTS': [ + static_url('liblo.js') + ] + } +] + +''' +Additional files we want included that come from a third part. +''' +THIRD_PARTY = { + 'css/fontawesome_all.css': d.FONTAWESOME_CSS, + 'webfonts/fa-solid-900.woff2': d.FONTAWESOME_WOFF2, + 'webfonts/fa-solid-900.ttf': d.FONTAWESOME_TTF +} + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': '/lo_action_summary/dash/lo-action-summary', + 'icon': { + 'type': 'fas', + 'icon': 'fa-play-circle' + } +}] diff --git a/modules/lo_action_summary/lo_action_summary/reducers.py b/modules/lo_action_summary/lo_action_summary/reducers.py new file mode 100644 index 000000000..d125fdb7b --- /dev/null +++ b/modules/lo_action_summary/lo_action_summary/reducers.py @@ -0,0 +1,14 @@ +from learning_observer.stream_analytics.helpers import student_event_reducer + +KEYS_TO_IGNORE = ['metadata', 'source', 'version', 'auth'] + + +@student_event_reducer(null_state={"events": []}) +async def student_event_history(event, internal_state): + ''' + An example of a per-student event counter + ''' + cleaned_event = {key: value for key, value in event['client'].items() if key not in KEYS_TO_IGNORE} + internal_state['events'].append(cleaned_event) + + return internal_state, internal_state diff --git a/modules/lo_action_summary/setup.cfg b/modules/lo_action_summary/setup.cfg new file mode 100644 index 000000000..701b6f893 --- /dev/null +++ b/modules/lo_action_summary/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = Learning Observer Action Summary +description = Use this as a base template for creating new modules on the Learning Observer. + +[options] +packages = lo_action_summary + +[options.entry_points] +lo_modules = + lo_action_summary = lo_action_summary.module diff --git a/modules/lo_action_summary/setup.py b/modules/lo_action_summary/setup.py new file mode 100644 index 000000000..4c65f7300 --- /dev/null +++ b/modules/lo_action_summary/setup.py @@ -0,0 +1,14 @@ +''' +Install script. Everything is handled in setup.cfg + +To set up locally for development, run `python setup.py develop`, in a +virtualenv, preferably. +''' +from setuptools import setup + +setup( + name="lo_action_summary", + package_data={ + 'lo_action_summary': ['assets/*'], + } +) diff --git a/modules/lo_dash_react_components/.babelrc b/modules/lo_dash_react_components/.babelrc new file mode 100644 index 000000000..06a8ae6b8 --- /dev/null +++ b/modules/lo_dash_react_components/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": ["@babel/preset-env", "@babel/preset-react"], + "env": { + "production": { + "plugins": ["@babel/plugin-proposal-object-rest-spread", "styled-jsx/babel"] + }, + "development": { + "plugins": ["@babel/plugin-proposal-object-rest-spread", "styled-jsx/babel"] + }, + "test": { + "plugins": ["@babel/plugin-proposal-object-rest-spread", "styled-jsx/babel-test"] + } + } +} diff --git a/modules/lo_dash_react_components/.gitignore b/modules/lo_dash_react_components/.gitignore new file mode 100644 index 000000000..8bd4c66bd --- /dev/null +++ b/modules/lo_dash_react_components/.gitignore @@ -0,0 +1,292 @@ +# Created by .ignore support plugin (hsz.mobi) +### Webpack +.build_cache +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +### SublimeText template +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + +# Julia manifest file names +Manifest.toml +JuliaManifest.toml + +src/LoDashReactComponents.jl +src/jl/ +man/ +R/ +deps/ +man/ +lo_dash_react_components/*.map +lo_dash_react_components/*.js +lo_dash_react_components/*.json +lo_dash_react_components/*.py +DESCRIPTION diff --git a/modules/lo_dash_react_components/MANIFEST.in b/modules/lo_dash_react_components/MANIFEST.in new file mode 100644 index 000000000..9d0565fb9 --- /dev/null +++ b/modules/lo_dash_react_components/MANIFEST.in @@ -0,0 +1,13 @@ +include lo_dash_react_components/lo_dash_react_components.min.js +include lo_dash_react_components/lo_dash_react_components.min.js.map +include lo_dash_react_components/async-*.js +include lo_dash_react_components/async-*.js.map +include lo_dash_react_components/*-shared.js +include lo_dash_react_components/*-shared.js.map +include lo_dash_react_components/metadata.json +include lo_dash_react_components/package-info.json +recursive-include lo_dash_react_components/css *.css +include README.md +include LICENSE +include package.json +include requirements.txt diff --git a/modules/lo_dash_react_components/README.md b/modules/lo_dash_react_components/README.md new file mode 100644 index 000000000..1db5654bb --- /dev/null +++ b/modules/lo_dash_react_components/README.md @@ -0,0 +1,82 @@ +# Dash/React Components + +## Building and Using Components in Dashboards + +In Learning Observer, we create React components and generate a Python package for them to be used with the Dash framework. These components are housed in the `lo_dash_react_components` module and enable the creation of highly customizable dashboards. This document guides you through the component development process, the build process, and using the components in your dashboards. + +### Pre-built installation + +These components take a bit of extra infrastructure to build (mainly `node`). If you just with to use these components, without developing new ones or changing current ones, we suggest installing the pre-built package available in the [LO Assets github repository](https://github.com/ETS-Next-Gen/lo_assets/tree/main/lo_dash_react_components). + +### Requirements + +TODO verify which Node versions work. Previously, we encountered dependency issues with newer versions of node. + +A downstream dependency in the build process may cause breaking behavior depending on your node version. The latest version of Node `v16` (tested on `v16.19.1`) should work fine; however, we've noticed errors on Node `v18`. + +If you already have `v18` on your system, install [nvm: Node Version Manager](https://github.com/nvm-sh/nvm). When running `nvm` commands within the project without specifying a specific version, it will automatically look for the version defined in the `.nvmrc` file in the root directory. + +To install and activate the node version, run: + +```bash +nvm install +nvm use +``` + +Any `npm` command will now use Node `v16`. +Next, make sure to change into the components directory and install all dependencies. +Note that all future `npm` commands should be ran from within the components directory. + +```bash +cd modules/lo_dash_react_components +npm install +``` + +### Component Development + +1. Create a React component file with the `.react.js` extension in the `src/lib/components` directory. Use a class structure instead of a function definition due to a limitation in the Dash auto-generation process. + - Remember to use class (not functional) syntax for compatibility with `dash`. + - Remember to use the setProps property whenever props change internally. This is used by `dash` to handle properly handle callbacks. +1. For component-specific CSS, create a file with the same name and an `.scss` extension in the `src/lib/components` directory. +1. If the component needs data, toss in a `.testdata.js` file in `src/lib/components`. Note that this file should not use modern JavaScript (we're limited to ES5.1, except for `export default`). +1. For `dash` to be aware of the component, it should be added to `src/lib/index.js` +1. Use `npm run start-all` to start the react, scss, and dash workflow (automatically refreshs on file change). +1. Run `npm run build` to generate the appropriate Python components (same as above, but without fresh capability). +1. (Optional) Package them into a distribution using the `build:python` command. + +### Build Process + +The build process provides various commands to build specific pieces or watch sets of files for changes, triggering auto-rebuilds. This includes converting SCSS files using SASS and building React to Dash Python packages. + +Here are some useful NPM scripts for building components: + +- `build:js`: Builds JavaScript files using Webpack in production mode. +- `build:backends`: Generates components in the `lo_dash_react_components` module. +- `build`: Builds CSS, JS, and backends. +- `build-css`: Converts SCSS files to CSS. +- `watch-css`: Watches and automatically rebuilds SCSS files. +- `build:python`: Cleans the `dist/` and `build/` directories and creates a Python distribution. + +### Using Components in Dashboards + +To use the components in your dashboards, simply import the desired component from the `lo_dash_react_components` module and use them as you would any other Dash component. + +```python +import lo_dash_react_components as lodrc + +ws_component = lodrc.LOConnection() +... +``` + +## Share + +We encourage you to share your components back with us. That way: + +- We're more likely to understand your use-case +- We're less likely to break them as we evolve the system. + +## Issues + +If ESLint errors block your app, edit `node_modules/react-dev-utils/webpackHotDevClient.js` to disable `handleWarnings`. See: + +https://stackoverflow.com/questions/48714225/how-to-not-show-warnings-in-create-react-app diff --git a/modules/lo_dash_react_components/index.html b/modules/lo_dash_react_components/index.html new file mode 100644 index 000000000..02c64a647 --- /dev/null +++ b/modules/lo_dash_react_components/index.html @@ -0,0 +1,10 @@ + + + + my-dash-component + + +
+ + + diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py new file mode 100644 index 000000000..70de0ef42 --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -0,0 +1,168 @@ +''' +This file creates an All-In-One component for the Learning +Observer server connection. This handles updating data from the +server (based on individual tree updates), storing any errors +that occured, and showing the time since it was last updated. +''' +from dash import html, dcc, clientside_callback, Output, Input, State, MATCH +import uuid + +from .LOConnection import LOConnection + +class LOConnectionAIO(html.Div): + class ids: + websocket = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'websocket', + 'aio_id': aio_id + } + connection_status = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'connection_status', + 'aio_id': aio_id + } + last_updated_store = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'last_updated_store', + 'aio_id': aio_id + } + last_updated_time = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'last_updated_time', + 'aio_id': aio_id + } + last_updated_interval = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'last_updated_interval', + 'aio_id': aio_id + } + ws_store = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'ws_store', + 'aio_id': aio_id + } + error_store = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'error_store', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None, data_scope=None): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + # Determine which state we are in + component = [ + html.I(id=self.ids.connection_status(aio_id)), + html.Span('Last Updated:', className='mx-1'), + html.Span(id=self.ids.last_updated_time(aio_id)), + dcc.Interval(id=self.ids.last_updated_interval(aio_id), interval=5000), + LOConnection(id=self.ids.websocket(aio_id), data_scope=data_scope), + dcc.Store(id=self.ids.last_updated_store(aio_id), data=-1), + dcc.Store(id=self.ids.ws_store(aio_id), data={}), + dcc.Store(id=self.ids.error_store(aio_id), data={}) + ] + super().__init__(component) + + # Update connection status information + clientside_callback( + '''function (status) { + const icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger']; + const titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server']; + if (status === undefined) { + return [icons[3], titles[3]]; + } + return [icons[status.readyState], titles[status.readyState]]; + } + ''', + Output(ids.connection_status(MATCH), 'className'), + Output(ids.connection_status(MATCH), 'title'), + Input(ids.websocket(MATCH), 'state'), + ) + + # Update connection last modified text + clientside_callback( + '''function (lastTime, intervals) { + if (lastTime === -1) { + return 'Never'; + } + const currTime = new Date(); + const secondDiff = (currTime.getTime() - lastTime.getTime())/1000 + if (secondDiff < 1) { + return 'just now' + } + const ms_since_last_message = rendertime2(secondDiff); + return `${ms_since_last_message} ago`; + } + ''', + Output(ids.last_updated_time(MATCH), 'children'), + Input(ids.last_updated_store(MATCH), 'data'), + Input(ids.last_updated_interval(MATCH), 'n_intervals') + ) + + # Update when the data was last modified + clientside_callback( + '''function (data) { + if (data !== undefined) { + return new Date(); + } + return window.dash_clientside.no_update; + }''', + Output(ids.last_updated_store(MATCH), 'data'), + Input(ids.websocket(MATCH), 'message') + ) + + # Handle incoming message from server + clientside_callback( + '''function (incomingMessage, currentData, errorStore) { + // console.log('LOConnection', incomingMessage, currentData, errorStore); + if (incomingMessage !== undefined) { + const messages = JSON.parse(incomingMessage.data); + messages.forEach(message => { + const pathKeys = message.path.split('.'); + let current = currentData; + + // Traverse the path to get to the right location + for (let i = 0; i < pathKeys.length - 1; i++) { + const key = pathKeys[i]; + if (!(key in current)) { + current[key] = {}; // Create path if it doesn't exist + } + current = current[key]; + } + + if ('error' in message.value) { + errorStore[message.path] = message.value; + } else { + delete errorStore[message.path]; + } + const finalKey = pathKeys[pathKeys.length - 1]; + if (message.op === 'update') { + if (current[finalKey] === undefined) { + current[finalKey] = {}; + } + if ('error' in message.value) { + current[finalKey]['error'] = message.value; + current[finalKey]['option_hash'] = message.value['option_hash']; + } else { + delete current[finalKey]['error']; + // Shallow merge using spread syntax + current[finalKey] = { + ...current[finalKey], // Existing data + ...message.value // New data (overwrites where necessary) + }; + } + } + }); + return [currentData, errorStore]; // Return updated data + } + return window.dash_clientside.no_update; + }''', + Output(ids.ws_store(MATCH), 'data'), + Output(ids.error_store(MATCH), 'data'), + Input(ids.websocket(MATCH), 'message'), + State(ids.ws_store(MATCH), 'data'), + State(ids.error_store(MATCH), 'data') + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py new file mode 100644 index 000000000..aeb8c4cea --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py @@ -0,0 +1,106 @@ +''' +This file creates an All-In-One component for the Learning +Observer server connection. This handles updating data from the +server and showing the time since it was last updated. + +TODO this file is still being used by the cookiecutter module. +This was replaced by LOConnectionAIO to utilize the new method +of updating data. +''' +from dash import html, dcc, clientside_callback, Output, Input, MATCH +import uuid + +from .LOConnection import LOConnection + +class LOConnectionStatusAIO(html.Div): + class ids: + websocket = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'websocket', + 'aio_id': aio_id + } + connection_status = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'connection_status', + 'aio_id': aio_id + } + last_updated_store = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'last_updated_store', + 'aio_id': aio_id + } + last_updated_time = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'last_updated_time', + 'aio_id': aio_id + } + last_updated_interval = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'last_updated_interval', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None, data_scope=None): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + # Determine which state we are in + component = [ + html.I(id=self.ids.connection_status(aio_id)), + html.Span('Last Updated:', className='mx-1'), + html.Span(id=self.ids.last_updated_time(aio_id)), + dcc.Interval(id=self.ids.last_updated_interval(aio_id), interval=5000), + LOConnection(id=self.ids.websocket(aio_id), data_scope=data_scope), + dcc.Store(id=self.ids.last_updated_store(aio_id), data=-1) + ] + super().__init__(component) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_status_icon'), + '''function (status) { + const icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger']; + const titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server']; + if (status === undefined) { + return [icons[3], titles[3]]; + } + return [icons[status.readyState], titles[status.readyState]]; + } + ''', + Output(ids.connection_status(MATCH), 'className'), + Output(ids.connection_status(MATCH), 'title'), + Input(ids.websocket(MATCH), 'state'), + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_text'), + '''function (lastTime, intervals) { + if (lastTime === -1) { + return 'Never'; + } + const currTime = new Date(); + const secDiff = (currTime.getTime() - lastTime.getTime())/1000 + if (secDiff < 1) { + return 'just now' + } + const ms_since_last_message = rendertime2(secDiff); + return `${ms_since_last_message} ago`; + } + ''', + Output(ids.last_updated_time(MATCH), 'children'), + Input(ids.last_updated_store(MATCH), 'data'), + Input(ids.last_updated_interval(MATCH), 'n_intervals') + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_store'), + '''function (data) { + if (data !== undefined) { + return new Date(); + } + return window.dash_clientside.no_update; + }''', + Output(ids.last_updated_store(MATCH), 'data'), + Input(ids.websocket(MATCH), 'message') + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py new file mode 100644 index 000000000..8025ba28f --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py @@ -0,0 +1,167 @@ +''' +This file creates an All-In-One component for the Learning +Observer server connection. This handles updating data from the +server (based on individual tree updates), storing any errors +that occured, and showing the time since it was last updated. +''' +from dash import html, dcc, clientside_callback, Output, Input, State, MATCH +import dash_bootstrap_components as dbc +import datetime +import uuid + +class LODocumentSourceSelectorAIO(dbc.Card): + class ids: + source_selector = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'source_selector', + 'aio_id': aio_id + } + assignment_wrapper = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'assignment_wrapper', + 'aio_id': aio_id + } + assignment_input = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'assignment_input', + 'aio_id': aio_id + } + datetime_wrapper = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'datetime_wrapper', + 'aio_id': aio_id + } + date_input = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'date_input', + 'aio_id': aio_id + } + timestamp_input = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'timestamp_input', + 'aio_id': aio_id + } + kwargs_store = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'kwargs_store', + 'aio_id': aio_id + } + apply = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'apply', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + + card_body = dbc.CardBody([ + dbc.Label('Source'), + dbc.RadioItems( + id=self.ids.source_selector(aio_id), + options={'latest': 'Latest Document', + 'assignment': 'Assignment', + 'timestamp': 'Specific Time'}, + inline=True, + value='latest'), + html.Div('Additional Arguments'), + html.Div([ + dbc.RadioItems(id=self.ids.assignment_input(aio_id)), + ], id=self.ids.assignment_wrapper(aio_id)), + html.Div([ + dbc.InputGroup([ + dcc.DatePickerSingle( + id=self.ids.date_input(aio_id), + date=datetime.date.today()), + dbc.Input( + type='time', + id=self.ids.timestamp_input(aio_id), + value=datetime.datetime.now().strftime("%H:%M")) + ]) + ], id=self.ids.datetime_wrapper(aio_id)), + dbc.Button('Apply', id=self.ids.apply(aio_id), class_name='mt-1', n_clicks=0), + dcc.Store(id=self.ids.kwargs_store(aio_id), data={'src': 'latest'}) + ]) + component = [ + dbc.CardHeader('Document Source'), + card_body + ] + super().__init__(component) + + # Update data + clientside_callback( + '''function (clicks, src, assignment, date, time) { + if (clicks === 0) { return window.dash_clientside.no_update; } + let kwargs = {}; + if (src === 'assignment') { + kwargs.assignment = assignment; + } else if (src === 'timestamp') { + kwargs.requested_timestamp = new Date(`${date}T${time}`).getTime().toString() + } + return {src, kwargs}; + } + ''', + Output(ids.kwargs_store(MATCH), 'data'), + Input(ids.apply(MATCH), 'n_clicks'), + State(ids.source_selector(MATCH), 'value'), + State(ids.assignment_input(MATCH), 'value'), + State(ids.date_input(MATCH), 'date'), + State(ids.timestamp_input(MATCH), 'value'), + ) + + clientside_callback( + '''function (src) { + if (src === 'assignment') { + return ['d-none', '']; + } else if (src === 'timestamp') { + return ['', 'd-none'] + } + return ['d-none', 'd-none']; + } + ''', + Output(ids.datetime_wrapper(MATCH), 'className'), + Output(ids.assignment_wrapper(MATCH), 'className'), + Input(ids.source_selector(MATCH), 'value'), + ) + + clientside_callback( + '''async function (id, hash) { + if (hash.length === 0) { return window.dash_clientside.no_update; } + const decoded = decode_string_dict(hash.slice(1)); + if (!decoded.course_id) { return window.dash_clientside.no_update; } + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/google/course_work/${decoded.course_id}`); + const data = await response.json(); + const options = data.courseWork.map(function (item) { + return { label: item.title, value: item.id }; + }); + return options; + } + ''', + Output(ids.assignment_input(MATCH), 'options'), + Input(ids.source_selector(MATCH), 'id'), + Input('_pages_location', 'hash'), + ) + + clientside_callback( + '''function (src, assignment, date, time, current) { + if (src === 'assignment' & (assignment === undefined | current.kwargs?.assignment === assignment)) { + return true; + } + if (src === 'timestamp' & current.kwargs?.requested_timestamp === new Date(`${date}T${time}`).getTime().toString()) { + return true; + } + if (src === 'latest' & current.src === 'latest') { return true; } + return false; + } + ''', + Output(ids.apply(MATCH), 'disabled'), + Input(ids.source_selector(MATCH), 'value'), + Input(ids.assignment_input(MATCH), 'value'), + Input(ids.date_input(MATCH), 'date'), + Input(ids.timestamp_input(MATCH), 'value'), + Input(ids.kwargs_store(MATCH), 'data'), + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py new file mode 100644 index 000000000..0205429c3 --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py @@ -0,0 +1,82 @@ +''' +This file creates an All-In-One component for a sidebar +component that allows users to navigate throughout the platform. +The sidebar shows a Home and Logout button as well as a list +of available dashboards. +''' +from dash import html, clientside_callback, Output, Input, State, MATCH +import dash_bootstrap_components as dbc +import uuid + +class ProfileSidebarAIO(html.Div): + class ids: + toggle_open = lambda aio_id: { + 'component': 'ProfileSidebarAIO', + 'subcomponent': 'toggle_open', + 'aio_id': aio_id + } + offcanvas = lambda aio_id: { + 'component': 'ProfileSidebarAIO', + 'subcomponent': 'offcanvas', + 'aio_id': aio_id + } + modules = lambda aio_id: { + 'component': 'ProfileSidebarAIO', + 'subcomponent': 'module_list', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None, class_name='', color='primary'): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + component = [ + dbc.Button(html.I(className='fas fa-user'), id=self.ids.toggle_open(aio_id), color=color, class_name=class_name), + dbc.Offcanvas([ + dbc.Button([html.I(className='fas fa-home me-1'), 'Home'], href='/', external_link=True), + html.H4('Modules'), + html.Ul(id=self.ids.modules(aio_id)), + dbc.Button([html.I(className='fas fa-right-from-bracket me-1'), 'Logout'], color='danger', href='/auth/logout', external_link=True), + ], title='Profile', id=self.ids.offcanvas(aio_id), placement='end') + ] + super().__init__(component) + + # Toggle sidebar + clientside_callback( + '''function (clicks, isOpen) { + if (clicks > 0) { return !isOpen; } + return isOpen; + } + ''', + Output(ids.offcanvas(MATCH), 'is_open'), + Input(ids.toggle_open(MATCH), 'n_clicks'), + State(ids.offcanvas(MATCH), 'is_open') + ) + + # Update available dashboard items + clientside_callback( + # TODO include the course_id in these - will need to parse it out of the current string + '''async function (empty) { + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/webapi/course_dashboards`); + + const modules = await response.json(); + const items = modules.map((x) => { + const link = { + namespace: 'dash_html_components', + type: 'A', + props: { children: x.name, href: x.url + window.location.hash } + } + return { + namespace: 'dash_html_components', + type: 'Li', + props: { children: link } + } + }) + return items; + } + ''', + Output(ids.modules(MATCH), 'children'), + Input(ids.modules(MATCH), 'className'), + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/__init__.py b/modules/lo_dash_react_components/lo_dash_react_components/__init__.py new file mode 100644 index 000000000..f22c1ea64 --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/__init__.py @@ -0,0 +1,94 @@ +from __future__ import print_function as _ + +import os as _os +import sys as _sys +import json + +import dash as _dash + +# noinspection PyUnresolvedReferences +from ._imports_ import * +from ._imports_ import __all__ + +from .LOConnectionStatusAIO import LOConnectionStatusAIO +from .LOConnectionAIO import LOConnectionAIO +from .LODocumentSourceSelectorAIO import LODocumentSourceSelectorAIO +from .ProfileSidebarAIO import ProfileSidebarAIO + +if not hasattr(_dash, '__plotly_dash') and not hasattr(_dash, 'development'): + print('Dash was not successfully imported. ' + 'Make sure you don\'t have a file ' + 'named \n"dash.py" in your current directory.', file=_sys.stderr) + _sys.exit(1) + +_basepath = _os.path.dirname(__file__) +_filepath = _os.path.abspath(_os.path.join(_basepath, 'package-info.json')) +with open(_filepath) as f: + package = json.load(f) + +package_name = package['name'].replace(' ', '_').replace('-', '_') +__version__ = package['version'] + +_current_path = _os.path.dirname(_os.path.abspath(__file__)) + +_this_module = _sys.modules[__name__] + +async_resources = [] + +_js_dist = [] + +_js_dist.extend( + [ + { + "relative_package_path": "async-{}.js".format(async_resource), + "external_url": ( + "https://unpkg.com/{0}@{2}" + "/{1}/async-{3}.js" + ).format(package_name, __name__, __version__, async_resource), + "namespace": package_name, + "async": True, + } + for async_resource in async_resources + ] +) + +# TODO: Figure out if unpkg link works +_js_dist.extend( + [ + { + "relative_package_path": "async-{}.js.map".format(async_resource), + "external_url": ( + "https://unpkg.com/{0}@{2}" + "/{1}/async-{3}.js.map" + ).format(package_name, __name__, __version__, async_resource), + "namespace": package_name, + "dynamic": True, + } + for async_resource in async_resources + ] +) + +_js_dist.extend( + [ + { + 'relative_package_path': 'lo_dash_react_components.min.js', + 'namespace': package_name + }, + { + 'relative_package_path': 'lo_dash_react_components.min.js.map', + 'namespace': package_name, + 'dynamic': True + } + ] +) + +_css_dist = [ + { + 'relative_package_path': _os.path.relpath(_os.path.join(dirpath, filename), _basepath), + 'namespace': package_name + } for dirpath, dirnames, filenames in _os.walk(_os.path.join(_basepath, 'css')) for filename in filenames +] + +for _component in __all__: + setattr(locals()[_component], '_js_dist', _js_dist) + setattr(locals()[_component], '_css_dist', _css_dist) diff --git a/modules/lo_dash_react_components/nodemon.json b/modules/lo_dash_react_components/nodemon.json new file mode 100644 index 000000000..3c0255d06 --- /dev/null +++ b/modules/lo_dash_react_components/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["src/lib/components", "src/*.scss", "src/*.css", "src/*.js", "usage.py"], + "exec": "npm run build && python usage.py" +} diff --git a/modules/lo_dash_react_components/package-lock.json b/modules/lo_dash_react_components/package-lock.json new file mode 100644 index 000000000..e9c4b7c64 --- /dev/null +++ b/modules/lo_dash_react_components/package-lock.json @@ -0,0 +1,20550 @@ +{ + "name": "lo_dash_react_components", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lo_dash_react_components", + "version": "0.0.1", + "license": "AGPL-3.0", + "dependencies": { + "bootstrap": "^5.3.3", + "ramda": "^0.30.1", + "react": "^18.3.1", + "react-bootstrap": "^2.10.5", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "react-scripts": "^5.0.1", + "react-tooltip": "^5.28.0", + "recharts": "^2.13.3", + "web-vitals": "^4.2.4" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/preset-env": "^7.26.0", + "@babel/preset-react": "^7.25.9", + "@plotly/webpack-dash-dynamic-import": "^1.3.0", + "autoprefixer": "^10.4.20", + "babel-loader": "^9.2.1", + "css-loader": "^7.1.2", + "eslint-config-prettier": "^9.1.0", + "eslint-config-standard": "^17.1.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-standard": "^4.1.0", + "nodemon": "^3.1.7", + "npm-run-all": "^4.1.5", + "prettier": "^3.3.3", + "react-docgen": "^5.4.3", + "sass": "^1.80.6", + "style-loader": "^4.0.0", + "styled-jsx": "^5.1.6", + "tailwindcss": "^3.4.14", + "webpack": "^5.96.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", + "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.25.9.tgz", + "integrity": "sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==", + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", + "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", + "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.10" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.25.9.tgz", + "integrity": "sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-decorators": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", + "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz", + "integrity": "sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.26.0.tgz", + "integrity": "sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", + "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", + "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", + "license": "MIT", + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.25.9.tgz", + "integrity": "sha512-/VVukELzPDdci7UUsWQaSkhgnjIWXnIyRpM02ldxaVoFK96c41So8JcKT3m0gYjyv7j5FNPGS5vfELrWalkbDA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-flow": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.25.9.tgz", + "integrity": "sha512-Ncw2JFsJVuvfRsa2lSHiC55kETQVLSnsYGQ1JDDwkUeWGTL/8Tom8aLTnlqgoeuopWrbbGndrc9AlLYrIosrow==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz", + "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", + "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz", + "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz", + "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.25.9.tgz", + "integrity": "sha512-nZp7GlEl+yULJrClz0SwHPqir3lc0zsPrDHQUcxGspSL7AKrexNSEfTbfqnDNJUO13bgKyfuOLMF8Xqtu8j3YQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", + "integrity": "sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.25.9.tgz", + "integrity": "sha512-D3to0uSPiWE7rBrdIICCd0tJSIGpLaaGptna2+w7Pft5xMqLpA1sz99DK5TZ1TjGbdQ/VI1eCSZ06dv3lT4JOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-transform-react-display-name": "^7.25.9", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/plugin-transform-react-jsx-development": "^7.25.9", + "@babel/plugin-transform-react-pure-annotations": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", + "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "license": "MIT" + }, + "node_modules/@csstools/normalize.css": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", + "integrity": "sha512-YAYeJ+Xqh7fUou1d1j9XHl44BmsuThiTr4iNrgCQ3J27IbhXsxXDGZ1cXv8Qvs99d4rBbLiSKy3+WZiet32PcQ==", + "license": "CC0-1.0" + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", + "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.2", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", + "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "license": "CC0-1.0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.10" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", + "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", + "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", + "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", + "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", + "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", + "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "license": "CC0-1.0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.10" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", + "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", + "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", + "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", + "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", + "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", + "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", + "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", + "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/reporters/node_modules/v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "license": "ISC", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@jest/reporters/node_modules/v8-to-istanbul/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@jest/transform/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.0.tgz", + "integrity": "sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.5.0.tgz", + "integrity": "sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@plotly/webpack-dash-dynamic-import": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@plotly/webpack-dash-dynamic-import/-/webpack-dash-dynamic-import-1.3.0.tgz", + "integrity": "sha512-JuleFNu/DqpzjYABv54j2eJ9+bCUut2EjTrEbyPCvAnjdhabOLnJmGl3Tf6ChDadIfao2Q5yH/R1K2q6dElroQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", + "integrity": "sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ==", + "license": "MIT", + "dependencies": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.6.tgz", + "integrity": "sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.8.0.tgz", + "integrity": "sha512-xJEOXUOTmT4FngTmhdjKFRrVVF0hwCLNPdatLCHkyS4dkiSK12cEu1Y0fjxktjJrdst9jJIc5J6ihMJCoWEN/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@popperjs/core": "^2.11.6", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.4.9", + "@types/warning": "^3.0.0", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz", + "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==", + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "license": "MIT", + "dependencies": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.12.6" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", + "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "deepmerge": "^4.2.2", + "svgo": "^1.2.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-svgo/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/webpack": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", + "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/preset-env": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@svgr/core": "^5.5.0", + "@svgr/plugin-jsx": "^5.5.0", + "@svgr/plugin-svgo": "^5.5.0", + "loader-utils": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "8.56.12", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", + "integrity": "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz", + "integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz", + "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", + "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prettier": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", + "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "license": "MIT" + }, + "node_modules/@types/q": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz", + "integrity": "sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "16.0.9", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz", + "integrity": "sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", + "integrity": "sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "license": "ISC" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz", + "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.reduce": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", + "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz", + "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-loader": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-named-asset-import": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", + "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-transform-react-remove-prop-types": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", + "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==", + "license": "MIT" + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-react-app": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", + "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-decorators": "^7.16.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-numeric-separator": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", + "@babel/plugin-proposal-private-methods": "^7.16.0", + "@babel/plugin-transform-flow-strip-types": "^7.16.0", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "babel-plugin-macros": "^3.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "license": "MIT" + }, + "node_modules/bfj": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", + "integrity": "sha512-I6MMLkn+anzNdCUp9hMRyui1HaNEUCco50lxbvNS4+EyXg8lN3nJ48PjPWtbH8UVS9CuMoaKE9U2V3l29DaRQw==", + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2", + "check-types": "^11.2.3", + "hoopy": "^0.1.4", + "jsonpath": "^1.1.1", + "tryer": "^1.0.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bootstrap": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "license": "BSD-2-Clause" + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/builtins": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.1.0.tgz", + "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/builtins/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c8": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-7.14.0.tgz", + "integrity": "sha512-i04rtkkcNcCf7zsQcSv/T9EbUn4RXQ6mropeMcjFOsQXQ0iGLAr/xT6TImQg4+U9hmNpN9XdvPkjUL1IzbgxJw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^2.0.0", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-reports": "^3.1.4", + "rimraf": "^3.0.2", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-types": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", + "integrity": "sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "license": "MIT", + "dependencies": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/coa/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/coa/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/coa/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/coa/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", + "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", + "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.39.0.tgz", + "integrity": "sha512-7fEcWwKI4rJinnK+wLTezeg2smbFFdSBP6E2kQZNbnzM2s1rpKQ6aaRteZSSg7FLU3P0HGGVo/gbpfanU36urg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-blank-pseudo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", + "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-blank-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", + "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", + "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-has-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "license": "MIT", + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", + "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", + "license": "CC0-1.0", + "bin": { + "css-prefers-color-scheme": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssdb": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.11.2.tgz", + "integrity": "sha512-lhQ32TFkc1X4eTefGfYPvgovRSzIMofHkigfH8nWtyRL4XJLsRhJFreRvEgKzept7x1rjBuy3J/MurXLaFxW/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "CC0-1.0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", + "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "license": "MIT", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/csso/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "license": "MIT", + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "license": "MIT", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.63", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz", + "integrity": "sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==", + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", + "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz", + "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.3", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-compat-utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-config-react-app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", + "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/eslint-parser": "^7.16.3", + "@rushstack/eslint-patch": "^1.1.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "babel-preset-react-app": "^10.0.1", + "confusing-browser-globals": "^1.0.11", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.3.0", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.0" + } + }, + "node_modules/eslint-config-standard": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-promise": "^6.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", + "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-flowtype": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", + "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", + "license": "BSD-3-Clause", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@babel/plugin-syntax-flow": "^7.14.5", + "@babel/plugin-transform-react-jsx": "^7.14.9", + "eslint": "^8.1.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-n": { + "version": "16.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.6.2.tgz", + "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "builtins": "^5.0.1", + "eslint-plugin-es-x": "^7.5.0", + "get-tsconfig": "^4.7.0", + "globals": "^13.24.0", + "ignore": "^5.2.4", + "is-builtin-module": "^3.2.1", + "is-core-module": "^2.12.1", + "minimatch": "^3.1.2", + "resolve": "^1.22.2", + "semver": "^7.5.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-n/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-node": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", + "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-es": "^3.0.0", + "eslint-utils": "^2.0.0", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "peerDependencies": { + "eslint": ">=5.16.0" + } + }, + "node_modules/eslint-plugin-promise": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", + "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz", + "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.1.0", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.0", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.11", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-standard": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-4.1.0.tgz", + "integrity": "sha512-ZL7+QRixjTR6/528YNGyDotyffm5OQst/sGxKDwGb9Uqs4In5Egi4+jbobhqJoyoCM6/7v/1A5fhQ7ScMtDjaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, + "node_modules/eslint-plugin-testing-library": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.1.tgz", + "integrity": "sha512-5eX9e1Kc2PqVRed3taaLnAAqPZGEX75C+M/rXzUAI3wIg/ZxzUm1OVAwfe/O+vE+6YXOLetSe9g5GKD2ecXipw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^5.58.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0", + "npm": ">=6" + }, + "peerDependencies": { + "eslint": "^7.5.0 || ^8.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-webpack-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", + "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", + "license": "MIT", + "dependencies": { + "@types/eslint": "^7.29.0 || ^8.4.1", + "jest-worker": "^28.0.2", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0", + "webpack": "^5.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/jest-worker": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", + "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-to-babel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/estree-to-babel/-/estree-to-babel-3.2.1.tgz", + "integrity": "sha512-YNF+mZ/Wu2FU/gvmzuWtYc8rloubL7wfXCTgouFrnjGVXPA/EeYYA7pupXWrb3Iv1cTBeSSxxJIbK23l4MRNqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.1.6", + "@babel/types": "^7.2.0", + "c8": "^7.6.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", + "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "license": "ISC" + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", + "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "license": "MIT" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "license": "MIT" + }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "license": "(Apache-2.0 OR MPL-1.1)" + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", + "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "license": "MIT", + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "license": "MIT", + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-network-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", + "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.3.tgz", + "integrity": "sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "license": "MIT", + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "license": "MIT", + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "license": "MIT", + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watch-typeahead": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", + "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "license": "MIT", + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-watch-typeahead/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "license": "MIT", + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "license": "MIT", + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", + "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "license": "MIT", + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", + "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", + "license": "MIT", + "dependencies": { + "esprima": "1.2.2", + "static-eval": "2.0.2", + "underscore": "1.12.1" + } + }, + "node_modules/jsonpath/node_modules/esprima": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", + "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/launch-editor": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", + "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", + "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-dir": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", + "integrity": "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.2" + }, + "engines": { + "node": ">= 0.10.5" + } + }, + "node_modules/node-forge": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", + "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", + "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/npm-run-all/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-all/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/npm-run-all/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/npm-run-all/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz", + "integrity": "sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", + "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", + "license": "MIT", + "dependencies": { + "array.prototype.reduce": "^1.0.6", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "gopd": "^1.0.1", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/pkg-dir/node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", + "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-browser-comments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", + "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "license": "CC0-1.0", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "browserslist": ">=4", + "postcss": ">=8" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-calc/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", + "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", + "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", + "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-custom-media": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", + "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-custom-properties": { + "version": "12.1.11", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.11.tgz", + "integrity": "sha512-0IDJYhgU8xDv1KY6+VgUwuQkVtmYzRwu+dMjnmdMafXYv86SWqfxkc7qdDvWS38vsjaEtv8e0vGOUQrAiMBLpQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", + "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", + "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", + "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-env-function": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", + "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-flexbugs-fixes": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", + "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-focus-within": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", + "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", + "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-image-set-function": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", + "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", + "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-lab-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", + "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/postcss-logical": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", + "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-media-minmax": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", + "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "license": "MIT", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.1.0.tgz", + "integrity": "sha512-rm0bdSv4jC3BDma3s9H19ZddW0aHX6EoqwDYU2IfZhRN+53QrufTRo2IdkAbRqLx4R2IYbZnbjKKxg4VN5oU9Q==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-nesting": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", + "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", + "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "license": "CC0-1.0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.10" + } + }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-normalize": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", + "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/normalize.css": "*", + "postcss-browser-comments": "^4", + "sanitize.css": "*" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "browserslist": ">= 4", + "postcss": ">= 8" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "license": "MIT", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.3.tgz", + "integrity": "sha512-An6Ba4pHBiDtyVpSLymUUERMo2cU7s+Obz6BTrS+gxkbnSBNKSuD0AVUc+CpBMrpVPKKfoVz0WQCX+Tnst0i4A==", + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "license": "MIT", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", + "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", + "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-preset-env": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.3.tgz", + "integrity": "sha512-T1LgRm5uEVFSEF83vHZJV2z19lHg4yJuZ6gXZZkqVsqv63nlr6zabMH3l4Pc01FQCyfWVrh2GaUeCVy9Po+Aag==", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-cascade-layers": "^1.1.1", + "@csstools/postcss-color-function": "^1.1.1", + "@csstools/postcss-font-format-keywords": "^1.0.1", + "@csstools/postcss-hwb-function": "^1.0.2", + "@csstools/postcss-ic-unit": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^2.0.7", + "@csstools/postcss-nested-calc": "^1.0.0", + "@csstools/postcss-normalize-display-values": "^1.0.1", + "@csstools/postcss-oklab-function": "^1.1.1", + "@csstools/postcss-progressive-custom-properties": "^1.3.0", + "@csstools/postcss-stepped-value-functions": "^1.0.1", + "@csstools/postcss-text-decoration-shorthand": "^1.0.0", + "@csstools/postcss-trigonometric-functions": "^1.0.2", + "@csstools/postcss-unset-value": "^1.0.2", + "autoprefixer": "^10.4.13", + "browserslist": "^4.21.4", + "css-blank-pseudo": "^3.0.3", + "css-has-pseudo": "^3.0.4", + "css-prefers-color-scheme": "^6.0.3", + "cssdb": "^7.1.0", + "postcss-attribute-case-insensitive": "^5.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^4.2.4", + "postcss-color-hex-alpha": "^8.0.4", + "postcss-color-rebeccapurple": "^7.1.1", + "postcss-custom-media": "^8.0.2", + "postcss-custom-properties": "^12.1.10", + "postcss-custom-selectors": "^6.0.3", + "postcss-dir-pseudo-class": "^6.0.5", + "postcss-double-position-gradients": "^3.1.2", + "postcss-env-function": "^4.0.6", + "postcss-focus-visible": "^6.0.4", + "postcss-focus-within": "^5.0.4", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^3.0.5", + "postcss-image-set-function": "^4.0.7", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^4.2.1", + "postcss-logical": "^5.0.4", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^10.2.0", + "postcss-opacity-percentage": "^1.1.2", + "postcss-overflow-shorthand": "^3.0.4", + "postcss-page-break": "^3.0.4", + "postcss-place": "^7.0.5", + "postcss-pseudo-class-any-link": "^7.1.6", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", + "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", + "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz", + "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/postcss-svgo/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.10.0.tgz", + "integrity": "sha512-KSKHEbjAnpUuAUserOq0FxGXCUrzC3WniuSJhvdbs102rL55266ZcHBqLWOsG30spQMlPdpy7icATiAQehg/iA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/ramda": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", + "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-app-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", + "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "license": "MIT", + "dependencies": { + "core-js": "^3.19.2", + "object-assign": "^4.1.1", + "promise": "^8.1.0", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.9", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-app-polyfill/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/react-bootstrap": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz", + "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.6.9", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-docgen": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", + "integrity": "sha512-xlLJyOlnfr8lLEEeaDZ+X2J/KJoe6Nr9AzxnkdQWush5hz2ZSu66w6iLMOScMmxoSHWpWMn+k3v5ZiyCfcWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.7.5", + "@babel/generator": "^7.12.11", + "@babel/runtime": "^7.7.6", + "ast-types": "^0.14.2", + "commander": "^2.19.0", + "doctrine": "^3.0.0", + "estree-to-babel": "^3.1.0", + "neo-async": "^2.6.1", + "node-dir": "^0.1.10", + "strip-indent": "^3.0.0" + }, + "bin": { + "react-docgen": "bin/react-docgen.js" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", + "license": "MIT" + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", + "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", + "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0", + "react-router": "6.28.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-scripts": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", + "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", + "@svgr/webpack": "^5.5.0", + "babel-jest": "^27.4.2", + "babel-loader": "^8.2.3", + "babel-plugin-named-asset-import": "^0.3.8", + "babel-preset-react-app": "^10.0.1", + "bfj": "^7.0.2", + "browserslist": "^4.18.1", + "camelcase": "^6.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "css-loader": "^6.5.1", + "css-minimizer-webpack-plugin": "^3.2.0", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "eslint": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-webpack-plugin": "^3.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.0.0", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.3", + "jest-resolve": "^27.4.2", + "jest-watch-typeahead": "^1.0.0", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.4", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-loader": "^6.2.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^7.0.1", + "prompts": "^2.4.2", + "react-app-polyfill": "^3.0.0", + "react-dev-utils": "^12.0.1", + "react-refresh": "^0.11.0", + "resolve": "^1.20.0", + "resolve-url-loader": "^4.0.0", + "sass-loader": "^12.3.0", + "semver": "^7.3.5", + "source-map-loader": "^3.0.0", + "style-loader": "^3.3.1", + "tailwindcss": "^3.0.2", + "terser-webpack-plugin": "^5.2.5", + "webpack": "^5.64.4", + "webpack-dev-server": "^4.6.0", + "webpack-manifest-plugin": "^4.0.2", + "workbox-webpack-plugin": "^6.4.1" + }, + "bin": { + "react-scripts": "bin/react-scripts.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + }, + "peerDependencies": { + "react": ">= 16", + "typescript": "^3.2.1 || ^4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/react-scripts/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/react-scripts/node_modules/babel-loader": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.4.1.tgz", + "integrity": "sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==", + "license": "MIT", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.4", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/react-scripts/node_modules/babel-loader/node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/react-scripts/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-scripts/node_modules/css-loader": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", + "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/react-scripts/node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/react-scripts/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-scripts/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-scripts/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-scripts/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/react-scripts/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-scripts/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-scripts/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-scripts/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-scripts/node_modules/postcss-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/react-scripts/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-scripts/node_modules/style-loader": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", + "integrity": "sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/react-scripts/node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/react-scripts/node_modules/webpack-dev-server": { + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", + "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.4", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/react-scripts/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", + "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-tooltip": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz", + "integrity": "sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.1", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.3.tgz", + "integrity": "sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", + "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", + "integrity": "sha512-TVILVSz2jY5D47F4mA4MppkBrafEaiUWJO/TcZHEIuI13AqoZMkK1WMA4Om1YkYbTx+9Ki1/tSUXbceyr9saRg==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/regexpu-core": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.1.1.tgz", + "integrity": "sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.11.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.2.tgz", + "integrity": "sha512-3OGZZ4HoLJkkAZx/48mTXJNlmqTGOzc0o9OWQPuWpkOlXXPbyN6OafCcoXUnBqE2D3f/T5L+pWc1kdEmnfnRsA==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-url-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", + "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^7.0.35", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=8.9" + }, + "peerDependencies": { + "rework": "1.0.1", + "rework-visit": "1.0.0" + }, + "peerDependenciesMeta": { + "rework": { + "optional": true + }, + "rework-visit": { + "optional": true + } + } + }, + "node_modules/resolve-url-loader/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/resolve-url-loader/node_modules/picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "license": "ISC" + }, + "node_modules/resolve-url-loader/node_modules/postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "license": "MIT", + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", + "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize.css": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", + "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==", + "license": "CC0-1.0" + }, + "node_modules/sass": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.81.0.tgz", + "integrity": "sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "license": "MIT", + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "license": "ISC" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "license": "ISC" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", + "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", + "license": "MIT", + "dependencies": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "license": "MIT" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/static-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", + "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "license": "MIT", + "dependencies": { + "escodegen": "^1.8.1" + } + }, + "node_modules/static-eval/node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/static-eval/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/static-eval/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/static-eval/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/static-eval/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/static-eval/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-eval/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", + "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "regexp.prototype.flags": "^1.5.2", + "set-function-name": "^2.0.2", + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/stylehacks/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "license": "MIT" + }, + "node_modules/svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/svgo/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/svgo/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/svgo/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/svgo/node_modules/css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "node_modules/svgo/node_modules/css-what": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", + "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/svgo/node_modules/domutils/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause" + }, + "node_modules/svgo/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/svgo/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/svgo/node_modules/nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "~1.0.0" + } + }, + "node_modules/svgo/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz", + "integrity": "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", + "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/throat": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", + "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==", + "license": "MIT" + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "license": "MIT" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", + "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "license": "MIT", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.96.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", + "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", + "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.6.0", + "mime-types": "^2.1.31", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/memfs": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.14.0.tgz", + "integrity": "sha512-JUeY0F/fQZgIod31Ja1eJgiSxLn7BfQlCnqhwXFBzFHEw63OdLK7VJUJ7bnzNsWgCyoUP5tEp1VRY8rDaYzqOA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz", + "integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.21.2", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.7", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-manifest-plugin": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", + "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "license": "MIT", + "dependencies": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "webpack": "^4.44.2 || ^5.47.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "license": "MIT", + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.4.tgz", + "integrity": "sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==", + "license": "MIT", + "dependencies": { + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-background-sync": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", + "integrity": "sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==", + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz", + "integrity": "sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-build": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.6.0.tgz", + "integrity": "sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==", + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.11.1", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-replace": "^2.4.1", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "6.6.0", + "workbox-broadcast-update": "6.6.0", + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-google-analytics": "6.6.0", + "workbox-navigation-preload": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-range-requests": "6.6.0", + "workbox-recipes": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0", + "workbox-streams": "6.6.0", + "workbox-sw": "6.6.0", + "workbox-window": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "license": "MIT", + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/workbox-build/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" + }, + "node_modules/workbox-build/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.6.0.tgz", + "integrity": "sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==", + "deprecated": "workbox-background-sync@6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.6.0.tgz", + "integrity": "sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==", + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.6.0.tgz", + "integrity": "sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==", + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz", + "integrity": "sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==", + "deprecated": "It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained", + "license": "MIT", + "dependencies": { + "workbox-background-sync": "6.6.0", + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.6.0.tgz", + "integrity": "sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-precaching": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.6.0.tgz", + "integrity": "sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.6.0.tgz", + "integrity": "sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-recipes": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.6.0.tgz", + "integrity": "sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==", + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-routing": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.6.0.tgz", + "integrity": "sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-strategies": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.6.0.tgz", + "integrity": "sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-streams": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.6.0.tgz", + "integrity": "sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0" + } + }, + "node_modules/workbox-sw": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.6.0.tgz", + "integrity": "sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==", + "license": "MIT" + }, + "node_modules/workbox-webpack-plugin": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.6.0.tgz", + "integrity": "sha512-xNZIZHalboZU66Wa7x1YkjIqEy1gTR+zPM+kjrYJzqN7iurYZBctBLISyScjhkJKYuRrZUP0iqViZTh8rS0+3A==", + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "^2.1.0", + "pretty-bytes": "^5.4.1", + "upath": "^1.2.0", + "webpack-sources": "^1.4.3", + "workbox-build": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": "^4.4.0 || ^5.9.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "license": "MIT", + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "node_modules/workbox-window": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.6.0.tgz", + "integrity": "sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "6.6.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "license": "Apache-2.0" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/modules/lo_dash_react_components/package.json b/modules/lo_dash_react_components/package.json new file mode 100644 index 000000000..fc12ef115 --- /dev/null +++ b/modules/lo_dash_react_components/package.json @@ -0,0 +1,75 @@ +{ + "name": "lo_dash_react_components", + "version": "0.0.1", + "description": "React and Dash components for Learning Observer dashboards", + "main": "build/index.js", + "scripts": { + "webpack-start": "webpack serve --config ./webpack.serve.config.js --open", + "react-start": "react-scripts start", + "dash-start": "nodemon", + "build:js": "webpack --mode production", + "build:backends": "dash-generate-components ./src/lib/components lo_dash_react_components -p package-info.json --r-prefix '' --jl-prefix '' --ignore \\.test\\.", + "build": "npm run build-css && npm run build:js && npm run build:backends", + "build-css": "sass src/lib:lo_dash_react_components/css --no-source-map", + "watch-css": "sass src/lib:lo_dash_react_components/css --watch", + "start-all": "npm-run-all --parallel watch-css react-start webpack-start dash-start", + "clean-build:python": "rm -rf dist/ && rm -rf build/", + "build:python": "npm run clean-build:python && npm run build && python setup.py sdist bdist_wheel" + }, + "author": "Piotr Mitros ", + "license": "AGPL-3.0", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "bootstrap": "^5.3.3", + "ramda": "^0.30.1", + "react": "^18.3.1", + "react-bootstrap": "^2.10.5", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "react-scripts": "^5.0.1", + "react-tooltip": "^5.28.0", + "recharts": "^2.13.3", + "web-vitals": "^4.2.4" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/preset-env": "^7.26.0", + "@babel/preset-react": "^7.25.9", + "@plotly/webpack-dash-dynamic-import": "^1.3.0", + "autoprefixer": "^10.4.20", + "babel-loader": "^9.2.1", + "css-loader": "^7.1.2", + "eslint-config-prettier": "^9.1.0", + "eslint-config-standard": "^17.1.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-standard": "^4.1.0", + "nodemon": "^3.1.7", + "npm-run-all": "^4.1.5", + "prettier": "^3.3.3", + "react-docgen": "^5.4.3", + "sass": "^1.80.6", + "style-loader": "^4.0.0", + "styled-jsx": "^5.1.6", + "tailwindcss": "^3.4.14", + "webpack": "^5.96.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.1" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/modules/lo_dash_react_components/public/index.html b/modules/lo_dash_react_components/public/index.html new file mode 100644 index 000000000..aa069f27c --- /dev/null +++ b/modules/lo_dash_react_components/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/modules/lo_dash_react_components/pytest.ini b/modules/lo_dash_react_components/pytest.ini new file mode 100644 index 000000000..b6302a609 --- /dev/null +++ b/modules/lo_dash_react_components/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests/ +addopts = -rsxX -vv +log_format = %(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s +log_cli_level = ERROR diff --git a/modules/lo_dash_react_components/requirements.txt b/modules/lo_dash_react_components/requirements.txt new file mode 100644 index 000000000..804e5c082 --- /dev/null +++ b/modules/lo_dash_react_components/requirements.txt @@ -0,0 +1,3 @@ +# dash is required to call `build:py` +dash[dev]>=2.0.0 +js2py diff --git a/modules/lo_dash_react_components/setup.py b/modules/lo_dash_react_components/setup.py new file mode 100644 index 000000000..db4f40e40 --- /dev/null +++ b/modules/lo_dash_react_components/setup.py @@ -0,0 +1,45 @@ +import json +import os +from setuptools import setup +from setuptools.command.develop import develop as _develop +import subprocess +import sys + +with open('package.json') as f: + package = json.load(f) + +package_name = package["name"].replace(" ", "_").replace("-", "_") +new_path = '/'.join(sys.executable.split('/')[:-1]) +current_path = os.environ['PATH'] +modified_env = {'PATH': f'{new_path}{os.pathsep}{current_path}'} + +package_path = os.path.abspath(os.path.dirname(__file__)) + +class ReactSetup(_develop): + ''' + LO_dash_react_components relies on Dash to be installed. + To ensure Dash (and any other downstream dependencies) are resolved first, + we install LODRC after the install_requires + ''' + def run(self): + _develop.run(self) + subprocess.run(['npm', 'install', package_path], env=modified_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run(['npm', 'run', '--prefix', package_path, 'build'], env=modified_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +setup( + name=package_name, + version=package["version"], + author=package['author'], + packages=[package_name], + include_package_data=True, + license=package['license'], + description=package.get('description', package_name), + install_requires=[s.strip() for s in open(os.path.join(package_path, 'requirements.txt')).readlines() if len(s) > 1], + classifiers=[ + 'Framework :: Dash', + ], + cmdclass={ + 'develop': ReactSetup + } +) diff --git a/modules/lo_dash_react_components/src/App.js b/modules/lo_dash_react_components/src/App.js new file mode 100644 index 000000000..8090783dc --- /dev/null +++ b/modules/lo_dash_react_components/src/App.js @@ -0,0 +1,135 @@ +import React, { Component } from "react"; +import { BrowserRouter, Link, Routes, Route } from "react-router-dom"; +import PropTypes from 'prop-types'; + +import "./css/index.css"; +import "./css/components/WOMetrics.css" +import "./css/components/LONameTag.css" +import "./css/components/LOPanelLayout.css" +import "./css/components/LOCollapse.css" +import "bootstrap/dist/css/bootstrap.min.css" + +const components = {}; +const noop = () => null; + +// Import all components and their respective test data dynamically +const importAll = (r) => { + r.keys().forEach((key) => { + const componentName = key.replace("./", "").replace(".react.js", ""); + components[componentName] = { + component: r(key).default, + testdata: testData(componentName), + }; + }); +}; + +// Load test data for the component, if available +function testData(component_name) { + let testData = {}; + try { + testData = + require(`./lib/components/${component_name}.testdata.js`).default; + } catch (error) { + noop(); + } + return testData; +} + +importAll(require.context("./lib/components", true, /\.react.js$/)); + +// Display a list of installed components for navigation +function ComponentList(_props) { + return ( +
+
+

Component list

+
    + {Object.entries(components).map(([name, _component]) => ( +
  • + {" "} + + {name} + {" "} +
  • + ))} +
+
+ ); +} + +class SetPropsWrapper extends Component { + constructor(props) { + super(props); + this.state = { + ...props, + }; + this.setProps = this.setProps.bind(this); + } + + setProps(newProps) { + this.setState(newProps); + } + + render() { + const { component } = this.props; + const { ...state } = this.state; + const mergeProps = { + ...component.testdata, + ...state, + setProps: this.setProps, + }; + return ; + } +} +SetPropsWrapper.propTypes = { + /** + * An object containing the React component to be rendered and any additional props to be passed to it. + * The object must have two properties: + * - `component`: a React component to be rendered. + * - `testdata`: an object containing any additional props to be passed to the component. + */ + component: PropTypes.shape({ + component: PropTypes.elementType.isRequired, + testdata: PropTypes.object, + }).isRequired, + }; + +class App extends Component { + constructor() { + super(); + this.state = { + selected: "Bart", + }; + this.setProps = this.setProps.bind(this); + } + + setProps(newProps) { + this.setState(newProps); + } + + render() { + return ( +
+ + + } /> + {Object.entries(components).map(([name, component]) => ( + + + +
+ } + /> + ))} + + +
+ ); + } +} + +export default App; diff --git a/modules/lo_dash_react_components/src/css b/modules/lo_dash_react_components/src/css new file mode 120000 index 000000000..ba7ec9577 --- /dev/null +++ b/modules/lo_dash_react_components/src/css @@ -0,0 +1 @@ +../lo_dash_react_components/css \ No newline at end of file diff --git a/modules/lo_dash_react_components/src/demo/index.js b/modules/lo_dash_react_components/src/demo/index.js new file mode 100644 index 000000000..6317449d3 --- /dev/null +++ b/modules/lo_dash_react_components/src/demo/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from '../App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/modules/lo_dash_react_components/src/index.css b/modules/lo_dash_react_components/src/index.css new file mode 100644 index 000000000..496bcb2cf --- /dev/null +++ b/modules/lo_dash_react_components/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/modules/lo_dash_react_components/src/index.js b/modules/lo_dash_react_components/src/index.js new file mode 100644 index 000000000..d563c0fb1 --- /dev/null +++ b/modules/lo_dash_react_components/src/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/modules/lo_dash_react_components/src/lib/components/DAProblemDisplay.react.js b/modules/lo_dash_react_components/src/lib/components/DAProblemDisplay.react.js new file mode 100644 index 000000000..871fe60b8 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/DAProblemDisplay.react.js @@ -0,0 +1,474 @@ +/** + * This is a two-column display, showing problems and their related scaffolds. + */ + +import React, { useEffect, useLayoutEffect, useRef } from 'react'; + +import PropTypes from 'prop-types'; +import { PieChart, Pie, Cell, Label } from 'recharts'; +import { Arrow, initArrows, updateArrowPositions, stagger, djb2 } from './helperlib'; + +const studentPropType = { + initials: PropTypes.string.isRequired, + problem: PropTypes.string.isRequired, + scaffold: PropTypes.string, + id: PropTypes.string.isRequired, +}; + +const studentsPropType = PropTypes.arrayOf(PropTypes.shape(studentPropType)); + +const problemPropType = { + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + relatedScaffolds: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + students: studentsPropType, + zones: PropTypes.shape({[PropTypes.string]: PropTypes.number}), +}; + +const problemsPropType = PropTypes.arrayOf(PropTypes.shape(problemPropType)); + +const scaffoldPropType = { + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + students: studentsPropType, +}; + +const scaffoldsPropType = PropTypes.arrayOf(PropTypes.shape(scaffoldPropType)); + +/** + * Add a count and index to each object in the input data array based on a given field. + * + * We use this so that we can count (1) how many students are working on each problem (2) give + * each of them a unique position within (0, count), so we can space them nicely. + * + * Args: + * data (list): A list of objects. + * field (str): A string representing the field to be used for counting and indexing. + * + * Returns: + * list: A new list of objects, where each object has an additional "count" and "index" key. + * The "count" value represents the total number of objects in the input data array that have the same + * value for the field, and the "index" value represents the index of the object among that subset of objects. + */ +function addCountAndIndex(data, field) { + const counts = {}; + let indexedData = data.map((obj) => { + const fieldValue = obj[field]; + if (fieldValue in counts) { + counts[fieldValue]++; + } else { + counts[fieldValue] = 0; + } + return { ...obj, index: counts[fieldValue] }; + }); + indexedData = indexedData.map((obj) => { + const fieldValue = obj[field]; + return { ...obj, count: counts[fieldValue] + 1 }; + }); + return indexedData; +} + +/* + * This function takes in a list of students, scaffolds, and problems and annotates each scaffold and problem with the list of students working on them. + * + * This should probably be a function which is called twice, once for problems and once for scaffolds, taking + * a generic 'items,' eventually. + * + * This will pass over items which already have lists of associated students, as we expect will eventually come from our reducers (but not for our test data) + * + * Args: + * students (list): A list of dictionaries containing information about each student. + * scaffolds (list): A list of dictionaries containing information about each scaffold. + * problems (list): A list of dictionaries containing information about each problem. + * + * Returns: + * None. This function modifies the scaffolds and problems lists in place by adding a 'students' field to each dictionary with the list of students working on the scaffold or problem. + */ +function annotateWithStudents(students, scaffolds, problems) { + for (const scaffold of scaffolds) { + if(scaffold.students) { + continue; + } + scaffold.students = []; + for (const student of students) { + if (student.scaffold === scaffold.id) { + scaffold.students.push(student); + } + } + } + for (const problem of problems) { + if(problem.students) { + continue; + } problem.students = []; + for (const student of students) { + if (student.problem === problem.id) { + problem.students.push(student); + } + } + } +} + + +/** + * A card representing a single problem. + */ +function Problem({ id, title, description, students, zones }) { + /* Dummy data. We'll bring in real data later. */ + const pie_chart_data = Object.entries(zones).map(([name, value]) => ({name, value})); + + console.log(zones); + const totalStudents = students.length; + + const colors = { + none: 'var(--none-color)', + znd: 'var(--fail-color)', + zpd: 'var(--zpd-color)', + zad: 'var(--mastery-color)', + }; + + return ( +
+
+
+

{title}

+

{description}

+
+
+ + + {pie_chart_data.map((entry) => { + console.log(colors[entry.name]); + return ( + + )})} + + +
+
+
+
+ ); +} + +Problem.propTypes = problemPropType; + +/** + * A card representing a single scaffold + */ +function Scaffold({ id, title, description }) { + return ( +
+
+
+

{title}

+

{description}

+
+
+ ); +} + +Scaffold.propTypes = scaffoldPropType; + +/** + * A hidden box with circles for all the students in absolute + * positions. Students will be placed in the appropriate problems with + * JavaScript. + */ +function Students({ students }) { + useEffect(() => { + function moveStudents() { + const index_students = addCountAndIndex(students, 'problem'); + index_students.forEach((student) => { + const targetBox = document.querySelector( + `#${`initials-problem-${student.problem}`}` + ); + // const sourceBox = document.querySelector('#student-container'); + // const sourceRect = sourceBox.getBoundingClientRect(); + const studentCircle = document.querySelector(`#${student.id}`); + + const targetRect = targetBox.getBoundingClientRect(); + const studentRect = studentCircle.getBoundingClientRect(); + const top = + targetRect.top - studentRect.height / 2 + targetRect.height / 2 + window.scrollY; + const left = + targetRect.left - + studentRect.width / 2 + + targetRect.width * stagger(student.index, student.count) + window.scrollX; + studentCircle.style.position = 'absolute'; + studentCircle.style.top = `${top}px`; + studentCircle.style.left = `${left}px`; + }); + } + moveStudents(); + window.addEventListener('resize', moveStudents); + return () => { + window.removeEventListener('resize', moveStudents); + }; + }, [students]); + + return ( +
+ {students.map((student) => ( +
+ {student.initials} +
+ ))} +
+ ); +} + +Students.propTypes = { + students: studentsPropType.isRequired +}; + +/** + * A spacer div, so we have room for arrows and curves connecting problems + * to scaffolds. + */ +function Connectors() { + return
; +} + +/** + * A zero-sized div for all the arrows. These are absolute positioned objects which + * we move out of the div with JavaScript. + */ +function Arrows({ students }) { + useLayoutEffect(() => { + updateArrowPositions(); + }, []); + + const arrows = []; + + students.forEach((student) => { + const { id, scaffold } = student; + if (scaffold) { + const scaffoldId = 'initials-target-' + scaffold; + arrows.push( + + ); + } + }); + + return
{arrows}
; +} + +Arrows.propTypes = { + students: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + initials: PropTypes.string.isRequired, + problem: PropTypes.string.isRequired + })).isRequired +}; + +Arrows.propTypes = { + students: studentsPropType.isRequired +}; + +/** + * We draw connectors on this background + */ +function BackgroundCanvas({ problems }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas.getContext('2d'); + function renderCanvas() { + context.clearRect(0, 0, canvas.width, canvas.height); + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + context.translate(-rect.left, -rect.top); + + // We really need something like this: + // + // canvas.width = document.documentElement.scrollWidth; + // canvas.height = document.documentElement.scrollHeight; + // context.translate(-window.pageXOffset, -window.pageYOffset); + // + // But it doesn't work since scaling is wrong, and the canvas doesn't + // fill the full document in CSS. + + // Now you can draw on the canvas using ClientRect coordinates + context.lineWidth = 6; + for (const problem of problems) { + for (const scaffold of problem.relatedScaffolds) { + context.beginPath(); + const problem_element = document.getElementById( + 'problem-' + problem.id + ); + const scaffold_element = document.getElementById( + 'scaffold-' + scaffold + ); + const problem_rect = problem_element.getBoundingClientRect(); + const scaffold_rect = scaffold_element.getBoundingClientRect(); + + const startX = problem_rect.right; + const startY = problem_rect.top + problem_rect.height / 2; + const endX = scaffold_rect.left; + const endY = scaffold_rect.top + scaffold_rect.height / 2; + + const control1X = startX + (endX - startX) / 2; + const control1Y = startY; + const control2X = control1X; + const control2Y = endY; + + context.moveTo(startX, startY); + context.bezierCurveTo( + control1X, + control1Y, + control2X, + control2Y, + endX, + endY + ); + const gradient = context.createLinearGradient(startX, 0, endX, 0); + const degreesInCircle = 360; + const problem_hash = djb2(problem.id) % degreesInCircle; + const scaffold_hash = djb2(scaffold) % degreesInCircle; + gradient.addColorStop(0, `hsl(${problem_hash}, 56%, 80%)`); + gradient.addColorStop(1, `hsl(${scaffold_hash}, 56%, 80%)`); + context.strokeStyle = gradient; + context.stroke(); + } + } + } + renderCanvas(); + window.addEventListener('resize', renderCanvas); + window.addEventListener('scroll', renderCanvas); + + return () => { + window.removeEventListener('resize', renderCanvas); + window.removeEventListener('scroll', renderCanvas); + }; + }, []); + + return ; +} + +BackgroundCanvas.propTypes ={ + problems: problemsPropType.isRequired +}; + +/** + * This is the main display. It's a subcomponent, since the top-level component + * has a bunch of statically-positioned helper divs for students, arrows, and + * background. + */ +function Container({ problems, scaffolds }) { + return ( +
+
+ {problems.map((problem, index) => ( + + ))} +
+ +
+ {scaffolds.map((scaffold, index) => ( + + ))} +
+
+ ); +} + +Container.propTypes ={ + problems: problemsPropType.isRequired, + scaffolds: scaffoldsPropType.isRequired +}; + +/** + * The DAProblemDisplay component is responsible for displaying + * information about students, problems, and scaffolds. It takes in an + * array of student objects, each containing their initials, the + * problem they are working on, the scaffold they are using (if any), + * and their unique ID. + * + * It renders which scaffolds associate with which problems, and which + * scaffolds or problems students are currently using. + */ +class DAProblemDisplay extends React.Component { + render() { + const { students, problems, scaffolds } = this.props; + annotateWithStudents(students, scaffolds, problems); + + return ( +
+ {/**/} + + + +
+ ); + } +} + +DAProblemDisplay.propTypes = { + /** + * students (array): An array of objects containing information about each student: + * - initials (string): The student's initials. + * - problem (string): The ID of the problem the student is working on. + * - scaffold (string): The ID of the scaffold the student is using (if any). + * - id (string): The unique ID of the student. + */ + students: PropTypes.arrayOf( + PropTypes.shape({ + initials: PropTypes.string.isRequired, + problem: PropTypes.string.isRequired, + scaffold: PropTypes.string, + id: PropTypes.string.isRequired, + }) + ).isRequired, + /** + * problems (array): An array of objects containing information about each problem: + * - title (string): The title of the problem. + * - description (string): A description of the problem. + * - id (string): The unique ID of the problem. + * - relatedScaffolds (array): An array of strings representing the IDs of scaffolds related to the problem. + */ + problems: PropTypes.arrayOf(PropTypes.shape({ + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + relatedScaffolds: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + })).isRequired, + /** + * scaffolds (array): An array of objects containing information about each scaffold: + * - id (string): The unique ID of the scaffold. + * - title (string): The title of the scaffold. + * - description (string): A description of the scaffold. + * - content (string): The content of the scaffold. + */ + scaffolds: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + })).isRequired,}; + +initArrows(); + +export default DAProblemDisplay; diff --git a/modules/lo_dash_react_components/src/lib/components/DAProblemDisplay.testdata.js b/modules/lo_dash_react_components/src/lib/components/DAProblemDisplay.testdata.js new file mode 100644 index 000000000..688c9a279 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/DAProblemDisplay.testdata.js @@ -0,0 +1,150 @@ +/* eslint-disable no-magic-numbers */ +/** + * Example data for the problem display + */ + +const zones = ['zad', 'zpd', 'znd', 'none']; + +/** + * Randomly assign between lowerBound and upperBound students to each zone. + */ +function assignRandomZones(list, lowerBound, upperBound) { + var dict = {}; + for (var i = 0; i < list.length; i++) { + var randNum = Math.floor(Math.random() * (upperBound - lowerBound + 1)) + lowerBound; + dict[list[i]] = randNum; + } + return dict; +} + +/** + * An example list of problems, from ChatGPT + */ +const SAMPLE_PROBLEMS = [ + { + title: 'Bouncing Ball', + description: + 'Determine the maximum height, time to reach maximum height, and total time a ball is in the air when thrown straight up with a given velocity.', + id: 'ms-bouncing-ball', + relatedScaffolds: ['scaffold-graphs', 'scaffold-parabolas'], + zones: assignRandomZones(zones, 0, 10), + }, + { + title: 'Pizza Party', + description: + 'Calculate the number of pizzas needed and the cost of a pizza party based on the number of guests and their average pizza consumption.', + id: 'ms-pizza-party', + relatedScaffolds: ['scaffold-rates', 'scaffold-percentages'], + zones: assignRandomZones(zones, 0, 10), + }, + { + title: 'Scaling Shapes', + description: + 'Determine the scale factor, new dimensions, and area of a shape after it is scaled by a given factor.', + id: 'ms-scaling-shapes', + relatedScaffolds: [ + 'scaffold-equations', + 'scaffold-graphs', + 'scaffold-units', + ], + zones: assignRandomZones(zones, 0, 10), + }, + { + title: 'Sports Stats', + description: + 'Compute and compare statistics such as batting average, on-base percentage, and slugging percentage for different baseball players or teams.', + id: 'ms-sports-stats', + relatedScaffolds: ['scaffold-rates', 'scaffold-graphs'], + zones: assignRandomZones(zones, 0, 10), + }, +]; + +/** + * An example list of scaffolds, from ChatGPT + */ +const SAMPLE_SCAFFOLDS = [ + { + id: 'scaffold-rates', + title: 'Rates', + description: + 'A worked example that shows how to calculate rate of change and apply it to real-world problems.', + }, + { + id: 'scaffold-parabolas', + title: 'Parabolas', + description: + 'A formula sheet that explains the properties and graphs of parabolas, and how to find the equation of a parabola from given information.', + }, + { + id: 'scaffold-equations', + title: 'Equations', + description: + 'A list of formulas and tips for solving equations, including linear equations, quadratic equations, and systems of equations.', + }, + { + id: 'scaffold-units', + title: 'Units', + description: + 'A reference sheet that shows common units of measurement for length, weight, time, and temperature, and how to convert between them.', + }, + { + id: 'scaffold-graphs', + title: 'Graphs', + description: + 'A set of sample graphs that illustrate various types of relationships, including linear, quadratic, and exponential.', + }, + { + id: 'scaffold-percentages', + title: 'Percentages', + description: + 'A step-by-step guide to calculating percentages, including tips for solving percentage word problems.', + }, +]; + +/** + * generateInitials generates a two-letter string that represents a person's + * initials. The function randomly generates two uppercase letters from the + * alphabet and concatenates them into a string. + * + * @returns {string} A two-letter string representing a person's initials. + */ +function generateInitials() { + const first = String.fromCharCode(65 + Math.floor(Math.random() * 26)); + const last = String.fromCharCode(65 + Math.floor(Math.random() * 26)); + return first + last; +} + +function generateSampleStudents(n, problems, scaffolds, p) { + const students = []; + + for (let i = 0; i < n; i++) { + const initials = generateInitials(); + const problem = problems[Math.floor(Math.random() * problems.length)].id; + const scaffold = + Math.random() < p + ? scaffolds[Math.floor(Math.random() * scaffolds.length)].id + : null; + const id = 'student-' + i; + students.push({ initials: initials, problem: problem, scaffold: scaffold, id: id }); + } + + return students; +} + +/** + * An example list of students, autogenerated with JS + */ +const SAMPLE_STUDENTS = generateSampleStudents( + 10, + SAMPLE_PROBLEMS, + SAMPLE_SCAFFOLDS, + 0.5 +); + +const testData = { + students: SAMPLE_STUDENTS, + problems: SAMPLE_PROBLEMS, + scaffolds: SAMPLE_SCAFFOLDS +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/LOCards.css b/modules/lo_dash_react_components/src/lib/components/LOCards.css new file mode 100644 index 000000000..54f16ae28 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOCards.css @@ -0,0 +1,24 @@ +.flow { + display: flex; + flex-wrap: nowrap; + align-items: center; +} + +.lo-card { + border: 1px solid #ccc; + border-radius: 8px; + padding: 16px; + margin: 0 8px; + min-width: 200px; + text-align: center; +} + +.arrow { + position: relative; + width: 0; + height: 0; + margin: 0 4px; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 8px solid #ccc; +} diff --git a/modules/lo_dash_react_components/src/lib/components/LOCards.react.js b/modules/lo_dash_react_components/src/lib/components/LOCards.react.js new file mode 100644 index 000000000..c5890f100 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOCards.react.js @@ -0,0 +1,44 @@ +/* + Unfinished code. Committing to branch to sync. + */ + +import React from "react"; +import "./LOCards.css"; + +const LOCard = ({ title, description }) => { + return ( +
+

{title}

+

{description}

+
+ ); +}; + +const LOFlow = ({ children }) => { + const cards = React.Children.toArray(children); + + return ( +
+ {cards.map((card, index) => ( + + {card} + {index < cards.length - 1 &&
} + + ))} +
+ ); +}; + +const App = () => { + return ( +
+ + + + + +
+ ); +}; + +export default App; diff --git a/modules/lo_dash_react_components/src/lib/components/LOCollapse.react.js b/modules/lo_dash_react_components/src/lib/components/LOCollapse.react.js new file mode 100644 index 000000000..ae9618de2 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOCollapse.react.js @@ -0,0 +1,67 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +/** + * LOCollapse provides a simple collapsible component with a title + */ +export default class LOCollapse extends Component { + + handleClick = () => { + this.props.setProps({is_open: !this.props.is_open}) + } + + render() { + const { is_open, children, label, className, id } = this.props + + return ( +
+
+ {label} + +
+
+ {children} +
+
+ ) + } +} +LOCollapse.defaultProps = { + id: "", + className: "", + label: '', + is_open: false, +}; + +LOCollapse.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * The children of the main window + */ + children: PropTypes.node, + + /** + * The children of the main window + */ + label: PropTypes.node, + + /** + * Which panels (by id) are currently being is_open + */ + is_open: PropTypes.bool, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/LOCollapse.scss b/modules/lo_dash_react_components/src/lib/components/LOCollapse.scss new file mode 100644 index 000000000..58ac6084a --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOCollapse.scss @@ -0,0 +1,14 @@ +.LOCollapse { + height: auto; + .contents { + overflow: hidden; + max-height: 0; + transform-origin: top; + transform: scaleY(0); + transition: transform 0.3s ease-in-out, max-height 0.3s ease-in-out; + } + .contents.expanded { + max-height: 1000px; /* This should be larger than the content will ever be */ + transform: scaleY(1); + } +} diff --git a/modules/lo_dash_react_components/src/lib/components/LOCollapse.testdata.js b/modules/lo_dash_react_components/src/lib/components/LOCollapse.testdata.js new file mode 100644 index 000000000..a92738e1b --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOCollapse.testdata.js @@ -0,0 +1,7 @@ +const testData = { + id: "example", + children: 'this is the main child', + label: 'label', + is_open: false +} +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/LOConnection.react.js b/modules/lo_dash_react_components/src/lib/components/LOConnection.react.js new file mode 100644 index 000000000..b453640a3 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOConnection.react.js @@ -0,0 +1,164 @@ +import {Component} from 'react'; +import PropTypes from 'prop-types'; + +/** + * A simple web socket interface to the Learning Observer + * + * We need to define an appropriate protocol here. + * TODO remove anything to do with data scope + */ +export default class LOConnection extends Component { + encode_query_string(obj) { + // Creates a query string from an object + // Example: {a:'b', c:'d'} ==> "a=b&c=d" + + const str = []; + + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + str.push( + encodeURIComponent(prop) + "=" + encodeURIComponent(obj[prop]) + ); + } + } + + return str.join("&"); + } + + _init_client() { + // Create a new client. + let {url} = this.props; + + // Encode query string parameters + const {data_scope} = this.props; + const get_params = this.encode_query_string(data_scope); + const params = (get_params ? `?${get_params}` : "") + + // Determine url + const protocol = {"http:": "ws:", "https:": "wss:"}[window.location.protocol]; + url = url ? url : `${protocol}//${window.location.hostname}:${window.location.port}/wsapi/communication_protocol`; + this.client = new WebSocket(url); + // Listen for events. + this.client.onopen = (e) => { + // TODO: Add more properties here? + this.props.setProps({ + state: { + // Mandatory props. + readyState: WebSocket.OPEN, + isTrusted: e.isTrusted, + timeStamp: e.timeStamp, + // Extra props. + origin: e.origin, + } + }) + } + this.client.onmessage = (e) => { + // TODO: Add more properties here? + this.props.setProps({ + message: { + data: e.data, + isTrusted: e.isTrusted, + origin: e.origin, + timeStamp: e.timeStamp + } + }) + } + this.client.onerror = (e) => { + // TODO: Implement error handling. + this.props.setProps({error: JSON.stringify(e)}) + } + this.client.onclose = (e) => { + // TODO: Add more properties here? + this.props.setProps({ + state: { + // Mandatory props. + readyState: WebSocket.CLOSED, + isTrusted: e.isTrusted, + timeStamp: e.timeStamp, + // Extra props. + code: e.code, + reason: e.reason, + wasClean: e.wasClean, + } + }) + } + } + + componentDidMount() { + this._init_client() + } + + componentDidUpdate(prevProps) { + const {send, data_scope} = this.props; + // Send messages. + if (send && send !== prevProps.send) { + if (this.props.state.readyState === WebSocket.OPEN) { + this.client.send(send) + } + } + // Close and re-open the websocket with new data + if (JSON.stringify(data_scope) !== JSON.stringify(prevProps.data_scope)) { + this.client.close(); + this._init_client(); + } + } + + componentWillUnmount() { + // Clean up (close the connection). + this.client.close(); + } + + render() { + return (null); + } + +} + +LOConnection.defaultProps = { + state: {readyState: WebSocket.CONNECTING} +} + +LOConnection.propTypes = { + + /** + * This websocket state (in the readyState prop) and associated information. + */ + state: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + + /** + * When messages are received, this property is updated with the message content. + */ + message: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + + /** + * This property is set with the content of the onerror event. + */ + error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + + /** + * When this property is set, a message is sent with its content. + */ + send: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + + /** + * The websocket endpoint (e.g. wss://echo.websocket.org). + */ + url: PropTypes.string, + + /** + * Supported websocket key (optional). + */ + data_scope: PropTypes.object, + + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func + +} diff --git a/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js b/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js new file mode 100644 index 000000000..87970be99 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js @@ -0,0 +1,75 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +/** + * LONameTag provides the image and name pair for a given profile + */ +export default class LONameTag extends Component { + render() { + const { id, profile, className, includeName } = this.props; + + // Check for the existence of necessary profile keys + const hasValidPhotoUrl = profile?.photo_url && profile.photo_url !== '//lh3.googleusercontent.com/a/default-user'; + const givenName = profile?.name?.given_name ?? ''; + const familyName = profile?.name?.family_name ?? ''; + const fullName = profile?.name?.full_name ?? ''; + + return ( +
+ { + hasValidPhotoUrl + ? + : {`${givenName.slice(0, 1)}${familyName.slice(0, 1)}`} + } + {includeName ? {fullName} : } +
+ ); + } +} + +LONameTag.defaultProps = { + id: "", + className: "", + includeName: false +}; + +LONameTag.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * System profile object + * `{ + email_address: "example@example.com", + name: { + family_name: "Doe", + full_name: "John Doe", + given_name: "John" + }, + photo_url: "//lh3.googleusercontent.com/a/default-user" + }` + */ + profile: PropTypes.object.isRequired, + + /** + * Include name or just use the image + */ + includeName: PropTypes.bool, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/LONameTag.scss b/modules/lo_dash_react_components/src/lib/components/LONameTag.scss new file mode 100644 index 000000000..4670217be --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LONameTag.scss @@ -0,0 +1,17 @@ +.LONameTag { + img.name-tag-photo { + border-radius: 50%; + width: 42px; + } + span.name-tag-photo { + font-family: monospace; + text-align: center; + background-color: white; + padding: 0.25rem; + border-radius: 50%; + border: 1px solid lightgray; + } + .name-tag-name { + margin-left: 0.5em; + } +} \ No newline at end of file diff --git a/modules/lo_dash_react_components/src/lib/components/LONameTag.testdata.js b/modules/lo_dash_react_components/src/lib/components/LONameTag.testdata.js new file mode 100644 index 000000000..aac0f5fda --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LONameTag.testdata.js @@ -0,0 +1,14 @@ +const testData = { + id: "example", + profile: { + email_address: "example@example.com", + name: { + family_name: "Doe", + full_name: "John Doe", + given_name: "John" + }, + photo_url: "//lh3.googleusercontent.com/a/default-user" + } +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js new file mode 100644 index 000000000..deb6282a4 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js @@ -0,0 +1,94 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +/** + * LOPanelLayout provides the image and name pair for a given profile + */ +export default class LOPanelLayout extends Component { + render() { + const { id, className, children, panels, shown } = this.props + + const shownPanels = panels.filter(panel => shown.includes(panel.id)); + const totalPanelWidth = shownPanels.reduce((total, panel) => total + parseFloat(panel.width), 0); + + let leftPanels = panels.filter(panel => panel.side === 'left'); + let rightPanels = panels.filter(panel => panel.side !== 'left'); + + leftPanels = leftPanels.sort((a, b) => a.offset - b.offset); + rightPanels = rightPanels.sort((a, b) => a.offset - b.offset); + + const mainContentWidth = `${100 - totalPanelWidth}%`; + + return ( +
+ {leftPanels.map(panel => +
+ {panel.children} +
+ )} +
+ {children} +
+ {rightPanels.map(panel => +
+ {panel.children} +
+ )} +
+ ) + } +} +LOPanelLayout.defaultProps = { + id: "", + className: "", + panels: [], + shown: [] +}; + +LOPanelLayout.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * The children of the main window + */ + children: PropTypes.node, + + /** + * The panels to be included in the display + */ + panels: PropTypes.arrayOf(PropTypes.exact({ + children: PropTypes.node, + width: PropTypes.string, + offset: PropTypes.number, + side: PropTypes.string, + className: PropTypes.string, + id: PropTypes.string.isRequired + })), + + /** + * Which panels (by id) are currently being shown + */ + shown: PropTypes.arrayOf(PropTypes.string), + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.scss b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.scss new file mode 100644 index 000000000..b3248d82b --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.scss @@ -0,0 +1,16 @@ +.LOPanelLayout { + display: flex; + transition: all 0.3s ease-in-out; + + .main-content { + transition: all 0.3s ease-in-out; + } + + .side-panel { + transition: all 0.3s ease-in-out; + } + .side-panel.closed { + overflow: hidden; + height: 0; + } +} diff --git a/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.testdata.js b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.testdata.js new file mode 100644 index 000000000..64baa0436 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.testdata.js @@ -0,0 +1,12 @@ +const testData = { + id: "example", + children: 'this is the main child', + panels: [ + {children: 'boy panel', width: '25%', id: 'boy', side: 'left'}, + {children: 'girl panel', width: '14%', id: 'girl', side: 'left'}, + {children: 'dog panel', width: '11%', id: 'dog'}, + {children: 'cat panel', width: '18%', id: 'cat'} + ], + shown: ['cat', 'dog', 'girl'] +} +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/LOStudentTable.css b/modules/lo_dash_react_components/src/lib/components/LOStudentTable.css new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_dash_react_components/src/lib/components/LOStudentTable.react.js b/modules/lo_dash_react_components/src/lib/components/LOStudentTable.react.js new file mode 100644 index 000000000..7369540ed --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOStudentTable.react.js @@ -0,0 +1,177 @@ +import React, { useState } from 'react'; +import PropTypes from "prop-types"; + +const LOTableView = ({ children }) => { + const [leftPanel, rightPanel] = children; + + return ( +
+ {/* Left Panel */} +
+ {leftPanel} +
+ + {/* Right Panel */} +
+ {rightPanel} +
+
+ ); +}; + +LOTableView.propTypes = { + children: PropTypes.arrayOf(PropTypes.element).isRequired, +} + +const LOControls = ({ title, options, selectedOption, onOptionChange }) => { + return ( +
+

{title}

+ {options.map((option) => ( + + ))} +
+ ); +}; + +LOControls.propTypes = { + title: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.string).isRequired, + selectedOption: PropTypes.string.isRequired, + onOptionChange: PropTypes.func.isRequired, +}; + +const LOOptions = ({ children }) => { + return
{children}
; +}; + +LOOptions.propTypes = { + children: PropTypes.arrayOf(PropTypes.element).isRequired, +}; + +const CardList = ({ cards }) => { + + const [isGridLayout, setIsGridLayout] = useState(false); + + // Debug code, so we can see both layouts easily. + const handleClick = () => { + setIsGridLayout(!isGridLayout); + }; + + return ( +
+ {cards.map((item, index) => ( +
+
+ +
{item.name}
+
+
+ {item.columns.map((column, index) => ( +
+
{column.label}
+
{column.value}
+
+ ))} +
+
+ ))} +
+ ); +}; + +/** + * PropTypes for the columns of the table + */ +const columnPropTypes = PropTypes.shape({ + /** Column header label */ + label: PropTypes.string.isRequired, + /** Data type of column values can be string, number, or boolean */ + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]).isRequired, +}); + +/** + * PropTypes for a single card + */ +const cardPropTypes = PropTypes.shape({ + /** Unique identifier for the card */ + id: PropTypes.string.isRequired, + /** Name of the user displayed on the card */ + name: PropTypes.string.isRequired, + /** URL for the user's profile picture */ + picture: PropTypes.string.isRequired, + /** Array of columns to display on the card */ + columns: PropTypes.arrayOf(columnPropTypes).isRequired, + /** Optional color for the card background */ + color: PropTypes.string, +}); + +/** + * PropTypes for the CardList component + */ +CardList.propTypes = { + /** Array of cards to display in the list */ + cards: PropTypes.arrayOf(cardPropTypes).isRequired, +}; + +const LOStudentTable = ({ cards, controlGroups }) => { + // Use the useState hook to keep track of which option is selected + const [selectedOptions, setSelectedOptions] = useState({}); + + const handleOptionChange = (event) => { + const { name, value } = event.target; + console.log(name, value); + setSelectedOptions((prevSelectedOptions) => ({ + ...prevSelectedOptions, + [name]: value, + })); + }; + + return ( +
+ + + + {/* Use the map function to render multiple control groups */} + {controlGroups.map(({ title, options }) => ( + + ))} + + +
+ ); +} + +LOStudentTable.propTypes = { + /** Array of cards to display in the list */ + cards: PropTypes.arrayOf(cardPropTypes).isRequired, + /** Array of control groups to display */ + controlGroups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.string).isRequired, + }) + ).isRequired, +}; + +export default LOStudentTable; diff --git a/modules/lo_dash_react_components/src/lib/components/LOStudentTable.testdata.js b/modules/lo_dash_react_components/src/lib/components/LOStudentTable.testdata.js new file mode 100644 index 000000000..f8170d89e --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOStudentTable.testdata.js @@ -0,0 +1,82 @@ +/* eslint-disable no-magic-numbers */ + +const testData = { + cards: [ + { + id: '1', + name: 'Alice', + avatar: 'https://randomuser.me/api/portraits/women/68.jpg', + columns: [ + { label: 'Essay Topic', value: 'The Importance of Sleep' }, + { label: 'Word Count', value: 1350 }, + { label: 'Writing Time', value: '2 hours 15 mins' }, + { label: 'Grade', value: 'A' }, + { label: 'Comments', value: 'Excellent work!' }, + { label: 'Sources Used', value: 3, tooltip: "Stanford Medicine: Sleep\nNational Sleep Foundation\nSleep Foundation" }, + { label: 'Late Submission', value: false }, + ], + color: 'lightblue', + }, + { + id: '2', + name: 'Bob', + avatar: 'https://randomuser.me/api/portraits/men/12.jpg', + columns: [ + { label: 'Essay Topic', value: 'The Ethics of AI' }, + { label: 'Word Count', value: 1675 }, + { label: 'Writing Time', value: '3 hours 20 mins' }, + { label: 'Grade', value: 'B' }, + { label: 'Comments', value: 'Good effort.' }, + { label: 'Sources Used', value: 2, tooltip: "Wired\nTechTalks\n" }, + { label: 'Late Submission', value: true }, + ], + color: 'lightgreen', + }, + { + id: '3', + name: 'Charlie', + avatar: 'https://randomuser.me/api/portraits/men/44.jpg', + columns: [ + { label: 'Essay Topic', value: 'Climate Change and Society' }, + { label: 'Word Count', value: 1200 }, + { label: 'Writing Time', value: '2 hours 5 mins' }, + { label: 'Grade', value: 'B+' }, + { label: 'Comments', value: 'Good effort, but could use more research.' }, + { label: 'Sources Used', value: 2, tooltip: "NASA\nNOAA\n" }, + { label: 'Late Submission', value: false }, + ], + color: 'pink', + }, + { + id: '4', + name: 'David', + avatar: 'https://randomuser.me/api/portraits/men/22.jpg', + columns: [ + { label: 'Essay Topic', value: 'The Future of Work' }, + { label: 'Word Count', value: 1400 }, + { label: 'Writing Time', value: '2 hours 30 mins' }, + { label: 'Grade', value: 'A-' }, + { label: 'Comments', value: 'Great work, keep it up!' }, + { label: 'Sources Used', value: 4, tooltip: "Wikipedia: Future of Work\nMcKinsey\nDeloitte\nForbes\n" }, + { label: 'Late Submission', value: false }, + ], + color: 'lightyellow', + } + ], + controlGroups: [ + { + id: 'group1', + title: 'Student Grouping', + options: ['Topic', 'Grade', 'Writing Time'], + selectedOption: 'Writing Time' + }, + { + id: 'group2', + title: 'View', + options: ['Overview', 'Progress', 'Scoring'], + selectedOption: 'Overview' + } + ] +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/LOTextMinibars.react.js b/modules/lo_dash_react_components/src/lib/components/LOTextMinibars.react.js new file mode 100644 index 000000000..ee8b53c22 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOTextMinibars.react.js @@ -0,0 +1,171 @@ +/* Create a mini-bar-graph showing rhythm of text. Each bar represents + * the length of one sentences, and each group of bars, one paragraph. + * + * It's not clear if this is an MVP yet; sentences should be stacked + * bars (showing words), and sections should be clearly indicated. + * + * We'll also want to figure out how to handle multiple student texts + * on one page, and in particular, axes and scaling. + */ + +import * as React from 'react'; +import PropTypes from "prop-types"; + +import { BarChart, Bar, Cell, Tooltip, XAxis, YAxis } from 'recharts'; + +const DEFAULT_HEIGHT = 60; +const DEFAULT_WIDTH = 200; + +// Split the text based on newlines or multiple newlines +function segmentTextIntoParagraphs(text) { + return text.split(/\n+/).filter(paragraph => paragraph.trim() !== ""); +} + +// Split the text based on newlines or multiple newlines +function segmentParagraph(paragraph) { + // Regular expression to match sentence-ending punctuation + const sentenceEnd = /[.?!]/g; + return paragraph.split(sentenceEnd).filter(Boolean).map(s => s.trim()); +} + +function segmentParagraphsIntoSentences(paragraphs) { + // Map the segmentParagraph function over each paragraph + return paragraphs.map(segmentParagraph); +} + +// Count the number of words in a sentence +function countWords(text) { + return text.split(/\s+/).length; +} + +/** + * Annotates each sentence in a list of paragraphs with its corresponding word count. + * + * @param {Array} list - A list of paragraphs, where each paragraph is a list of sentences. + * + * @returns {Array} A list of paragraphs, where each paragraph is a list of objects representing sentences. + * Each object has two properties: 'text' (the sentence text) and 'count' (the number of words in the sentence). + * + * @example + * + * const paragraphs = [['This is the first sentence.', 'This is the second sentence.'], ['Another paragraph with one sentence.']]; + * const annotatedParagraphs = annotateWithWordCount(paragraphs); + * // annotatedParagraphs is now: + * // [ + * // [ + * // { text: 'This is the first sentence.', count: 5 }, + * // { text: 'This is the second sentence.', count: 5 } + * // ], + * // [ + * // { text: 'Another paragraph with one sentence.', count: 5 } + * // ] + * // ] + */ +function annotateWithWordCount(list) { + return list.map((sentences) => + sentences.map((sentence) => ( { + text: sentence, + count: countWords(sentence) + })) + ) +} + +const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + return ( +
+

{`${payload[0].payload.name}`}

+
+ ); + } + return (
) +}; + +CustomTooltip.propTypes = { + active: PropTypes.bool.isRequired, + payload: PropTypes.arrayOf(PropTypes.shape({ + payload: PropTypes.shape({ + name: PropTypes.string.isRequired + }).isRequired + })).isRequired +}; + +/** + * A component that renders a miniature bar graph representation of text, where each bar represents a sentence, and each + * set of bars, a paragraph + */ +export default class LOTextMinibars extends React.Component { + /** + * Prepares the chart data from the given text and yscale value + * @param {string} text - The input text + * @param {number} [ymax] - The scaling factor for the y-axis. This is used if there are multiple graphs on the same page (optional) + * @returns {object[]} The chart data, an array of objects with properties: name, value, group, fill + */ + prepareChartData = (text) => { + const annotatedTextLength = annotateWithWordCount(segmentParagraphsIntoSentences(segmentTextIntoParagraphs(text))); + const angleGoldenRatio = 137.5; + const DegreesInACircle = 360 + const ratioColor = index => `hsl(${(index * angleGoldenRatio) % DegreesInACircle}, 75%, 75%)`; + const chartData = annotatedTextLength.flatMap((array, index) => [ + ...array.map(value => ({ name:value.text, value: value.count, group: index, fill: ratioColor(index) })), + { value: 0, group: -1, name: '' } + ]).slice(0, -1); + return chartData; + } + + render() { + const { text, height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH, ymax, xmax, className } = this.props; + const chartData = this.prepareChartData(text); + console.log(chartData); + return ( +
+ + }/> + + { + chartData.map((entry, index) => ( + + + )) + } + + { + xmax && + } + { + ymax && + } + +
+ ); + } +} + +LOTextMinibars.propTypes = { + /** + * The text from which the chart is generated + */ + text: PropTypes.string.isRequired, + /** + * Optional: A class to attach to the main div of the chart + */ + className: PropTypes.string, + /** + * Optional: The height of the chart + */ + height: PropTypes.number, + /** + * Optional: The height of the chart + */ + width: PropTypes.number, + /** + * Optional: The maximum value of x-axis on the chart (e.g. number + * of sentences + paragraph breaks). Leave blank to autorange + */ + xmax: PropTypes.number, + /** + * Optional: The maximum value of y-axis on the chart (e.g. maximum + * sentence length). Leave blank to autorange + */ + ymax: PropTypes.number, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/LOTextMinibars.testdata.js b/modules/lo_dash_react_components/src/lib/components/LOTextMinibars.testdata.js new file mode 100644 index 000000000..112dd3d6b --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/LOTextMinibars.testdata.js @@ -0,0 +1,9 @@ +/* eslint-disable no-magic-numbers */ + +const sample_text = "Why Dogs are the Best Pets? \n\nWhen it comes to having a pet, there is no doubt that dogs are the best companion. There are a lot of reasons to support that statement. Dogs are loyal, friendly, and protective towards their owner. They are also great for physical activities and can be trained to perform various tasks. These are just a few reasons why dogs are the best pets for anyone.\n\nFirstly, dogs are known to be the most loyal pets. They are always by your side, wagging their tails, and giving you cuddles and kisses. No matter how bad your day is going, a dog’s unwavering loyalty makes the world seem that little bit brighter. This level of devotion is hard to find in any other animal. \n\nMoreover, dogs are very friendly and can bring so much joy to anyone’s life. They love meeting new people and make great companions even to strangers. They have an infectious and playful energy that always lifts your mood. That’s why they are also a great choice for families with children. They can help kids learn about responsibility, compassion, and friendship.\n\nAside from being great company, dogs also have a unique way of protecting their owners. They have a heightened sense of recognition when it comes to sensing danger or any suspicious activity. When they sense something amiss, they bark to alarm and protect their owner. A dog’s protective nature is an excellent asset to have, especially for elderly people living alone.\n\nLastly, dogs are very active and can keep their owners physically active too. Whether it's going for a walk or jog, playing fetch or joining their owner on hikes, dogs will make sure that their owner never gets bored. They can also be trained to perform various tasks like hunting, herding, police work, and search and rescue. These abilities show the intelligence and versatility of dogs as animals.\n\nIn conclusion, dogs are the best kind of pets for several reasons. They are loyal, friendly, and protective towards their owners that provide companionship, joy, and safety. They also have a unique ability to keep you active and are adaptable to perform various tasks. These positive qualities make dogs an excellent choice for anyone who wants a pet.\n" + +const testData = { + text: sample_text +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/StudentSelectHeader.react.js b/modules/lo_dash_react_components/src/lib/components/StudentSelectHeader.react.js new file mode 100644 index 000000000..617c9dcd0 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/StudentSelectHeader.react.js @@ -0,0 +1,131 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import classnames from "classnames"; + +/** + * Displays a header menu for selecting students + */ +export default class StudentSelectHeader extends Component { + /** + * Constructor for the StudentSelectHeader component. + * + * @param {object} props - The props for the component. + */ + constructor(props) { + super(props); + this.state = { + showDropdown: false, + }; + this.handleClick = this.handleClick.bind(this); + this.iterateName = this.iterateName.bind(this); + this.handleSelect = this.handleSelect.bind(this); + } + + /** + * Toggles the visibility of the dropdown menu when the user clicks on the student name. + */ + handleClick() { + this.setState((prevState) => ({ showDropdown: !prevState.showDropdown })); + } + + /** + * Iterates through the list of students and updates the selected student accordingly. + * + * @param {number} offset - The offset to apply to the current student index. + */ + iterateName(offset) { + const { students, selectedStudent, setProps } = this.props; + const curr_index = students.indexOf(selectedStudent); + const new_name = + students[ + curr_index + offset >= 0 + ? (curr_index + offset) % students.length + : students.length - 1 + ]; + setProps({ selectedStudent: new_name }); + } + + /** + * Handles the user selecting a new student from the dropdown menu. + * + * @param {string} name - The name of the selected student. + */ + handleSelect(name) { + const { setProps } = this.props; + setProps({ selectedStudent: name }); + this.setState({ showDropdown: false }); + } + + /** + * Renders the StudentSelectHeader component. + * + * @returns {JSX.Element} - The rendered component. + */ + render() { + const { id, className, students, selectedStudent } = this.props; + const { showDropdown } = this.state; + const classNames = classnames("student-select-header", className); + + return ( +
+
+
+ +
+
+ {selectedStudent} +
+ {showDropdown && ( +
    + {students.map((name) => ( +
  • this.handleSelect(name)} + className={ + name === selectedStudent + ? "dropdown-item dropdown-item-selected" + : "dropdown-item" + } + > + {name} +
  • + ))} +
+ )} +
+ +
+
+
+ ); + } +} + +StudentSelectHeader.propTypes = { + /** A unique identifier for the component, used to identify it in Dash callbacks */ + id: PropTypes.string, + + /** A string of class names to be added to the outermost div */ + className: PropTypes.string, + + /** An array of student names to be displayed in the dropdown */ + students: PropTypes.arrayOf(PropTypes.string), + + /** The currently selected student name */ + selectedStudent: PropTypes.string, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; + +StudentSelectHeader.defaultProps = { + students: [], + selectedStudent: "", +}; diff --git a/modules/lo_dash_react_components/src/lib/components/StudentSelectHeader.testdata.js b/modules/lo_dash_react_components/src/lib/components/StudentSelectHeader.testdata.js new file mode 100644 index 000000000..34f714f4c --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/StudentSelectHeader.testdata.js @@ -0,0 +1,10 @@ +/* eslint-disable no-magic-numbers */ + +const testData = { + id: "studentheader-test", + students: ["Bart", "Milhouse", "Nelson"], + selectedStudent: "Bart", + className: "studentheader-container", +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js new file mode 100644 index 000000000..a50e35e20 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js @@ -0,0 +1,166 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; + +import 'react-tooltip/dist/react-tooltip.css'; + +/** + * WOAnnotatedText + */ +export default class WOAnnotatedText extends Component { + constructor (props) { + super(props); + this.state = { + selectedItem: null + }; + } + + replaceNewLines = (str) => { + const split = str.split('\n'); + if (split.length > 1) { + return split.map((line, index) => ( + + {line} + {split.length - 1 === index ? :
} +
+ )); + } + return str; + }; + + render () { + const { breakpoints, text, className } = this.props; + + const breaks = new Set(); + breakpoints.forEach(obj => { + breaks.add(obj.start); + breaks.add(obj.start + obj.offset); + }); + breaks.add(0); + breaks.add(text.length); + + const ids = {}; + breaks.forEach(item => { + ids[item] = []; + }); + + const breaksList = [...breaks].sort((a, b) => a - b); + let matchingBreaks = []; + + breakpoints.forEach(obj => { + matchingBreaks = breaksList.filter(v => (v >= obj.start & v < (obj.start + obj.offset))); + matchingBreaks.forEach(b => { + ids[b] = ids[b].concat({ tooltip: obj.tooltip, style: obj.style }); + }); + }); + + const chunks = Array(breaksList.length - 1); + let curr, textChunk; + for (let i = 0; i < chunks.length; i++) { + curr = ids[breaksList[i]]; + textChunk = text.substring(breaksList[i], breaksList[i + 1]); + if (curr.length === 0) { + chunks[i] = { + text: textChunk, + annotated: false + }; + } else { + chunks[i] = { + text: textChunk, + annotated: true, + id: i, + tooltip: curr.map(o => o.tooltip), + style: curr[0].style + }; + } + } + + if (chunks.length === 0) { + return
+ {this.replaceNewLines(text)} +
; + } + + // TODO figure out how empty breakpoints + if (chunks[chunks.length - 1].end < text.length) { + chunks.push({ + text: text.substring(chunks[chunks.length - 1].end), + annotated: false + }); + } + return ( +
+ {chunks.map((chunk, index) => ( + chunk.annotated + ? + Annotations + +
    + {[...new Set(chunk.tooltip)].map((item, index) => ( +
  • + {item} +
  • + ))} +
+
+ + } + > + + {this.replaceNewLines(chunk.text)} + +
+ : + {this.replaceNewLines(chunk.text)} + + ))} +
+ ); + } +} + +WOAnnotatedText.defaultProps = { + id: '', + className: '', + text: '', + breakpoints: [] +}; + +WOAnnotatedText.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * The breakpoints of our text + */ + breakpoints: PropTypes.arrayOf(PropTypes.exact({ + id: PropTypes.string, + start: PropTypes.number, + offset: PropTypes.number, + tooltip: PropTypes.string, + style: PropTypes.object + })), + + /** + * Text of essay + */ + text: PropTypes.string, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js new file mode 100644 index 000000000..6f9c3136b --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js @@ -0,0 +1,29 @@ +const testData = { + id: "example", + text: "Lorem ipsum dolor sit amet, \nconsectetur adipiscing elit, sed do eiusmod tempor incididunt\n ut labore et dolore magna aliqua. Dictumst quisque sagittis purus sit amet. Mi quis hendrerit dolor magna eget est lorem ipsum. Arcu bibendum at varius vel pharetra. Nulla malesuada pellentesque elit eget gravida cum. Tincidunt tortor aliquam nulla facilisi cras fermentum odio. Amet venenatis urna cursus eget nunc scelerisque viverra mauris. Diam vel quam elementum pulvinar. Morbi tincidunt augue interdum velit euismod in pellentesque massa. Dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu. Enim praesent elementum facilisis leo vel fringilla est.\n\nSodales ut etiam sit amet nisl purus in mollis nunc. Suspendisse interdum consectetur libero id faucibus. Morbi leo urna molestie at elementum. In iaculis nunc sed augue lacus viverra. Tristique senectus et netus et malesuada fames ac turpis egestas. Accumsan lacus vel facilisis volutpat est. Consequat semper viverra nam libero justo laoreet sit. Euismod nisi porta lorem mollis aliquam ut porttitor leo. Enim facilisis gravida neque convallis a cras. Odio ut enim blandit volutpat maecenas. Justo nec ultrices dui sapien eget mi proin sed. Non sodales neque sodales ut etiam. Nulla aliquet enim tortor at auctor urna. At volutpat diam ut venenatis.\n\nNulla facilisi cras fermentum odio eu feugiat. Imperdiet massa tincidunt nunc pulvinar sapien et. Fermentum odio eu feugiat pretium nibh ipsum consequat nisl. Pellentesque pulvinar pellentesque habitant morbi tristique senectus et netus. Ac turpis egestas sed tempus urna et. Libero volutpat sed cras ornare arcu dui vivamus arcu. Varius duis at consectetur lorem. Tincidunt augue interdum velit euismod. Praesent elementum facilisis leo vel fringilla est ullamcorper. Facilisis magna etiam tempor orci eu lobortis. Amet est placerat in egestas erat imperdiet sed. Odio eu feugiat pretium nibh ipsum consequat nisl vel pretium. Lectus proin nibh nisl condimentum id venenatis a condimentum vitae. Lacus suspendisse faucibus interdum posuere lorem ipsum. Vel turpis nunc eget lorem dolor. Feugiat nibh sed pulvinar proin gravida hendrerit lectus. Convallis aenean et tortor at risus viverra adipiscing. Aliquet nec ullamcorper sit amet risus nullam eget felis. Massa eget egestas purus viverra accumsan in nisl nisi. Orci nulla pellentesque dignissim enim sit.\n\nUltrices mi tempus imperdiet nulla malesuada pellentesque elit eget. Augue neque gravida in fermentum. Sapien eget mi proin sed libero enim sed faucibus turpis. Velit sed ullamcorper morbi tincidunt. Enim sed faucibus turpis in eu mi bibendum neque. Gravida in fermentum et sollicitudin ac orci phasellus egestas. Risus at ultrices mi tempus imperdiet nulla malesuada. Ridiculus mus mauris vitae ultricies leo. Montes nascetur ridiculus mus mauris vitae ultricies leo integer. Mollis aliquam ut porttitor leo. Elementum nibh tellus molestie nunc non. Malesuada bibendum arcu vitae elementum. Nibh mauris cursus mattis molestie.\n\nMollis nunc sed id semper risus in hendrerit. In ornare quam viverra orci sagittis eu. Cursus vitae congue mauris rhoncus aenean vel elit. Imperdiet massa tincidunt nunc pulvinar. Lobortis scelerisque fermentum dui faucibus in. Sit amet consectetur adipiscing elit pellentesque habitant morbi. Interdum velit laoreet id donec ultrices tincidunt arcu. Elementum curabitur vitae nunc sed velit. Sed euismod nisi porta lorem mollis. Pretium aenean pharetra magna ac. Enim diam vulputate ut pharetra sit. In fermentum et sollicitudin ac orci phasellus egestas tellus rutrum. Sed viverra tellus in hac habitasse platea dictumst. Tellus rutrum tellus pellentesque eu. Velit dignissim sodales ut eu sem.", + breakpoints: [ + { + id: 'split0', + tooltip: 'This is the first tooltip', + start: 220, + offset: 5, + style: {textDecoration: 'underline'} + }, + { + id: 'split1', + tooltip: 'This is a tooltip', + start: 240, + offset: 25, + style: {textDecoration: 'underline'} + }, + { + id: 'split2', + tooltip: 'This is another tooltip', + start: 310, + offset: 15, + style: {backgroundColor: 'green'} + } + ] +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOIndicatorBars.react.js b/modules/lo_dash_react_components/src/lib/components/WOIndicatorBars.react.js new file mode 100644 index 000000000..78f47d4d8 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOIndicatorBars.react.js @@ -0,0 +1,94 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import ProgressBar from "react-bootstrap/ProgressBar"; + +/** + * WOIndicatorBars provide progress bars. + * It takes a property, `data`, and + * outputs each item as a label/progress bar pair. + * If the id of the item is not in the property `shown`, + * it will not appear. + */ +export default class WOIndicatorBars extends Component { + renderIndicatorBar = ([key, indicator], shown) => { + const indicator_colors = ["danger", "warning", "success"]; + const INDICATOR_STEP = 34; + if (!shown.includes(key)) { + return null; + } + return ( +
+
{indicator.label}:
+
+ +
+
+ ); + }; + + render() { + const { id, data, shown, className = "" } = this.props; + + const indicatorBars = Object.entries(data).map((entry) => + this.renderIndicatorBar(entry, shown) + ); + + return ( +
+ {indicatorBars} +
+ ); + } +} + +WOIndicatorBars.defaultProps = { + shown: [], + data: {}, +}; + +WOIndicatorBars.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * Data for the metrics should be in form: + * `{ + "transitions": { + "id": "transitions", + "value": 81, + "label": "Transitions", + "help": "Percentile based on total number of transitions used" + }, + }` + */ + data: PropTypes.object, + + /** + * Which ids to show. + */ + shown: PropTypes.array, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOIndicatorBars.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOIndicatorBars.testdata.js new file mode 100644 index 000000000..2952069ac --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOIndicatorBars.testdata.js @@ -0,0 +1,16 @@ +/* eslint-disable no-magic-numbers */ + +const testData = { + id: "indicator-test", + data: { + sentences: { + id: "sentences", + value: 33, + label: " sentences", + }, + }, + shown: ["sentences"], + className: "indicator-container", +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOMetrics.react.js b/modules/lo_dash_react_components/src/lib/components/WOMetrics.react.js new file mode 100644 index 000000000..6917904ad --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOMetrics.react.js @@ -0,0 +1,78 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import Badge from "react-bootstrap/Badge"; + +import "../../css/components/WOMetrics.css" +/** + * WOMetrics creates badges for numeric values. + * It takes a property, `data`, and + * outputs each item as a badge. + * If the id of the item is not in the property `shown`, + * it will not appear. + */ +export default class WOMetrics extends Component { + renderMetricBadge = ([key, metric], shown) => { + if (!shown.includes(key)) { + return null; + } + + return ( + + {metric.value} {metric.label} + + ); + }; + render() { + const { id, data, shown, className = "" } = this.props; + + const metricBadges = Object.entries(data).map((entry) => + this.renderMetricBadge(entry, shown) + ); + + return ( +
+ {metricBadges} +
+ ); + } +} + +WOMetrics.defaultProps = { + shown: [], + data: {}, +}; + +WOMetrics.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * Data for the metrics should be in form: + * `{ + "sentences": { + "id": "sentences", + "value": 33, + "label": " sentences" + }, + }` + */ + data: PropTypes.object, + + /** + * Which ids to show. + */ + shown: PropTypes.array, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOMetrics.scss b/modules/lo_dash_react_components/src/lib/components/WOMetrics.scss new file mode 100644 index 000000000..ffb35891c --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOMetrics.scss @@ -0,0 +1,5 @@ +.WOMetrics { + span { + font-size: 12px; + } +} \ No newline at end of file diff --git a/modules/lo_dash_react_components/src/lib/components/WOMetrics.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOMetrics.testdata.js new file mode 100644 index 000000000..6b8d27b9f --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOMetrics.testdata.js @@ -0,0 +1,26 @@ +/* eslint-disable no-magic-numbers */ + +const testData = { + id: "metric-test", + data: { + sentences: { + id: "sentences", + value: 33, + label: " sentences", + }, + paragraphs: { + id: "paragraphs", + value: 3, + label: " paragraphs", + }, + metric: { + id: "metric", + value: 55, + label: " metrics", + }, + }, + shown: ["sentences", "paragraphs", "metric"], + className: "metric-container", +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js b/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js new file mode 100644 index 000000000..5ab1a7ee2 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js @@ -0,0 +1,209 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +function generateNewHighlightColor () { + // Generate random RGB values + const r = Math.floor(Math.random() * 128) + 128; // 128-255 for brighter colors + const g = Math.floor(Math.random() * 128) + 128; + const b = Math.floor(Math.random() * 128) + 128; + + // Convert RGB to hex + const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + return hex; +} + +function sortOptionsIntoTree (options) { + const optionsMap = new Map(); + options.forEach(option => optionsMap.set(option.id, option)); + + const sortedOptions = []; + + function addChildren (parentId, depth) { + options + .filter(option => option.parent === parentId) + .forEach(option => { + sortedOptions.push({ ...option, depth }); + addChildren(option.id, depth + 1); + }); + } + + addChildren('', 0); + + return sortedOptions; +} + +export default class WOSettings extends Component { + constructor (props) { + super(props); + this.state = { + collapsed: {}, + showHighlight: props.options.some(option => option.types && option.types.includes('highlight')), + showMetric: props.options.some(option => option.types && option.types.includes('metric')) + }; + this.handleRowEvent = this.handleRowEvent.bind(this); + this.renderRow = this.renderRow.bind(this); + this.toggleCollapse = this.toggleCollapse.bind(this); + } + + handleRowEvent (event, key, type, colorPicker = false) { + const { setProps, value } = this.props; + const currentValue = structuredClone(value); + if (!(key in currentValue)) { + currentValue[key] = {}; + } + if (colorPicker) { + currentValue[key][type].color = event.target.value; + } else { + const { checked } = event.target; + if (!(type in currentValue[key])) { + currentValue[key][type] = {}; + } + currentValue[key][type].value = checked; + if (type === 'highlight') { + currentValue[key][type].color = currentValue[key][type].color || generateNewHighlightColor(); + } + } + setProps({ value: currentValue }); + } + + toggleCollapse (id) { + this.setState(prevState => ({ + collapsed: { + ...prevState.collapsed, + [id]: !prevState.collapsed[id] + } + })); + } + + renderRow (row, allRows) { + const { collapsed, showHighlight, showMetric } = this.state; + const { value } = this.props; + const hasChildren = allRows.some(option => option.parent === row.id); + const isCollapsed = collapsed[row.id] || false; + + const highlightCell = row.types && row.types.includes('highlight') + ? (<> + this.handleRowEvent(e, row.id, 'highlight')} + className='me-1' + /> + {value[row.id]?.highlight.value + ? ( this.handleRowEvent(e, row.id, 'highlight', true)} + />) + : null} + ) + : null; + const metricCell = (row.types && row.types.includes('metric')) + ? this.handleRowEvent(e, row.id, 'metric')} /> + : null; + + return ( + <> + + +
{row.label}
+ {hasChildren && ( + + )} + + {showHighlight && {highlightCell}} + {showMetric && {metricCell}} + + {/* Render children rows if not collapsed */} + {!isCollapsed && + allRows + .filter(child => child.parent === row.id) + .map(child => this.renderRow(child, allRows))} + + ); + } + + render () { + const { id, className, options } = this.props; + const { showHighlight, showMetric } = this.state; + const rows = sortOptionsIntoTree(options); + + return ( + + + + + {showHighlight && } + {showMetric && } + + + + {rows + .filter(row => row.parent === '') // Start with top-level rows + .map(row => this.renderRow(row, rows))} + +
NameHighlightMetric
+ ); + } +} + +WOSettings.defaultProps = { + id: '', + className: '', + options: [], + value: {} +}; + +WOSettings.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, + + /** + * Array of available options + */ + options: PropTypes.arrayOf(PropTypes.exact({ + id: PropTypes.string, + label: PropTypes.string, + parent: PropTypes.string, + types: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.undefined + ]) + })), + + /** + * Dictionary of selected items + */ + value: PropTypes.object + +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js new file mode 100644 index 000000000..b4815610e --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js @@ -0,0 +1,13 @@ + +const testData = { + options: [ + { id: 'a1', label: 'A1', parent: 'a' }, + { id: 'a2', types: { highlight: {}, metric: {} }, label: 'A2', parent: 'a' }, + { id: 'a1a', types: { highlight: {}, metric: {} }, label: 'A1A', parent: 'a1' }, + { id: 'a', label: 'A', parent: '' }, + { id: 'b', types: { highlight: {}, metric: {} }, label: 'B', parent: '' }, + { id: 'c', types: { highlight: {}, metric: {} }, label: 'C', parent: '' } + ] +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js new file mode 100644 index 000000000..1bfecbf43 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -0,0 +1,107 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Button from 'react-bootstrap/Button'; +import ButtonGroup from 'react-bootstrap/ButtonGroup'; +import Card from 'react-bootstrap/Card'; + +import LONameTag from './LONameTag.react'; + +/** + * WOStudentTextTile + */ +export default class WOStudentTextTile extends Component { + render () { + const { id, className, style, showName, profile, currentStudentHash, currentOptionHash, childComponent, additionalButtons } = this.props; + const isLoading = currentOptionHash !== currentStudentHash; + let bodyClassName = isLoading ? 'loading' : ''; + bodyClassName = `${bodyClassName} overflow-auto position-relative`; + + return ( + + + + + {isLoading && ( + + )} + {additionalButtons && additionalButtons} + + + + {childComponent} + + + ); + } +} + +WOStudentTextTile.defaultProps = { + className: '', + showName: true, + style: {}, + profile: {} +}; + +WOStudentTextTile.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * Style to apply to the outer most item. This + * is usually used to set the size of the tile. + */ + style: PropTypes.object, + + /** + * Determine whether the header with the student + * name should be visible or not + */ + showName: PropTypes.bool, + + /** + * Hash of the current options, used to determine if we + * should be in a loading state or not. + */ + currentOptionHash: PropTypes.string, + + /** + * Hash of the current student, used to determine if we + * should be in a loading state or not. + */ + currentStudentHash: PropTypes.string, + + /** + * Component to use for within the card body + */ + childComponent: PropTypes.node, + + /** + * Buttons to add to the button group + */ + additionalButtons: PropTypes.node, + + /** + * The profile of the student + */ + profile: PropTypes.object, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js new file mode 100644 index 000000000..c12774038 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js @@ -0,0 +1,17 @@ +const testData = { + id: 'example', + showName: true, + currentOptionHash: '123', + currentStudentHash: '123', + profile: { + email_address: 'example@example.com', + name: { + family_name: 'Doe', + full_name: 'John Doe', + given_name: 'John' + }, + photo_url: '//lh3.googleusercontent.com/a/default-user' + } +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js new file mode 100644 index 000000000..3112e5a3e --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js @@ -0,0 +1,141 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +/** + * WOTextHighlight provides breakpoints and classes to allow for later highlighting. + * It takes a property, `text`, and + * and breaks it up based on all possible breakpoints in property `highlight_breakpoints`. + * The text is output as a variety of spans with classnames corresponding to ids. + */ +export default class WOTextHighlight extends Component { + // Define the render method for the component + render() { + // Destructure the props object and assign default values where necessary + const { + id, + text = "", + highlight_breakpoints = {}, + className = "", + } = this.props; + + // Define arrays to hold the highlight information and breakpoints + const highlights = []; + const breakpoints = [0]; + + // Iterate over the highlight_breakpoints object to extract highlight information + Object.entries(highlight_breakpoints).forEach(([key, value]) => { + value.value.forEach(([start, length]) => { + // Add breakpoints for the start and end of the highlighted section + breakpoints.push(start); + breakpoints.push(start + length); + // Add the highlight information to the highlights array + highlights.push([start, start + length, key]); + }); + }); + + // Sort the highlight and breakpoint arrays in ascending order + highlights.sort((a, b) => a[0] - b[0]); + breakpoints.sort((a, b) => a - b); + + // Initialize the child variable with the original text, and update it if there are highlights + let child = text; + if (highlights.length > 0) { + child = new Array(); + let start = 0; + let end = 0; + let classes = []; + // Iterate over the breakpoints to split the text into highlighted and non-highlighted sections + child = breakpoints.map((bp, i) => { + start = bp; + end = i === breakpoints.length - 1 ? text.length : breakpoints[i + 1]; + // Extract the current section of text + const text_slice = text.slice(start, end); + // Get the classes to apply to the current section of text based on the highlights + classes = highlights + .filter(([s, e]) => s <= start && e >= end) + .map(([_, __, c]) => c) + .join(" "); + // Split the text by newline characters to handle multi-line highlights + const text_newline_split = text_slice.split("\n"); + // Return a span element for the current section of text, with appropriate classes and line breaks + return ( + + {text_newline_split.length === 1 + ? text_slice + : text_newline_split.map((line, i) => ( + + {line} + {i === text_newline_split.length - 1 ? "" :
} +
+ ))} +
+ ); + }); + } else { + const text_split = text.split("\n"); + child = text_split.length === 1 + ? child + : text_split.map((line, i) => ( + + {line} + {i === text_split.length - 1 ? "" :
} +
+ )) + } + // Return a div element with the child elements and appropriate attributes + return ( +
+ {child} +
+ ); + } +} + +WOTextHighlight.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * The text to be highlighted. + */ + text: PropTypes.string, + + /** + * highlight breakpoints in the form of: + * `{ + "coresentences": { + "id": "coresentences", + "value": [ + [ + 0, + 13 + ], + [ + 20, + 25 + ] + ], + "label": "Main ideas" + }, + }` + * + */ + highlight_breakpoints: PropTypes.object, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js new file mode 100644 index 000000000..fdc0a235c --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js @@ -0,0 +1,19 @@ +/* eslint-disable no-magic-numbers */ + +const testData = { + id: "text-highlight-test", + text: "This is a test of the text highlight component.\nThis is a new line of text data.\n\n\nHow about 3 new lines?", + highlight_breakpoints: { + testHighlight: { + id: "testHighlight", + value: [ + [5, 7], + [19, 28], + ], + label: "Test Highlight", + }, + }, + className: "highlight-container", +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/ZPDPlot.react.js b/modules/lo_dash_react_components/src/lib/components/ZPDPlot.react.js new file mode 100644 index 000000000..de6630195 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/ZPDPlot.react.js @@ -0,0 +1,140 @@ +/* + * Traditional, per-student Vygotskian display of which concepts are in the zone of + * proximal development, actual development, and which a student cannot do + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Arrow, LEFT, updateArrowPositions, initArrows } from './helperlib'; +import StudentSelectHeader from "./StudentSelectHeader.react"; + +/* Gives information about one card for one student. */ +function ZPDItemCard({id, cardIndex, cardCount, itemName, visited, attempts, supports, zone}) { + const halfCircle = 180; + const cardAngle = halfCircle * (cardIndex+1) / (cardCount+1); + const cardOffsetRadius = 0.8; + return ( +
+
{itemName}
+
+
+
Visited
+
Attempts
+
Supports
+
+
+
{visited ? "✓" : "X"}
+
{attempts}
+
{supports}
+
+
+ {(zone !== "None") && ()} +
+ ); +} + +ZPDItemCard.propTypes = { + cardIndex: PropTypes.number.isRequired, + cardCount: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + itemName: PropTypes.string.isRequired, + visited: PropTypes.bool.isRequired, + attempts: PropTypes.string.isRequired, + supports: PropTypes.string.isRequired, + zone: PropTypes.string, +}; + +/* Static display of the three zones */ +const ZPDRing = (_props) => { + return ( +
+
+
Can't do
+
+
Zone of Proximal Development
+
+
Mastery
+
+
+
+
+ ); +} + +/** + * This is a component which shows which zones different problems fall into for a given student. + */ +export default class ZPDPlot extends React.Component { + componentDidMount() { + updateArrowPositions(); + } + + render() { + const { ZPDItemCards, setProps, students, selectedStudent } = this.props; + + return ( +
+ + +
+
+ {ZPDItemCards.map((card, index) => ( + + ))} +
+
+
+ ); + } +} + +ZPDPlot.defaultProps = {}; + +ZPDPlot.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + /** + * A list of all available students. This is so we can select a student. + * This should be moved out of this, one level up, so the logic is common + * to any per-student view. + */ + students: PropTypes.arrayOf(PropTypes.string), + /** + * Data for cards for the items students worked through. + */ + ZPDItemCards: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + itemName: PropTypes.string, + zone: PropTypes.string, + attempts: PropTypes.string, + supports: PropTypes.string, + visited: PropTypes.bool + })).isRequired, + /** + * The current student for whom we're showing data + */ + selectedStudent: PropTypes.string, + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func +}; + +initArrows(); diff --git a/modules/lo_dash_react_components/src/lib/components/ZPDPlot.testdata.js b/modules/lo_dash_react_components/src/lib/components/ZPDPlot.testdata.js new file mode 100644 index 000000000..bfff40f43 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/ZPDPlot.testdata.js @@ -0,0 +1,34 @@ +/* eslint-disable no-magic-numbers */ + +const testData = { + 'ZPDItemCards': [ + { + "id": "area_circle", + "itemName": "Area of a Circle", + "zone": "ZAD", + "attempts": "5", + "supports": "3/7", + "visited": true + }, + { + "id": "radius_pentagon", + "itemName": "Radius of a pentagon", + "zone": "None", + "attempts": "4", + "supports": "6/7", + "visited": false + }, + { + "id": "radius_pentagon_2", + "itemName": "Radius of a pentagon", + "zone": "ZPD", + "attempts": "4", + "supports": "6/7", + "visited": true + } + ], + 'selectedStudent': "Sue", + 'students': ['Jim', 'Sue', 'Bob'] +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/helperlib.js b/modules/lo_dash_react_components/src/lib/components/helperlib.js new file mode 100644 index 000000000..7245d7200 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/helperlib.js @@ -0,0 +1,225 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { debounce, once } from 'lodash'; + +const RIGHT_DIRECTION = 90; +const LEFT_DIRECTION = -90; +const UP_DIRECTION = 0; +const DOWN_DIRECTION = -180; + +export const CENTER = [0, 0]; +export const RIGHT = [1, RIGHT_DIRECTION]; +export const LEFT = [1, LEFT_DIRECTION]; +export const TOP = [1, UP_DIRECTION]; +export const BOTTOM = [1, DOWN_DIRECTION]; + +const LONG_DEBOUNCE_TIME = 1000; +const SHORT_DEBOUNCE_TIME = 1000; + +/** + * Returns a fraction indicating the staggered position of an item within a list. + * + * @param {number} index - The index of the element in the sequence (0-based). + * @param {number} count - The total number of elements in the sequence. + * @returns {number} The stagger value for the element, which is (index + 1) / (count + 1). + * + * For example, [stagger(0, 2), stagger(1,2)] would return [1/3, 2/3] (as a floating point) + */ +export function stagger(index, count) { + return (index + 1) / (count + 1); +} + + +/** + * djb2 is a hash function that produces a 32-bit hash value from a string. + * The algorithm is attributed to Dan Bernstein. + * + * @param {string} str - The input string to hash. + * @returns {number} The 32-bit hash value of the input string. + */ +export function djb2(str) { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + // eslint-disable-next-line no-magic-numbers + hash = (hash * 33) ^ str.charCodeAt(i); + } + return hash >>> 0; +} + +/** + * Update arrow positions on the page based on the current position + * of their source and target elements. + * + * This function finds all elements with the class "zpd-arrow" and + * updates their position based on the data attributes associated + * with each arrow. + * + * These data attributes should include: + * + * * data-source: ID of element the arrow comes from + * * data-target: ID of element the arrow points to + * * data-wrapper: An element that is the parent of the + * source and target elements + * + * And may include: + * + * * data-source-offset and data-target-offset which should contain + * JSON-encoded arrays of two values [distance, angle], corresponding + * to the distance and angle from the center of the element + * + * This function calculates the distance, angle, and start and end + * positions of the arrow, and updates the arrow's position on the + * page accordingly. + * + * It should be run on page load, page resize, and any other time arrows + * should be updated. +*/ +export const updateArrowPositions = debounce(function() { + const arrows = document.getElementsByClassName("arrow"); + + Array.from(arrows).forEach((arrow) => { + const source = document.getElementById(arrow.getAttribute('data-source')); + const target = document.getElementById(arrow.getAttribute('data-target')); + const offsetParent = arrow.offsetParent; + + const [source_distance, source_angle] = JSON.parse(arrow.getAttribute('data-source-offset') ?? '[0, 0]'); + const [target_distance, target_angle] = JSON.parse(arrow.getAttribute('data-target-offset') ?? '[0, 0]'); + + // get positions of source and target elements + const sourceRect = source.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + let sourceX = sourceRect.left + (sourceRect.width / 2); + let sourceY = sourceRect.top + (sourceRect.height / 2); + let targetX = targetRect.left + (targetRect.width / 2); + let targetY = targetRect.top + (targetRect.height / 2); + + const RIGHT_ANGLE = 90; + const STRAIGHT_ANGLE = 180; + + targetX += target_distance * targetRect.width * Math.cos((target_angle-RIGHT_ANGLE) * Math.PI / STRAIGHT_ANGLE)/2; + targetY += target_distance * targetRect.height * Math.sin((target_angle-RIGHT_ANGLE) * Math.PI / STRAIGHT_ANGLE)/2; + sourceX += source_distance * sourceRect.width * Math.cos((source_angle-RIGHT_ANGLE) * Math.PI / STRAIGHT_ANGLE)/2; + sourceY += source_distance * sourceRect.height * Math.sin((source_angle-RIGHT_ANGLE) * Math.PI / STRAIGHT_ANGLE)/2; + + + // calculate the angle of the line + const angle = Math.atan2(sourceY - targetY, sourceX - targetX); + const degrees = angle * STRAIGHT_ANGLE / Math.PI - RIGHT_ANGLE; + arrow.style.transform = `rotate(${degrees}deg)`; + arrow.style.transformOrigin = "top left"; + + // calculate the length of the line + const distance = Math.sqrt(Math.pow(targetX - sourceX, 2) + Math.pow(targetY - sourceY, 2)); + arrow.style.height = distance + "px"; + + // position the line at the center of the source element + arrow.style.left = (targetX - offsetParent.offsetLeft + window.scrollX) + "px"; + arrow.style.top = (targetY - offsetParent.offsetTop + window.scrollY) + "px"; + }); +}, SHORT_DEBOUNCE_TIME, { leading: true, trailing: true }); + +export const initArrows=once(function() { + window.addEventListener('resize', function(){ + updateArrowPositions(); + }); +}); + +/** + * Arrow: A component that represents an arrow between two targets. It is + * currently for internal use only. + */ +export function Arrow(props) { + const { source, target, wrapper, sourceOffset = CENTER, targetOffset = CENTER } = props; + return ( +
+ ); +} + +Arrow.propTypes = { + /** + * source: The ID of the source element. + */ + source: PropTypes.string.isRequired, + /** + * target: The ID of the target element. + */ + target: PropTypes.string.isRequired, + /** + * wrapper: The ID of any containing element. Obsolete. Should be removed. + */ + wrapper: PropTypes.string, + /** + * Where on the source we point to. Typically, TOP/LEFT/CENTER/BOTTOM/RIGHT, + * but can be an array with a length (0-1) and an angle (in degrees). + */ + sourceOffset: PropTypes.arrayOf(PropTypes.number), + /** + * Where on the target we point to. Typically, TOP/LEFT/CENTER/BOTTOM/RIGHT, + * but can be an array with a length (0-1) and an angle (in degrees). + */ + targetOffset: PropTypes.arrayOf(PropTypes.number), +}; + +document.addEventListener("DOMContentLoaded", () => { + updateArrowPositions(); + setTimeout(() => { + updateArrowPositions(); + }, LONG_DEBOUNCE_TIME); +}); + +document.addEventListener("resize", () => { + updateArrowPositions(); +}); + +document.addEventListener("load", () => { + updateArrowPositions(); + setTimeout(() => { + updateArrowPositions(); + }, LONG_DEBOUNCE_TIME); +}); + + +/* DEBUG CODE. This should eventually go away. */ + +/* + * Creates a div covering the given element ID with absolute positioning. + * The X overlay is intended for use during debugging and is not part of the final product. + * It's helpful for understanding coordinates. + * + * @param {string} elementId - The ID of the element to create an X overlay for. + */ +export function createXOverlay(elementId) { + const targetElement = document.getElementById(elementId); + if (!targetElement) { + console.error(`Element with ID ${elementId} not found.`); + return; + } + const xElement = document.createElement('div'); + xElement.textContent = 'X'; + xElement.style.position = 'absolute'; + xElement.style.top = `${targetElement.offsetTop}px`; + xElement.style.left = `${targetElement.offsetLeft}px`; + xElement.style.width = `${targetElement.offsetWidth}px`; + xElement.style.height = `${targetElement.offsetHeight}px`; + xElement.style.display = 'flex'; + xElement.style.justifyContent = 'center'; + xElement.style.alignItems = 'center'; + xElement.style.fontSize = '24px'; + xElement.style.fontWeight = 'bold'; + xElement.style.color = 'white'; + xElement.style.backgroundColor = 'red'; + xElement.style.borderRadius = '50%'; + xElement.style.cursor = 'pointer'; + xElement.addEventListener('click', () => { + xElement.remove(); + }); + document.body.appendChild(xElement); +} diff --git a/modules/lo_dash_react_components/src/lib/components/helperlib.testdata.js b/modules/lo_dash_react_components/src/lib/components/helperlib.testdata.js new file mode 100644 index 000000000..a9a43dfb1 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/helperlib.testdata.js @@ -0,0 +1,14 @@ +/* eslint-disable no-magic-numbers */ + +// This is just here so we don't break the build for the arrow component. +// +// The arrow component (and the helperlib) might be moved elsewhere, +// so that a dash component for the arrow isn't generated, or the arrow +// component should be extended to work independently. + +const testData = { + source: 'start', + target: 'end' +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/scaffolds.js b/modules/lo_dash_react_components/src/lib/components/scaffolds.js new file mode 100644 index 000000000..65deb80b1 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/scaffolds.js @@ -0,0 +1,164 @@ +/* + Unfinished code. Committing to branch to sync between devices. + */ + + +// A function that re-orders scaffolds to be in the same order as their associated problems +export function reorderScaffolds(problems, scaffolds) { + // A helper function that returns the median of an array of numbers + function median(array) { + // Sort the array in ascending order + array.sort((a, b) => a - b); + // Find the middle index + let mid = Math.floor(array.length / 2); + // If the array has an odd length, return the middle element + if (array.length % 2 === 1) { + return array[mid]; + } + // If the array has an even length, return the average of the middle two elements + else { + return (array[mid - 1] + array[mid]) / 2; + } + } + + // Annotate each scaffold with the indexes of the problems it's associated with + for (let scaffold of scaffolds) { + // Initialize an empty array to store the indexes + scaffold.indexes = []; + // Loop through the problems + for (let i = 0; i < problems.length; i++) { + // If the scaffold id is in the problem's scaffolds array, push the index to the scaffold's indexes array + if (problems[i].scaffolds.includes(scaffold.id)) { + scaffold.indexes.push(i); + } + } + } + + // Take the median value of each scaffold's indexes array + for (let scaffold of scaffolds) { + // If the scaffold has no indexes, set its median to Infinity + if (scaffold.indexes.length === 0) { + scaffold.median = Infinity; + } + // Otherwise, use the helper function to calculate its median + else { + scaffold.median = median(scaffold.indexes); + } + } + + // Sort scaffolds by their median value in ascending order + scaffolds.sort((a, b) => a.median - b.median); + + // Return the reordered scaffolds array + return scaffolds; +} + +// A function that re-orders scaffolds to be in the same order as their associated problems +function reorderScaffolds(problems, scaffolds) { + // A helper function that returns the median of an array of numbers + function median(array) { + // Sort the array in ascending order + array.sort((a, b) => a - b); + // Find the middle index + let mid = Math.floor(array.length / 2); + // If the array has an odd length, return the middle element + if (array.length % 2 === 1) { + return array[mid]; + } + // If the array has an even length, return the average of the middle two elements + else { + return (array[mid - 1] + array[mid]) / 2; + } + } + + // Annotate each scaffold with the indexes of the problems it's associated with + for (let scaffold of scaffolds) { + // Initialize an empty array to store the indexes + scaffold.indexes = []; + // Loop through the problems + for (let i = 0; i < problems.length; i++) { + // If the scaffold id is in the problem's scaffolds array, push the index to the scaffold's indexes array + if (problems[i].scaffolds.includes(scaffold.id)) { + scaffold.indexes.push(i); + } + } + } + + // Take the median value of each scaffold's indexes array + for (let scaffold of scaffolds) { + // If the scaffold has no indexes, set its median to Infinity + if (scaffold.indexes.length === 0) { + scaffold.median = Infinity; + } + // Otherwise, use the helper function to calculate its median + else { + scaffold.median = median(scaffold.indexes); + } + } + + // Sort scaffolds by their median value in ascending order + scaffolds.sort((a, b) => a.median - b.median); + + // Return the reordered scaffolds array + return scaffolds; +} + + +// A function that checks if two arrays are equal +function arrayEqual(a, b) { + // If the arrays have different lengths, return false + if (a.length !== b.length) { + return false; + } + // Loop through the elements of the arrays + for (let i = 0; i < a.length; i++) { + // If the elements are objects, recursively check their equality + if (typeof a[i] === "object" && typeof b[i] === "object") { + if (!arrayEqual(a[i], b[i])) { + return false; + } + } + // If the elements are not objects, compare them directly + else { + if (a[i] !== b[i]) { + return false; + } + } + } + // If no difference is found, return true + return true; +} + +// A sample input for the problems array +let problems = [ + { id: "p1", scaffolds: ["s1", "s2", "s3"] }, + { id: "p2", scaffolds: ["s2", "s4"] }, + { id: "p3", scaffolds: ["s1", "s5"] }, + { id: "p4", scaffolds: ["s3", "s4", "s6"] }, +]; + +// A sample input for the scaffolds array +let scaffolds = [ + { id: "s1" }, + { id: "s2" }, + { id: "s3" }, + { id: "s4" }, + { id: "s5" }, + { id: "s6" }, +]; + +// A sample output for the reordered scaffolds array +let expected = [ + { id: "s1", indexes: [0, 2], median: 1 }, + { id: "s2", indexes: [0, 1], median: 0.5 }, + { id: "s3", indexes: [0, 3], median: 1.5 }, + { id: "s4", indexes: [1, 3], median: 2 }, + { id: "s5", indexes: [2], median: 2 }, + { id: "s6", indexes: [3], median: 3 }, +]; + +// Call the reorderScaffolds function with the sample inputs +let actual = reorderScaffolds(problems, scaffolds); + +// Check if the actual output matches the expected output using the arrayEqual function +console.log(arrayEqual(actual, expected)); // should print true diff --git a/modules/lo_dash_react_components/src/lib/index.js b/modules/lo_dash_react_components/src/lib/index.js new file mode 100644 index 000000000..61fe40819 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/index.js @@ -0,0 +1,31 @@ +import LOConnection from './components/LOConnection.react'; +import LONameTag from './components/LONameTag.react'; +import LOPanelLayout from './components/LOPanelLayout.react'; +import LOCollapse from './components/LOCollapse.react'; +import WOAnnotatedText from './components/WOAnnotatedText.react'; +import WOMetrics from './components/WOMetrics.react'; +import WOIndicatorBars from './components/WOIndicatorBars.react'; +import WOSettings from './components/WOSettings.react'; +import WOStudentTextTile from './components/WOStudentTextTile.react'; +import WOTextHighlight from './components/WOTextHighlight.react'; +import StudentSelectHeader from './components/StudentSelectHeader.react'; +import LOTextMinibars from './components/LOTextMinibars.react'; +import ZPDPlot from './components/ZPDPlot.react'; +import DAProblemDisplay from './components/DAProblemDisplay.react'; + +export { + LOConnection, + LONameTag, + LOPanelLayout, + LOCollapse, + WOAnnotatedText, + WOStudentTextTile, + WOSettings, + WOMetrics, + WOIndicatorBars, + WOTextHighlight, + LOTextMinibars, + StudentSelectHeader, + ZPDPlot, + DAProblemDisplay +}; diff --git a/modules/lo_dash_react_components/src/lib/index.scss b/modules/lo_dash_react_components/src/lib/index.scss new file mode 100644 index 000000000..c98e8f10b --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/index.scss @@ -0,0 +1,558 @@ +@import "../../node_modules/bootstrap/scss/bootstrap"; +@import "../../node_modules/bootstrap/scss/variables"; + +:root { + --mastery-color: hsl(120, 80%, 75%); + --zpd-color: hsl(60, 80%, 75%); + --fail-color: hsl(10, 80%, 75%); + --none-color: hsl(0, 0%, 80%); + --scale: 200px; + --mastery-background: palegreen; + --zpd-background: lightyellow; + --fail-background: bisque; + --mastery-foreground: darkgreen; + --zpd-foreground: brown; + --fail-foreground: darkred; + --background-color: whitesmoke; + --text-color: darkslategray; + --table-background: whitesmoke; + font-family: open-sans; +} + +.LOTableView { + .LOControls { + display: flex; + flex-direction: column; + margin-bottom: 12px; + padding: 8px; + background-color: rgba(255,255,255,0.1); + box-shadow: 0px 5px 5px -5px rgba(0,0,0,0.25), + 0px -5px 5px -5px rgba(0,0,0,0.15); + h2 { + font-size: 12pt; + text-align: center; + font-weight: bold; + } + + label { + margin-left: 8px; + font-weight: normal; + font-weight: 200; + } + input { + margin-right: 8px; + } + } + + .card { + align-items: center; + background-color: white; + box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1); + border-radius: 4px; + margin: 8px; + padding: 16px; + display: flex; + } + + .card-list { + display: flex; + flex-wrap: wrap; + margin: -8px; + + .column:hover { + background-color: rgba(0,0,0,0.1); + } + + .avatar { + border-radius: 50%; + width: 64px; + height: 64px; + } + + .label { + font-size: 12px; + text-transform: uppercase; + color: #555; + } + + .name { + font-weight: bold; + margin-top: 8px; + } + + .columns { + display: flex; + } + + .column { + display: flex; + flex-direction: column; + align-items: center; + vertical-align: top; + } + + .value { + font-size: 16px; + } + + &:not(.card-grid-layout) { + .card { + width: 100%; + height: 125px; + flex-direction: row; + justify-content: space-between; + } + + .columns { + flex-direction: row; + width: 100%; + } + + .column { + justify-content: top; + margin-right: 20px; + width: calc(100% / 4); + text-align: center; + } + + .value { + max-height: 100px; + overflow: scroll; + } + } + + &.card-grid-layout { + .card { + width: 300px; + flex-direction: column; + align-items: center; + } + + .columns { + flex-direction: column; + margin-top: 16px; + } + + .column { + margin-bottom: 8px; + } + } + } +} + +#student-select-header { + .nav-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: lightgray; + width: 100vw; + padding: 10px 0; + box-sizing: border-box; + margin: 0; + font-family: Open Sans Condensed, sans-serif; + font-size: 28px; + font-weight: lighter; + color: var(--text-color); + } + + .header-student { + text-align: center; + flex: 1; + cursor: pointer; + } + + .button-right { + text-align: right; + } + + .button-left { + text-align: left; + } + + .button { + padding: 10px; + } + + .dropdown { + position: absolute; + top: 40px; + left: 50%; + transform: translate(-50%, 0); + min-width: 300px; + width: 33.33%; + background-color: white; + border: 1px solid #ccc; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow-x: hidden; + max-height: 600px; + overflow-y: scroll; + background-color: white; + border: 1px solid gray; + z-index: 2; + } + + .dropdown-item { + padding: 10px; + display: block; + text-align: center; + } + + .dropdown-item-selected { + background-color: lightgray; + } +} + +body { + background-color: var(--background-color); + margin: 0; + padding: 0; +} + +.arrow { + position: absolute; + width: 2px; + background-color: red; + transform-origin: bottom center; + z-index: 2; +} + +.arrow::before { + position: absolute; + height: 0px; + width: 0px; + border: 6px solid transparent; + border-bottom: 8px solid red; + content: ""; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + z-index: 2; +} + + +#zpd-wrapper { + position: relative; + + @mixin columnMixin { + display: flex; + align-items: center; + justify-content: center; + width: 50%; + height: 100vh; + float: left; + } + + .left-column { + @include columnMixin; + } + + .right-column { + @include columnMixin; + flex-direction: column; + } + + @mixin circleMixin($size, $zone) { + position: absolute; + width: calc(var(--scale) * #{$size}); + height: calc(var(--scale) * #{$size}); + border-radius: 50%; + background-color: var(--#{$zone}-background); + box-shadow: 2px 2px 5px var(--#{$zone}-foreground); + } + + .znd-circle { + @include circleMixin(3, fail); + } + + .znd-circle-label { + padding: calc(var(--scale) * 0.5); + } + + .zpd-circle { + @include circleMixin(2, zpd); + top: calc(var(--scale) * 0.5); + left: calc(var(--scale) * 0.5); + } + /* Shift label inside by about w/4√2. We made it a smidge + less because it looks better */ + .zpd-circle-label { + padding: calc(var(--scale) * 0.3); + } + + .zad-circle { + @include circleMixin(1, mastery); + top: calc(var(--scale) * 0.5); + left: calc(var(--scale) * 0.5); + align-items: center; + display: flex; + justify-content: center; + } + + .label { + font-family: "Open Sans", sans-serif; + max-width: 150px; + font-size: 16px; + } + + .item-card { + width: 300px; + height: 100px; + border-radius: 20px 20px 20px 20px; + background-color: lightgray; + margin: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-order: 1; + box-shadow: 2px 2px 5px var(--text-color); + } + + /* new styling for arrows on page 2 */ + #problem-page-wrapper .arrow { + background-color: blue; + width: 3px; + opacity: 0.3; /* make the arrows translucent */ + } + + #problem-page-wrapper .arrow::before { + border-bottom-color: blue; + border: 10px solid transparent; + border-bottom: 12px solid blue; + opacity: 1; /* make the arrows translucent */ + } + + .card-header { + font-weight: bold; + text-align: center; + margin-bottom: 10px; + font-family: "Open Sans Condensed", sans-serif; + font-size: 22px; + font-weight: bold; + color: var(--text-color);; + text-align: center; + margin-bottom: 10px; + } + + .card-table { + display: flex; + flex-direction: column; + font-family: "Open Sans Condensed", sans-serif; + font-size: 18px; + color: var(--font-color); + background-color: var(--table-background); + text-align: center; + width: 250px; + border: 1px solid #ccc; + border-radius: 10px; + box-shadow: 2px 2px 5px #bbb; + margin-bottom: 10px; + } + + .card-row { + display: flex; + flex-direction: row; + justify-content: space-evenly; + width: 250px; + } + + .card-cell { + font-size: 12px; + text-align: center; + width: 50px; + } + + .card-cell:hover { + background-color: #f2f2f2; + } + + .nav-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: lightgray; + width: 100vw; + padding: 10px 0; + box-sizing: border-box; + margin: 0; + font-family: Open Sans Condensed, sans-serif; + font-size: 28px; + font-weight: lighter; + color: var(--text-color); + } + + .header-student { + text-align: center; + flex: 1; + cursor: pointer; + } + + .button-right { + text-align: right; + } + + .button-left { + text-align: left; + } + + .button { + padding: 10px; + } + + .dropdown { + position: absolute; + top: 40px; + left: 50%; + transform: translate(-50%, 0); + min-width: 300px; + width: 33.33%; + background-color: white; + border: 1px solid #ccc; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow-x: hidden; + max-height: 600px; + overflow-y: scroll; + background-color: white; + border: 1px solid gray; + z-index: 2; + } + + .dropdown-item { + padding: 10px; + display: block; + text-align: center; + } + + .dropdown-item-selected { + background-color: lightgray; + } +} + + +/* Problem page view */ + +#problem-page-wrapper { + body { + /*font-family: Lato;*/ + } + + .card { + background-color: #f2f2f2; + border: 1px solid #ccc; + padding: 10px; + } + + .problem-container { + display: grid; + grid-template-columns: 1fr; + grid-gap: 10px; + } + + .scaffold-container { + display: grid; + grid-template-columns: 1fr; + grid-gap: 10px; + } + + .container { + display: grid; + grid-template-columns: 1fr 0.3fr 1fr; + grid-gap: 10px; + } + .problem-container, .scaffold-container { + grid-template-columns: 1fr; + } + + .card { + border: 1px solid black; + border-radius: 5px; + padding: 10px; + margin: 10px; + } + + ul { + list-style: none; + margin: 0; + padding: 0; + } + + li { + display: inline-block; + margin-right: 5px; + } + + .pie-chart { + width: 100px; + height: 100px; + } + + .initials-box { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #ccc; + background-color: #f0f0f0; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + border-radius: 5px; + padding: 10px; + height: 60px; + } + + .student-initials { + width: 40px; + height: 40px; + padding: 3px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-left: 5px; + background-color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + .background-canvas { + position: fixed; + z-index: -1; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + .card.scaffold { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + border: 1px solid black; + } + + .card.scaffold h3 { + margin: 0 0 8px 16px; + } + + .card.scaffold p { + margin: 0 0 16px 16px; + } + + .card.scaffold > .target-box { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #ccc; + background-color: #f0f0f0; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + border-radius: 50%; + padding: 10px; + width: 40px; + height: 40px; + margin-right: 16px; + } +} + diff --git a/modules/lo_dash_react_components/src/logo.svg b/modules/lo_dash_react_components/src/logo.svg new file mode 100644 index 000000000..9dfc1c058 --- /dev/null +++ b/modules/lo_dash_react_components/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/lo_dash_react_components/src/reportWebVitals.js b/modules/lo_dash_react_components/src/reportWebVitals.js new file mode 100644 index 000000000..5253d3ad9 --- /dev/null +++ b/modules/lo_dash_react_components/src/reportWebVitals.js @@ -0,0 +1,13 @@ +const reportWebVitals = onPerfEntry => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/modules/lo_dash_react_components/tests/__init__.py b/modules/lo_dash_react_components/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_dash_react_components/tests/requirements.txt b/modules/lo_dash_react_components/tests/requirements.txt new file mode 100644 index 000000000..588dd3e9a --- /dev/null +++ b/modules/lo_dash_react_components/tests/requirements.txt @@ -0,0 +1,5 @@ +# Packages needed to run the tests. +# Switch into a virtual environment +# pip install -r requirements.txt + +dash[dev,testing]>=1.15.0 diff --git a/modules/lo_dash_react_components/tests/test_usage.py b/modules/lo_dash_react_components/tests/test_usage.py new file mode 100644 index 000000000..a86673d72 --- /dev/null +++ b/modules/lo_dash_react_components/tests/test_usage.py @@ -0,0 +1,25 @@ +from dash.testing.application_runners import import_app + + +# Basic test for the component rendering. +# The dash_duo pytest fixture is installed with dash (v1.0+) +def test_render_component(dash_duo): + # Start a dash app contained as the variable `app` in `usage.py` + app = import_app('usage') + dash_duo.start_server(app) + + # Get the generated component input with selenium + # The html input will be a children of the #input dash component + my_component = dash_duo.find_element('#input > input') + + assert 'my-value' == my_component.get_attribute('value') + + # Clear the input + dash_duo.clear_input(my_component) + + # Send keys to the custom input. + my_component.send_keys('Hello dash') + + # Wait for the text to equal, if after the timeout (default 10 seconds) + # the text is not equal it will fail the test. + dash_duo.wait_for_text_to_equal('#output', 'You have entered Hello dash') diff --git a/modules/lo_dash_react_components/usage.py b/modules/lo_dash_react_components/usage.py new file mode 100644 index 000000000..d59cd970e --- /dev/null +++ b/modules/lo_dash_react_components/usage.py @@ -0,0 +1,78 @@ +import inspect +import os.path + +import js2py + +import dash.development +from dash import Dash, html +import dash_bootstrap_components as dbc + +import lo_dash_react_components + +debug_test_data = { +} + + +def test_data(component): + """ + This function is used to retrieve test data for a specific + component. + + It first checks if the component exists in the test data + dictionary. This is mostly for use during development. If it does, + it returns the corresponding test data. If not, it checks if a + test data file exists for the component. If it does, it reads the + file and evaluates it using js2py, and returns the 'testData' + object defined in the file. If neither the component or the test + data file exists, it returns an empty dictionary.. + + :param component: The name of the component to get test data for. + :type component: str + :return: The test data for the specified component, or None if it doesn't exist. + :rtype: dict or None + """ + if component in debug_test_data: + return debug_test_data[component] + path = f"src/lib/components/{component}.testdata.js" + + if os.path.exists(path): + with open(path) as f: + text = f.read() + context = js2py.EvalJs({}) + # js2py is ES5.1, which doesn't support export + context.execute(text.replace("export default ", "")) + component_data = context.testData + return component_data.to_dict() + return {} + + +def get_subclasses(module, base_class): + """Returns a list of all items in a module that are subclasses of `base_class`.""" + return [name for name, obj in inspect.getmembers(module) + if inspect.isclass(obj) and issubclass(obj, base_class)] + + +component_list = get_subclasses(lo_dash_react_components, dash.development.base_component.Component) +link_items = [html.Li(html.A(href=f"/components/{component}", children=component)) for component in component_list] +ul = html.Ul(children=link_items) + +app = Dash(__name__, use_pages=True, pages_folder="", external_stylesheets=[dbc.themes.BOOTSTRAP]) + +for component in component_list: + urlpath = f"/components/{component}" + component_class = getattr(lo_dash_react_components, component) + parameters = test_data(component) + layout = component_class(**parameters) + dash.register_page(component, path=urlpath, layout=layout) + +app.layout = html.Div([ + dash.page_container, + html.Hr(), + html.Div(id='output'), + html.H1("Components"), + ul +]) + + +if __name__ == '__main__': + app.run_server(debug=True) diff --git a/modules/lo_dash_react_components/webpack.config.js b/modules/lo_dash_react_components/webpack.config.js new file mode 100644 index 000000000..f91f4a382 --- /dev/null +++ b/modules/lo_dash_react_components/webpack.config.js @@ -0,0 +1,119 @@ +const path = require('path'); +const TerserPlugin = require('terser-webpack-plugin'); +const webpack = require('webpack'); +const WebpackDashDynamicImport = require('@plotly/webpack-dash-dynamic-import'); +const packagejson = require('./package.json'); + +const dashLibraryName = packagejson.name.replace(/-/g, '_'); + +module.exports = (env, argv) => { + + let mode; + + const overrides = module.exports || {}; + + // if user specified mode flag take that value + if (argv && argv.mode) { + mode = argv.mode; + } + + // else if configuration object is already set (module.exports) use that value + else if (overrides.mode) { + mode = overrides.mode; + } + + // else take webpack default (production) + else { + mode = 'production'; + } + + let filename = (overrides.output || {}).filename; + if(!filename) { + const modeSuffix = mode === 'development' ? 'dev' : 'min'; + filename = `${dashLibraryName}.${modeSuffix}.js`; + } + + const entry = overrides.entry || {main: './src/lib/index.js'}; + + const devtool = overrides.devtool || 'source-map'; + + const externals = ('externals' in overrides) ? overrides.externals : ({ + react: 'React', + 'react-dom': 'ReactDOM', + 'plotly.js': 'Plotly', + 'prop-types': 'PropTypes', + }); + + return { + mode, + entry, + output: { + path: path.resolve(__dirname, dashLibraryName), + chunkFilename: '[name].js', + filename, + library: dashLibraryName, + libraryTarget: 'window', + }, + devtool, + externals, + module: { + rules: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + }, + }, + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + options: { + } + }, + { + loader: 'css-loader', + }, + ], + }, + ], + }, + optimization: { + minimizer: [ + new TerserPlugin({ + parallel: true, + terserOptions: { + warnings: false, + ie8: false + } + }) + ], + splitChunks: { + cacheGroups: { + async: { + chunks: 'async', + minSize: 0, + name(module, chunks, cacheGroupKey) { + return `${cacheGroupKey}-${chunks[0].name}`; + } + }, + shared: { + chunks: 'all', + minSize: 0, + minChunks: 2, + name: 'lo_dash_react_components-shared' + } + } + } + }, + plugins: [ + new WebpackDashDynamicImport(), + new webpack.SourceMapDevToolPlugin({ + filename: '[file].map', + exclude: ['async-plotlyjs'] + }) + ] + } +}; diff --git a/modules/lo_dash_react_components/webpack.serve.config.js b/modules/lo_dash_react_components/webpack.serve.config.js new file mode 100644 index 000000000..09a1a7ae3 --- /dev/null +++ b/modules/lo_dash_react_components/webpack.serve.config.js @@ -0,0 +1,12 @@ +const config = require('./webpack.config.js'); +const path = require('path'); + +config.entry = {main: './src/demo/index.js'}; +config.output = { + filename: './output.js', + path: path.resolve(__dirname), +}; +config.mode = 'development'; +config.externals = undefined; // eslint-disable-line +config.devtool = 'inline-source-map'; +module.exports = config; diff --git a/modules/lo_event/.eslintrc.json b/modules/lo_event/.eslintrc.json new file mode 100644 index 000000000..ecdd72d93 --- /dev/null +++ b/modules/lo_event/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "env": { + "webextensions": true, + "browser": true + } +} \ No newline at end of file diff --git a/modules/lo_event/README.md b/modules/lo_event/README.md new file mode 100644 index 000000000..2c3b7945a --- /dev/null +++ b/modules/lo_event/README.md @@ -0,0 +1,59 @@ +# Learning Observer Event Library + +This is a module used to stream events into the Learning Observer (and, in the future, potentially other Learning Record Stores). This is in development. The requirements are: + +- We would like to be able to stream events with multiple loggers. + - In most cases, in practice, we use a websocket logger, with a persistent connection. + - For occasional events, we support AJAX logging. + - In addition, for ease-of-debugging, we can print events to the console + - We are beginning to support a workflow with `react` integration, which provides for very good observability +- We follow the general format used in Caliper, xAPI, and Open edX of one JSON object per event +- We use a free-form JSON format, but encourage following Caliper / xAPI guidelines where convenient +- We currently support JavaScript, but would like to support other languages in the future + +Examples of places where we intentionally diverge from standards: + +- Good events are like onions -- they have layers. We don't assume we can e.g. trust timestamps or authentication from the system generating events, or that we will have all context up-front. Systems can add timestamps, authentication, and similar, much like e.g. SMTP messages being passed between systems. +- We do need to have a header for metadata + authentication +- We'd like to be at least sensitive to bandwidth. It's not worth resending data with each event that can be in a header or in update events. A lot of standards have large, cumbersome events (which are not human-friendly, and expensive to store and process) +- We're a lot more freeform in what we send and accept, since learning contexts can be pretty rich (and technology evolves) in ways which standards don't always keep up with. + +Our goal is to simplify compatibility and to maintain compliance where reasonable, but to be more flexible than strict compliance with xAPI or Caliper. + +## Installation + +This package will support browser-based events, Node, and Python. + +However, before installing in either environment, we need to download the [xAPI](https://xapi.com/overview/) components. These components are used to determine the type of events being used. + +```bash +cd xapi +./download_xapi_json.sh +``` + +### As a Node package + +To use in a separate node project, such as the `/extension/writing_process`, you need to make the project available on your system. + +```bash +npm install +npm link +``` + +Then from the other node project, run + +```bash +npm link lo_event +``` + +*Note:* you may need to rerun `npm link lo_event` after you run `npm install` at the target location. + +If this runs into issues, a more robust way is to run `npm pack` to create a tarball npm package, and then to `npm install` that package. This has the downside of requiring a reinstall on every change, which is somewhat cumbersome. + +### As a Python package + +Simply install the package as a normal python module. + +```bash +pip install . +``` diff --git a/modules/lo_event/examples/browser_events.html b/modules/lo_event/examples/browser_events.html new file mode 100644 index 000000000..5b7e59317 --- /dev/null +++ b/modules/lo_event/examples/browser_events.html @@ -0,0 +1,50 @@ + + + +

HTML DOM Events

+ +

Demo page for HTML events.

+ + + +

+ +
+ + + + diff --git a/modules/lo_event/examples/index.html b/modules/lo_event/examples/index.html new file mode 100644 index 000000000..60b3fa4fc --- /dev/null +++ b/modules/lo_event/examples/index.html @@ -0,0 +1,14 @@ + + + + + Examples + + +

Examples

+ + + diff --git a/modules/lo_event/examples/redux_loop.html b/modules/lo_event/examples/redux_loop.html new file mode 100644 index 000000000..35f20acb5 --- /dev/null +++ b/modules/lo_event/examples/redux_loop.html @@ -0,0 +1,11 @@ + + + + + Redux loop with actions + + +
+ + + diff --git a/modules/lo_event/examples/redux_loop.js b/modules/lo_event/examples/redux_loop.js new file mode 100644 index 000000000..7b04d4980 --- /dev/null +++ b/modules/lo_event/examples/redux_loop.js @@ -0,0 +1,33 @@ +import { createRoot } from "react-dom/client"; +import * as lo_event from '../lo_event/lo_event'; +import * as reduxLogger from '../lo_event/reduxLogger.js'; +import { consoleLogger } from '../lo_event/consoleLogger.js'; +import * as debug from '../lo_event/debugLog.js'; +import { init } from '../lo_event/lo_assess/lo_assess.js'; +import { ActionButton, PopupAction, ConsoleAction } from '../lo_event/lo_assess/components/components.jsx'; + +init(); + +lo_event.go(); + +export function App() { + return ( + <> +

Hello world!!

+

This demos how we can build actions with our API. Pressing the button will cause an alert and a console.log

+ + Test! + + I am a little action, short and stout! + + + I am a bit of text! + + + + ); +} + +const container = document.getElementById("app"); +const root = createRoot(container); +root.render(); diff --git a/modules/lo_event/license.md b/modules/lo_event/license.md new file mode 100644 index 000000000..e9f673a76 --- /dev/null +++ b/modules/lo_event/license.md @@ -0,0 +1,7 @@ +This code is open source. The license is TBD. You're obviously welcome to use it under the AGPL, like the rest of the Learning Observer and Writing Observer. + +For this specific module, we would like to also include a more permissive license in the future. That's TBD. If you'd like to use it under a more permissive license right now, please contact us, and we can try to make the future be now. + +If you contribute code specifically to `lo_event`, you should be aware we will probably license all code in `lo_event` under a more permissive (but AGPL-compatible) license, and you should make sure that you are okay with that before contributing. We would like proprietary systems to be able to use `lo_event` to stream into the _Learning Observer_, so this is necessary. + +(The rest of the system is AGPLv3, and we would need to seperately ask permission from all contributors for a relicense; we don't want that same constraint here.) diff --git a/modules/lo_event/lo_cli.js b/modules/lo_event/lo_cli.js new file mode 100755 index 000000000..dfe75dc2a --- /dev/null +++ b/modules/lo_event/lo_cli.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +import * as bl from './lo_event/lo_assess/components/buildlib.js'; +import { config } from './lo_event/lo_assess/components/buildConfig.js'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +console.log("Welcome to the lo_cli!"); + +// Define the command-line options using yargs +const argv = yargs(hideBin(process.argv)) + .command('build', 'Build JSX files from XML, in lo_assess', (argv) => { + console.log("Building LO Assess Components!", argv); + bl.compileXMLComponents(); + const cfs = bl.writeComponentFile(); + console.log("Wrote:\n", cfs); + }) + .command('info', 'See what we would build', (args) => { + console.log(bl.componentsFile); + console.log(config()); + console.log(bl.listXmlFiles()); + console.log(bl.listComponentFiles()); + }) + .command('build_next', 'Build JSX files from XML, in a child project', (argv) => { + console.log("Building Local Components"); + }) + .demandCommand(1, "Please provide a command") + .help() + .argv; + diff --git a/modules/lo_event/lo_event/__init__.py b/modules/lo_event/lo_event/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_event/lo_event/browserStorage.js b/modules/lo_event/lo_event/browserStorage.js new file mode 100644 index 000000000..181eeb5b0 --- /dev/null +++ b/modules/lo_event/lo_event/browserStorage.js @@ -0,0 +1,139 @@ +// We often need a KVS to store things like settings. + +// This code is not fully tested. Much of it works, but there are +// pathways and fallbacks which might not. + +// TODO: Add test cases for all pathways and fallbacks. This kind of code +// is very tough to debug otherwise. + +// This code is a little bit rough since it needs to work across many +// environments (different browsers, browser permissions, in node, in +// extensions, etc.). It's hard to know how it will behave everywhere. + +// The idea here is we need a key-value store for many purposes, but +// especially, opt-out / opt-in. This allows us to use: +// * Extension-specific storage APIs (chrome.storage) +// * Normal browser storage APIs +// * As a final fall-back (and debugging aid), in-memory storage +// +// We copy the chrome.storage API since it is the most annoying, +// being asynchronous. It's not possible to implement the other APIs +// on top of it, but the reverse is simple. +// +// At the end, we export one storage object, with a consistent API, +// using our most reliable storage. +import * as debug from './debugLog.js'; + +// thunkStorage mirrors the capability of `chrome.storage.sync` +// this is used for testing purposes, as well as a fallback if +// chrome.storage.sync is unavailable. +const thunkStorage = { + data: {}, + set: function (items, callback) { + this.data = { ...this.data, ...items }; + if (callback) callback(); + }, + get: function (keys, callback) { + let result = {}; + if (Array.isArray(keys)) { + keys.forEach(key => { + if (Object.prototype.hasOwnProperty.call(this.data, key)) { + result[key] = this.data[key]; + } + }); + } else if (typeof keys === 'string') { + if (Object.prototype.hasOwnProperty.call(this.data, keys)) { + result[keys] = this.data[keys]; + } + } else { + result = { ...this.data }; + } + if (callback) callback(result); + } +}; + +/** + * Returns a `get` function that wraps a provided `getItem` + * function to mirror the workflow and capabilities of the + * `storage.sync.get`/`chrome.sync.get` API. + */ +function getWithCallback (getItem) { + function get (items, callback = () => {}) { + if (typeof items === 'string') { + items = [items]; + } + const results = {}; + for (const item of items) { + results[item] = getItem(item); + } + callback(results); + } + return get; +} + +/** + * Returns a `set` function that wraps a provided `setItem` + * function to mirror the capabilities of the + * `storage.sync.set`/`chrome.sync.set` API. + */ +function setWithCallback (setItem) { + function set (items, callback = () => {}) { + for (const item in items) { + setItem(item, items[item]); + } + if (callback) callback(); + } + return set; +} + +export let storage; + +let b; + +if (typeof browser !== 'undefined') { + b = browser; +} else if (typeof chrome !== 'undefined') { + b = chrome; +} + +/** + * Determine which backend storage API to use and (sometimes) + * add compatibility wrappers around them. + * + * Backend API priority: + * - Browser's storage.sync (extension only) + * - Browser's storage.local (extension only) + * - localStorage + * - window.localStorage + * - thunkStorage + */ +if (typeof b !== 'undefined') { + if (b.storage && b.storage.sync) { + debug.info('Setting storage to storage.sync'); + storage = b.storage.sync; + } else if (b.storage && b.storage.local) { + debug.info('Setting storage to storage.local'); + storage = b.storage.local; + } else { + debug.info('Setting storage to default, thunkStorage'); + storage = thunkStorage; + } +} else if (typeof localStorage !== 'undefined') { + // Add compatibility modifications for localStorage + debug.info('Setting storage to localStorage'); + storage = { + get: getWithCallback(localStorage.getItem.bind(localStorage)), + set: setWithCallback(localStorage.setItem.bind(localStorage)) + }; +} else if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') { + // Add compatibility modifications for window.localStorage + debug.info('Setting storage to window.localStorage'); + storage = { + get: getWithCallback(window.localStorage.getItem.bind(window.localStorage)), + set: setWithCallback(window.localStorage.setItem.bind(window.localStorage)) + }; +} else { + // If none of the above options exist, fall back to thunkStorage or exit gracefully + debug.info('Setting storage to default, thunkStorage'); + storage = thunkStorage; +} diff --git a/modules/lo_event/lo_event/browser_events.js b/modules/lo_event/lo_event/browser_events.js new file mode 100644 index 000000000..4db039832 --- /dev/null +++ b/modules/lo_event/lo_event/browser_events.js @@ -0,0 +1,312 @@ +import { treeget, copyFields } from './util.js'; + +function selectionData(event) { + return window.getSelection().toString(); +} + +function windowSize(event){ + return { h: window.innerHeight, w: window.innerWidth }; +} + +function mediaInfo(event){ + if(event.video) { + return copyFields(event.video, ["src", "width", "height", "duration", "currentRime", "muted", "paused", "controls"]); + } else { + return null; + } +} + +let parent_events = { + generic: {properties: ["timeStamp", "type"]}, + animation: {parent: ["generic"], properties: ["animationName", "elapsedTime", "pseudoElement"]}, + clipboard: {parent: ["generic"]}, + composition: {parent: ["generic"], properties: ["data", "locale"]}, + key: {parent: ["generic"], properties: ["altKey", "charCode", "code", "ctrlKey", "iscomposing", "key", "keyCode", "metaKey", "repeat", "shiftKey", "timeStamp", "type", "which"], functions: {}}, + mouse: {parent: ["generic"], properties: ["x", "y", "layerX", "layerY", "movementX", "movementY", "offsetY", "offsetY", "pageX", "pageY", "screenX", "screenY", "altKey", "metaKey", "shiftKey"]}, + pointer: {parent: ["mouse"], properties: ["altitudeAngle", "azimuthAngle", "pointerId", "width", "height", "pressure", "tangentialPressure", "tiltX", "tiltY", "twist", "pointerType", "isPrimary"]}, + scroll: {parent: ["generic"], properties: ["detail", "layerX", "layerY", "which", "rangeOffset", "SCROLL_PAGE_UP", "SCROLL_PAGE_DOWN"]}, + touch: {parent: ["mouse"], properties: ["changedTouches", "targetTouches", "touches", "rotation", "scale"]}, // Rotation / scale are nonstandard, but helpful where they work. + transition: {parent: ["generic"], properties: ["propertyName", "elapsedTime", "pseudoElement"]}, + media: {parent: ["generic"], functions: { mediaInfo }}, +}; + +// TODO: Find some way to either aggregate or debounce dense events like mouseMove, seeks, etc. +// These can occur many times in each second, and naively treated, they will overwhelm the server. +let events = { + // Key events (done) + keydown: {parent: ["key"]}, + keypress: {parent: ["key"]}, + keyup: {parent: ["key"]}, + + // Composition events are used in entry of e.g. Chinese characters (probably done, but untested) + compositionupdate: {parent: ["composition"]}, + compositionstart: {parent: ["composition"]}, + compositionend: {parent: ["composition"]}, + + // Clipboard events (done, but we don't log data for cut) + cut: {parent: ["clipboard"]}, + copy: {parent: ["clipboard"], functions: {selectionData}}, + paste: {parent: ["clipboard"], functions: {clipboardData : (event) => event.clipboardData.getData('text')}}, + + // Drag-and-drop events (probably done, but we may want to extract dataTransfer, and untested) +// drag: {parent: "mouse", properties: [], functions: {}}, <-- Large number of events + dragend: {parent: "mouse", properties: [], functions: {}}, + dragenter: {parent: "mouse", properties: [], functions: {}}, + dragleave: {parent: "mouse", properties: [], functions: {}}, +// dragover: {parent: "mouse", properties: [], functions: {}}, + dragstart: {parent: "mouse", properties: [], functions: {}}, + drop: {parent: "mouse", properties: [], functions: {}}, + + // Animations, videos, media, etc. + // Animation (done) + animationend: {parent: ["animation"]}, + animationiteration: {parent: ["animation"]}, + animationstart: {parent: ["animation"]}, + // CSS transitions + transitionstart: {parent: ["transition"]}, + transitionrun: {parent: ["transition"]}, + transitionend: {parent: ["transition"]}, + // Resources + loadstart: {parent: ["media"], properties: ["lengthComputable", "loaded", "total"]}, + abort: {parent: ["generic"]}, // User aborts loading an element + error: {parent: ["generic"]}, // Could not load image + // Videos + durationchange: {parent: ["media"]}, // Video duration changes. Don't know why. + play: {parent: ["media"]}, // These two happen when a video is played / unpaused, with nuanced differences. + playing: {parent: ["media"]}, + stalled: {parent: ["media"]}, + seeked: {parent: ["media"]}, // + // seeking: {parent: ["media"]}, <-- Many events + // timeupdate <-- Many events + canplay: {parent: ["media"]}, + canplaythrough: {parent: ["media"]}, + ended: {parent: ["media"]}, + loadeddata: {parent: ["media"]}, // First video frame available + loadedmetadata: {parent: ["media"]}, // Audio / video metadata available + pause: {parent: ["media"]}, + progress: {parent: ["media"]}, + ratechange: {parent: ["media"]}, + volumechange: {parent: ["media"]}, + waiting: {parent: ["media"]}, + suspend: {parent: ["media"]}, + + // Mouse / pointer (probably done) + // There is a transition from mouse events to pointer events, which also handle touch, pen, etc. events + //pointermove: {parent: ["pointer"]}, + //pointerrawupdate: {parent: ["pointer"]}, + pointerup: {parent: ["pointer"]}, + pointercancel: {parent: ["pointer"]}, + // pointerout: {parent: ["pointer"]}, + pointerleave: {parent: ["pointer"]}, + gotpointercapture: {parent: ["pointer"]}, + lostpointercapture: {parent: ["pointer"]}, + // Mouse events + // wheel: {parent: ["mouse"], properties: ["deltaX", "deltaY", "deltaZ", "deltaMode", "wheelDelta"]}, // To do: debounce + mousedown: {parent: ["mouse"]}, + mouseenter: {parent: ["mouse"]}, + mouseleave: {parent: ["mouse"]}, + // mousemove: {properties: [], functions: {}}, <-- Massive number of events + // mouseover: {properties: [], functions: {}}, <-- Moderate number of events + // mouseout: {properties: [], functions: {}}, <-- Moderate number of events + mouseup: {parent: ["mouse"]}, + // Touch events + touchcancel: {parent: ["touch"]}, + touchend: {parent: ["touch"]}, + // touchmove: {parent: ["touch"]}, + touchstart: {parent: ["touch"]}, + // Pointer actions + click: {parent: ["mouse"]}, + dblclick: {parent: ["mouse"]}, + contextmenu: {parent: ["pointer"]}, + show: {parent: ["generic"]}, // context menu + // Scroll + // scroll: {parent: ["generic"]}, // Massive number of events. These sorts of events should be debounced, perhaps. + scrollend: {parent: ["generic"], properties: [], functions: {}}, + + // Form / input-style elements + change: {parent: ["generic"]}, // changed. Typically when unfocused + input: {parent: ["generic"], properties: ["data", "inputType"]}, // Similar, typically, keystoke-by-keystroke (web search for nuanced difference) + invalid: {parent: ["generic"], properties: [], functions: {}}, + toggle: {parent: ["generic"], properties: [], functions: {}}, // Details opened or closed + reset: {parent: ["generic"]}, // Form is reset + submit: {parent: ["generic"]}, + + // General + DOMContentLoaded: {parent: ["generic"]}, + readystatechange: {parent: ["generic"]}, + prereadychange: {parent: ["generic"]}, + load: {parent: ["generic"]}, + unload: {parent: ["generic"]}, // Deprecated, but keeping around just in case it works. Page closed. + beforeunload: {parent: ["generic"]}, // Should do the same thing, but work. May have spurious events if cancelled. + pagehide: {parent: ["generic"], properties: ["persisted"]}, + pageshow: {parent: ["generic"], properties: ["persisted"]}, + hashchange: {parent: ["generic"], properties: ["newURL", "oldURL"]}, + fullscreenchange: {parent: ["generic"]}, + fullscreenerror: {parent: ["generic"]}, + offline: {parent: ["generic"]}, + online: {parent: ["generic"]}, + visibilitychange: {parent: ["generic"], functions: {visibility: (event) => document.visibilityState}}, + deviceorientation: {parent: ["generic"]}, + + // Element focus (probably done) + blur: {parent: ["generic"]}, + focus: {parent: ["generic"]}, + focusin: {parent: ["generic"]}, + focusout: {parent: ["generic"]}, + // Printing + afterprint: {parent: ["generic"]}, + beforeprint: {parent: ["generic"]}, + // PWA install + appinstalled: {parent: ["generic"]}, + beforeinstallprompt: {parent: ["generic"]}, + // Selection + select: {parent: ["generic"], functions: { selectionStart: (event) => event.target.selectionStart, selectionEnd: (event) => event.target.selectionEnd, selectionData }}, + selectionchange: {parent: ["generic"], functions: { selectionData }}, + + // Uncategorized +// message: {parent: ["generic"], properties: [], functions: {}}, <-- We want to avoid the possibility of infinite loops +// open: {parent: ["generic"], properties: [], functions: {}}, <-- We want to avoid the possibility of infinite loops +// resize: {parent: ["generic"], functions: { windowSize }}, <-- This would be helpful with a debounce, so we have the final resize + +} + + +/* + These functions navigated the structures above and integrate fields from parents + */ + +// Helper for building event property list +function compileEventProperties(event) { + let properties = []; + if (event.parent) { + event.parent.forEach((parentEvent) => { + properties = properties.concat(compileEventProperties(parent_events[parentEvent])); + }); + } + if(event.properties) { + properties = properties.concat(event.properties); + } + return properties; +} + +// Helper for building event function dictionary +function compileEventFunctions(event) { + let functions = {}; + if (event.parent) { + event.parent.forEach((parentEvent) => { + functions = { ...functions, ...compileEventFunctions(parent_events[parentEvent]) }; + }); + } + functions = { ...functions, ...event.functions }; + return functions; +} + +// Helper for building event function list +function compileEvent(event) { + const properties = compileEventProperties(event); + const functions = compileEventFunctions(event); + + return { properties, functions }; +} + +/* + These functions extract relevant data from a given event + */ + +// This grabs information about an element on a page, typically an event target. +function targetInfo(target) { + let fields = copyFields(target, ["className", "nodeType", "localName", "tagName", "nodeName", "id", "value"]); + if(target.classList) { + fields.classlist = Array.from(target.classList); + } + + return fields; +} + +// This copies information about the targets and elements related to +// an event into a dictionary. +function copyTargets(event) { + // These are the potential elements associated with an event + // This is very redundant, but most of the redundancy disappears with compression. + // We should consider scaling this back if these are identical, however, just for readability + const targets = [ + "target", + "currentTarget", + "srcElement", + "view", + "relatedTarget" + ]; + + let compiledTargets = {}; // The information we return + let uncompiledTargets = {}; // The actual target object themselves + + // For each candidate target which exists.... + targets.forEach((targetKey) => { + if (event[targetKey]) { + // Check if we've already processed it + let duplicateKeys = Object.keys(uncompiledTargets).filter(key => { + return uncompiledTargets[key] === event[targetKey]; + }); + // If so, we just add it to our list of duplicates + if (duplicateKeys.length > 0) { + if(!compiledTargets[duplicateKeys[0]].dupes) { + compiledTargets[duplicateKeys[0]].dupes = []; + } + compiledTargets[duplicateKeys[0]].dupes.push(targetKey); + // Otherwise, we include it in the main dictionary. + } else { + uncompiledTargets[targetKey] = event[targetKey]; + compiledTargets[targetKey] = targetInfo(event[targetKey]); + } + } + }); + + return compiledTargets; +} + +export function lo_event_name(event) { + return `browser.${events[event.type].parent[0]}.${event.type}`; +} + +export function lo_event_props(event) { + const { properties, functions } = compileEvent(events[event.type]); + const copiedProperties = copyFields(event, properties); + let props = {...copiedProperties, ...copyTargets(event)}; + for (const f in functions) { + const d = functions[f](event); + if(d) { + props[f] = d; + } + } + return props; +} + +function debounce(func, wait) { + // TODO +} + +function eventListener(dispatch) { + return function(event) { + const eventType = lo_event_name(event); + const browser_props = lo_event_props(event); + + const lodict = { + browser_props + }; + if (events[event.type].debounce) { + lodict.debounced = true; + debounce( + eventType, + () => dispatch(eventType, lodict) + ); + } else { + dispatch(eventType, lodict); + } + }; +} + +export function subscribeToEvents({ target = document, eventList = events, dispatch=console.log } = {}) { + for (let key in eventList) { + target.addEventListener(key, eventListener(dispatch)); + } +} diff --git a/modules/lo_event/lo_event/consoleLogger.js b/modules/lo_event/lo_event/consoleLogger.js new file mode 100644 index 000000000..157681e25 --- /dev/null +++ b/modules/lo_event/lo_event/consoleLogger.js @@ -0,0 +1,14 @@ +function consoleLog (event) { + console.log(event); +} + +export function consoleLogger () { + /* + Log to browser JavaScript console + */ + consoleLog.init = function () { console.log('Initializing console logger!'); }; + consoleLog.setField = function (metadata) { console.log('setField:', metadata); }; + consoleLog.lo_name = 'Console Logger'; + + return consoleLog; +} diff --git a/modules/lo_event/lo_event/debugLog.js b/modules/lo_event/lo_event/debugLog.js new file mode 100644 index 000000000..566a2fdeb --- /dev/null +++ b/modules/lo_event/lo_event/debugLog.js @@ -0,0 +1,117 @@ +/** + * The debugLog handles formatting and routing debug statements to + * different logging outputs. + */ + +/** + * Returns a function that will route debugLog events + * through the `sendEvent` function. This is typically used + * for sending events through to the current `lo_event` loggers. + * Events are transmitted when the count of a specific event + * type reaches a power of 10. + * + * @param {*} sendEvent a `function(event_type, message)` to route events to + * @returns a function to log events + */ +function sendEventToLogger (sendEvent) { + const counts = {}; + return function (messageType, message, stackTrace) { + if (!Object.prototype.hasOwnProperty.call(counts, messageType)) { + counts[messageType] = 0; + } + counts[messageType]++; + // we confirmed that `Math.log10` does not produce any rounding errors on + // Firefox and Chrome, but gives exact integer answers for powers of 10 + if (Math.log10(counts[messageType]) % 1 === 0) { + const payload = { message_type: messageType, message, count: counts[messageType] }; + if (stackTrace) { + payload.stack = stackTrace; + } + sendEvent('debug', payload); + } + }; +} + +/** + * Send debugLog event to the browser console + */ +function sendToConsole (messageType, message, stackTrace) { + const stackOutput = stackTrace ? `\n Stacktrace: ${stackTrace}` : ''; + console.log(`${messageType}, ${message} ${stackOutput}`); +} + +/** + * LOG_OUTPUT refer to where we route debug events + * `CONSOLE`: routes events to the browser console + * `LOGGER`: routes events to standard `lo_event` pipeline + */ +export const LOG_OUTPUT = { + CONSOLE: sendToConsole, + LOGGER: sendEventToLogger +}; + +/** + * LEVEL corresponds to how much information we include when we log something + * `none`: does not log any information + * `simple`: logs the data as is + * `extended`: logs the data in conjuction with timestamp and stack trace + */ +export const LEVEL = { + NONE: 'none', + SIMPLE: 'simple', + EXTENDED: 'extended' +}; + +let debugLevel = LEVEL.SIMPLE; + +let debugLogOutputs = [LOG_OUTPUT.CONSOLE]; + +export function setLevel (level) { + if (![LEVEL.NONE, LEVEL.SIMPLE, LEVEL.EXTENDED].includes(level)) { + throw new Error(`Invalid debug log type ${level}`); + } + debugLevel = level; +} + +export function setLogOutputs (outputs) { + debugLogOutputs = outputs; +} + +export function info (log, stack) { + const formattedLog = formatLog(log); + for (const logDestination of debugLogOutputs) { + logDestination('info', formattedLog, stack); + } +} + +export function error (log, error) { + const formattedLog = formatLog(log); + const errorString = (typeof error === 'string' ? error : (error && error.name ? error.name : "Error")); + for (const logDestination of debugLogOutputs) { + logDestination(errorString, formattedLog, error.stack); + } +} + +/** + * Format text of debugLog event based on our current LEVEL + */ +function formatLog (text) { + let message; + if (debugLevel === LEVEL.NONE) { + return; + } else if (debugLevel === LEVEL.SIMPLE) { + message = text; + } else if (debugLevel === LEVEL.EXTENDED) { + const stackTrace = getStackTrace(); + const time = new Date().toISOString(); + message = `${time}: ${text}\n${stackTrace.padEnd(60)}`; + } + return message; +} + +// helper function for generating a stack trace to use with `LEVEL.EXTENDED` +function getStackTrace () { + const stack = new Error().stack.split('\n'); + const stackTrace = [stack[2], stack[3], stack[4], stack[5], stack[6]].join('\n'); + return stackTrace; +} diff --git a/modules/lo_event/lo_event/disabler.js b/modules/lo_event/lo_event/disabler.js new file mode 100644 index 000000000..0a7476829 --- /dev/null +++ b/modules/lo_event/lo_event/disabler.js @@ -0,0 +1,129 @@ +/* + * Code to handle blacklists, opt-ins, opt-outs, etc. + * + * Unfinished. We need to flush out storage.js and connect to make + * this work. + * + * TODO: At least document what's unfinished and what work is remaining. It + * looks like there was some progress since the above was written. + */ + +/* + * We have different types of opt-in/opt-out scenarios. For example: + * - If we have a contractual gap with a school, we might want to hold + * events on the client-side pending resolution + * - If a school or student does not want us to collect their data, but + * have the extension installed, we don't want to store them + * client-side. + */ +import { storage } from './browserStorage.js'; +import * as debug from './debugLog.js'; +import * as util from './util.js'; + +export const EVENT_ACTION = { + TRANSMIT: 'TRANSMIT', + MAINTAIN: 'MAINTAIN', + DROP: 'DROP' +}; + +/* + * We make the time limit stochastic, so we don't have all clients + * retry at the same time if we e.g. block many clients at once. + */ +export const TIME_LIMIT = { + PERMANENT: -1, + MINUTES: 1000 * 60 * 5 * (1 + Math.random()), // 5-10 minutes + DAYS: 1000 * 60 * 60 * 24 * (1 + Math.random()) // 1-2 days +}; + +const DISABLER_STORE = 'disablerState'; + +export class BlockError extends Error { + constructor (message, timeLimit, action) { + super(message); + this.name = 'BlockError'; + this.message = message; + this.timeLimit = isNaN(timeLimit) ? TIME_LIMIT[timeLimit] : timeLimit; + this.action = EVENT_ACTION[action]; // <-- Check we're in EVENT_ACTION. + } +} + +const DEFAULTS = { + action: EVENT_ACTION.TRANSMIT, + expiration: null +}; + +let { action, expiration } = DEFAULTS; + +export async function init (defaults = null) { + defaults = defaults || DEFAULTS; + + return new Promise((resolve, reject) => { + // Check if storage is defined + if (!storage || !storage.get) { + debug.error('Storage is not set or storage.get is undefined. This should never happen.'); + reject(new Error('Storage or storage.get is undefined')); + } else { + // Fetch initial values from storage upon loading + storage.get(DISABLER_STORE, (storedState) => { + storedState = storedState[DISABLER_STORE] || {}; + action = storedState.action || DEFAULTS.action; + expiration = storedState.expiration || DEFAULTS.expiration; + debug.info(`Initialized disabler. action: ${action} expiration: ${new Date(expiration).toString()}`); + resolve(); + }); + } + }); +} + +export function handleBlockError (error) { + action = error.action; + if (error.timeLimit === TIME_LIMIT.PERMANENT) { + expiration = TIME_LIMIT.PERMANENT; + } else { + expiration = Date.now() + error.timeLimit; + } + storage.set({ [DISABLER_STORE]: { action, expiration } }); +} + +export function storeEvents () { + return action !== EVENT_ACTION.DROP; +} + +export function streamEvents () { + return action === EVENT_ACTION.TRANSMIT; +} + +/** + * Determines if a client should retry based on the `expiration` status. + * This function: + * 1. Returns `false` if the expiration is permanent. + * 2. Waits for the expiration to pass, resets `storage` (for future + * initializations), and then returns `true` to allow a retry. + * + * @returns {boolean} Indicates if a retry should occur. + */ +export async function retry () { + if (expiration === TIME_LIMIT.PERMANENT) { + return false; + } + const now = Date.now(); + if (now < expiration) { + debug.info(`waiting for expiration to happen ${new Date(expiration).toString()}`); + await util.delay(expiration - now); + debug.info('we are done waiting'); + } + action = DEFAULTS.action; + expiration = DEFAULTS.expiration; + // NOTE: If the client continuously sends messages to the server and + // keeps generating new messages, we may hit a rate limit depending on + // the `storage` API in use. To avoid this, we only call the `set` method + // when the new value differs from the existing value in `storage`. + storage.get([DISABLER_STORE], (result) => { + const currentValue = result[DISABLER_STORE]; + if (!currentValue || currentValue.action !== action || currentValue.expiration !== expiration) { + storage.set({ [DISABLER_STORE]: { action, expiration } }); + } + }); + return true; +} diff --git a/modules/lo_event/lo_event/indexeddbQueue.js b/modules/lo_event/lo_event/indexeddbQueue.js new file mode 100644 index 000000000..228563c0d --- /dev/null +++ b/modules/lo_event/lo_event/indexeddbQueue.js @@ -0,0 +1,227 @@ +/** + * This files functions as a Queue using an indexeddb backend. + * + * If we are operating in a browser environment, we will use + * the built-in indexeddb. In node environments, we will use + * packages that mirror the functionality of indexeddb. + * + * Each item can be added to the end of the queue with `enqueue(item)`. + * Items can be retrieved from the queue with `item = await dequeue()`. + * + * TODO + * This code works in the browser, but breaks in a node environment. + * autoIncrement is NOT supported when working in the node + * environment. We will likely need to make some form of wrapper + * to achieve this behavior for node. + * See https://github.com/metagriffin/indexeddb-js/blob/master/src/indexeddb-js.js#L418C1-L418C53 + * NOTE: When we had our own counter for the id, we did notice that the node + * environment (indexeddb-js or sqlite3) handled keys differently, thus + * returning items out of order. + * + * TODO: This needs a very good code review. We weren't able to do + * this before merge. + */ +import * as debug from './debugLog.js'; +import * as util from './util.js'; + +const ENQUEUE = 'enqueue'; +const DEQUEUE = 'dequeue'; + +export class Queue { + constructor (queueName) { + this.db = null; + this.dbOperationQueue = []; + this.nextDBOperationPromise = null; + this.nextItemPromise = null; + this.queueName = queueName; + + this.initialize = this.initialize.bind(this); + this.addItemToDB = this.addItemToDB.bind(this); + this.nextItemFromDB = this.nextItemFromDB.bind(this); + this.nextDBOperation = util.once(this.nextDBOperation.bind(this)); + this.startProcessing = this.startProcessing.bind(this); + this.addItemToDBOperationQueue = this.addItemToDBOperationQueue.bind(this); + this.enqueue = this.enqueue.bind(this); + this.dequeue = this.dequeue.bind(this); + + this.dbOperationDispatch = { + [ENQUEUE]: this.addItemToDB, + [DEQUEUE]: this.nextItemFromDB + }; + this.initialize(); + } + + /** + * Determine which environment we are in to set + * the appropriate indexeddb information. + */ + async initialize () { + let request; + if (typeof indexedDB === 'undefined') { + debug.info('idbQueue: Importing indexedDB compatibility'); + + const sqlite3 = await import('sqlite3'); + const indexeddbjs = await import('indexeddb-js'); + + const engine = new sqlite3.default.Database('queue.sqlite'); + const scope = indexeddbjs.makeScope('sqlite3', engine); + request = scope.indexedDB.open(this.queueName); + } else { + debug.info('idbQueue: Using browser consoleDB'); + request = indexedDB.open(this.queueName, 1); + } + + request.onerror = (event) => { + debug.error('QUEUE ERROR: could not open database', event.target.error); + }; + + request.onupgradeneeded = async (event) => { + this.db = event.target.result; + const objectStore = this.db.createObjectStore(this.queueName, { keyPath: 'id', autoIncrement: true }); + objectStore.createIndex('id', 'id'); + }; + + request.onsuccess = (event) => { + this.db = event.target.result; + this.startProcessing(); + }; + } + + /** + * Perform transaction to add item into indexeddb + * If we are waiting for an item to available to dequeue, + * we resolve the item immediately and don't add it to + * the indexeddb. + */ + async addItemToDB ({ payload }) { + if (this.nextItemPromise) { + this.nextItemPromise(payload.payload); + this.nextItemPromise = null; + return; + } + debug.info(`idbQueue: adding item to database, ${payload}`); + const transaction = this.db.transaction([this.queueName], 'readwrite'); + const objectStore = transaction.objectStore(this.queueName); + + const request = objectStore.add(payload); + + request.onsuccess = (event) => { + // successful request added + }; + + request.onerror = (event) => { + if (event.target.error.name === 'ConstraintError') { + debug.error('IDBQUEUE ERROR: Item already exists', event.target.error); + } else { + debug.error('IDBQUEUE ERROR: Error adding item to the queue:', event.target.error); + } + }; + } + + /** + * Perform transaction to fetch next item in indexeddb + */ + async nextItemFromDB ({ resolve, reject }) { + debug.info('idbQueue: Fetching next item from database'); + const transaction = this.db.transaction([this.queueName], 'readwrite'); + const objectStore = transaction.objectStore(this.queueName); + const request = objectStore.openCursor(); + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + const item = cursor.value; + const deleteRequest = objectStore.delete(cursor.key); + + deleteRequest.onsuccess = () => { + resolve(item.payload); + }; + + deleteRequest.onerror = (event) => { + debug.error('IDBQUEUE ERROR: Error removing item from the queue:', event.target.error); + reject(event.target.error); + }; + } else { + // No more items in the IndexedDB. + resolve(new Promise((resolve) => { + this.nextItemPromise = resolve; + })); + } + }; + + request.onerror = (event) => { + debug.error('IDBQUEUE ERROR: Error reading queue cursor:', event.target.error); + reject(event.target.error); + }; + } + + /** + * The processing loop continually waits for the next + * dbOperation to come using the following generator. + */ + async * nextDBOperation () { + while (true) { + let operation; + if (this.dbOperationQueue.length > 0) { + operation = this.dbOperationQueue.shift(); + } else { + operation = await new Promise(resolve => { + this.nextDBOperationPromise = resolve; + }); + } + debug.info(`idbQueue: Yielding next operation, ${operation}`); + yield operation; + } + } + + /** + * This method processes incoming dbOperations + */ + async startProcessing () { + const dbOperationStream = this.nextDBOperation(); + + for await (const operation of dbOperationStream) { + debug.info(`idbQueue: processing operation ${operation}`); + try { + await this.dbOperationDispatch[operation.operation](operation); + } catch (error) { + debug.error('Unable to perform operation on DB', error); + } + } + } + + // helper function for enqueue/dequeue + addItemToDBOperationQueue (payload) { + if (this.nextDBOperationPromise) { + this.nextDBOperationPromise(payload); + this.nextDBOperationPromise = null; + } else { + this.dbOperationQueue.push(payload); + } + } + + /** + * This functions will append an enqueue message to the + * current operation stream. + */ + enqueue (item) { + debug.info(`idbQueue: Enqueuing item ${item}`); + const payload = { + operation: ENQUEUE, + payload: { payload: item } + }; + this.addItemToDBOperationQueue(payload); + } + + /** + * This function appends a dequeue message to the operation + * stream and returns the result. + */ + dequeue () { + debug.info('idbQueue: dequeueing item'); + return new Promise((resolve, reject) => { + const payload = { operation: DEQUEUE, resolve, reject }; + this.addItemToDBOperationQueue(payload); + }); + } +} diff --git a/modules/lo_event/lo_event/lo_assess/README.md b/modules/lo_event/lo_event/lo_assess/README.md new file mode 100644 index 000000000..1e528bbf9 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/README.md @@ -0,0 +1,10 @@ +Learning Observer Activity Framework +==================================== + +This is a framework for creating activities which provide process +information to the Learning Observer. + +This will eventually be its own module. However, for now, we are +developing this here for convenience. + +This code is AGPL. \ No newline at end of file diff --git a/modules/lo_event/lo_event/lo_assess/component.jsx.template b/modules/lo_event/lo_event/lo_assess/component.jsx.template new file mode 100644 index 000000000..9fe9db9c9 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/component.jsx.template @@ -0,0 +1,10 @@ +{{{ header }}} + +import React from 'react'; + +{{{ imports }}} + +export function {{ componentName }}( { children } ) { + return ( +{{{ xml }}}); +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/actions.jsx b/modules/lo_event/lo_event/lo_assess/components/actions.jsx new file mode 100644 index 000000000..1858b1acc --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/actions.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import * as util from '../util.js'; + +export function createAction({f, name}) { + let Action = ({props}) => (<>); + Action.isAction = true; + Action.action = f; + Object.defineProperty(Action, 'name', {value: name, writable: false}); + return Action; +} + +/* +export function LLMPrompt({children}) { + return <>; +} + +export const reset=() => reduxLogger.setState({}); +*/ diff --git a/modules/lo_event/lo_event/lo_assess/components/actions/ConsoleAction.jsx b/modules/lo_event/lo_event/lo_assess/components/actions/ConsoleAction.jsx new file mode 100644 index 000000000..3e20facd8 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/actions/ConsoleAction.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import * as util from '../../util.js'; +import { createAction } from '../actions.jsx'; + +export const ConsoleAction = createAction({ + name: "ConsoleLog", + f: ({node}) => console.log(util.extractChildrenText(node)) +}); diff --git a/modules/lo_event/lo_event/lo_assess/components/actions/PopupAction.jsx b/modules/lo_event/lo_event/lo_assess/components/actions/PopupAction.jsx new file mode 100644 index 000000000..627136b01 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/actions/PopupAction.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import * as util from '../../util.js'; +import { createAction } from '../actions.jsx'; + +export const PopupAction = createAction({ + name: "PopupAction", + f: ({node}) => alert(util.extractChildrenText(node)) +}); diff --git a/modules/lo_event/lo_event/lo_assess/components/actions/TargetAction.jsx b/modules/lo_event/lo_event/lo_assess/components/actions/TargetAction.jsx new file mode 100644 index 000000000..698f28db1 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/actions/TargetAction.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import * as util from '../../util.js'; +import { createAction } from '../actions.jsx'; + +export const TargetAction = createAction({ + name: "TargetAction", + f: ({ node }) => { + const message = util.extractChildrenText(node); + const target = node.props.target; + console.log(target, message); + } +}); diff --git a/modules/lo_event/lo_event/lo_assess/components/base/Spinner.css b/modules/lo_event/lo_event/lo_assess/components/base/Spinner.css new file mode 100644 index 000000000..71c3c35b5 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/base/Spinner.css @@ -0,0 +1,42 @@ +.spinner { + display: inline-block; + position: relative; + width: 80px; + height: 80px; + /* We added this to center. This might be teased out as an option + * (e.g. spinner align=Center) */ + left: 50%; + transform: translateX(-50%); +} +.spinner div { + display: inline-block; + position: absolute; + left: 8px; + width: 16px; + animation: spinner 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; + /* We added color appropriate for a white background. This might be + a parameter. */ + background: lightgrey; +} +.spinner div:nth-child(1) { + left: 8px; + animation-delay: -0.24s; +} +.spinner div:nth-child(2) { + left: 32px; + animation-delay: -0.12s; +} +.spinner div:nth-child(3) { + left: 56px; + animation-delay: 0; +} +@keyframes spinner { + 0% { + top: 8px; + height: 64px; + } + 50%, 100% { + top: 24px; + height: 32px; + } +} diff --git a/modules/lo_event/lo_event/lo_assess/components/base/Spinner.xml b/modules/lo_event/lo_event/lo_assess/components/base/Spinner.xml new file mode 100644 index 000000000..801f65a66 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/base/Spinner.xml @@ -0,0 +1,5 @@ +
+
+
+
+
diff --git a/modules/lo_event/lo_event/lo_assess/components/buildConfig.js b/modules/lo_event/lo_event/lo_assess/components/buildConfig.js new file mode 100644 index 000000000..7b56dc57e --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/buildConfig.js @@ -0,0 +1,30 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +export function getTemplatePath(templateFileName) { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const parentDir = path.join(moduleDir, '..'); + return path.join(parentDir, templateFileName); +} + +const _baseConfig = { + loa: { + jsPattern: './lo_event/lo_assess/components/**/{[A-Z]*,use[A-Z]*}.jsx', + xmlPattern: './lo_event/lo_assess/components/**/[A-Z]*.xml', + componentsFile: "./lo_event/lo_assess/components/components.jsx", + }, + next: { + compileDestination: "./src/app/compiledComponents/", + componentXMLPattern: "./xml/components/**/*.xml", + nextPageXMLPattern: "./xml/pages/**/*.xml", + nextComponentFile: "./src/app/compiledComponents.js", + nextPageTarget: "./src/app/{{page}}/page.js", + nextPageDir: "./src/app/{{page}}/page.js", + }, + templateLocations: { + componentFile: getTemplatePath("component.jsx.template"), + pageFile: getTemplatePath("pages.jsx.template"), + } +}; + +export const config = () => _baseConfig; diff --git a/modules/lo_event/lo_event/lo_assess/components/buildlib.js b/modules/lo_event/lo_event/lo_assess/components/buildlib.js new file mode 100644 index 000000000..299857712 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/buildlib.js @@ -0,0 +1,145 @@ +/* + * This tool will create the components.js top-level file containing + * all components. + */ + +import glob from 'glob'; +import fs from 'fs'; +import path from 'path'; +import { parseString } from 'xml2js'; +import Mustache from 'mustache'; +import { fileURLToPath } from 'url'; + +import { config } from './buildConfig.js'; + +let header = "/* AUTOMATICALLY GENERATED by buildlib.js: Do not edit. */\n\n"; +export const componentsFile = config().loa.componentsFile; +const componentTemplate = fs.readFileSync(config().templateLocations.componentFile, 'utf8'); + +export function getTemplatePath(templateFileName) { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const parentDir = path.join(moduleDir, '..'); + return path.join(parentDir, templateFileName); +} + +function parsePath(path) { + const componentsIndex = path.indexOf('/components/') + '/components/'.length; + const relativePath = './' + path.substring(componentsIndex); + + const lastSlashIndex = path.lastIndexOf('/'); + const fileName = path.substring(lastSlashIndex + 1).split('.')[0]; + const componentName = fileName; + const fullPathWithoutExtension = path.split('.').slice(0, -1).join('.'); + const jsxFullPath = fullPathWithoutExtension + ".jsx"; + const xmlFullPath = fullPathWithoutExtension + ".xml"; + const cssFullPath = fullPathWithoutExtension + ".css"; + + return { + relativePath, + componentName, + fullPathWithoutExtension, + jsxFullPath, + xmlFullPath, + cssFullPath + }; +} + +/* + This function reads an XML file from the given file path and returns + an array of unique tags found in the XML data. + + This is designed so we can know what to import when converting XML -> JSX + */ +export function getTagsFromFile(filePath) { + const xmlData = fs.readFileSync(filePath, 'utf8'); + + const tags = new Set(); + + parseString( + xmlData, + { tagNameProcessors: [ (x) => { tags.add(x); return x;} ] + }, + (err, result) => { + if (err) { + console.error(err); + throw err; + } + }); + + return [...tags].sort(); +} + +function xmlToJSX(xmlFile) { + const data = parsePath(xmlFile); + const xml = fs.readFileSync(xmlFile, 'utf8'); + data['xml'] = xml; + data['tags'] = getTagsFromFile(xmlFile); + data['header'] = header; + if( fs.existsSync(data.cssFullPath) ) { + data['imports'] = `import './${data.componentName}.css';`; + } else { + data['imports'] = ''; + } + + console.log( xmlFile, data ); + const rendered = Mustache.render(componentTemplate, data); + console.log(rendered); + safeWrite(data.jsxFullPath, rendered); +} + +export function listXmlFiles() { + return glob.sync(config().loa.xmlPattern); +} + +export function compileXMLComponents() { + const xmlFiles = listXmlFiles(); + + console.log("XML Files: ", xmlFiles); + + xmlFiles.forEach((file) => { + xmlToJSX(file); + }); +} + +export function listComponentFiles() { + return glob.sync(config().loa.jsPattern); +} + +function generateComponentFileString() { + let cfs = ""; + cfs += header; + + const jsFiles = listComponentFiles(); + + jsFiles.forEach((file) => { + const { relativePath, componentName } = parsePath(file); + console.log(file, relativePath, componentName); + cfs += `import { ${ componentName } } from '${relativePath}';\n`; + cfs += `export { ${ componentName } };\n`; + }); + return cfs; +} + +// Make sure that, if the file exists, it's not human-generated +function safeWrite(filename, data) { + if (fs.existsSync(filename)) { + const fileContents = fs.readFileSync(filename, 'utf8'); + if (!fileContents.startsWith(header)) { + throw new Error("Existing components.jsx file seems human-written. Aborting!"); + } + } + fs.writeFileSync(filename, data, 'utf8'); +} + +export function writeComponentFile() { + const cfs = generateComponentFileString(); + safeWrite(componentsFile, cfs); + console.log(`${componentsFile} file generated successfully!`); + return cfs; +} + +export function compileLocalComponents() { + compileXMLComponents(); + const cfs = writeComponentFile(); + console.log("Wrote:\n", cfs); +} diff --git a/modules/lo_event/lo_event/lo_assess/components/buttons/ActionButton.jsx b/modules/lo_event/lo_event/lo_assess/components/buttons/ActionButton.jsx new file mode 100644 index 000000000..ec09c08e0 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/buttons/ActionButton.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import * as reduxLogger from '../../../reduxLogger.js'; +import { Button } from './Button.jsx'; + +export function executeChildActions(children) { + console.log("Running child actions"); + console.log(children); + React.Children.forEach(children, (child) => { + console.log("eca:", child); + if (React.isValidElement(child)) { + console.log("eca.type:", child.type); + console.log("eca.type.isaction:", child.type.isAction); + console.log("ive:", React.isValidElement(child)); + } + if (React.isValidElement(child) && child.type.isAction) { + console.log("Running executor"); + child.type.action( { node: child } ); + } + }); +} + +export const ActionButton = ({ children, target, systemPrompt, showPrompt = true, ...props }) => { + const onClick = () => executeChildActions(children); + return ( + + ); +} diff --git a/modules/lo_event/lo_event/lo_assess/components/buttons/Button.css b/modules/lo_event/lo_event/lo_assess/components/buttons/Button.css new file mode 100644 index 000000000..ff60ff8da --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/buttons/Button.css @@ -0,0 +1,12 @@ +.blue-button { + padding: 10px; + margin-right: 10px; /* Add margin-right to create space between buttons */ + background-color: #007bff; + color: #fff; + border: none; + cursor: pointer; +} + +.blue-button:last-child { + margin-right: 0; /* Remove margin-right from last button */ +} diff --git a/modules/lo_event/lo_event/lo_assess/components/buttons/Button.jsx b/modules/lo_event/lo_event/lo_assess/components/buttons/Button.jsx new file mode 100644 index 000000000..4ecd6564b --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/buttons/Button.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import './Button.css'; + +/* + * Parent of all buttons! + */ +export function Button( {...props} ) { + const className = props.className ?? "blue-button"; + return + ); +} diff --git a/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx b/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx new file mode 100644 index 000000000..92179bcda --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx @@ -0,0 +1,7 @@ +export const LO_CONNECTION_STATUS = { + UNINSTANTIATED: -1, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 +} diff --git a/modules/lo_event/lo_event/lo_assess/components/input/TextInput.jsx b/modules/lo_event/lo_event/lo_assess/components/input/TextInput.jsx new file mode 100644 index 000000000..ca49ddcc4 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/input/TextInput.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useEffect } from 'react'; + +import { fixCursor, handleInputChange } from './inputHelpers.js'; +import { useComponentSelector, useSettingSelector } from '../../selectors.js'; + +export function TextInput({id, className, children}) { + let value = useComponentSelector(id, s => s?.value ?? ''); + let selectionStart = useComponentSelector(id, s => s?.selectionStart ?? 1); + let selectionEnd = useComponentSelector(id, s => s?.selectionEnd ?? 1); + + useEffect(fixCursor(id, selectionStart, selectionEnd), [value]); + + return ( + <> + { children } + + + ); +} diff --git a/modules/lo_event/lo_event/lo_assess/components/input/inputHelpers.js b/modules/lo_event/lo_event/lo_assess/components/input/inputHelpers.js new file mode 100644 index 000000000..6e9048ac0 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/input/inputHelpers.js @@ -0,0 +1,28 @@ +import React from 'react'; + +import * as lo_event from '../../../lo_event.js'; +import { UPDATE_INPUT } from '../../reducers.js'; + +export function handleInputChange(id) { + return event => { + lo_event.logEvent( + UPDATE_INPUT, { + id, + value: event.target.value, + selectionStart: event.target.selectionStart, //retrieved by the useEffect method below to reset cursor location + selectionEnd: event.target.selectionEnd, //stored for completeness, not absolutely neccesary + }); + }; +} + +export function fixCursor(id, selectionStart, selectionEnd) { + return () => { + //This will fire after the textarea is rendered and value has changed + //Without this code, the cursor in the textarea will jump to the end of the + //text, making editing the text in the middle difficult. + const input = document.getElementsByName(id); + if (input) { + input[0].setSelectionRange(selectionStart, selectionEnd); + } + }; +} diff --git a/modules/lo_event/lo_event/lo_assess/components/layouts/sidebar/MainPane.jsx b/modules/lo_event/lo_event/lo_assess/components/layouts/sidebar/MainPane.jsx new file mode 100644 index 000000000..ee03e2f89 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/layouts/sidebar/MainPane.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export function MainPane( {...props} ) { + return
; +} diff --git a/modules/lo_event/lo_event/lo_assess/components/layouts/sidebar/SideBarPanel.jsx b/modules/lo_event/lo_event/lo_assess/components/layouts/sidebar/SideBarPanel.jsx new file mode 100644 index 000000000..520c7cb80 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/layouts/sidebar/SideBarPanel.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export function SideBarPanel({children}) { + const childArray = React.Children.toArray(children); + + return ( +
+
+ { childArray[0] } +
+ +
+ {childArray.slice(1).map((card, index) => ( +
+ {card} +
+ ))} +
+
); +} diff --git a/modules/lo_event/lo_event/lo_assess/components/llm/LLMAction.jsx b/modules/lo_event/lo_event/lo_assess/components/llm/LLMAction.jsx new file mode 100644 index 000000000..84b659991 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/llm/LLMAction.jsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import * as util from '../../util.js'; +import { createAction } from '../actions.jsx'; +import { run_llm } from './llm.jsx'; + +export const LLMAction = createAction({ + name: "LLMAction", + f: ({node}) => { + React.Children.forEach(node, (child) => { + if (React.isValidElement(child) && child.type === LLMAction) { + const promptText = util.extractChildrenText(child); + run_llm(child.props.target, { prompt: promptText }); + } + }); + } +}); diff --git a/modules/lo_event/lo_event/lo_assess/components/llm/LLMFeedback.jsx b/modules/lo_event/lo_event/lo_assess/components/llm/LLMFeedback.jsx new file mode 100644 index 000000000..d04120854 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/llm/LLMFeedback.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { useDispatch } from 'react-redux'; +import { useComponentSelector } from '../../selectors.js'; +import { extractChildrenText } from '../../util.js'; +import { LLM_RUNNING, LLM_INIT } from './llm.jsx'; +import { Spinner } from '../components.jsx'; + +export const LLMFeedback = ({children, id}) => { + const dispatch = useDispatch(); + let feedback = useComponentSelector(id, s => s?.value ?? ''); + let state = useComponentSelector(id, s => s?.state ?? LLM_INIT); + + return ( +
+
🤖
+ {state === LLM_RUNNING ? () : feedback} +
+ ); +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/llm/llm.jsx b/modules/lo_event/lo_event/lo_assess/components/llm/llm.jsx new file mode 100644 index 000000000..ce95fa748 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/llm/llm.jsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import * as lo_event from '../../../lo_event.js'; +import * as reducers from '../../reducers.js'; + +export const LLM_INIT = 'LLM_INIT'; +export const LLM_RESPONSE = 'LLM_RESPONSE'; +export const LLM_ERROR = 'LLM_ERROR'; +export const LLM_RUNNING = 'LLM_RUNNING'; + + +export const run_llm = (target, llm_params) => { + lo_event.logEvent( + reducers.UPDATE_LLM_RESPONSE, { + id: target, + state: LLM_RUNNING + }); + fetch('/api/llm', { + method: 'POST', + body: JSON.stringify(llm_params), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .then((data) => { + lo_event.logEvent( + reducers.UPDATE_LLM_RESPONSE, { + id: target, + value: data.response, + state: LLM_RESPONSE + }); + }) + .catch((error) => { + lo_event.logEvent( + reducers.UPDATE_LLM_RESPONSE, { + id: target, + value: "Error calling LLM", + state: LLM_ERROR + }); + console.error(error); + }); +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx new file mode 100644 index 000000000..1c9bbb009 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx @@ -0,0 +1,69 @@ +/** + * LOConnectionLastUpdated is a helper function for displaying + * connection information and last received message information + * about a websocket. + * + * Usage: + * ```js + * const { connectionStatus, message, } = LOConnection({ url, dataScope }); // or some other websocket + * return ( ); + * ``` + */ +import React from 'react'; +import { useState, useEffect } from 'react'; +import { LO_CONNECTION_STATUS } from '../constants/LO_CONNECTION_STATUS'; +import { renderTime } from '../../../util'; + +function renderReadableTimeSinceUpdate (timeDifference) { + if (timeDifference < 5) { + return 'Just now'; + } + return `${renderTime(timeDifference)} ago`; +} + +export const LOConnectionLastUpdated = ({ message, connectionStatus, showText=false }) => { + const [lastUpdated, setLastUpdated] = useState(null); + const [lastUpdatedMessage, setLastUpdatedMessage] = useState(''); + + const icons = { + [LO_CONNECTION_STATUS.UNINSTANTIATED]: 'fas fa-circle', + [LO_CONNECTION_STATUS.CONNECTING]: 'fas fa-sync-alt', + [LO_CONNECTION_STATUS.OPEN]: 'fas fa-check text-success', + [LO_CONNECTION_STATUS.CLOSING]: 'fas fa-sync-alt', + [LO_CONNECTION_STATUS.CLOSED]: 'fas fa-times text-danger' + }; + const titles = { + [LO_CONNECTION_STATUS.UNINSTANTIATED]: 'Uninstantiated', + [LO_CONNECTION_STATUS.CONNECTING]: 'Connecting to server', + [LO_CONNECTION_STATUS.OPEN]: 'Connected to server', + [LO_CONNECTION_STATUS.CLOSING]: 'Closing connection', + [LO_CONNECTION_STATUS.CLOSED]: 'Disconnected from server' + }; + + // Set last updated time when new message arrives + useEffect(() => { + if (message) { + setLastUpdated(new Date()); + } + }, [message]); + + // Every second update last updated message + useEffect(() => { + const interval = setInterval(() => { + if (lastUpdated) { + const now = new Date(); + const timeDifference = Math.floor((now - lastUpdated) / 1000); // Time difference in seconds + setLastUpdatedMessage(renderReadableTimeSinceUpdate(timeDifference)); + } else { setLastUpdatedMessage('Never'); } + }, 1000); + return () => clearInterval(interval); // Cleanup interval on unmount + }, [lastUpdated]); + + return ( +
+ + {showText ? {titles[connectionStatus]} : ''} + {lastUpdatedMessage} +
+ ); +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx new file mode 100644 index 000000000..c6f65caf6 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx @@ -0,0 +1,116 @@ +/** + * useLOConnection is a websocket hook used for connecting to + * the communication protocl on Learning Observer. + * + * The server expects some data before it will start sending messages. + * When the connection opens, useLOConnection will send the `dataScope`, + * if available, to initiate receiving messages from the server. + * Otherwise, users should use the `sendMessage` function to provide + * data to LO. + * + * useLOConnection exposes the following items: + * - `sendMessage`: function to send messages to the server + * - `message`: the most recent message received + * - `error`: any errors that occured + * - `connectionStatus`: the current status of the websocket connection + * - `openConnection`: function that opens the connection when called + * - `closeConnection`: function that closes the connection when called + */ +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { LO_CONNECTION_STATUS } from '../constants/LO_CONNECTION_STATUS'; + +export const useLOConnection = ({ + url, dataScope +}) => { + const [connectionStatus, setConnectionStatus] = useState(LO_CONNECTION_STATUS.CLOSED); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const clientRef = useRef(null); + + // Function to open the WebSocket connection + const openConnection = () => { + // Prevent opening a new connection if one is already open or connecting + if (clientRef.current && (clientRef.current.readyState === LO_CONNECTION_STATUS.OPEN || clientRef.current.readyState === LO_CONNECTION_STATUS.CONNECTING)) { + console.warn("WebSocket connection is already open or in progress."); + return; + } + + const protocol = { 'http:': 'ws:', 'https:': 'wss:' }[window.location.protocol]; + const newUrl = url || `${protocol}//${window.location.hostname}:${window.location.port}/wsapi/communication_protocol`; + const client = new WebSocket(newUrl); + clientRef.current = client; + + client.onopen = () => { + setConnectionStatus(LO_CONNECTION_STATUS.OPEN); + setError(null); // Clear any previous errors upon a successful connection + if (typeof dataScope !== 'undefined') { + client.send(JSON.stringify(dataScope)); + } + }; + + client.onmessage = (event) => { + setMessage(event.data); + }; + + client.onerror = (event) => { + setError(event.message); + }; + + client.onclose = () => { + setConnectionStatus(LO_CONNECTION_STATUS.CLOSED); + }; + }; + + // Function to close the WebSocket connection manually + const closeConnection = () => { + if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) { + clientRef.current.close(); + clientRef.current = null; + } else { + console.warn("WebSocket is not open; no connection to close."); + } + }; + + // Automatically attempt to open connection on mount + useEffect(() => { + openConnection(); + + // Cleanup on unmount + return () => { + closeConnection(); + }; + }, [url]); // Include `url` as a dependency in case it changes and requires a reconnection + + const messageQueue = []; + + // Send any messages on the queue + const processQueue = () => { + while (messageQueue.length > 0) { + if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) { + const message = messageQueue.shift(); + clientRef.current.send(message); + } else { + break; + } + } + }; + + // Start processing the queue when the connection opens + useEffect(() => { + if (connectionStatus === LO_CONNECTION_STATUS.OPEN) { + processQueue(); + } + }, [connectionStatus]); + + // Function to send a message via WebSocket + const sendMessage = (message) => { + if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) { + clientRef.current.send(message); + } else { + messageQueue.push(message); + } + }; + + return { sendMessage, message, error, connectionStatus, openConnection, closeConnection }; +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx new file mode 100644 index 000000000..1bcb1a2bb --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx @@ -0,0 +1,134 @@ +/** + * useLOConnectionDataManager handles storing and processing incoming + * messages from the communication protocol websocket connection. + * This hook wraps the useLOConnection hook to fetch updates. + * The communication protocol sends batches of updates to apply + * to a clientside data object. + * + * When the internal data is updated, we call the `onDataUpdate` + * parameter so parents can update accordingly. + * + * useLOConnectionDataManager exposes the following items: + * - `data`: current overall data received from websocket messages + * - `errors`: information about any errors received + * - `connection`: all returned items from useLOConnection + * + * Usage: + * ```js + const { data, errors, sendMessage } = useLOConnectionDataManager({ url, dataScope }); + + return ( +
+
+

User Data

+ {Object.keys(data).length > 0 ? ( +
{JSON.stringify(data, null, 2)}
+ ) : ( +

No user data available.

+ )} +
+ {Object.keys(errors).length > 0 && ( +
+

Errors

+
{JSON.stringify(errors, null, 2)}
+
+ )} +
+ ); + * ``` + */ +import { useReducer, useEffect } from 'react'; +import { useLOConnection } from './useLOConnection'; // Assuming LOConnection is renamed to useLOConnection + +// Reducer function for managing state updates +const dataReducer = (state, action) => { + switch (action.type) { + case 'update': { + const { path, value } = action.payload; + const pathKeys = path.split('.'); + + // Create a new `data` object with the updated value at the correct path + const newData = { ...state.data }; + let current = newData; // Start at the top-level copy + for (let i = 0; i < pathKeys.length - 1; i++) { + const key = pathKeys[i]; + if (!(key in current)) { + current[key] = {}; // Create path if it doesn't exist + } else { + current[key] = { ...current[key] }; // Copy the existing nested object + } + current = current[key]; + } + + const finalKey = pathKeys[pathKeys.length - 1]; + // TODO this doesn't handle a deep merge + current[finalKey] = { + ...current[finalKey], // Existing data + ...value, // New data (overwrites where necessary) + }; + + return { + ...state, + data: newData, + }; + } + case 'error': { + const { path, value } = action.payload; + return { + ...state, + errors: { + ...state.errors, + [path]: value, + }, + }; + } + case 'clearError': { + const { path } = action.payload; + const newErrors = { ...state.errors }; + delete newErrors[path]; + return { + ...state, + errors: newErrors, + }; + } + default: + console.warn(`Unhandled action type: ${action.type}`); + return state; + } +}; + +// Initial state for the reducer +const initialState = { + data: {}, + errors: {}, +}; + +export const useLOConnectionDataManager = ({ url, dataScope }) => { + const { message, ...connection } = useLOConnection({ url, dataScope }); + const [state, dispatch] = useReducer(dataReducer, initialState); + + useEffect(() => { + if (message) { + try { + const messages = JSON.parse(message); + + messages.forEach((msg) => { + if ('error' in msg.value) { + dispatch({ type: 'error', payload: { path: msg.path, value: msg.value } }); + } else { + dispatch({ type: 'clearError', payload: { path: msg.path } }); + dispatch({ type: msg.op, payload: { path: msg.path, value: msg.value } }); + } + }); + } catch (e) { + console.error('Failed to parse incoming message:', e); + } + } + }, [message]); + + return { + connection, + data: state.data, + errors: state.errors, + }; +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/variables/Element.jsx b/modules/lo_event/lo_event/lo_assess/components/variables/Element.jsx new file mode 100644 index 000000000..752949fd2 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/variables/Element.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useComponentSelector } from '../../selectors.js'; +import { store } from '../../../reduxLogger.js'; + +export function Element({children}) { + const id=children.trim(); + const value = useComponentSelector(id, s => s?.value); + + return <>{value}; +} + +Element.eval = function(element) { + const value=store.getState()?.application_state?.component_state?.[element.props.children.trim()]?.value; + return value; +} diff --git a/modules/lo_event/lo_event/lo_assess/components/variables/StoreVariable.jsx b/modules/lo_event/lo_event/lo_assess/components/variables/StoreVariable.jsx new file mode 100644 index 000000000..c01aef88d --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/variables/StoreVariable.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { extractChildrenText } from '../../util.js'; + +import * as lo_event from '../../../lo_event.js'; +import { STORE_VARIABLE } from '../../reducers.js'; + +export function StoreVariable({children, id}) { + useEffect( + () => { + const text = extractChildrenText(children); + lo_event.logEvent( + STORE_VARIABLE, { + id, + value: text, + }); + }, []); + + return <>{children}; +} diff --git a/modules/lo_event/lo_event/lo_assess/lo_assess.js b/modules/lo_event/lo_event/lo_assess/lo_assess.js new file mode 100644 index 000000000..9ca74d9c5 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/lo_assess.js @@ -0,0 +1,23 @@ +import * as lo_event from '../lo_event'; +import * as reduxLogger from '../reduxLogger.js'; +import { consoleLogger } from '../consoleLogger.js'; +import * as debug from '../debugLog.js'; + +/* + * Default convenience init function + */ +export function init() { + lo_event.init( + "org.ets.activity", + "0.0.1", + [consoleLogger(), reduxLogger.reduxLogger([], {})], + { + debugLevel: debug.LEVEL.EXTENDED, + debugDest: [debug.LOG_OUTPUT.CONSOLE], + useDisabler: false, + queueType: lo_event.QueueType.IN_MEMORY + } + ); + + lo_event.go(); +} diff --git a/modules/lo_event/lo_event/lo_assess/nodeutil.js b/modules/lo_event/lo_event/lo_assess/nodeutil.js new file mode 100644 index 000000000..12f15ec70 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/nodeutil.js @@ -0,0 +1,41 @@ +// There is a mess with package managers. For ease, to switch to +// require, replace the imports with: +// +// const fs = require('fs'); +// const xml2js = require('xml2js'); +// +// And then change parseString to xml2js.parseString + +import fs from 'fs'; +import path from 'path'; +import glob from 'glob'; + +/* + * Helpful test functions. Some of these might transition into the + * final system, and some might go away. + */ + +export function printDirs() { + console.log('Current working directory:', process.cwd()); + console.log('Program directory:', path.dirname(process.argv[1])); + console.log('File directory:', path.dirname(import.meta.url)); + console.log('Module directory:', path.dirname(import.meta.url)); +} + +export function findFiles(basedir, callback) { + glob.sync(`${basedir}/**/*.xml`).forEach((xmlName) => { + const baseName = path.basename(xmlName, ".xml"); + const jsxName = path.join(path.dirname(xmlName), `${baseName}.jsx`); + console.log(xmlName, baseName, jsxName); + if(callback) { + callback(xmlName, jsxFile); + } + //const xmlContent = fs.readFileSync(xmlFile, "utf8"); + //fs.writeFileSync(jsxFile, jsxContent, "utf8"); + }); +} + +export async function inspectModule(modulePath) { + const module = await import(modulePath); + return Object.keys(module); +} diff --git a/modules/lo_event/lo_event/lo_assess/page.jsx.template b/modules/lo_event/lo_event/lo_assess/page.jsx.template new file mode 100644 index 000000000..600d4cd3a --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/page.jsx.template @@ -0,0 +1,14 @@ +{{{ header }}} + +'use client'; +// @refresh reset + +import React from 'react'; + +{{{ imports }}} + +export default function Home( { children } ) { + return ( + {{ xml }} + ); +}; diff --git a/modules/lo_event/lo_event/lo_assess/reducers.js b/modules/lo_event/lo_event/lo_assess/reducers.js new file mode 100644 index 000000000..2f4ef610e --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/reducers.js @@ -0,0 +1,61 @@ +import { registerReducer, store } from '../reduxLogger.js'; + +// Local debug -- for development within this file +const DEBUG = false; +const dclog = (...args) => {if(DEBUG) {console.log.apply(console, Array.from(args));} }; + +export const LOAD_DATA_EVENT = 'LOAD_DATA_EVENT'; +export const LOAD_STATE = 'LOAD_STATE'; +export const NAVIGATE = 'NAVIGATE'; +export const SHOW_SECTION='SHOW_SECTION'; +export const STEPTHROUGH_NEXT = 'STEPTHROUGH_NEXT'; +export const STEPTHROUGH_PREV = 'STEPTHROUGH_PREV'; +export const STORE_VARIABLE = 'STORE_VARIABLE'; +export const STORE_SETTING = 'STORE_SETTING'; +export const UPDATE_INPUT = 'UPDATE_INPUT'; +export const UPDATE_LLM_RESPONSE = 'UPDATE_LLM_RESPONSE'; +export const VIDEO_TIME_EVENT = 'VIDEO_TIME_EVENT'; + +const initialState = { + component_state: {} +}; + +/* + This is our most common reducer. It simply updates the component's + state with any fields in the event. + + In the future, it would be nice to add some sanity checks. + */ +export const updateResponseReducer = (state = initialState, action) => { + const { id, ...rest } = action; + const new_state = { + ...state, + component_state: { + ...state.component_state, + [id]: {...state.component_state?.[id], ...rest} + } + }; + if(DEBUG) { + console.log("REGISTER REDUCER"); + console.log("Reducer action:", action); + console.log("Response reducer called"); + console.log("Old state", state); + console.log("Action", action); + console.log("New state", new_state); + } + return new_state; +} + +registerReducer( + [LOAD_DATA_EVENT, + LOAD_STATE, + NAVIGATE, + SHOW_SECTION, + STEPTHROUGH_NEXT, STEPTHROUGH_PREV, + STORE_SETTING, + STORE_VARIABLE, + UPDATE_INPUT, + UPDATE_LLM_RESPONSE, + VIDEO_TIME_EVENT], + updateResponseReducer +); diff --git a/modules/lo_event/lo_event/lo_assess/selectors.js b/modules/lo_event/lo_event/lo_assess/selectors.js new file mode 100644 index 000000000..d83042b8c --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/selectors.js @@ -0,0 +1,18 @@ +import React from 'react'; + +import { useSelector } from 'react-redux'; + +export function useApplicationSelector(selector = s => s) { + return useSelector(state => selector(state?.application_state)); +} + +// Get the state for any component, by ID. +export function useComponentSelector(id, selector = s => s) { + return useApplicationSelector( + s => selector(s?.component_state?.[id]) + ); +} + +export function useSettingSelector(setting) { + return useSelector(state => state?.settings?.[setting]); +} diff --git a/modules/lo_event/lo_event/lo_assess/util.js b/modules/lo_event/lo_event/lo_assess/util.js new file mode 100644 index 000000000..37283a8e9 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/util.js @@ -0,0 +1,28 @@ +import React from 'react'; + +/* + This function extracts the text content from the children of a React + element. If the element itself is a string, it trims and returns the + string. If the element is a React element, it recursively extracts + and concatenates the text content from its children. + + This is helpful if we want to e.g. send such text to an LLM or another + action. +*/ +export const extractChildrenText = (element) => { + if (typeof element === "string") { + return element.trim(); + } + const extractElementText = (element) => { + if (typeof element === "string") { + return element.trim(); + } else if (React.isValidElement(element)) { + return element.type.eval(element); + } + return ""; + }; + + const { children } = element.props; + const extractedChildren = React.Children.map(children, (element) => extractElementText(element)); + return extractedChildren.join(""); +}; diff --git a/modules/lo_event/lo_event/lo_event.js b/modules/lo_event/lo_event/lo_event.js new file mode 100644 index 000000000..6328f8d5c --- /dev/null +++ b/modules/lo_event/lo_event/lo_event.js @@ -0,0 +1,216 @@ +/* + Logging library for Learning Observer clients +*/ + +import { timestampEvent, mergeMetadata } from './util.js'; +import { getBrowserInfo } from './metadata/browserinfo.js'; +import * as Queue from './queue.js'; +import * as disabler from './disabler.js'; +import * as debug from './debugLog.js'; +import * as util from './util.js'; + +export const QueueType = Queue.QueueType; + +// We implement this as something like an FSM. +const INIT_STATES = { + NOT_STARTED: 'NOT_STARTED', // init() has not been called + IN_PROGRESS: 'IN_PROGRESS', // init() called, but waiting on loggers or metadata + LOGGERS_READY: 'LOGGERS_READY', // loggers initialized, but queuing initial events / auth + READY: 'READY', // Events streaming to loggers (which might have their own queues) + ERROR: 'ERROR' // Something went very, very wrong +}; + +let initialized = INIT_STATES.NOT_STARTED; // Current FSM state +let currentState = new Promise((resolve, reject) => { resolve(); }); // promise pipeline to ensure we handle all initialization + + +let loggersEnabled = []; // A list of all loggers which should receive events. +let queue; + +function isInitialized () { + return initialized === INIT_STATES.READY; +} + +/** + * Collect all enabled loggers with an init function, call it, + * and wait for all of them to finish initializing. We add this + * function to our `currentState` pipeline to ensure loggers + * are ready to go before we send events. + */ +async function initializeLoggers () { + debug.info('initializing loggers'); + const initializedLoggers = loggersEnabled + .filter(logger => typeof logger.init === 'function') // Filter out loggers without .init property + .map(logger => logger.init()); // Call .init() on each logger, which may return a promise + + try { + await Promise.all(initializedLoggers); + debug.info('Loggers initialized!'); + initialized = INIT_STATES.LOGGERS_READY; + } catch (error) { + initialized = INIT_STATES.ERROR; + debug.error('Error resolving logger initializers:', error); + } +} + +/** + * Executes and compiles metadata tasks into a single metadata object. + * + * When initializing `lo_event`, clients can set which metadata items + * they wish to include. + */ +export async function compileMetadata(metadataTasks) { + const taskPromises = metadataTasks.map(async task => { + try { + const result = await Promise.resolve(task.func()); + return { [task.name]: result }; + } catch (error) { + debug.error(`Error in initialization task ${task.name}:`, error); + return null; + } + }); + + const results = await Promise.all(taskPromises); + setFieldSet(results); + return results.filter(Boolean); +} + + +/** + * Set specific key/value pairs using the `lock_fields` + * event. We use this to set specific fields that we want + * included overall for subsequent events to prevent + * sending the same information in each event. + * + * This is useful for items such as `source` and `version` + * which should be the same for every event. + * + * This function works even after we are initialized and + * processing items from the queue (INIT_STATES.READY). + * + * Each individual logger should keep track of state and + * handle their respecitive reconnects properly. + */ +export function setFieldSet (data) { + currentState = currentState.then( + () => setFieldSetAsync(data) + ); +} + +/** + * Runs and awaits for all loggers to run their `setField` command + */ +async function setFieldSetAsync (data) { + const payload = { fields: await mergeMetadata(data), event: 'lock_fields' }; + timestampEvent(payload); + const authpromises = loggersEnabled + .filter(logger => typeof logger.setField === 'function') + .map(logger => logger.setField(JSON.stringify(payload))); + + await Promise.all(authpromises); +} + +// TODO: We should consider specifying a set of verbs, nouns, etc. we +// might use, and outlining what can be expected in the protocol +// TODO: We should consider structing / destructing here +export function init ( + source, + version, + loggers, // e.g. [console_logger(), websocket_logger("/foo/bar")] + { + debugLevel = debug.LEVEL.NONE, + debugDest = [debug.LOG_OUTPUT.CONSOLE], + useDisabler = true, + queueType = Queue.QueueType.AUTODETECT, + sendBrowserInfo = false, + verboseEvents = false, + metadata = [], + } = {} +) { + if (!source || typeof source !== 'string') throw new Error('source must be a non-null string'); + if (!version || typeof version !== 'string') throw new Error('version must be a non-null string'); + + util.setVerboseEvents(verboseEvents); + queue = new Queue.Queue('LOEvent', { queueType }); + + debug.setLevel(debugLevel); + debug.setLogOutputs(debugDest); + if (useDisabler) { + currentState = currentState.then(() => disabler.init(useDisabler)); + } + + loggersEnabled = loggers; + initialized = INIT_STATES.IN_PROGRESS; + currentState = currentState + .then(initializeLoggers) + .then(() => setFieldSet([{ source, version }])) + .then(() => compileMetadata(metadata)); + if(sendBrowserInfo) { + // In the future, some or all of this might be sent on every + // reconnect + logEvent("BROWSER_INFO", getBrowserInfo()); + } +} + +export function go () { + currentState.then(() => { + initialized = INIT_STATES.READY; + queue.startDequeueLoop({ + initialize: isInitialized, + shouldDequeue: disabler.retry, + onDequeue: sendEvent + }); + }); +} + +function sendEvent (event) { + const jsonEncodedEvent = JSON.stringify(event); + for (const logger of loggersEnabled) { + try { + logger(jsonEncodedEvent); + } catch (error) { + if (error instanceof disabler.BlockError) { + // Handle BlockError exception here + disabler.handleBlockError(error); + } else { + // Other types of exceptions will propagate up + throw error; + } + } + } +} + +export function logEvent (eventType, event) { + // opt out / dead + if (!disabler.storeEvents()) { + return; + } + event.event = eventType; + timestampEvent(event); + + queue.enqueue(event); +} + +/** + * We would like to be able to log events roughly following the xAPI + * conventions (and possibly Caliper conventions). This allows us to + * explicitly structure events with the same fields as xAPI, and + * might have validation logic in the future. However, we have not + * figured out the best way to do this, so please treath this as + * stub / in-progress code. + * + * In the long term, we'd like to be as close to standards as possible. + */ +export function logXAPILite ( + { + verb, + object, + result, + context, + attachments + } +) { + logEvent(verb, + { object, result, context, attachments } + ); +} diff --git a/modules/lo_event/lo_event/memoryQueue.js b/modules/lo_event/lo_event/memoryQueue.js new file mode 100644 index 000000000..017f7f044 --- /dev/null +++ b/modules/lo_event/lo_event/memoryQueue.js @@ -0,0 +1,42 @@ +/* + * This is a small in-memory queue class. It is designed to: + * - Allow us to experiment with interfaces, as we try to abstract the queue out of lo_event, websocket, etc., without moving all the indexeddb code + * - Works everywhere / act as a fallback where indexeddb is unavailable + * - Nice for dev, where we don't want to persist events from buggy code + * - Nice for simple use-cases + */ + +export class Queue { + constructor (queueName) { + this.queue = []; + this.queueName = queueName; + this.promise = null; + this.resolve = null; + + this.enqueue = this.enqueue.bind(this); + this.dequeue = this.dequeue.bind(this); + } + + async initialize () { + } + + enqueue (item) { + if (this.promise) { + this.resolve(item); + this.promise = null; + } else { + this.queue.push(item); + } + } + + dequeue () { + if (this.queue.length > 0) { + return this.queue.shift(); + } else { + this.promise = new Promise((resolve) => { + this.resolve = resolve; + }); + return this.promise; + } + } +} diff --git a/modules/lo_event/lo_event/metadata/browserinfo.js b/modules/lo_event/lo_event/metadata/browserinfo.js new file mode 100644 index 000000000..0405efa99 --- /dev/null +++ b/modules/lo_event/lo_event/metadata/browserinfo.js @@ -0,0 +1,112 @@ +import { copyFields } from '../util.js'; + +// These should be `export`ed, once helpful, since clients might wish +// to use these as a starting point. We'll do that when the first +// use-case comes up, though (so if you'd like access, just ask or +// make a PR). + +const defaultNavigatorFields = [ + 'appCodeName', + 'appName', + 'buildID', + 'cookieEnabled', + 'deviceMemory', + 'language', + 'languages', + 'onLine', + 'oscpu', + 'platform', + 'product', + 'productSub', + 'userAgent', + 'webdriver' +]; + +const defaultConnectionFields = [ + 'effectiveType', + 'rtt', + 'downlink', + 'type', + 'downlinkMax' +]; + +const defaultDocumentFields = [ + 'URL', + 'baseURI', + 'characterSet', + 'charset', + 'compatMode', + 'cookie', + 'currentScript', + 'designMode', + 'dir', + 'doctype', + 'documentURI', + 'domain', + 'fullscreen', + 'fullscreenEnabled', + 'hidden', + 'inputEncoding', + 'isConnected', + 'lastModified', + 'location', + 'mozSyntheticDocument', + 'pictureInPictureEnabled', + 'plugins', + 'readyState', + 'referrer', + 'title', + 'visibilityState' +]; + +const defaultWindowFields = [ + 'closed', + 'defaultStatus', + 'innerHeight', + 'innerWidth', + 'name', + 'outerHeight', + 'outerWidth', + 'pageXOffset', + 'pageYOffset', + 'screenX', + 'screenY', + 'status' +]; + +/* + Browser information object, primarily for debugging. Note that not + all fields will be available in all browsers and contexts. If not + available, it will return null (this is even usable in node.js, + but it will simply return that there is no navigator, window, or + document object). + + @returns {Object} An object containing the browser's navigator, window, and document information. + + Example usage: + const browserInfo = getBrowserInfo(); + console.log(browserInfo); +*/ + +export function getBrowserInfo({ + navigatorFields = defaultNavigatorFields, + connectionFields = defaultConnectionFields, + documentFields = defaultDocumentFields, + windowFields = defaultWindowFields +} = {}) { + const browserInfo = { + navigator: typeof navigator !== 'undefined' ? copyFields(navigator, navigatorFields) : null, + connection: typeof navigator !== 'undefined' && navigator.connection ? copyFields(navigator.connection, connectionFields) : null, + document: typeof document !== 'undefined' ? copyFields(document, documentFields) : null, + window: typeof window !== 'undefined' ? copyFields(window, windowFields) : null + }; + + return { browser_info: browserInfo }; +} + +export const browserInfo = () => ({ + name: 'browserInfo', + func: getBrowserInfo, + async: false, + static: false // Mostly static, but window size might change. +}); diff --git a/modules/lo_event/lo_event/metadata/chromeauth.js b/modules/lo_event/lo_event/metadata/chromeauth.js new file mode 100644 index 000000000..26cd45022 --- /dev/null +++ b/modules/lo_event/lo_event/metadata/chromeauth.js @@ -0,0 +1,49 @@ +/* + This function is a wrapper for retrieving profile information using + the Chrome browser's identity API. It addresses a bug in the Chrome + function and converts it into a modern async function. The bug it + works around can be found at + https://bugs.chromium.org/p/chromium/issues/detail?id=907425#c6. + + To do: Add chrome.identity.getAuthToken() to retrieve an + authentication token, so we can do real authentication. + + Returns: + A Promise that resolves with the user's profile information. + + Example usage: + const profileInfo = await chromeProfileInfoWrapper(); + console.log(profileInfo); + + Users should switch to chromeIdentityHeader(), below. This function name should also + mention `chrome`, but I don't want to do that without updating the extension at the + same time. +*/ +function chromeProfileInfoWrapper() { + if (typeof chrome !== 'undefined' && chrome.identity) { + try { + return new Promise((resolve, reject) => { + chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }, function (data) { + resolve(data); + }); + }); + } catch (e) { + return new Promise((resolve, reject) => { + chrome.identity.getProfileUserInfo(function (data) { + resolve(data); + }); + }); + } + } + // Default to an empty object + return new Promise((resolve, reject) => { + resolve({}); + }); +} + +export const chromeAuth = () => ({ + name: 'chrome_identity', + func: chromeProfileInfoWrapper, + async: true, + static: false +}); diff --git a/modules/lo_event/lo_event/metadata/storage.js b/modules/lo_event/lo_event/metadata/storage.js new file mode 100644 index 000000000..a0e38e8ad --- /dev/null +++ b/modules/lo_event/lo_event/metadata/storage.js @@ -0,0 +1,61 @@ +/** + * Fetch a set of keys from storage to include with metadata + */ +export function getStorageMetadata (storage, keys = null) { + if (!storage) { + return null; + } + + try { + const items = {}; + // If no keys provided, get all items + if (!keys) { + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + try { + items[key] = storage.getItem(key); + } catch (e) { + // TODO: This should probably be communicated out-of-line, rather than + // the same place as data. + items[key] = { + type: 'error', + error_type: e.name || 'Unknown', + error_message: e.message || '' + }; + } + } + } + // Otherwise, only get specified keys + else { + keys.forEach(key => { + try { + items[key] = storage.getItem(key); + } catch (e) { + items[key] = { + type: 'error', + error_type: e.name || 'Unknown', + error_message: e.message || '' + }; + } + }); + } + + return items; + } catch (e) { + return null; // Return null if storage is not accessible + } +} + +export const localStorageInfo = (keys = null) => ({ + name: 'localStorageInfo', + func: () => getStorageMetadata(typeof window !== 'undefined' ? window.localStorage : null, keys), + async: false, + static: false // Not static as storage can change +}); + +export const sessionStorageInfo = (keys = null) => ({ + name: 'sessionStorageInfo', + func: () => getStorageMetadata(typeof window !== 'undefined' ? window.sessionStorage : null, keys), + async: false, + static: false +}); diff --git a/modules/lo_event/lo_event/nullLogger.js b/modules/lo_event/lo_event/nullLogger.js new file mode 100644 index 000000000..fc4b377db --- /dev/null +++ b/modules/lo_event/lo_event/nullLogger.js @@ -0,0 +1 @@ +export function nullLogger () { return () => null; } diff --git a/modules/lo_event/lo_event/queue.js b/modules/lo_event/lo_event/queue.js new file mode 100644 index 000000000..bdf15f08b --- /dev/null +++ b/modules/lo_event/lo_event/queue.js @@ -0,0 +1,93 @@ +import * as indexeddbQueue from './indexeddbQueue.js'; +import * as memoryQueue from './memoryQueue.js'; +import * as debug from './debugLog.js'; +import * as util from './util.js'; + +export const QueueType = { + AUTODETECT: 'AUTODETECT', // Persistent if available, otherwise in-memory + IN_MEMORY: 'IN_MEMORY', // memoryQueue + PERSISTENT: 'PERSISTENT' // SQLite or IndexedDB. Raise an exception if not available. +}; + +const queueClasses = { + [QueueType.IN_MEMORY]: memoryQueue.Queue, + [QueueType.PERSISTENT]: indexeddbQueue.Queue +}; + +function autodetect () { + if (typeof indexedDB === 'undefined') { + return QueueType.IN_MEMORY; + } else { + return QueueType.PERSISTENT; + } +}; + +export class Queue { + constructor (queueName, { queueType = QueueType.AUTODETECT } = {}) { + this.queue = null; + + if (queueType === QueueType.AUTODETECT) { + queueType = autodetect(); + } + + const QueueClass = queueClasses[queueType]; + if (QueueClass) { + debug.info(`Queue: using ${queueType.toLowerCase()}Queue`); + this.queue = new QueueClass(queueName); + } else { + throw new Error('Invalid queue type'); + } + + this.enqueue = this.enqueue.bind(this); + this.startDequeueLoop = util.once(this.startDequeueLoop.bind(this)); + } + + enqueue (item) { + this.queue.enqueue(item); + } + + /** + * This function starts a loop to continually + * dequeue items and process them appropriately + * based on provided functions. + */ + async startDequeueLoop ({ + initialize = async () => true, + shouldDequeue = async () => true, + onDequeue = async (item) => {}, + onError = (message, error) => debug.error(message, error) + }) { + try { + if (!await initialize()) { + throw new Error('QUEUE ERROR: Initialization function returned false.'); + } + } catch (error) { + onError('QUEUE ERROR: Failure to initialize before starting dequeue loop', error); + return; + } + debug.info('QUEUE: Dequeue loop initialized.'); + + while (true) { + // Check if we are allowed to start dequeueing. + // exit dequeue loop if not allowed. + try { + if (!await shouldDequeue()) { + throw new Error('QUEUE ERROR: Dequeue streaming returned false.'); + } + } catch (error) { + onError('QUEUE ERROR: Not allowed to start dequeueing', error); + return; + } + + // do something with the item + const item = await this.queue.dequeue(); + try { + if (item !== null) { + await onDequeue(item); + } + } catch (error) { + onError('QUEUE ERROR: Unable to process item', error); + } + } + } +} diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js new file mode 100644 index 000000000..9dd53f33f --- /dev/null +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -0,0 +1,395 @@ +/* + * This is a logger which uses redux in order to route events to one + * or more subscribers. It is currently used for working with the test + * framework. + * + * In the future, our goal is to make this into a 'batteries included' + * framework for developing `react`/`redux`/`lo_event` applications + * which embodies good design practices for this domain. + * + * Our goal is NOT to be universal. Integrating `lo_event` into an + * exiting `redux` workflow is ≈25 lines of code. This framework is + * opinionated, and if there's a clash of opinions, you're better + * off writing those 25 lines. + * + * Beyond test cases, the major use case is to make the development of + * a broad class of simple educational activites, well, simple. For + * larger applications, it probably makes more sense to start with + * vanilla `react`/`redux`/`lo_event` without using this file, to just + * use bits and pieces, or to treat this code as an examplar. + */ +import * as redux from 'redux'; +import { thunk } from 'redux-thunk'; +import { createStateSyncMiddleware, initMessageListener } from 'redux-state-sync'; +import debounce from 'lodash/debounce'; + +import * as util from './util.js'; + +const EMIT_EVENT = 'EMIT_EVENT'; +const EMIT_LOCKFIELDS = 'EMIT_LOCKFIELDS'; +const EMIT_SET_STATE = 'SET_STATE'; + +let IS_LOADED = false; + +// TODO: Import debugLog and use those functions. +const DEBUG = false; + +function debug_log (...args) { + if (DEBUG) { + console.log(...args); + } +} + +/** + * Update the redux logger's state with `data`. + * This is fired when consuming a custom `fetch_blob` + * event. + */ +export function handleLoadState (data) { + IS_LOADED = true; + const state = store.getState(); + if (data) { + setState( + { + ...state, + ...data, + settings: { + ...state.settings, + reduxStoreStatus: IS_LOADED + } + }); + } else { + debug_log('No data provided while handling state from server, continuing.'); + setState( + { + ...state, + settings: { + ...state.settings, + reduxStoreStatus: IS_LOADED + } + }); + } +} + +async function saveStateToLocalStorage (state) { + if (!IS_LOADED) { + debug_log('Not saving store locally because IS_LOADED is set to false.'); + return; + } + + try { + const KEY = state?.settings?.reduxID || 'redux'; + const serializedState = JSON.stringify(state); + localStorage.setItem(KEY, serializedState); + } catch (e) { + // Ignore + } +} + +/** + * Dispatch a `save_blob` event on the redux + * logger. + */ +async function saveStateToServer (state) { + if (!IS_LOADED) { + debug_log('Not saving store on the server because IS_LOADED is set to false.'); + return; + } + + try { + // console.log("dispatching save_blob") + util.dispatchCustomEvent('save_blob', { detail: state }); + // store.dispatch('save_blob', { detail: state }); + } catch (e) { + // Ignore + debug_log('Error in dispatch', { e }); + } +} + +// Action creator function This is a little bit messy, since we +// duplicate type from the payload. It's not clear if this is a good +// idea. We used to have `type` be set to the current contents of +// `redux_type`. However, for debugging / logging tools +// (e.g. redux-dev-tools), it was convenient to have this match up to +// the internal event type. +const emitEvent = (event) => { + return { + redux_type: EMIT_EVENT, + type: JSON.parse(event).event, + payload: event + }; +}; + +// Action creator function +const emitSetField = (setField) => { + return { + redux_type: EMIT_LOCKFIELDS, + type: EMIT_LOCKFIELDS, + payload: setField + }; +}; + +const emitSetState = (state) => { + return { + redux_type: EMIT_SET_STATE, + type: EMIT_SET_STATE, + payload: state + }; +}; + +function store_last_event_reducer (state = {}, action) { + return { ...state, event: action.payload }; +}; + +function lock_fields_reducer (state = {}, action) { + const payload = JSON.parse(action.payload); + return { + ...state, + lock_fields: { + ...payload, + fields: { + ...(state.lock_fields ? state.lock_fields.fields : {}), + ...payload.fields + } + } + }; +} + +/* + * This is our most common reducer. It simply updates a component's + * state with the dictionary of an action. + * + * In the future, we plan to add various sorts of event validation and + * potentially preprocessing. We would like things like: + * + * updateComponentStateReducer({valid_fields: ['response'}) + * + * Ergo, the two-level call with the destruct. + */ +export const updateComponentStateReducer = ({}) => (state = initialState, action) => { + const { id, ...rest } = action; + const new_state = { + ...state, + component_state: { + ...state.component_state, + [id]: {...state.component_state?.[id], ...rest} + } + }; + + debug_log( + "==REGISTER REDUCER==\n", + "Reducer action:", action, "\n", + "Response reducer called\n", + "Old state", state, "\n", + "Action", action, "\n", + "New state", new_state + ); + + return new_state; +} + +function set_state_reducer (state = {}, action) { + return action.payload; +} + +const BASE_REDUCERS = { + [EMIT_EVENT]: [store_last_event_reducer], + [EMIT_LOCKFIELDS]: [lock_fields_reducer], + [EMIT_SET_STATE]: [set_state_reducer] +} + +const APPLICATION_REDUCERS = {}; + +export const registerReducer = (keys, reducer) => { + const reducerKeys = Array.isArray(keys) ? keys : [keys]; + + reducerKeys.forEach(key => { + debug_log('registering key: ' + key); + if (!APPLICATION_REDUCERS[key]) { + APPLICATION_REDUCERS[key] = []; + } + APPLICATION_REDUCERS[key].push(reducer); + }); + return reducer; +}; + +// Reducer function +const reducer = (state = {}, action) => { + let payload; + + debug_log('Reducing ', action, ' on ', state); + state = BASE_REDUCERS[action.redux_type] ? composeReducers(...BASE_REDUCERS[action.redux_type])(state, action) : state; + + if (action.redux_type === EMIT_EVENT) { + payload = JSON.parse(action.payload); + if (action.type === 'save_setting') { + return { + ...state, + settings: { + ...state.settings, + payload + } + }; + } + debug_log(Object.keys(payload)); + + if (APPLICATION_REDUCERS[payload.event]) { + state = { ...state, application_state: composeReducers(...APPLICATION_REDUCERS[payload.event])(state.application_state || {}, payload) }; + } + } + + return state; +}; + +const eventQueue = []; +const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || redux.compose; + +// This should just be redux.applyMiddleware(thunk)) +// There is a bug in our version of redux-thunk where, in node, this must be thunk.default. +// +// This shows up as an error in the test case. If the error goes away, we should switch this +// back to thunk. +// const presistedState = loadState(); + +export let store = redux.createStore( + reducer, + { event: null }, // Base state + composeEnhancers(redux.applyMiddleware((thunk.default || thunk), createStateSyncMiddleware())) +); + +initMessageListener(store); + +let promise = null; +let previousEvent = null; +let lockFields = null; +let eventSubscribers = []; + +/* + Compose reducers takes a dynamic number of reducers as arguments and + returns a new reducer function. This applies each reducer to the + state in the order they are provided, ultimately returning the + final state after all reducers have been applied. + + Example usage: + ``` + const rootReducer = composeReducers(reducer1, reducer2, reducer3); + const finalState = rootReducer(initialState, { redux_type: 'SOME_ACTION' }); + ``` +*/ +function composeReducers(...reducers) { + return (state, action) => reducers.reduce( + (currentState, reducer) => reducer(currentState, action), + state + ); +} + +export function setState(state) { + debug_log('Set state called'); + if (Object.keys(state).length === 0) { + const storeState = store.getState(); + state = { + settings: { + ...storeState.settings, + reduxStoreStatus: IS_LOADED + } + }; + } + store.dispatch(emitSetState(state)); +} + +const debouncedSaveStateToLocalStorage = debounce((state) => { + saveStateToLocalStorage(state); +}, 1000); + +const debouncedSaveStateToServer = debounce((state) => { + saveStateToServer(state); +}, 1000); + +function initializeStore () { + store.subscribe(() => { + const state = store.getState(); + // we use debounce to save the state once every second + // for better performances in case multiple changes occur in a short time + debouncedSaveStateToLocalStorage(state); + debouncedSaveStateToServer(state); + + if (state.lock_fields) { + lockFields = state.lock_fields.fields; + } + if (!state.event) return; + debug_log('Received event:', state.event); + const event = JSON.parse(state.event); + if (event === previousEvent) { + return; + } + previousEvent = event; + + if (promise) { + promise.resolve(event); + promise = null; + } else { + // This is only useful for awaitEvent below. Otherwise, events build up. Having + // this event queue may be good or a memory leak. We should figure out whether + // to have this behind a flag later. + eventQueue.push(event); + } + for (const i in eventSubscribers) { + eventSubscribers[i](event); + } + }); +} + +export function reduxLogger (subscribers, initialState = null) { + if (subscribers != null) { + eventSubscribers = subscribers; + } + + function logEvent (event) { + store.dispatch(emitEvent(event)); + } + logEvent.lo_name = 'Redux Logger'; // A human-friendly name for the logger + logEvent.lo_id = 'redux_logger'; // A machine-frienly name for the logger + + logEvent.init = async function () { + initializeStore(); + }; + + logEvent.setField = function (event) { + store.dispatch(emitSetField(event)); + }; + + logEvent.getLockFields = function () { return lockFields; }; + + // do we want to initialize the store here? We set it to the stored state in create store + // if (initialState) { + // } + + return logEvent; +} + +// This is a convenience function which lets us simply await events. +// +// Note that this should not be used in threaded code or in multiple +// places at the same time in async code. It's a convenience function +// for _simple_ code. +export const awaitEvent = () => { + if (eventQueue.length > 0) { + return eventQueue.shift(); // Return the first event in the queue + } + if (promise) { + throw new Error('Only one call to awaitEvent is allowed at a time'); + } + + // Create a new promise + let resolvePromise; + + promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + promise.resolve = resolvePromise; + return promise; +}; + +// Start listening for fetch +util.consumeCustomEvent('fetch_blob', handleLoadState); diff --git a/modules/lo_event/lo_event/util.js b/modules/lo_event/lo_event/util.js new file mode 100644 index 000000000..9971f1c9c --- /dev/null +++ b/modules/lo_event/lo_event/util.js @@ -0,0 +1,499 @@ +// import * as crypto from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; + +import { storage } from './browserStorage.js'; + +/** + * Helper function for copying specific field values + * from a given source. This is called to collect browser + * information if available. + * + * Example usage: + * const copied = copyFields({ a: 1, b: 2, c: 3 }, ['a', 'b']) + * console.log(copied) + * // expected output: { a: 1, b: 2 } + */ +export function copyFields (source, fields) { + const result = {}; + if (source) { + fields.forEach(field => { + if (field in source) { + result[field] = source[field]; + } + }); + } + return result; +} + +/* + Generate a unique key, which can be used for session IDs, anonymous user IDs, and other similar purposes. + + Parameters: + - prefix (str): Optional prefix to prepend to the generated key. + + Returns: + str: A string representing the unique key, in the format "{prefix}-{randomUUID}-{timestamp}". If no prefix is provided, the format will be "{randomUUID}-{timestamp}". + */ +export function keystamp (prefix) { + return `${prefix ? prefix + '-' : ''}${uuidv4()}-${Date.now()}`; + // return `${prefix ? prefix + '-' : ''}${crypto.randomUUID()}-${Date.now()}`; +} + +/* + Create a fully-qualified web socket URL. + + All parameters are optional when running on a web page. On an extension, + we need, at least, the base server. + + This will: + * Convert relative URLs into fully-qualified ones, if necessary + * Convert HTTP/HTTPS URLs into WS/WSS ones, if necessary + + Example usage: + fullyQualifiedWebsocketURL('/websocket/endpoint', 'http://websocket.server'); + // Expected output: ws://websocket.server/websocket/endpoint + // See tests for more examples + */ +export function fullyQualifiedWebsocketURL (defaultRelativeUrl, defaultBaseServer) { + const relativeUrl = defaultRelativeUrl || '/wsapi/in'; + const baseServer = defaultBaseServer || (typeof document !== 'undefined' && document.location); + + if (!baseServer) { + throw new Error('Base server is not provided.'); + } + + const url = new URL(relativeUrl, baseServer); + + const protocolMap = { 'https:': 'wss:', 'http:': 'ws:', 'ws:': 'ws:', 'wss:': 'wss' }; + + if (!protocolMap[url.protocol]) { + throw new Error('Protocol mapping not found.'); + } + + url.protocol = protocolMap[url.protocol]; + + return url.href; +} + +function browserStamp() { + const stampKey = 'loBrowserStamp'; // Key to store the browser stamp + let stamp = storage.get(stampKey); + + if (!stamp) { + // Generate and store browser stamp if not found + stamp = keystamp(); + storage.set({stampKey: stamp}); + } + + return stamp; +} + +let eventIndex = 0; // Initialize index counter +let sessionStamp = keystamp(); + +// TODO: +// (a) We probably want this elsewhere +// (b) With the current flow of logic, init() might be called after logEvent, +// and even if set to false, a few events might have extra metadata. +// This isn't a killer, since the reason not to do this is mostly due to +// bandwidth. +let verboseEvents = true; + +export function setVerboseEvents(value) { + verboseEvents = value; +} + +/** + * Example usage: + * event = { event: 'ADD', data: 'stuff' } + * timestampEvent(event) + * event + * // { event: 'ADD', data: 'stuff', metadata: { ts, human_ts, iso_ts, sessionIndex, sessionTag } } + */ +export function timestampEvent (event) { + if (!event.metadata) { + event.metadata = {}; + } + + event.metadata.iso_ts = new Date().toISOString(); + if(verboseEvents) { + event.metadata.ts = Date.now(); + event.metadata.human_ts = Date(); + event.metadata.sessionIndex = eventIndex++; + event.metadata.sessionTag = sessionStamp; + event.metadata.browserTag = browserStamp(); + } +} + +/** + * We provide an id for each system that is stored + * locally with the client. This allows us to more easily + * parse events when debugging in specific contexts. + * + * Example usage: + * const debugMetadata = await fetchDebuggingIdentifier(); + * console.log(debugMetadata); + * // Expected output: { logger_id: } + */ +export function fetchDebuggingIdentifier () { + return new Promise((resolve, reject) => { + const metadata = {}; + + storage.get(['logger_id', 'name'], (result) => { + if (result.logger_id) { + metadata.logger_id = result.logger_id; + } else { + metadata.logger_id = keystamp('lid'); + storage.set({ logger_id: metadata.logger_id }); + } + if (result.name) { + metadata.name = result.name; + } + resolve(metadata); + }); + }); +} + +/** + * Deeply merge `source` into `target`. + * `target` should be passed by reference + * + * This is a helper function for `mergeMetadata`. + * + * Example usage: + * const obj1 = { a: 1, b: { c: 3 } }; + * const obj2 = { b: { d: 4 }, e: 5 }; + * util.mergeDictionary(obj1, obj2); + * obj1 + * // { a: 1, b: { c: 3, d: 4 }, e: 5 } + */ +export function mergeDictionary (target, source) { + for (const key in source) { + if ( + Object.prototype.hasOwnProperty.call(target, key) && + typeof target[key] === 'object' && target[key] !== null && + typeof source[key] === 'object' && source[key] !== null + ) { + mergeDictionary(target[key], source[key]); + } else { + target[key] = source[key]; + } + } +} + +/** + * Merges the output of dictionaries, sync functions, and async + * functions into a single master dictionary. + * + * Functions and async functions should return dictionaries. + * + * @param {Array} inputList - List of dictionaries, sync functions, and async functions + * @returns {Promise} - A Promise that resolves to the compiled master dictionary + * + * Example usage: + * const metadata = await mergeMetadata([ browserInfo(), { source: '0.0.1' }, extraMetadata() ]) + * console.log(metadata); + * // { browserInfo: {}, source: '0.0.1', metadata: { extra: 'extra data' }} + */ +export async function mergeMetadata (inputList) { + // Initialize the master dictionary + const masterDict = {}; + + // Iterate over each item in the input list + for (const item of inputList) { + let result; + + if (typeof item === 'object') { + // If the item is a dictionary, merge it into the master dictionary + mergeDictionary(masterDict, item); + } else if (typeof item === 'function') { + // If the item is a function (sync or async), execute it + result = await item(); + + if (typeof result === 'object') { + // If the result of the function is a dictionary, merge it into the master dictionary + mergeDictionary(masterDict, result); + } else { + console.log('Ignoring non-dictionary result:', result); + } + } else { + console.log('Ignoring invalid item:', item); + } + } + + return masterDict; +} + +export function delay (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +const MS = 1; +const SECS = 1000 * MS; +const MINS = 60 * SECS; +const HOURS = 60 * MINS; + +export const TERMINATION_POLICY = { + DIE: 'DIE', + RETRY: 'RETRY' +}; + +/** + * This function repeatedly tries to run another function + * until it returns a truthy value while waiting a set amount + * of time inbetween each attempt. + * + * The system will either terminate when we have await each + * delay amount in the `delays` list (TERMINATION_POLICY.DIE) + * OR we continue retrying using the last item in our `delays` + * list until we reach the `maxRetries` (TERMINATION_POLICY.RETRY). + * + * Example usage: + * util.backoff(checkCondition, 'Condition not met after retries.') + * .then(() => console.log('Condition met.')) + * .catch(error => console.error(error.message)); + * + * @param {*} predicate function that returns truthy value + * @param {*} errorMessage message to be thrown when we run out of delays + * @param {*} delays list of MS values to be await in order + * @default delays defaults to [100ms, 1sec, 1min, 5min, 30min] + * @param {*} terminationPolicy when to be done retrying + * @default terminationPolicy defaults to TERMINATION_POLICY.DIE + * @param {*} maxRetries number of maximum retries when terminationPolicy is set to RETRY + * @default maxRetries defaults to Infinity + * @returns returns when predicate is true or throws errorMessage + */ +export async function backoff ( + predicate, + errorMessage = 'Could not resolve backoff function', + // In milliseconds, time between retries until we fail. + delays = [100 * MS, 1 * SECS, 10 * SECS, 1 * MINS, 5 * MINS, 30 * MINS], + terminationPolicy = TERMINATION_POLICY.DIE, + maxRetries = Infinity +) { + let retryCount = 0; + while (true) { + if (await predicate()) { + return true; + } + // terminate if we are done with delays list + if (terminationPolicy === TERMINATION_POLICY.DIE && retryCount >= delays.length) { + break; + } + const delayTime = retryCount < delays.length ? delays[retryCount] : delays[delays.length - 1]; + await delay(delayTime); + + retryCount++; + // terminate if past max retries + if (terminationPolicy === TERMINATION_POLICY.RETRY && retryCount > maxRetries) { + break; + } + } + throw new Error(errorMessage); +} + +// The `once` function is a decorater function that takes in a +// function `func` and returns a new function. The returned function +// can only be called once, and any subsequent calls will result in an +// error. It is intended for the event loops in the various queue +// code. +// +// This is similar to the underscore once, but in contrast to that +// one, subsequent calls give an error rather than silently doing +// nothing. This is important as we are debugging the code. In the +// future, we might make this configurable or just switch, but for +// now, we'd like to understand if this ever happens and make it very +// obvious, +export function once (func) { + const run = false; + return function () { + if (!run) { + return func.apply(this, arguments); + } else { + console.log('>>>> Function called more than once. This should never happen <<<<'); + throw new Error('Error! Function was called more than once! This should never happen'); + } + }; +} + +/* + Retrieve an element from a tree with dotted notation + + e.g. treeget( + {"hello": {"bar":"biff"}}, + "hello.bar" + ) + + This can also handle embbedded lists identified + using notations like addedNodes[0].className. + + If not found, return null + + This was created in the extension, but is being transferred into + `lo_event`. Once it is merged, the extension should be modified to + use the version from `lo_event`, and this should be removed from + there. +*/ +export function treeget(tree, key) { + let keylist = key.split("."); + let subtree = tree; + for(var i=0; i0) { + const item = keylist[i].split('[')[0]; + const idx_orig = keylist[i].split('[')[1]; + const idx = idx.split(']')[0]; + if (item in subtree) { + if (subtree[item][idx] !== undefined) { + subtree =subtree[item][idx]; + } else { + return null; + } + } else { + return null; + } + } else { + return null; + } + } + } + return subtree; +} + +/** + * Takes a number of seconds and converts it into a human-friendly time string in the format HH:MM:SS. + * + * @param {number} seconds - The number of seconds to format into a time string + * @returns {string} The formatted time string + * + * Will do things like omit hours (and perhaps be smarter in the future) + */ +export function formatTime(seconds) { + // Calculate hours, minutes, and remaining seconds + var hours = Math.floor(seconds / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + var remainingSeconds = (seconds % 60).toFixed(2); + + // Format hours, minutes, and remaining seconds to include leading zeros + var formattedHours = hours.toString().padStart(2, '0'); + var formattedMinutes = minutes.toString().padStart(2, '0'); + var formattedSeconds = remainingSeconds.padStart(5, '0'); + + // Concatenate and return the formatted time + if (hours > 0) { + return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; + } else { + return `${formattedMinutes}:${formattedSeconds}`; + } +} + +/** + * This function dispatches an event in the appropriate context for + * our environment. + * + * When working in an extension, we want to send a message via the + * `chrome.runtime` object. + * + * When working in a browser, we want to dispatch the event via the + * `window` object. + */ +export function dispatchCustomEvent (eventName, detail) { + const event = new CustomEvent(eventName, detail); + if (typeof window !== 'undefined') { + // Web page: dispatch directly on window + window.dispatchEvent(event); + } else if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { + // Chrome extension background script: use chrome.runtime to send messages + chrome.runtime.sendMessage({ eventName, detail }, (response) => { + if (chrome.runtime.lastError) { + console.warn(`No listeners found for event, ${eventName}, in this context.`); + } + }); + } else { + console.warn('Event dispatching is not supported in this environment.'); + } +} + +/** + * This function consumes a custom event in the appropriate context for + * our environment. + * + * When working in an extension, it listens for messages via the + * `chrome.runtime.onMessage` object. + * + * When working in a browser, it listens for events on the + * `window` object. + */ +export function consumeCustomEvent (eventName, callback) { + if (typeof window !== 'undefined') { + // Web page: listen for the event on the window object + const listener = (event) => { + callback(event.detail); + }; + window.addEventListener(eventName, listener); + + // Return a function to remove the event listener + return () => window.removeEventListener(eventName, listener); + } else if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + // Chrome extension background script: listen for messages via chrome.runtime + const listener = (message, sender, sendResponse) => { + if (message.eventName === eventName) { + callback(message.detail, sender); + sendResponse?.({ status: 'received' }); + } + }; + chrome.runtime.onMessage.addListener(listener); + + // Return a function to remove the message listener + return () => chrome.runtime.onMessage.removeListener(listener); + } else { + console.warn('Event consumption is not supported in this environment.'); + return () => { + console.warn('No listener to remove in this environment.'); + }; + } +} + +/** + * Convert seconds to a time string. + * + * Compact representation. + * 10 ==> 10s + * 125 ==> 2m + * 3600 ==> 1h + * 7601 ==> 2h + * 764450 ==> 8d + */ +export function renderTime (t) { + const seconds = Math.floor(t) % 60; + const minutes = Math.floor(t / 60) % 60; + const hours = Math.floor(t / 3600) % 60; + const days = Math.floor(t / 3600 / 24); + + if (days > 0) { + return String(days) + 'd'; + } + if (hours > 0) { + return String(hours) + 'h'; + } + if (minutes > 0) { + return String(minutes) + 'm'; + } + if (seconds > 0) { + return String(seconds) + 's'; + } + return '-'; +} diff --git a/modules/lo_event/lo_event/websocketLogger.js b/modules/lo_event/lo_event/websocketLogger.js new file mode 100644 index 000000000..8724b72d7 --- /dev/null +++ b/modules/lo_event/lo_event/websocketLogger.js @@ -0,0 +1,196 @@ +import { Queue } from './queue.js'; +import * as disabler from './disabler.js'; +import * as util from './util.js'; +import * as debug from './debugLog.js'; +import { storage } from './browserStorage.js'; + +function wsHost(overrides = {}, loc=window.location) { + const { hostname, port, path, url } = overrides; + const loServer = storage.get('lo_server'); + if (loServer) { + console.log("Overriding server from storage"); + return loServer; + } + const protocol = loc.protocol === 'https:' ? 'wss://' : 'ws://'; + const host = hostname || loc.hostname; + const portNumber = port || loc.port || (loc.protocol === 'https:' ? 443 : 80); + const pathname = path || '/wsapi/in/'; + const fullUrl = url || `${host}:${portNumber}${pathname}`; + + return `${protocol}${fullUrl}`; +} + + +export function websocketLogger (server = {}) { + /* + This is a pretty complex logger, which sends events over a web + socket. + + `server` can be a URL (usually, ws:// or wss://) or an object + containing one or more of hostname, port, path, and url. + + Note that if the server is an object, it can be overwritten in + storage (key loServer). + + Most of the complexity comes from reconnections, retries, + etc. and the need to keep robust queues, as well as the need be + robust about queuing events before we have a socket open or during + a network failure. + */ + let socket; // Our actual socket used to send and receive data. + let WSLibrary; // For compatibility between node and browser, this either points to the browser WebSocket or to a compatibility library. + const queue = new Queue('websocketLogger'); + // This holds an exception, if we're blacklisted, between the web + // socket and the API. We generate this when we receive a message, + // which is not a helpful place to raise the exception from, so we + // keep this around until we're called from the client, and then we + // raise it there. + let blockerror; + let metadata = {}; + + // This logic might be moved into wsHost, so it's a little bit more + // consistent. + if(!server) { + server = wsHost(); + } else if(typeof server === 'object') { + server = wsHost(server); + } + // else the server is likely already a url string + + function calculateExponentialBackoff (n) { + return Math.min(1000 * Math.pow(2, n), 1000 * 60 * 15); + } + + let failures = 0; + let READY = false; + let wsFailureResolve = null; + let wsFailurePromise = null; + let wsConnectedResolve = null; + + async function startWebsocketConnectionLoop () { + while (true) { + const connected = await newWebsocket(); + if (!connected) { + failures++; + await util.delay(calculateExponentialBackoff(failures)); + } else { + READY = true; + failures = 0; + await socketClosed(); + READY = false; + } + } + } + + function socketClosed () { return wsFailurePromise; } + + function newWebsocket () { + socket = new WSLibrary(server); + wsFailurePromise = new Promise((resolve, reject) => { + wsFailureResolve = resolve; + }); + const wsConnectedPromise = new Promise((resolve, reject) => { + wsConnectedResolve = resolve; + }); + socket.onopen = () => { prepareSocket(); wsConnectedResolve(true); }; + socket.onerror = function (e) { + debug.error('Could not connect to websocket', e); + wsConnectedResolve(false); + wsFailureResolve(); + }; + socket.onclose = () => { wsConnectedResolve(false); wsFailureResolve(); }; + socket.onmessage = receiveMessage; + return wsConnectedPromise; + } + + function prepareSocket () { + if(Object.keys(metadata).length > 0) { + queue.enqueue(JSON.stringify(metadata)); + } + + queue.startDequeueLoop({ + initialize: waitForWSReady, + shouldDequeue: waitForWSReady, + onDequeue: socketSend + }); + } + + async function socketSend (item) { + socket.send(item); + } + + async function waitForWSReady () { + return await util.backoff(() => (READY)); + } + + function receiveMessage (event) { + const response = JSON.parse(event.data); + switch (response.status) { + case 'blocklist': + debug.info('Received block error from server'); + blockerror = new disabler.BlockError( + response.message, + response.time_limit, + response.action + ); + break; + case 'auth': + storage.set({ user_id: response.user_id }); + util.dispatchCustomEvent('auth', { detail: { user_id: response.user } }); + break; + // These should probably be behind a feature flag, as they assume + // we trust the server. + case 'local_storage': + storage.set({ [response.key]: response.value }); + break; + case 'browser_event': + util.dispatchCustomEvent(response.event_type, { detail: response.detail }); + break; + case 'fetch_blob': + util.dispatchCustomEvent('fetch_blob', { detail: response.data }); + break; + default: + debug.info(`Received response we do not yet handle: ${response}`); + break; + } + } + + function checkForBlockError () { + if (blockerror) { + console.log('Throwing block error'); + const b = blockerror; + blockerror = null; + socket.close(); + throw b; + } + } + + function wsLogData (data) { + checkForBlockError(); + queue.enqueue(data); + } + + wsLogData.init = async function () { + if (typeof WebSocket === 'undefined') { + debug.info('Importing ws'); + WSLibrary = (await import('ws')).WebSocket; + } else { + debug.info('Using built-in websocket'); + WSLibrary = WebSocket; + } + startWebsocketConnectionLoop(); + }; + + wsLogData.setField = function (data) { + util.mergeDictionary(metadata, JSON.parse(data)); + queue.enqueue(data); + }; + + function handleSaveBlob (blob) { + queue.enqueue(JSON.stringify({ event: 'save_blob', blob })); + } + + util.consumeCustomEvent('save_blob', handleSaveBlob) + + return wsLogData; +} diff --git a/modules/lo_event/lo_event/xapi.cjs b/modules/lo_event/lo_event/xapi.cjs new file mode 100644 index 000000000..cd8af396d --- /dev/null +++ b/modules/lo_event/lo_event/xapi.cjs @@ -0,0 +1,59 @@ +// TODO: All of this needs better function names, comments, etc. +// +// We want a general code cleanup. + +const activityTypeApi = require('../xapi/activityType.json'); +const attachmentUsageApi = require('../xapi/attachmentUsage.json'); +const extensionApi = require('../xapi/extension.json'); +const profileApi = require('../xapi/profile.json'); +const verbApi = require('../xapi/verb.json'); + +function aaevSelector (t) { + const nameField = t.metadata.metadata.name; + return nameField['en-US'] || nameField['en-us']; +} + +function cleanName (n) { + return n.toUpperCase().trim().replace(/ |-|\./g, '_').replace(/[()]/g, ''); +} + +function toDicts (jsonBlock, selector, namesDict, objectsDict) { + for (let i = 0; i < jsonBlock.length; i++) { + const name = cleanName(selector(jsonBlock[i])); + namesDict[name] = name; + objectsDict[name] = jsonBlock[i]; + } +} + +const ACTIVITYTYPE = {}; +const ActivityTypeObjects = {}; +toDicts(activityTypeApi, aaevSelector, ACTIVITYTYPE, ActivityTypeObjects); + +const ATTACHMENTUSAGE = {}; +const AttachmentUsageObjects = {}; +toDicts(attachmentUsageApi, aaevSelector, ATTACHMENTUSAGE, AttachmentUsageObjects); + +const EXTENSION = {}; +const ExtensionObjects = {}; +toDicts(extensionApi, aaevSelector, EXTENSION, ExtensionObjects); + +const PROFILE = {}; +const ProfileObjects = {}; +toDicts(profileApi, (t) => t.name, PROFILE, ProfileObjects); + +const VERB = {}; +const VerbObjects = {}; +toDicts(verbApi, aaevSelector, VERB, VerbObjects); + +module.exports = { + ACTIVITYTYPE, + ActivityTypeObjects, + ATTACHMENTUSAGE, + AttachmentUsageObjects, + EXTENSION, + ExtensionObjects, + PROFILE, + ProfileObjects, + VERB, + VerbObjects +}; diff --git a/modules/lo_event/lo_event/xapi.py b/modules/lo_event/lo_event/xapi.py new file mode 100644 index 000000000..e8ee309be --- /dev/null +++ b/modules/lo_event/lo_event/xapi.py @@ -0,0 +1,113 @@ +""" +This is an interface to the XAPI registry of vocabulary. + +This code parses JSON files and creates enum-like objects with mappings from cleaned names to original names and full objects. + +For this to work, you should first download the .json files with the download_xapi_json.sh script in the xapi directory. + +Example usage: +```python +import xapi + +# Access the enum-like objects: +print(dir(xapi.ACTIVITYTYPE)) +print(xapi.ACTIVITYTYPE.QUESTION) +print(xapi.ActivitytypeObjects.QUESTION) +``` +""" + +import json +import sys + +sources = ["activityType", "attachmentUsage", "extension", "profile", "verb"] + + +def clean_name(name): + """ + Cleans the name of a vocabulary entry so that it is a valid Python identifier by converting to upper case, and removing or replacing special characters + + Args: + name (str): The original name of the vocabulary entry. + + Returns: + str: The cleaned name of the vocabulary entry. + """ + return name.upper().strip().replace(' ', '_').replace('-', '_').replace('.', '_').replace('(', '').replace(')', '') + + +def get_name(x): + """ + Retrieves the English language name of a vocabulary entry. This is a work-around since the API sometimes retrieves en-us and sometimes en-US. + + Args: + x (dict): The JSON object representing the vocabulary entry. + + Returns: + str: The English language name of the vocabulary entry. + """ + return x.get('en-us', x.get('en-US', None)) + + +def parse_json(source): + """ + Parses a JSON file containing vocabulary entries into a tuple of clean names and the source JSON data + + Args: + source (str): The name of the JSON file. + + Returns: + tuple: A tuple containing cleaned names, and JSON data of the vocabulary entries. + """ + json_data = json.load(open(f"xapi/{source}.json")) + names = [] + names_cleaned = [] + if source == "profile": + selector = lambda x: x['name'] + else: + selector = lambda x: get_name(x['metadata']['metadata']['name']) + + for d in json_data: + names.append(selector(d)) + names_cleaned.append(clean_name(selector(d))) + + return names_cleaned, json_data + + +def create_enum_map(names): + """ + Creates an enum-like object with mappings from cleaned names to original names in the module namespace. + + Args: + names (list): The original names of the vocabulary entries. + names_cleaned (list): The cleaned names of the vocabulary entries. + """ + class EnumMap: + pass + enum_map = EnumMap() + setattr(sys.modules[__name__], source.upper(), enum_map) + for name in names: + setattr(enum_map, name, name) + + +def create_object_map(names, json_data): + """ + Creates an object with mappings from cleaned names to full objects in the module namespace. + + Args: + names_cleaned (list): The cleaned names of the vocabulary entries. + json_data (list): The JSON data of the vocabulary entries. + """ + class ObjectMap: + pass + object_map = ObjectMap() + setattr(sys.modules[__name__], f'{source.capitalize()}Objects', object_map) + for i, obj in enumerate(json_data): + setattr(object_map, names[i], obj) + + +sources = ["activityType", "attachmentUsage", "extension", "profile", "verb"] + +for source in sources: + names_cleaned, json_data = parse_json(source) + create_enum_map(names_cleaned) + create_object_map(names_cleaned, json_data) diff --git a/modules/lo_event/package.json b/modules/lo_event/package.json new file mode 100644 index 000000000..f5e85ee58 --- /dev/null +++ b/modules/lo_event/package.json @@ -0,0 +1,67 @@ +{ + "name": "lo_event", + "version": "0.0.3", + "description": "Event logging library for the Learning Observer", + "main": "lo_event/lo_event.js", + "scripts": { + "prebuild": "./lo_cli.js build", + "test": "jasmine \"tests/*.test.js\"", + "test-verbose": "jasmine --verbose \"tests/*.test.js\"", + "browser": "parcel examples/*.html --open", + "clean": "rm -Rf .cache/ dist/ .parcel-cache/ lo_event-*.tgz" + }, + "bin": { + "lo_cli": "lo_cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ETS-Next-Gen/writing_observer.git" + }, + "author": "Piotr Mitros", + "license": "SEE LICENSE IN license.txt", + "bugs": { + "url": "https://github.com/ETS-Next-Gen/writing_observer/issues" + }, + "homepage": "https://github.com/ETS-Next-Gen/writing_observer#readme", + "type": "module", + "dependencies": { + "aws-sdk": "^2.1614.0", + "debounce": "^2.2.0", + "http-server": "^14.1.1", + "indexeddb-js": "^0.0.14", + "jasmine": "^5.1.0", + "lodash": "^4.17.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "redux": "^5.0.1", + "redux-state-sync": "^3.1.4", + "redux-thunk": "^3.1.0", + "sqlite3": "^5.1.6", + "uuid": "^10.0.0", + "ws": "^8.14.2", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@babel/cli": "^7.24.5", + "@babel/core": "^7.24.5", + "@babel/preset-env": "^7.24.5", + "@babel/preset-react": "^7.24.7", + "assert": "^2.1.0", + "buffer": "^6.0.3", + "crypto-browserify": "^3.12.0", + "events": "^3.3.0", + "mustache": "^4.2.0", + "os-browserify": "^0.3.0", + "parcel": "^2.12.0", + "path-browserify": "^1.0.1", + "process": "^0.11.10", + "querystring-es3": "^0.2.1", + "stream-browserify": "^3.0.0", + "url": "^0.11.3", + "util": "^0.12.5", + "vm-browserify": "^1.1.2" + }, + "browser": { + "fs": false + } +} diff --git a/modules/lo_event/setup.cfg b/modules/lo_event/setup.cfg new file mode 100644 index 000000000..f9b41d0c5 --- /dev/null +++ b/modules/lo_event/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = LO Event +description = Outgoing event module for the learning observer platform +version = 0.0.1 + +[options] +packages = lo_event diff --git a/modules/lo_event/setup.py b/modules/lo_event/setup.py new file mode 100644 index 000000000..8d054a210 --- /dev/null +++ b/modules/lo_event/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup, find_packages + +setup( +) diff --git a/modules/lo_event/tests/lo_event.test.js b/modules/lo_event/tests/lo_event.test.js new file mode 100644 index 000000000..6f8c089d5 --- /dev/null +++ b/modules/lo_event/tests/lo_event.test.js @@ -0,0 +1,45 @@ +/* + * Test of basic functionality. This uses the redux logger, primarily. + */ + +import * as loEvent from '../lo_event/lo_event.js'; +import * as reduxLogger from '../lo_event/reduxLogger.js'; +import { consoleLogger } from '../lo_event/consoleLogger.js'; +import * as debug from '../lo_event/debugLog.js'; +import { getBrowserInfo } from '../lo_event/metadata/browserinfo.js'; + +const rl = reduxLogger.reduxLogger(); + +console.log('lo_event test: Initializing loEvent'); +loEvent.init( + 'org.ets.lo_event.test', + '1', + [consoleLogger(), rl], + debug.LEVEL.SIMPLE, + [debug.LOG_OUTPUT.LOGGER(loEvent.logEvent)] +); +loEvent.setFieldSet([{ preauth_type: 'test' }]); +loEvent.setFieldSet([{ postauth_type: 'test' }, getBrowserInfo()]); +loEvent.go(); + +console.log('lo_event test: Initialized'); +loEvent.logEvent('test', { event_number: 1 }); +loEvent.logEvent('test', { event_number: 2 }); +loEvent.logEvent('test', { event_number: 3 }); + +console.log('lo_event test: Preparing to run test cases'); + +describe('loEvent testing', () => { + it('Check basic event handling', async () => { + console.log('lo_event test: Running test cases'); + // TODO revisit why we need this additional awaitEvent call. + // Spent some time poking, but didn't fully understand the + // why so I'm leaving it as is now. + // Are events coming in in the right order? + expect((await reduxLogger.awaitEvent()).event_number).toBe(1); + expect((await reduxLogger.awaitEvent()).event_number).toBe(2); + console.log('lo_event test: Test Event 3: ', reduxLogger.awaitEvent()); // <- event number 3 + // Are metadata being sent? + console.log('lo_event test: redux lock fields', rl.getLockFields()); + }); +}); diff --git a/modules/lo_event/tests/queue.test.js b/modules/lo_event/tests/queue.test.js new file mode 100644 index 000000000..b96cdcbe5 --- /dev/null +++ b/modules/lo_event/tests/queue.test.js @@ -0,0 +1,28 @@ +// Start of a test of the queue. +// +// TODO: Finish, then test both types of queue, and then in node and +// browser, as well as various failure conditions. + +import { Queue } from '../lo_event/queue.js'; +import { delay } from '../lo_event/util.js'; + +const DEBUG = false; + +function debug_log(...args) { + if(DEBUG) { + console.log(...args); + } +} + +const queue = new Queue('queueNew'); +await delay(1000); +const max = 5; +debug_log('Queue test: Queueing events'); +for (let i = 0; i < max; i++) { + debug_log('Queue test: Queueing', i); + queue.enqueue(i); +} + +queue.startDequeueLoop({ + onDequeue: debug_log +}); diff --git a/modules/lo_event/tests/util.test.js b/modules/lo_event/tests/util.test.js new file mode 100644 index 000000000..4f36a496c --- /dev/null +++ b/modules/lo_event/tests/util.test.js @@ -0,0 +1,106 @@ +// TODO: +// * Document +// * More test cases +// * Much better description strings + +import * as util from '../lo_event/util.js'; + +let someAsyncCondition; +global.document = {}; + +const DEBUG = false; + +function debug_log(...args) { + if(DEBUG) { + console.log(...args); + } +} + +async function checkCondition () { + // Example of a condition check + // Replace this with your actual condition check logic + debug_log('Util test: checking', someAsyncCondition); + return someAsyncCondition; +} + +xdescribe('Testing Backoff functionality', () => { + beforeEach(() => { + console.log('util.js:backoff Setting condition to false'); + someAsyncCondition = false; + }); + + it('Check for generic backoff functionality', async () => { + console.log('TESTING THIS FUNCTION'); + // Set the condition to true after some time for demonstration + setTimeout(() => { + console.log('Util test: setting someAsyncCondition'); + someAsyncCondition = true; + }, 1000); + + try { + await util.backoff(checkCondition, 'This should not be seen in the console'); + expect(someAsyncCondition).toBe(true); + } catch (error) { + console.error(error.message); + } + }, 10000); + + it('Test max retry on backoff', async () => { + try { + await util.backoff( + checkCondition, + 'Condition not met after max retries.', + [1000, 1000, 1000], + util.TERMINATION_POLICY.RETRY, + 5 + ); + } catch (error) { + console.error(error.message); + } + expect(someAsyncCondition).toBe(false); + }, 10000); +}); + +describe('util.js testing', () => { + console.log("Testing rest of util.js"); + it('Test fullyQualifiedWebsocketURL', () => { + // We need a function wrapper to check for thrown errors + expect(function () { + util.fullyQualifiedWebsocketURL(); + }).toThrow(new Error('Base server is not provided.')); + + global.document.location = 'http://www.example.com'; + expect(util.fullyQualifiedWebsocketURL()).toBe('ws://www.example.com/wsapi/in'); + expect(util.fullyQualifiedWebsocketURL('/ws')).toBe('ws://www.example.com/ws'); + expect(util.fullyQualifiedWebsocketURL('/ws', 'https://learning-observer.org')).toBe('wss://learning-observer.org/ws'); + expect(function () { + util.fullyQualifiedWebsocketURL('/ws', 'fake://learning-observer.org'); + }).toThrow(new Error('Protocol mapping not found.')); + }); + + it('test deeply merging metadata', async () => { + const obj1 = { a: 1, b: { c: 3 } }; + const func1 = function () { + return { b: { d: 4 }, e: 5 }; + }; + expect(await util.mergeMetadata([obj1, func1])).toEqual({ a: 1, b: { c: 3, d: 4 }, e: 5 }); + }); + + it('it should copy specified fields from the source object', () => { + console.log("Testing copy fields"); + const source = { foo: 'bar', baz: 'qux' }; + const fields = ['foo', 'baz']; + expect(util.copyFields(source, fields)).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + it('it should return an empty object if source is null', () => { + expect(util.copyFields(null, ['foo', 'baz'])).toEqual({}); + }); + + it('it should only copy fields that exist in the source object', () => { + const source = { foo: 'bar' }; + const fields = ['foo', 'baz']; + expect(util.copyFields(source, fields)).toEqual({ foo: 'bar' }); + }); +}); + diff --git a/modules/lo_event/tests/ws.test.js b/modules/lo_event/tests/ws.test.js new file mode 100644 index 000000000..a8b525344 --- /dev/null +++ b/modules/lo_event/tests/ws.test.js @@ -0,0 +1,108 @@ +/* + * This is a cursory test of the web socket logger. + * + * It takes some time to run, so it is not part of the main test suite. + */ + +import { WebSocketServer } from 'ws'; +import * as debug from '../lo_event/debugLog.js'; +import { getBrowserInfo } from '../lo_event/metadata/browserinfo.js'; + +import * as loEvent from '../lo_event/lo_event.js'; +import * as websocketLogger from '../lo_event/websocketLogger.js'; + +const DEBUG = false; + +function debug_log(...args) { + if(DEBUG) { + console.log(...args); + } +} + +debug_log('WS test: Launching server'); + +const host = 'localhost'; +const port = 8087; + +const wss = new WebSocketServer({ host, port }); + +debug_log('WS test: Setting up connection'); + +const dispatch = { + terminate: function (event, ws) { + debug_log('WS test: Terminating'); + + // It takes a little bit of voodoo to convince the server to + // terminate. + + // We need to close all open socket connections (of which we have + // just one), close the listening socket, and terminate the + // process. + ws.close(); + wss.close(() => { + debug_log('WS test: Server closed'); + process.exit(0); + }); + }, + test: function (event) { + debug_log('WS test: Test event: ', event.event_number); + }, + lock_fields: function (event) { + debug_log('WS test: Metadata: ', event); + }, + blocklist: function (event, ws) { + debug_log('WS test: Sending blocklist'); + ws.send(JSON.stringify({ + status: 'blocklist', + time_limit: 'MINUTES', + action: 'DROP' + })); + }, + debug: function (event) { + debug_log('WS test: DEBUG', event); + } +}; + +wss.on('connection', (ws) => { + debug_log('WS test: New connection'); + ws.on('message', (data) => { + // Verify received data + const j = JSON.parse(data.toString()); + debug_log('WS test: Dispatching: ', j); + dispatch[j.event](j, ws); + }); +}); + +const wsl = websocketLogger.websocketLogger(`ws://${host}:${port}`); + +loEvent.init( + 'org.ets.lo_event.test', + '1', + [wsl], + debug.LEVEL.SIMPLE, + [debug.LOG_OUTPUT.LOGGER(loEvent.logEvent), debug.LOG_OUTPUT.CONSOLE] +); +loEvent.setFieldSet([{ preauth_type: 'test' }]); +loEvent.setFieldSet([{ postauth_type: 'test' }, getBrowserInfo()]); +loEvent.go(); + +loEvent.logEvent('test', { event_number: 1 }); +loEvent.logEvent('test', { event_number: 2 }); +loEvent.logEvent('test', { event_number: 3 }); + +// Check the blocklist. +// In this test, we might receive one more event or so. +// +// This takes a second, and the terminate event never comes in, so +// after that the server hangs, so this is behind a flag. +const TEST_BLOCKLIST = false; +if (TEST_BLOCKLIST) { + loEvent.logEvent('blocklist', { action: 'Send us back a block event!' }); + await new Promise(resolve => setTimeout(resolve, 1000)); +} + +loEvent.logEvent('test', { event_number: 4 }); +loEvent.logEvent('test', { event_number: 5 }); +loEvent.logEvent('terminate', {}); + +debug_log('WS test: Done'); diff --git a/modules/lo_event/xapi/activityType.json b/modules/lo_event/xapi/activityType.json new file mode 100644 index 000000000..72ef6a119 --- /dev/null +++ b/modules/lo_event/xapi/activityType.json @@ -0,0 +1,2488 @@ +[ + { + "id": 53, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/alert", + "createdAt": "2013-08-07T22:02:44.000Z", + "updatedAt": "2013-08-07T22:02:44.000Z", + "metadata": { + "id": 53, + "metadata": { + "name": { + "en-US": "alert" + }, + "description": { + "en-US": "Represents any kind of significant notification." + } + }, + "uriId": 53, + "editNotes": "", + "createdAt": "2013-08-07T22:02:44.000Z", + "updatedAt": "2013-08-07T22:02:44.000Z" + } + }, + { + "id": 54, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/application", + "createdAt": "2013-08-07T22:02:54.000Z", + "updatedAt": "2013-08-07T22:02:54.000Z", + "metadata": { + "id": 54, + "metadata": { + "name": { + "en-US": "application" + }, + "description": { + "en-US": "Represents any kind of software application." + } + }, + "uriId": 54, + "editNotes": "", + "createdAt": "2013-08-07T22:02:54.000Z", + "updatedAt": "2013-08-07T22:02:54.000Z" + } + }, + { + "id": 55, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/article", + "createdAt": "2013-08-07T22:03:07.000Z", + "updatedAt": "2013-08-07T22:03:07.000Z", + "metadata": { + "id": 55, + "metadata": { + "name": { + "en-US": "article" + }, + "description": { + "en-US": "Represents objects such as news articles, knowledge base entries, or other similar construct. Such objects generally consist of paragraphs of text, in some cases incorporating embedded media such as photos and inline hyperlinks to other resources." + } + }, + "uriId": 55, + "editNotes": "", + "createdAt": "2013-08-07T22:03:07.000Z", + "updatedAt": "2013-08-07T22:03:07.000Z" + } + }, + { + "id": 56, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/audio", + "createdAt": "2013-08-07T22:03:22.000Z", + "updatedAt": "2013-08-07T22:03:22.000Z", + "metadata": { + "id": 56, + "metadata": { + "name": { + "en-US": "audio" + }, + "description": { + "en-US": "Represents audio content of any kind. Objects of this type MAY contain an additional property as specified in Section 3.1." + } + }, + "uriId": 56, + "editNotes": "", + "createdAt": "2013-08-07T22:03:22.000Z", + "updatedAt": "2013-08-07T22:03:22.000Z" + } + }, + { + "id": 57, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/badge", + "createdAt": "2013-08-07T22:03:33.000Z", + "updatedAt": "2013-08-07T22:03:33.000Z", + "metadata": { + "id": 57, + "metadata": { + "name": { + "en-US": "badge" + }, + "description": { + "en-US": "Represents a badge or award granted to an object (typically a person object)" + } + }, + "uriId": 57, + "editNotes": "", + "createdAt": "2013-08-07T22:03:33.000Z", + "updatedAt": "2013-08-07T22:03:33.000Z" + } + }, + { + "id": 166, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/binary", + "createdAt": "2013-08-08T13:38:51.000Z", + "updatedAt": "2013-08-08T13:38:51.000Z", + "metadata": { + "id": 166, + "metadata": { + "name": { + "en-US": "binary" + }, + "description": { + "en-US": "Objects of this type are used to carry arbitrary Base64-encoded binary data within an Activity Stream object. It is primarily intended to attach binary data to other types of objects through the use of the attachments property. Objects of this type will contain the additional properties specified in Section 3.2.\n\nThis activity type is included for data conversion with Activity Streams, it's not recommended for use in new Tin Can statements." + } + }, + "uriId": 166, + "editNotes": "", + "createdAt": "2013-08-08T13:38:51.000Z", + "updatedAt": "2013-08-08T13:38:51.000Z" + } + }, + { + "id": 58, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/bookmark", + "createdAt": "2013-08-07T22:03:41.000Z", + "updatedAt": "2013-08-07T22:03:41.000Z", + "metadata": { + "id": 58, + "metadata": { + "name": { + "en-US": "bookmark " + }, + "description": { + "en-US": "Represents a pointer to some URL -- typically a web page. In most cases, bookmarks are specific to a given user and contain metadata chosen by that user. Bookmark Objects are similar in principle to the concept of bookmarks or favorites in a web browser. A bookmark represents a pointer to the URL, not the URL or the associated resource itself. Objects of this type SHOULD contain an additional targetUrl property whose value is a String containing the IRI of the target of the bookmark." + } + }, + "uriId": 58, + "editNotes": "", + "createdAt": "2013-08-07T22:03:41.000Z", + "updatedAt": "2013-08-07T22:03:41.000Z" + } + }, + { + "id": 167, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/collection", + "createdAt": "2013-08-08T13:39:15.000Z", + "updatedAt": "2013-08-08T13:39:15.000Z", + "metadata": { + "id": 167, + "metadata": { + "name": { + "en-US": "collection" + }, + "description": { + "en-US": "Represents a generic collection of objects of any type. This object type can be used, for instance, to represent a collection of files like a folder; a collection of photos like an album; and so forth. Objects of this type MAY contain an additional objectTypes property whose value is an Array of Strings specifying the expected objectType of objects contained within the collection.\n\nThis activity type is included for data conversion with Activity Streams, it's not recommended for use in new Tin Can statements." + } + }, + "uriId": 167, + "editNotes": "", + "createdAt": "2013-08-08T13:39:15.000Z", + "updatedAt": "2013-08-08T13:39:15.000Z" + } + }, + { + "id": 59, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/comment", + "createdAt": "2013-08-07T22:04:09.000Z", + "updatedAt": "2013-08-07T22:04:09.000Z", + "metadata": { + "id": 59, + "metadata": { + "name": { + "en-US": "comment" + }, + "description": { + "en-US": "Represents a textual response to another object. Objects of this type MAY contain an additional inReplyTo property whose value is an Array of one or more other Activity Stream Objects for which the object is to be considered a response." + } + }, + "uriId": 59, + "editNotes": "", + "createdAt": "2013-08-07T22:04:09.000Z", + "updatedAt": "2013-08-07T22:04:09.000Z" + } + }, + { + "id": 60, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/device", + "createdAt": "2013-08-07T22:04:20.000Z", + "updatedAt": "2013-08-07T22:04:20.000Z", + "metadata": { + "id": 60, + "metadata": { + "name": { + "en-US": "device " + }, + "description": { + "en-US": "Represents a device of any sort." + } + }, + "uriId": 60, + "editNotes": "", + "createdAt": "2013-08-07T22:04:20.000Z", + "updatedAt": "2013-08-07T22:04:20.000Z" + } + }, + { + "id": 61, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/event", + "createdAt": "2013-08-07T22:04:28.000Z", + "updatedAt": "2013-08-07T22:04:28.000Z", + "metadata": { + "id": 61, + "metadata": { + "name": { + "en-US": "event" + }, + "description": { + "en-US": "Represents an event that occurs at a certain location during a particular period of time. Objects of this type MAY contain the additional properties specified in Section 3.3." + } + }, + "uriId": 61, + "editNotes": "", + "createdAt": "2013-08-07T22:04:28.000Z", + "updatedAt": "2013-08-07T22:04:28.000Z" + } + }, + { + "id": 62, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/file", + "createdAt": "2013-08-07T22:04:36.000Z", + "updatedAt": "2013-08-07T22:04:36.000Z", + "metadata": { + "id": 62, + "metadata": { + "name": { + "en-US": "file" + }, + "description": { + "en-US": "Represents any form of document or file. Objects of this type MAY contain an additional fileUrl property whose value a dereferenceable IRI that can be used to retrieve the file; and an additional mimeType property whose value is the MIME type of the file described by the object." + } + }, + "uriId": 62, + "editNotes": "", + "createdAt": "2013-08-07T22:04:36.000Z", + "updatedAt": "2013-08-07T22:04:36.000Z" + } + }, + { + "id": 63, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/game", + "createdAt": "2013-08-07T22:04:43.000Z", + "updatedAt": "2013-08-07T22:04:43.000Z", + "metadata": { + "id": 63, + "metadata": { + "name": { + "en-US": "game" + }, + "description": { + "en-US": "Represents a game or competition of any kind." + } + }, + "uriId": 63, + "editNotes": "", + "createdAt": "2013-08-07T22:04:43.000Z", + "updatedAt": "2013-08-07T22:04:43.000Z" + } + }, + { + "id": 64, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/group", + "createdAt": "2013-08-07T22:04:52.000Z", + "updatedAt": "2013-08-07T22:04:52.000Z", + "metadata": { + "id": 64, + "metadata": { + "name": { + "en-US": "group" + }, + "description": { + "en-US": "Represents a grouping of objects in which member objects can join or leave." + } + }, + "uriId": 64, + "editNotes": "", + "createdAt": "2013-08-07T22:04:52.000Z", + "updatedAt": "2013-08-07T22:04:52.000Z" + } + }, + { + "id": 65, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/image", + "createdAt": "2013-08-07T22:05:14.000Z", + "updatedAt": "2013-08-07T22:05:14.000Z", + "metadata": { + "id": 65, + "metadata": { + "name": { + "en-US": "image" + }, + "description": { + "en-US": "Represents a graphical image. Objects of this type MAY contain an additional fullImage property whose value is an Activity Streams Media Link to a \"full-sized\" representation of the image." + } + }, + "uriId": 65, + "editNotes": "", + "createdAt": "2013-08-07T22:05:14.000Z", + "updatedAt": "2013-08-07T22:05:14.000Z" + } + }, + { + "id": 66, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/issue", + "createdAt": "2013-08-07T22:05:34.000Z", + "updatedAt": "2013-08-07T22:05:34.000Z", + "metadata": { + "id": 66, + "metadata": { + "name": { + "en-US": "issue" + }, + "description": { + "en-US": "Represents a report about a problem or situation that needs to be resolved. For instance, the issue object can be used to represent reports detailing software defects, or reports of acceptable use violations, and so forth. Objects of this type MAY contain the additional properties specified in Section 3.4." + } + }, + "uriId": 66, + "editNotes": "", + "createdAt": "2013-08-07T22:05:34.000Z", + "updatedAt": "2013-08-07T22:05:34.000Z" + } + }, + { + "id": 67, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/job", + "createdAt": "2013-08-07T22:08:48.000Z", + "updatedAt": "2013-08-07T22:08:48.000Z", + "metadata": { + "id": 67, + "metadata": { + "name": { + "en-US": "job" + }, + "description": { + "en-US": "Represents information about a job or a job posting." + } + }, + "uriId": 67, + "editNotes": "", + "createdAt": "2013-08-07T22:08:48.000Z", + "updatedAt": "2013-08-07T22:08:48.000Z" + } + }, + { + "id": 68, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/note", + "createdAt": "2013-08-07T22:09:18.000Z", + "updatedAt": "2013-08-07T22:09:18.000Z", + "metadata": { + "id": 68, + "metadata": { + "name": { + "en-US": "note " + }, + "description": { + "en-US": "Represents a short-form text message. This object is intended primarily for use in \"micro-blogging\" scenarios and in systems where users are invited to publish short, often plain-text messages whose useful lifespan is generally shorter than that of an article of weblog entry. A note is similar in structure to an article, but typically does not have a title or distinct paragraphs and tends to be much shorter in length." + } + }, + "uriId": 68, + "editNotes": "", + "createdAt": "2013-08-07T22:09:18.000Z", + "updatedAt": "2013-08-07T22:09:18.000Z" + } + }, + { + "id": 69, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/offer", + "createdAt": "2013-08-07T22:09:28.000Z", + "updatedAt": "2013-08-07T22:09:28.000Z", + "metadata": { + "id": 69, + "metadata": { + "name": { + "en-US": "offer" + }, + "description": { + "en-US": "Represents an offer of any kind." + } + }, + "uriId": 69, + "editNotes": "", + "createdAt": "2013-08-07T22:09:28.000Z", + "updatedAt": "2013-08-07T22:09:28.000Z" + } + }, + { + "id": 70, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/organization", + "createdAt": "2013-08-07T22:09:34.000Z", + "updatedAt": "2013-08-07T22:09:34.000Z", + "metadata": { + "id": 70, + "metadata": { + "name": { + "en-US": "organization " + }, + "description": { + "en-US": "Represents an organization of any kind." + } + }, + "uriId": 70, + "editNotes": "", + "createdAt": "2013-08-07T22:09:34.000Z", + "updatedAt": "2013-08-07T22:09:34.000Z" + } + }, + { + "id": 71, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/page", + "createdAt": "2013-08-07T22:09:43.000Z", + "updatedAt": "2013-08-07T22:09:43.000Z", + "metadata": { + "id": 71, + "metadata": { + "name": { + "en-US": "page" + }, + "description": { + "en-US": "Represents an area, typically a web page, that is representative of, and generally managed by a particular entity. Such areas are usually dedicated to displaying descriptive information about the entity and showcasing recent content such as articles, photographs and videos. Most social networking applications, for example, provide individual users with their own dedicated \"profile\" pages. Several allow similar types of pages to be created for commercial entities, organizations or events. While the specific details of how pages are implemented, their characteristics and use may vary, the one unifying property is that they are typically \"owned\" by a single entity that is represented by the content provided by the page itself." + } + }, + "uriId": 71, + "editNotes": "", + "createdAt": "2013-08-07T22:09:43.000Z", + "updatedAt": "2013-08-07T22:09:43.000Z" + } + }, + { + "id": 168, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/person", + "createdAt": "2013-08-08T13:39:27.000Z", + "updatedAt": "2013-08-08T13:39:27.000Z", + "metadata": { + "id": 168, + "metadata": { + "name": { + "en-US": "person" + }, + "description": { + "en-US": "Represents an individual person.\n\nThis activity type is included for data conversion with Activity Streams, it's not recommended for use in new Tin Can statements. Agent should be used instead of person." + } + }, + "uriId": 168, + "editNotes": "", + "createdAt": "2013-08-08T13:39:27.000Z", + "updatedAt": "2013-08-08T13:39:27.000Z" + } + }, + { + "id": 72, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/place", + "createdAt": "2013-08-07T22:09:53.000Z", + "updatedAt": "2013-08-07T22:09:53.000Z", + "metadata": { + "id": 72, + "metadata": { + "name": { + "en-US": "place " + }, + "description": { + "en-US": "Represents a physical location. Locations can be represented using geographic coordinates, a physical address, a free-form location name, or any combination of these. Objects of this type MAY contain the additional properties specified in Section 3.5." + } + }, + "uriId": 72, + "editNotes": "", + "createdAt": "2013-08-07T22:09:53.000Z", + "updatedAt": "2013-08-07T22:09:53.000Z" + } + }, + { + "id": 73, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/process", + "createdAt": "2013-08-07T22:10:04.000Z", + "updatedAt": "2013-08-07T22:10:04.000Z", + "metadata": { + "id": 73, + "metadata": { + "name": { + "en-US": "process " + }, + "description": { + "en-US": "Represents any form of process. For instance, a long-running task that is started and expected to continue operating for a period of time." + } + }, + "uriId": 73, + "editNotes": "", + "createdAt": "2013-08-07T22:10:04.000Z", + "updatedAt": "2013-08-07T22:10:04.000Z" + } + }, + { + "id": 74, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/product", + "createdAt": "2013-08-07T22:10:17.000Z", + "updatedAt": "2013-08-07T22:10:17.000Z", + "metadata": { + "id": 74, + "metadata": { + "name": { + "en-US": "product" + }, + "description": { + "en-US": "Represents a commercial good or service. Objects of this type MAY contain an additional fullImage property whose value is an Activity Streams Media Link to an image resource representative of the product." + } + }, + "uriId": 74, + "editNotes": "", + "createdAt": "2013-08-07T22:10:17.000Z", + "updatedAt": "2013-08-07T22:10:17.000Z" + } + }, + { + "id": 75, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/question", + "createdAt": "2013-08-07T22:10:32.000Z", + "updatedAt": "2013-08-07T22:10:32.000Z", + "metadata": { + "id": 75, + "metadata": { + "name": { + "en-US": "question" + }, + "description": { + "en-US": "Represents a question or a poll. Objects of this type MAY contain an additional options property whose value is an Array of possible answers to the question in the form of Activity Stream objects of any type." + } + }, + "uriId": 75, + "editNotes": "", + "createdAt": "2013-08-07T22:10:32.000Z", + "updatedAt": "2013-08-07T22:10:32.000Z" + } + }, + { + "id": 76, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/review", + "createdAt": "2013-08-07T22:10:43.000Z", + "updatedAt": "2013-08-07T22:10:43.000Z", + "metadata": { + "id": 76, + "metadata": { + "name": { + "en-US": "review" + }, + "description": { + "en-US": "Represents a primarily prose-based commentary on another object. Objects of this type MAY contain a rating property as specified in Section 4.4." + } + }, + "uriId": 76, + "editNotes": "", + "createdAt": "2013-08-07T22:10:43.000Z", + "updatedAt": "2013-08-07T22:10:43.000Z" + } + }, + { + "id": 77, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/service", + "createdAt": "2013-08-07T22:10:52.000Z", + "updatedAt": "2013-08-07T22:10:52.000Z", + "metadata": { + "id": 77, + "metadata": { + "name": { + "en-US": "service" + }, + "description": { + "en-US": "Represents any form of hosted or consumable service that performs some kind of work or benefit for other entities. Examples of such objects include websites, businesses, etc." + } + }, + "uriId": 77, + "editNotes": "", + "createdAt": "2013-08-07T22:10:52.000Z", + "updatedAt": "2013-08-07T22:10:52.000Z" + } + }, + { + "id": 78, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/task", + "createdAt": "2013-08-07T22:11:00.000Z", + "updatedAt": "2013-08-07T22:11:00.000Z", + "metadata": { + "id": 78, + "metadata": { + "name": { + "en-US": "task" + }, + "description": { + "en-US": "Represents an activity that has yet to be completed. Objects of this type can contain additional properties as specified in Section 3.6." + } + }, + "uriId": 78, + "editNotes": "", + "createdAt": "2013-08-07T22:11:00.000Z", + "updatedAt": "2013-08-07T22:11:00.000Z" + } + }, + { + "id": 79, + "kind": "activityType", + "uri": "http://activitystrea.ms/schema/1.0/video", + "createdAt": "2013-08-07T22:11:11.000Z", + "updatedAt": "2013-08-07T22:11:11.000Z", + "metadata": { + "id": 79, + "metadata": { + "name": { + "en-US": "video" + }, + "description": { + "en-US": "Represents video content of any kind. Objects of this type MAY contain additional properties as specified in Section 3.1." + } + }, + "uriId": 79, + "editNotes": "", + "createdAt": "2013-08-07T22:11:11.000Z", + "updatedAt": "2013-08-07T22:11:11.000Z" + } + }, + { + "id": 7, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/assessment", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 7, + "metadata": { + "name": { + "en-us": "Assessment" + }, + "description": { + "en-us": "An assessment is an activity that determines a learner's mastery of a particular subject area. An assessment typically has one or more questions." + } + }, + "uriId": 7, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 173, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/cmi.interaction", + "createdAt": "2013-08-14T16:04:09.000Z", + "updatedAt": "2013-08-14T16:04:09.000Z", + "metadata": { + "id": 173, + "metadata": { + "name": { + "en-US": "cmi.interactions" + }, + "description": { + "en-US": "Interaction activities of \"cmi.interaction\" type as defined in the SCORM 2004 4th Edition Run-Time Environment." + } + }, + "uriId": 173, + "editNotes": "", + "createdAt": "2013-08-14T16:04:09.000Z", + "updatedAt": "2013-08-14T16:04:09.000Z" + } + }, + { + "id": 1, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/course", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 1, + "metadata": { + "name": { + "en-us": "Course" + }, + "description": { + "en-us": "A course represents an entire \"content package\" worth of material. The largest level of granularity. Unless flat, a course consists of multiple modules. A course is not content." + } + }, + "uriId": 1, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 8, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/interaction", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 8, + "metadata": { + "name": { + "en-us": "Interaction" + }, + "description": { + "en-us": "An interaction is typically a part of a larger activity (such as assessment or simulation) and refers to a control to which a learner provides input. An interaction can be either an asset or function independently." + } + }, + "uriId": 8, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 11, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/link", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 11, + "metadata": { + "name": { + "en-us": "Link" + }, + "description": { + "en-us": "A link is simply a means of expressing a link to another resource within, or external to, an activity. A link is not synonymous with launching another resource and should be considered external to the current resource. Links are not learning content, nor SCOs. If a link is intended for this purpose, it should be re-categorized." + } + }, + "uriId": 11, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 4, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/media", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 4, + "metadata": { + "name": { + "en-us": "Media" + }, + "description": { + "en-us": "Media refers to text, audio, or video used to convey information. Media can be consumed (tracked: completed), but doesn't have an interactive component that may result in a score, success, or failure." + } + }, + "uriId": 4, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 3, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/meeting", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 3, + "metadata": { + "name": { + "en-us": "Meeting" + }, + "description": { + "en-us": "A meeting is a gathering of multiple people for a common cause" + } + }, + "uriId": 3, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 2, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/module", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 2, + "metadata": { + "name": { + "en-us": "Module" + }, + "description": { + "en-us": "A module represents any \"content aggregation\" at least one level below the course level. Modules of modules can exist for layering purposes. Modules are not content. Modules are one level up from all content." + } + }, + "uriId": 2, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 10, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/objective", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 10, + "metadata": { + "name": { + "en-us": "Objective" + }, + "description": { + "en-us": "An objective determines whether competency has been achieved in a desired area. Objectives typically are associated with questions and assessments. Objectives are not learning content and cannot be SCOs." + } + }, + "uriId": 10, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 5, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/performance", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 5, + "metadata": { + "name": { + "en-us": "Performance" + }, + "description": { + "en-us": "A performance is an attempted task or series of tasks within a particular context. Tasks would likely take on the form of interactions, or the performance could be self-contained content." + } + }, + "uriId": 5, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 9, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/question", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 9, + "metadata": { + "name": { + "en-us": "Question" + }, + "description": { + "en-us": "A question is typically part of an assessment and requires a response from the learner, a response that is then evaluated for correctness." + } + }, + "uriId": 9, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 6, + "kind": "activityType", + "uri": "http://adlnet.gov/expapi/activities/simulation", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 6, + "metadata": { + "name": { + "en-us": "Simulation" + }, + "description": { + "en-us": "A simulation is an attempted task or series of tasks in an artificial context that mimics reality. Tasks would likely take on the form of interactions, or the simulation could be self-contained content." + } + }, + "uriId": 6, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 352, + "kind": "activityType", + "uri": "http://curatr3.com/define/type/level", + "createdAt": "2015-01-06T19:17:01.000Z", + "updatedAt": "2015-01-06T19:17:01.000Z", + "metadata": { + "id": 352, + "metadata": { + "name": { + "en-US": "Game level" + }, + "description": { + "en-US": "A level of a game or gamifed learning platform. " + } + }, + "uriId": 352, + "editNotes": "", + "createdAt": "2015-01-06T19:17:01.000Z", + "updatedAt": "2015-01-06T19:17:01.000Z" + } + }, + { + "id": 353, + "kind": "activityType", + "uri": "http://curatr3.com/define/type/organisation", + "createdAt": "2015-01-06T19:17:06.000Z", + "updatedAt": "2015-01-06T19:17:06.000Z", + "metadata": { + "id": 353, + "metadata": { + "name": { + "en-US": "Curatr Organisation" + }, + "description": { + "en-US": "An organisation within the Curatr platform. This is a collection of learners and courses. " + } + }, + "uriId": 353, + "editNotes": "", + "createdAt": "2015-01-06T19:17:06.000Z", + "updatedAt": "2015-01-06T19:17:06.000Z" + } + }, + { + "id": 388, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/blog", + "createdAt": "2015-04-27T15:04:39.000Z", + "updatedAt": "2015-04-27T15:04:39.000Z", + "metadata": { + "id": 388, + "metadata": { + "name": { + "en-US": "blog" + }, + "description": { + "en-US": "A regularly updated website or web page, typically one authored by an individual or small group, that is written in an informal or conversational style.\n" + } + }, + "uriId": 388, + "editNotes": "", + "createdAt": "2015-04-27T15:04:39.000Z", + "updatedAt": "2015-04-27T15:04:39.000Z" + } + }, + { + "id": 268, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/book", + "createdAt": "2014-03-13T14:24:38.000Z", + "updatedAt": "2014-03-13T14:24:38.000Z", + "metadata": { + "id": 268, + "metadata": { + "name": { + "en-US": "Book" + }, + "description": { + "en-US": "A book, generally paper, but could also be an ebook. The Activity's ID will often include an ISBN though it is not required. The Definition can likely leverage the ISBN extension, 'http://id.tincanapi.com/extension/isbn', if known." + } + }, + "uriId": 268, + "editNotes": "", + "createdAt": "2014-03-13T14:24:38.000Z", + "updatedAt": "2014-03-13T14:24:38.000Z" + } + }, + { + "id": 470, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/category", + "createdAt": "2016-08-01T16:13:34.000Z", + "updatedAt": "2016-08-01T16:13:34.000Z", + "metadata": { + "id": 470, + "metadata": { + "name": { + "en-US": "Category" + }, + "description": { + "en-US": "Activity generally used in the \"category\" Context Activities lists to mark a statement as being related to a particular subject area. Distinct from tag in that it is used in conjunction with Subcategory to imply a hierarchy of categorization. " + } + }, + "uriId": 470, + "editNotes": "", + "createdAt": "2016-08-01T16:13:34.000Z", + "updatedAt": "2016-08-01T16:13:34.000Z" + } + }, + { + "id": 442, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/chapter", + "createdAt": "2016-08-01T16:03:26.000Z", + "updatedAt": "2016-08-01T16:03:26.000Z", + "metadata": { + "id": 442, + "metadata": { + "name": { + "en-US": "chapter" + }, + "description": { + "en-US": "A chapter of a book or e-book. " + } + }, + "uriId": 442, + "editNotes": "", + "createdAt": "2016-08-01T16:03:26.000Z", + "updatedAt": "2016-08-01T16:03:26.000Z" + } + }, + { + "id": 403, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/chat-channel", + "createdAt": "2015-09-28T15:06:12.000Z", + "updatedAt": "2015-09-28T15:06:12.000Z", + "metadata": { + "id": 403, + "metadata": { + "name": { + "en-US": "Chat Channel" + }, + "description": { + "en-US": "A channel, room or conversation within an instant chat application such as Slack. " + } + }, + "uriId": 403, + "editNotes": "", + "createdAt": "2015-09-28T15:06:12.000Z", + "updatedAt": "2015-09-28T15:06:12.000Z" + } + }, + { + "id": 404, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/chat-message", + "createdAt": "2015-09-28T15:07:05.000Z", + "updatedAt": "2015-09-28T15:07:05.000Z", + "metadata": { + "id": 404, + "metadata": { + "name": { + "en-US": "Chat Message" + }, + "description": { + "en-US": "A message sent or received within the context of an instant chat platform such as Slack. The id of this activity should uniquely identify the particular chat message." + } + }, + "uriId": 404, + "editNotes": "", + "createdAt": "2015-09-28T15:07:05.000Z", + "updatedAt": "2015-09-28T15:07:05.000Z" + } + }, + { + "id": 296, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/checklist", + "createdAt": "2014-05-30T15:30:29.000Z", + "updatedAt": "2014-05-30T15:30:29.000Z", + "metadata": { + "id": 296, + "metadata": { + "name": { + "en-US": "checklist" + }, + "description": { + "en-US": "A list of tasks to be completed, names to be consulted, conditions to be verified and similar." + } + }, + "uriId": 296, + "editNotes": "", + "createdAt": "2014-05-30T15:30:29.000Z", + "updatedAt": "2014-05-30T15:30:29.000Z" + } + }, + { + "id": 297, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/checklist-item", + "createdAt": "2014-05-30T15:30:38.000Z", + "updatedAt": "2014-05-30T15:30:38.000Z", + "metadata": { + "id": 297, + "metadata": { + "name": { + "en-US": "checklist item" + }, + "description": { + "en-US": "An individual item contained in a checklist." + } + }, + "uriId": 297, + "editNotes": "", + "createdAt": "2014-05-30T15:30:38.000Z", + "updatedAt": "2014-05-30T15:30:38.000Z" + } + }, + { + "id": 402, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/code-commit", + "createdAt": "2015-09-28T15:03:22.000Z", + "updatedAt": "2015-09-28T15:03:22.000Z", + "metadata": { + "id": 402, + "metadata": { + "name": { + "en-US": "Code Commit" + }, + "description": { + "en-US": "A commit to a code repository e.g. Github. " + } + }, + "uriId": 402, + "editNotes": "", + "createdAt": "2015-09-28T15:03:22.000Z", + "updatedAt": "2015-09-28T15:03:22.000Z" + } + }, + { + "id": 463, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/collection-simple", + "createdAt": "2016-08-01T16:10:57.000Z", + "updatedAt": "2016-08-01T16:10:57.000Z", + "metadata": { + "id": 463, + "metadata": { + "name": { + "en-US": "simple collection" + }, + "description": { + "en-US": "It is a collection of items of the same activity type, for example, a collection of games. The collection can be generic, that is, the activity type of the items can be specified using the extension 'collection type', but it is optional." + } + }, + "uriId": 463, + "editNotes": "", + "createdAt": "2016-08-01T16:10:57.000Z", + "updatedAt": "2016-08-01T16:10:57.000Z" + } + }, + { + "id": 481, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/community-site", + "createdAt": "2016-08-01T16:19:10.000Z", + "updatedAt": "2016-08-01T16:19:10.000Z", + "metadata": { + "id": 481, + "metadata": { + "name": { + "en-US": "Community Site" + }, + "description": { + "en-US": "A space on a social platform (or other platform with social features) for a community to communicate, share and collaborate. For example a Google Plus Community, a Facebook Group, a Jive Space or a Pathgather Gathering. " + } + }, + "uriId": 481, + "editNotes": "", + "createdAt": "2016-08-01T16:19:10.000Z", + "updatedAt": "2016-08-01T16:19:10.000Z" + } + }, + { + "id": 170, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/conference", + "createdAt": "2013-08-13T19:25:56.000Z", + "updatedAt": "2013-08-13T19:25:56.000Z", + "metadata": { + "id": 170, + "metadata": { + "name": { + "en-US": "Conference" + }, + "description": { + "en-US": "A formal meeting which includes presentations or discussions.\n" + } + }, + "uriId": 170, + "editNotes": "", + "createdAt": "2013-08-13T19:25:56.000Z", + "updatedAt": "2013-08-13T19:25:56.000Z" + } + }, + { + "id": 172, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/conference-session", + "createdAt": "2013-08-13T19:26:17.000Z", + "updatedAt": "2013-08-13T19:26:17.000Z", + "metadata": { + "id": 172, + "metadata": { + "name": { + "en-US": "Conference Session" + }, + "description": { + "en-US": "A single presentation, discussion, gathering, or panel within a conference." + } + }, + "uriId": 172, + "editNotes": "", + "createdAt": "2013-08-13T19:26:17.000Z", + "updatedAt": "2013-08-13T19:26:17.000Z" + } + }, + { + "id": 171, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/conference-track", + "createdAt": "2013-08-13T19:26:04.000Z", + "updatedAt": "2013-08-13T19:26:04.000Z", + "metadata": { + "id": 171, + "metadata": { + "name": { + "en-US": "Conference Track" + }, + "description": { + "en-US": "A specific field of study within a conference." + } + }, + "uriId": 171, + "editNotes": "", + "createdAt": "2013-08-13T19:26:04.000Z", + "updatedAt": "2013-08-13T19:26:04.000Z" + } + }, + { + "id": 474, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/diaper-changed", + "createdAt": "2016-08-01T16:15:07.000Z", + "updatedAt": "2016-08-01T16:15:07.000Z", + "metadata": { + "id": 474, + "metadata": { + "name": { + "en-US": "Changed Diaper" + }, + "description": { + "en-US": "Create a Statement to represent that the educator or caretaker had to change the child\u2019s diapers. This action is generally associated to the statement ece:defecated or ece:urinated indicating the cause of the diaper change. Nonetheless, one can record the Statement without indicating the context to keep a record of the number of diaper changes during the day." + } + }, + "uriId": 474, + "editNotes": "", + "createdAt": "2016-08-01T16:15:07.000Z", + "updatedAt": "2016-08-01T16:15:07.000Z" + } + }, + { + "id": 307, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/discussion", + "createdAt": "2014-08-21T19:15:44.000Z", + "updatedAt": "2014-08-21T19:15:44.000Z", + "metadata": { + "id": 307, + "metadata": { + "name": { + "en-US": "discussion" + }, + "description": { + "en-US": "Represents an ongoing conversation between persons, such as an email thread or a forum topic. " + } + }, + "uriId": 307, + "editNotes": "", + "createdAt": "2014-08-21T19:15:44.000Z", + "updatedAt": "2014-08-21T19:15:44.000Z" + } + }, + { + "id": 479, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/document", + "createdAt": "2016-08-01T16:18:38.000Z", + "updatedAt": "2016-08-01T16:18:38.000Z", + "metadata": { + "id": 479, + "metadata": { + "name": { + "en-US": "Document" + }, + "description": { + "en-US": "An electronic document of the type produced by office productivity software such as a word processing document, spreadsheet, slides etc. " + } + }, + "uriId": 479, + "editNotes": "", + "createdAt": "2016-08-01T16:18:38.000Z", + "updatedAt": "2016-08-01T16:18:38.000Z" + } + }, + { + "id": 460, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/doubt", + "createdAt": "2016-08-01T16:08:01.000Z", + "updatedAt": "2016-08-01T16:08:01.000Z", + "metadata": { + "id": 460, + "metadata": { + "name": { + "en-US": "doubt" + }, + "description": { + "en-US": "Refers to something that the actor needs to cast light on, something that can be answered or solved." + } + }, + "uriId": 460, + "editNotes": "", + "createdAt": "2016-08-01T16:08:01.000Z", + "updatedAt": "2016-08-01T16:08:01.000Z" + } + }, + { + "id": 266, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/email", + "createdAt": "2014-01-13T13:48:38.000Z", + "updatedAt": "2014-01-13T13:48:38.000Z", + "metadata": { + "id": 266, + "metadata": { + "name": { + "en-US": "Email" + }, + "description": { + "en-US": "Electronic message sent over a computer network from a sender to one or many recipients." + } + }, + "uriId": 266, + "editNotes": "", + "createdAt": "2014-01-13T13:48:38.000Z", + "updatedAt": "2014-01-13T13:48:38.000Z" + } + }, + { + "id": 264, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/essay", + "createdAt": "2014-01-13T13:47:55.000Z", + "updatedAt": "2014-01-13T13:47:55.000Z", + "metadata": { + "id": 264, + "metadata": { + "name": { + "en-US": "Essay" + }, + "description": { + "en-US": "A short literary composition on a single subject, usually presenting the personal view of the author." + } + }, + "uriId": 264, + "editNotes": "", + "createdAt": "2014-01-13T13:47:56.000Z", + "updatedAt": "2014-01-13T13:47:56.000Z" + } + }, + { + "id": 412, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/forum-reply", + "createdAt": "2016-02-16T12:52:21.000Z", + "updatedAt": "2016-02-16T12:52:21.000Z", + "metadata": { + "id": 412, + "metadata": { + "name": { + "en-US": "Forum Reply" + }, + "description": { + "en-US": "Any post in a forum or discussion board thread that isn't the first." + } + }, + "uriId": 412, + "editNotes": "", + "createdAt": "2016-02-16T12:52:21.000Z", + "updatedAt": "2016-02-16T12:52:21.000Z" + } + }, + { + "id": 411, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/forum-topic", + "createdAt": "2016-02-16T12:52:07.000Z", + "updatedAt": "2016-02-16T12:52:07.000Z", + "metadata": { + "id": 411, + "metadata": { + "name": { + "en-US": "Forum Topic" + }, + "description": { + "en-US": "The first post in a thread on a forum or discussion board." + } + }, + "uriId": 411, + "editNotes": "", + "createdAt": "2016-02-16T12:52:07.000Z", + "updatedAt": "2016-02-16T12:52:07.000Z" + } + }, + { + "id": 454, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/goal", + "createdAt": "2016-08-01T16:06:50.000Z", + "updatedAt": "2016-08-01T16:06:50.000Z", + "metadata": { + "id": 454, + "metadata": { + "name": { + "en-US": "goal" + }, + "description": { + "en-US": "A goal is something that the actor wants to achieve, the purpose of doing a task or group of tasks. It can have subtasks and subgoals." + } + }, + "uriId": 454, + "editNotes": "", + "createdAt": "2016-08-01T16:06:50.000Z", + "updatedAt": "2016-08-01T16:06:50.000Z" + } + }, + { + "id": 499, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/job-title", + "createdAt": "2017-11-06T21:20:03.000Z", + "updatedAt": "2017-11-06T21:20:03.000Z", + "metadata": { + "id": 499, + "metadata": { + "name": { + "en-US": "job title" + }, + "description": { + "en-US": "The title of a person's job. May be used with the http://id.tincanapi.com/verb/was-assigned-job-title verb. " + } + }, + "uriId": 499, + "editNotes": "", + "createdAt": "2017-11-06T21:20:03.000Z", + "updatedAt": "2017-11-06T21:20:03.000Z" + } + }, + { + "id": 497, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/lab-on-demand", + "createdAt": "2017-11-06T21:17:40.000Z", + "updatedAt": "2017-11-06T21:17:40.000Z", + "metadata": { + "id": 497, + "metadata": { + "name": { + "en-US": "On Demand Lab" + }, + "description": { + "en-US": "A virtual lab built upon request which is used to prepare for a certification or elevated permissions." + } + }, + "uriId": 497, + "editNotes": "", + "createdAt": "2017-11-06T21:17:40.000Z", + "updatedAt": "2017-11-06T21:17:40.000Z" + } + }, + { + "id": 357, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/legacy-learning-standard", + "createdAt": "2015-01-09T15:50:09.000Z", + "updatedAt": "2015-01-09T15:50:09.000Z", + "metadata": { + "id": 357, + "metadata": { + "name": { + "en-US": "Legacy Learning Standard" + }, + "description": { + "en-US": "Activity representing a statement generated by a course originally implemented in a legacy learning standard such as SCORM 1.2, 2004, or AICC." + } + }, + "uriId": 357, + "editNotes": "", + "createdAt": "2015-01-09T15:50:09.000Z", + "updatedAt": "2015-01-09T15:50:09.000Z" + } + }, + { + "id": 383, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/lms", + "createdAt": "2015-04-10T15:55:54.000Z", + "updatedAt": "2015-04-10T15:55:54.000Z", + "metadata": { + "id": 383, + "metadata": { + "name": { + "en-US": "LMS" + }, + "description": { + "en-US": "Learning Management System. At it's core, a platform used to launch and track learning experiences. Many LMS also have a number of other additional features. " + } + }, + "uriId": 383, + "editNotes": "", + "createdAt": "2015-04-10T15:55:54.000Z", + "updatedAt": "2015-04-10T15:55:54.000Z" + } + }, + { + "id": 504, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/organization", + "createdAt": "2017-11-06T21:22:43.000Z", + "updatedAt": "2017-11-06T21:22:43.000Z", + "metadata": { + "id": 504, + "metadata": { + "name": { + "en-US": "Organization" + }, + "description": { + "en-US": "An organization such as a company, which may hire, fire and promote employees." + } + }, + "uriId": 504, + "editNotes": "", + "createdAt": "2017-11-06T21:22:43.000Z", + "updatedAt": "2017-11-06T21:22:43.000Z" + } + }, + { + "id": 265, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/paragraph", + "createdAt": "2014-01-13T13:48:19.000Z", + "updatedAt": "2014-01-13T13:48:19.000Z", + "metadata": { + "id": 265, + "metadata": { + "name": { + "en-US": "Paragraph" + }, + "description": { + "en-US": "A distinct division of written or printed matter that begins on a new, usually indented line, consists of one or more sentences, and typically deals with a single thought or topic or quotes one speaker's continuous words." + } + }, + "uriId": 265, + "editNotes": "", + "createdAt": "2014-01-13T13:48:19.000Z", + "updatedAt": "2014-01-13T13:48:19.000Z" + } + }, + { + "id": 473, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/playlist", + "createdAt": "2016-08-01T16:14:17.000Z", + "updatedAt": "2016-08-01T16:14:17.000Z", + "metadata": { + "id": 473, + "metadata": { + "name": { + "en-US": "Playlist" + }, + "description": { + "en-US": "A collection of resources or experiences grouped together as recommended resources by an individual. Generally used for informally curated resources rather than official collections such as an LMS course. " + } + }, + "uriId": 473, + "editNotes": "", + "createdAt": "2016-08-01T16:14:17.000Z", + "updatedAt": "2016-08-01T16:14:17.000Z" + } + }, + { + "id": 453, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/project", + "createdAt": "2016-08-01T16:06:39.000Z", + "updatedAt": "2016-08-01T16:06:39.000Z", + "metadata": { + "id": 453, + "metadata": { + "name": { + "en-US": "project" + }, + "description": { + "en-US": "A project is a specific plan or set of tasks with a common goal. It can have subtasks and subgoals, resources, etc.." + } + }, + "uriId": 453, + "editNotes": "", + "createdAt": "2016-08-01T16:06:39.000Z", + "updatedAt": "2016-08-01T16:06:39.000Z" + } + }, + { + "id": 485, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/project-site", + "createdAt": "2016-08-01T16:22:46.000Z", + "updatedAt": "2016-08-01T16:22:46.000Z", + "metadata": { + "id": 485, + "metadata": { + "name": { + "en-US": "Project" + }, + "description": { + "en-US": "A site, perhaps within a project management tool or social platform, used to manage a particular project. " + } + }, + "uriId": 485, + "editNotes": "", + "createdAt": "2016-08-01T16:22:46.000Z", + "updatedAt": "2016-08-01T16:22:46.000Z" + } + }, + { + "id": 289, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/recipe", + "createdAt": "2014-05-20T13:26:07.000Z", + "updatedAt": "2014-05-20T13:26:07.000Z", + "metadata": { + "id": 289, + "metadata": { + "name": { + "en-US": "recipe" + }, + "description": { + "en-US": "A recipe is an Activity that is used in the Context Activities \"category\" property of a Statement to indicate that the Statement is part of a pre-defined model that can be expected to have certain properties, object identifiers, or structure. Statements adhering to a particular recipe are recognizable by reporting systems for the purposes of testing, interoperability, etc." + } + }, + "uriId": 289, + "editNotes": "", + "createdAt": "2014-05-20T13:26:07.000Z", + "updatedAt": "2014-05-20T13:26:07.000Z" + } + }, + { + "id": 498, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/remote-lab-experiment", + "createdAt": "2017-11-06T21:19:25.000Z", + "updatedAt": "2017-11-06T21:19:25.000Z", + "metadata": { + "id": 498, + "metadata": { + "name": { + "en-US": "Remote Lab Experiment" + }, + "description": { + "en-US": "A remote lab experimentation is the act of experimenting with a real lab apparatus through the Internet." + } + }, + "uriId": 498, + "editNotes": "", + "createdAt": "2017-11-06T21:19:25.000Z", + "updatedAt": "2017-11-06T21:19:25.000Z" + } + }, + { + "id": 391, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/research-report", + "createdAt": "2015-05-05T13:15:07.000Z", + "updatedAt": "2015-05-05T13:15:07.000Z", + "metadata": { + "id": 391, + "metadata": { + "name": { + "en-US": "research report" + }, + "description": { + "en-US": "A research report from a government organization or other authoritative body giving information or proposals on an issue." + } + }, + "uriId": 391, + "editNotes": "", + "createdAt": "2015-05-05T13:15:07.000Z", + "updatedAt": "2015-05-05T13:15:07.000Z" + } + }, + { + "id": 459, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/resource", + "createdAt": "2016-08-01T16:07:49.000Z", + "updatedAt": "2016-08-01T16:07:49.000Z", + "metadata": { + "id": 459, + "metadata": { + "name": { + "en-US": "resource" + }, + "description": { + "en-US": "A resource is a generic item that the actor may use for something. It could be a video, a text article, a device, etc. However, it is recommended to use a more specific activity type like those mentioned." + } + }, + "uriId": 459, + "editNotes": "", + "createdAt": "2016-08-01T16:07:49.000Z", + "updatedAt": "2016-08-01T16:07:49.000Z" + } + }, + { + "id": 458, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/reward", + "createdAt": "2016-08-01T16:07:32.000Z", + "updatedAt": "2016-08-01T16:07:32.000Z", + "metadata": { + "id": 458, + "metadata": { + "name": { + "en-US": "reward" + }, + "description": { + "en-US": "Refers to a compensation that the actor wants to get for achieving something." + } + }, + "uriId": 458, + "editNotes": "", + "createdAt": "2016-08-01T16:07:32.000Z", + "updatedAt": "2016-08-01T16:07:32.000Z" + } + }, + { + "id": 249, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/sales-opportunity", + "createdAt": "2013-10-14T14:56:25.000Z", + "updatedAt": "2013-10-14T14:56:25.000Z", + "metadata": { + "id": 249, + "metadata": { + "name": { + "en-US": "sales opportunity" + }, + "description": { + "en-US": "Represents a sales opportunity, such as one might create in a CRM tool." + } + }, + "uriId": 249, + "editNotes": "", + "createdAt": "2013-10-14T14:56:25.000Z", + "updatedAt": "2013-10-14T14:56:25.000Z" + } + }, + { + "id": 299, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/scenario", + "createdAt": "2014-06-12T13:34:38.000Z", + "updatedAt": "2014-06-12T13:34:38.000Z", + "metadata": { + "id": 299, + "metadata": { + "name": { + "en-US": "Scenario" + }, + "description": { + "en-US": "Scenario - scenario based learning - is delivering the content embedded within a story or scenario rather than just pushing the content straight out. Usually a story or a situation is presented to ask for learner's action, feedback or branching follow. In this way learners see how the learning is applied to job environments or real world problems. " + } + }, + "uriId": 299, + "editNotes": "", + "createdAt": "2014-06-12T13:34:38.000Z", + "updatedAt": "2014-06-12T13:34:38.000Z" + } + }, + { + "id": 271, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/school-assignment", + "createdAt": "2014-03-13T18:57:40.000Z", + "updatedAt": "2014-03-13T18:57:40.000Z", + "metadata": { + "id": 271, + "metadata": { + "name": { + "en-US": "School Assignment" + }, + "description": { + "en-US": "A school task performed by a student to satisfy the teacher. Examples are assessments, assigned reading, practice exercises, watch video, etc." + } + }, + "uriId": 271, + "editNotes": "", + "createdAt": "2014-03-13T18:57:40.000Z", + "updatedAt": "2014-03-13T18:57:40.000Z" + } + }, + { + "id": 472, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/section", + "createdAt": "2016-08-01T16:14:03.000Z", + "updatedAt": "2016-08-01T16:14:03.000Z", + "metadata": { + "id": 472, + "metadata": { + "name": { + "en-US": "Section" + }, + "description": { + "en-US": "A section of an application or platform. For sales performance app might be divided into client demo, learning materials and reference documents sections. " + } + }, + "uriId": 472, + "editNotes": "", + "createdAt": "2016-08-01T16:14:03.000Z", + "updatedAt": "2016-08-01T16:14:03.000Z" + } + }, + { + "id": 270, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/security-role", + "createdAt": "2014-03-13T18:56:23.000Z", + "updatedAt": "2014-03-13T18:56:23.000Z", + "metadata": { + "id": 270, + "metadata": { + "name": { + "en-US": "Security Role" + }, + "description": { + "en-US": "A feature that enables system administrators to restrict system access and manage access on a per-user or per-group basis." + } + }, + "uriId": 270, + "editNotes": "", + "createdAt": "2014-03-13T18:56:23.000Z", + "updatedAt": "2014-03-13T18:56:23.000Z" + } + }, + { + "id": 282, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/slide", + "createdAt": "2014-04-29T14:00:53.000Z", + "updatedAt": "2014-04-29T14:00:53.000Z", + "metadata": { + "id": 282, + "metadata": { + "name": { + "en-US": "slide" + }, + "description": { + "en-US": "Slides are defined as a single page of a presentation or e-learning lesson." + } + }, + "uriId": 282, + "editNotes": "", + "createdAt": "2014-04-29T14:00:53.000Z", + "updatedAt": "2014-04-29T14:00:53.000Z" + } + }, + { + "id": 283, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/slide-deck", + "createdAt": "2014-04-29T14:05:56.000Z", + "updatedAt": "2014-04-29T14:05:56.000Z", + "metadata": { + "id": 283, + "metadata": { + "name": { + "en-US": "slide deck" + }, + "description": { + "en-US": "A deck of slides generally used for a presentation." + } + }, + "uriId": 283, + "editNotes": "", + "createdAt": "2014-04-29T14:05:56.000Z", + "updatedAt": "2014-04-29T14:05:56.000Z" + } + }, + { + "id": 461, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/solution", + "createdAt": "2016-08-01T16:08:15.000Z", + "updatedAt": "2016-08-01T16:08:15.000Z", + "metadata": { + "id": 461, + "metadata": { + "name": { + "en-US": "solution" + }, + "description": { + "en-US": "Refers to the answer for a doubt that provides a solution. If the answer is not a solution, it should be coded as answer, or if it is just a comment, as comment." + } + }, + "uriId": 461, + "editNotes": "", + "createdAt": "2016-08-01T16:08:15.000Z", + "updatedAt": "2016-08-01T16:08:15.000Z" + } + }, + { + "id": 363, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/source", + "createdAt": "2015-01-22T14:41:37.000Z", + "updatedAt": "2015-01-22T14:41:37.000Z", + "metadata": { + "id": 363, + "metadata": { + "name": { + "en-US": "Source" + }, + "description": { + "en-US": "Used with activities within the Context Activities \"category\" property of a Statement. Indicates the authoring tool, template or framework used to create the activity provider. This may help reporting tools to recognise that that data has come from a particular origin and handle the data correctly based on that information." + } + }, + "uriId": 363, + "editNotes": "", + "createdAt": "2015-01-22T14:41:37.000Z", + "updatedAt": "2015-01-22T14:41:37.000Z" + } + }, + { + "id": 491, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/status-update", + "createdAt": "2016-08-01T16:24:20.000Z", + "updatedAt": "2016-08-01T16:24:20.000Z", + "metadata": { + "id": 491, + "metadata": { + "name": { + "en-US": "Status update" + }, + "description": { + "en-US": "A status update e.g. on a social platform. " + } + }, + "uriId": 491, + "editNotes": "", + "createdAt": "2016-08-01T16:24:20.000Z", + "updatedAt": "2016-08-01T16:24:20.000Z" + } + }, + { + "id": 455, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/step", + "createdAt": "2016-08-01T16:07:02.000Z", + "updatedAt": "2016-08-01T16:07:02.000Z", + "metadata": { + "id": 455, + "metadata": { + "name": { + "en-US": "step" + }, + "description": { + "en-US": "A step is one of several actions that the actor has to do to achieve something, for instance, a goal or the completion of a task. For instance, a method, strategy or task could be divided into smaller steps." + } + }, + "uriId": 455, + "editNotes": "", + "createdAt": "2016-08-01T16:07:02.000Z", + "updatedAt": "2016-08-01T16:07:02.000Z" + } + }, + { + "id": 456, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/strategy", + "createdAt": "2016-08-01T16:07:12.000Z", + "updatedAt": "2016-08-01T16:07:12.000Z", + "metadata": { + "id": 456, + "metadata": { + "name": { + "en-US": "strategy" + }, + "description": { + "en-US": "A strategy is a plan or method for achieving any specific goal, and can be formed by a group of steps." + } + }, + "uriId": 456, + "editNotes": "", + "createdAt": "2016-08-01T16:07:12.000Z", + "updatedAt": "2016-08-01T16:07:12.000Z" + } + }, + { + "id": 457, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/strategy-embedded", + "createdAt": "2016-08-01T16:07:25.000Z", + "updatedAt": "2016-08-01T16:07:25.000Z", + "metadata": { + "id": 457, + "metadata": { + "name": { + "en-US": "embedded strategy" + }, + "description": { + "en-US": "Refers to a functionality embedded in the software system to facilitate the implementation of a strategy." + } + }, + "uriId": 457, + "editNotes": "", + "createdAt": "2016-08-01T16:07:25.000Z", + "updatedAt": "2016-08-01T16:07:25.000Z" + } + }, + { + "id": 471, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/subcategory", + "createdAt": "2016-08-01T16:13:45.000Z", + "updatedAt": "2016-08-01T16:13:45.000Z", + "metadata": { + "id": 471, + "metadata": { + "name": { + "en-US": "Subcategory" + }, + "description": { + "en-US": "Activity generally used in the \"category\" Context Activities lists to mark a statement as being related to a particular subject area. Distinct from tag in that it is used in conjunction with Category to imply a hierarchy of categorization. " + } + }, + "uriId": 471, + "editNotes": "", + "createdAt": "2016-08-01T16:13:45.000Z", + "updatedAt": "2016-08-01T16:13:45.000Z" + } + }, + { + "id": 480, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/suggestion", + "createdAt": "2016-08-01T16:18:48.000Z", + "updatedAt": "2016-08-01T16:18:48.000Z", + "metadata": { + "id": 480, + "metadata": { + "name": { + "en-US": "Suggestion" + }, + "description": { + "en-US": "A posted suggestion or idea. Typically these are things that can be discussed and/or voted on. " + } + }, + "uriId": 480, + "editNotes": "", + "createdAt": "2016-08-01T16:18:48.000Z", + "updatedAt": "2016-08-01T16:18:48.000Z" + } + }, + { + "id": 500, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/survey", + "createdAt": "2017-11-06T21:20:18.000Z", + "updatedAt": "2017-11-06T21:20:18.000Z", + "metadata": { + "id": 500, + "metadata": { + "name": { + "en-US": "Survey" + }, + "description": { + "en-US": "A survey where the respondent answers questions." + } + }, + "uriId": 500, + "editNotes": "", + "createdAt": "2017-11-06T21:20:18.000Z", + "updatedAt": "2017-11-06T21:20:18.000Z" + } + }, + { + "id": 303, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/tag", + "createdAt": "2014-07-21T18:27:55.000Z", + "updatedAt": "2014-07-21T18:27:55.000Z", + "metadata": { + "id": 303, + "metadata": { + "name": { + "en-US": "Tag" + }, + "description": { + "en-US": "Activity generally used in the \"other\" or \"grouping\" Context Activities lists to mark a statement as being related to a particular subject area. Implemented as a one word identifier used for search filtering or tag cloud generation." + } + }, + "uriId": 303, + "editNotes": "", + "createdAt": "2014-07-21T18:27:55.000Z", + "updatedAt": "2014-07-21T18:27:55.000Z" + } + }, + { + "id": 351, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/test-data-batch", + "createdAt": "2015-01-05T16:23:39.000Z", + "updatedAt": "2015-01-05T16:23:39.000Z", + "metadata": { + "id": 351, + "metadata": { + "name": { + "en-US": "Test data batch" + }, + "description": { + "en-US": "A test data batch is an Activity that is used in the Context Activities \"category\" property of a Statement to indicate that the Statement is part of a particular collection of test data. The Id of this Activity represents a single collection of test data e.g. the data generated for a particular test or by a particular tool. " + } + }, + "uriId": 351, + "editNotes": "", + "createdAt": "2015-01-05T16:23:39.000Z", + "updatedAt": "2015-01-05T16:23:39.000Z" + } + }, + { + "id": 169, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/tutor-session", + "createdAt": "2013-08-12T14:27:38.000Z", + "updatedAt": "2013-08-12T14:27:38.000Z", + "metadata": { + "id": 169, + "metadata": { + "name": { + "en-US": "tutor session" + }, + "description": { + "en-US": "This represents a tutoring session." + } + }, + "uriId": 169, + "editNotes": "", + "createdAt": "2013-08-12T14:27:38.000Z", + "updatedAt": "2013-08-12T14:27:38.000Z" + } + }, + { + "id": 407, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/tweet", + "createdAt": "2015-10-09T15:26:46.000Z", + "updatedAt": "2015-10-09T15:26:46.000Z", + "metadata": { + "id": 407, + "metadata": { + "name": { + "en-US": "Tweet" + }, + "description": { + "en-US": "A short message sent on Twitter. Used with the 'tweeted' verb. " + } + }, + "uriId": 407, + "editNotes": "", + "createdAt": "2015-10-09T15:26:47.000Z", + "updatedAt": "2015-10-09T15:26:47.000Z" + } + }, + { + "id": 281, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/unit-test", + "createdAt": "2014-04-17T14:52:33.000Z", + "updatedAt": "2014-04-17T14:52:33.000Z", + "metadata": { + "id": 281, + "metadata": { + "name": { + "en-US": "unit test" + }, + "description": { + "en-US": "A unit test in a test suite that is part of a programming project." + } + }, + "uriId": 281, + "editNotes": "", + "createdAt": "2014-04-17T14:52:33.000Z", + "updatedAt": "2014-04-17T14:52:33.000Z" + } + }, + { + "id": 280, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/unit-test-suite", + "createdAt": "2014-04-17T14:51:44.000Z", + "updatedAt": "2014-04-17T14:51:44.000Z", + "metadata": { + "id": 280, + "metadata": { + "name": { + "en-US": "unit test suite" + }, + "description": { + "en-US": "Suite of unit tests used by a programming project." + } + }, + "uriId": 280, + "editNotes": "", + "createdAt": "2014-04-17T14:51:44.000Z", + "updatedAt": "2014-04-17T14:51:44.000Z" + } + }, + { + "id": 486, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/user-profile", + "createdAt": "2016-08-01T16:23:02.000Z", + "updatedAt": "2016-08-01T16:23:02.000Z", + "metadata": { + "id": 486, + "metadata": { + "name": { + "en-US": "User profile" + }, + "description": { + "en-US": "A page displaying information about a user. " + } + }, + "uriId": 486, + "editNotes": "", + "createdAt": "2016-08-01T16:23:02.000Z", + "updatedAt": "2016-08-01T16:23:02.000Z" + } + }, + { + "id": 462, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/vocabulary-word", + "createdAt": "2016-08-01T16:10:40.000Z", + "updatedAt": "2016-08-01T16:10:40.000Z", + "metadata": { + "id": 462, + "metadata": { + "name": { + "en-US": "vocabulary word" + }, + "description": { + "en-US": "Refers to a word that the learner defines or translates. The vocabulary word can be part of a collection. that is part of a vocabulary collection. An actor can use more than one vocabulary collection, for instance, for several languages or several topics. Besides the \u201cname\u201d (the word) and a \u201cdescription\u201d (meaning or translation), we recommend the use of moreInfo to link to a definition in an online dictionary. As an option, you can use the extension tags to classify the words." + } + }, + "uriId": 462, + "editNotes": "", + "createdAt": "2016-08-01T16:10:40.000Z", + "updatedAt": "2016-08-01T16:10:40.000Z" + } + }, + { + "id": 267, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/voicemail", + "createdAt": "2014-01-13T13:49:36.000Z", + "updatedAt": "2014-01-13T13:49:36.000Z", + "metadata": { + "id": 267, + "metadata": { + "name": { + "en-US": "Voicemail" + }, + "description": { + "en-US": "A recorded audio message left for someone, generally via a phone or similar communication system." + } + }, + "uriId": 267, + "editNotes": "", + "createdAt": "2014-01-13T13:49:36.000Z", + "updatedAt": "2014-01-13T13:49:36.000Z" + } + }, + { + "id": 387, + "kind": "activityType", + "uri": "http://id.tincanapi.com/activitytype/webinar", + "createdAt": "2015-04-27T15:03:14.000Z", + "updatedAt": "2015-04-27T15:03:14.000Z", + "metadata": { + "id": 387, + "metadata": { + "name": { + "en-US": "webinar" + }, + "description": { + "en-US": "A seminar conducted over the Internet which may be live or recorded." + } + }, + "uriId": 387, + "editNotes": "", + "createdAt": "2015-04-27T15:03:14.000Z", + "updatedAt": "2015-04-27T15:03:14.000Z" + } + }, + { + "id": 317, + "kind": "activityType", + "uri": "http://risc-inc.com/annotator/activities/highlight", + "createdAt": "2014-12-15T15:38:09.000Z", + "updatedAt": "2014-12-15T15:38:09.000Z", + "metadata": { + "id": 317, + "metadata": { + "name": { + "en-US": "Highlighted text annotation" + }, + "description": { + "en-US": "An annotation of the 'highlight' type. Highlights are used to mark strings of text in a document with a color. \n\nThis activity type should only be used for highlighted text and not for highlighted images or other elements. " + } + }, + "uriId": 317, + "editNotes": "", + "createdAt": "2014-12-15T15:38:09.000Z", + "updatedAt": "2014-12-15T15:38:09.000Z" + } + }, + { + "id": 318, + "kind": "activityType", + "uri": "http://risc-inc.com/annotator/activities/note", + "createdAt": "2014-12-15T15:38:14.000Z", + "updatedAt": "2014-12-15T15:38:14.000Z", + "metadata": { + "id": 318, + "metadata": { + "name": { + "en-US": "Note annotation" + }, + "description": { + "en-US": "Indicates an annotation made to a document of the 'note' form. This is a string of text appended to the document at a specified location. Note annotations can be added anywhere on the page. \n\nThis activity type should not be used for other types of note that are not annotations to a document. " + } + }, + "uriId": 318, + "editNotes": "", + "createdAt": "2014-12-15T15:38:14.000Z", + "updatedAt": "2014-12-15T15:38:14.000Z" + } + }, + { + "id": 316, + "kind": "activityType", + "uri": "http://risc-inc.com/annotator/activities/underline", + "createdAt": "2014-12-15T15:38:04.000Z", + "updatedAt": "2014-12-15T15:38:04.000Z", + "metadata": { + "id": 316, + "metadata": { + "name": { + "en-US": "Underline annotation" + }, + "description": { + "en-US": "An annotation of the 'underline' type. Underlines are used to mark strings of text in a document with a line underneath the text. \n\nThis activity type should only be used for underlined text and not for images or other elements. " + } + }, + "uriId": 316, + "editNotes": "", + "createdAt": "2014-12-15T15:38:04.000Z", + "updatedAt": "2014-12-15T15:38:04.000Z" + } + }, + { + "id": 311, + "kind": "activityType", + "uri": "http://www.risc-inc.com/annotator/activities/freetext", + "createdAt": "2014-12-15T15:37:40.000Z", + "updatedAt": "2014-12-15T15:37:40.000Z", + "metadata": { + "id": 311, + "metadata": { + "name": { + "en-US": "Freetext annotation" + }, + "description": { + "en-US": "Indicates an annotation made to a document of the 'freetext' form. This is a string of text written direction onto the document at a specified location. Freetext annotations can be added anywhere on the page. Unlike note annotations, they have no border or background." + } + }, + "uriId": 311, + "editNotes": "", + "createdAt": "2014-12-15T15:37:40.000Z", + "updatedAt": "2014-12-15T15:37:40.000Z" + } + }, + { + "id": 406, + "kind": "activityType", + "uri": "http://www.tincanapi.co.uk/activitytypes/grade_classification", + "createdAt": "2015-09-28T15:07:24.000Z", + "updatedAt": "2015-09-28T15:07:24.000Z", + "metadata": { + "id": 406, + "metadata": { + "name": { + "en-US": "Grade classification" + }, + "description": { + "en-US": "Represents a grade given or received within a particular context, for example \u2018distinction\u2019 within XYZ music test or \u2018A\u2019 for ABC qualification." + } + }, + "uriId": 406, + "editNotes": "", + "createdAt": "2015-09-28T15:07:24.000Z", + "updatedAt": "2015-09-28T15:07:24.000Z" + } + }, + { + "id": 413, + "kind": "activityType", + "uri": "https://www.opigno.org/en/tincan_registry/activity_type/certificate", + "createdAt": "2016-02-16T12:53:43.000Z", + "updatedAt": "2016-02-16T12:53:43.000Z", + "metadata": { + "id": 413, + "metadata": { + "name": { + "en-US": "Certificate" + }, + "description": { + "en-US": "A document attesting to the fact that a person has completed an educational course." + } + }, + "uriId": 413, + "editNotes": "", + "createdAt": "2016-02-16T12:53:43.000Z", + "updatedAt": "2016-02-16T12:53:43.000Z" + } + } +] \ No newline at end of file diff --git a/modules/lo_event/xapi/attachmentUsage.json b/modules/lo_event/xapi/attachmentUsage.json new file mode 100644 index 000000000..db9705e21 --- /dev/null +++ b/modules/lo_event/xapi/attachmentUsage.json @@ -0,0 +1,112 @@ +[ + { + "id": 25, + "kind": "attachmentUsage", + "uri": "http://adlnet.gov/expapi/attachments/signature", + "createdAt": "2013-05-01T19:18:00.000Z", + "updatedAt": "2013-05-01T19:18:00.000Z", + "metadata": { + "id": 25, + "metadata": { + "name": { + "en-us": "Signature" + }, + "description": { + "en-us": "" + } + }, + "uriId": 25, + "editNotes": "", + "createdAt": "2013-05-01T19:18:00.000Z", + "updatedAt": "2013-05-01T19:18:00.000Z" + } + }, + { + "id": 253, + "kind": "attachmentUsage", + "uri": "http://id.tincanapi.com/attachment/certificate-of-completion", + "createdAt": "2013-11-13T14:19:02.000Z", + "updatedAt": "2013-11-13T14:19:02.000Z", + "metadata": { + "id": 253, + "metadata": { + "name": { + "en-US": "Certificate of Completion" + }, + "description": { + "en-US": "Certificate provided upon completion of an exercise, perhaps as part of a formal learning activity." + } + }, + "uriId": 253, + "editNotes": "", + "createdAt": "2013-11-13T14:19:02.000Z", + "updatedAt": "2013-11-13T14:19:02.000Z" + } + }, + { + "id": 252, + "kind": "attachmentUsage", + "uri": "http://id.tincanapi.com/attachment/contract", + "createdAt": "2013-11-13T14:18:31.000Z", + "updatedAt": "2013-11-13T14:18:31.000Z", + "metadata": { + "id": 252, + "metadata": { + "name": { + "en-US": "Contract" + }, + "description": { + "en-US": "A contract intended to be legally binding between two parties. May be part of a sales process, hiring process, real estate transaction, etc." + } + }, + "uriId": 252, + "editNotes": "", + "createdAt": "2013-11-13T14:18:31.000Z", + "updatedAt": "2013-11-13T14:18:31.000Z" + } + }, + { + "id": 175, + "kind": "attachmentUsage", + "uri": "http://id.tincanapi.com/attachment/supporting_media", + "createdAt": "2013-08-24T18:52:57.000Z", + "updatedAt": "2013-08-24T18:52:57.000Z", + "metadata": { + "id": 175, + "metadata": { + "name": { + "en-US": "SupportingMedia" + }, + "description": { + "en-US": "A media file that supports the experience. For example a video that shows the experience taking place." + } + }, + "uriId": 175, + "editNotes": "", + "createdAt": "2013-08-24T18:52:57.000Z", + "updatedAt": "2013-08-24T18:52:57.000Z" + } + }, + { + "id": 379, + "kind": "attachmentUsage", + "uri": "http://specification.openbadges.org/xapi/attachment/badge", + "createdAt": "2015-03-31T09:08:47.000Z", + "updatedAt": "2015-03-31T09:08:47.000Z", + "metadata": { + "id": 379, + "metadata": { + "name": { + "en-US": "Open Badges Baked Badge Image" + }, + "description": { + "en-US": "An attached Baked Badge Image. This is a png image containing additional metadata as defined by the Open Badges specification." + } + }, + "uriId": 379, + "editNotes": "", + "createdAt": "2015-03-31T09:08:47.000Z", + "updatedAt": "2015-03-31T09:08:47.000Z" + } + } +] \ No newline at end of file diff --git a/modules/lo_event/xapi/download_xapi_json.sh b/modules/lo_event/xapi/download_xapi_json.sh new file mode 100755 index 000000000..87140652c --- /dev/null +++ b/modules/lo_event/xapi/download_xapi_json.sh @@ -0,0 +1,15 @@ +# TODO: Remove original files, keep only the nicely formatted .json ones +# TODO: Consider moving into Python and main tool + +wget https://registry.tincanapi.com/api/v1/uris/verb +wget https://registry.tincanapi.com/api/v1/profile +wget https://registry.tincanapi.com/api/v1/uris/extension +wget https://registry.tincanapi.com/api/v1/uris/attachmentUsage +wget https://registry.tincanapi.com/api/v1/uris/activityType + +files=("verb" "profile" "extension" "attachmentUsage" "activityType") + +for file in "${files[@]}" +do + python -c "import json; json.dump(json.load(open('${file}')), open('${file}.json', 'w'), indent=2)" +done diff --git a/modules/lo_event/xapi/extension.json b/modules/lo_event/xapi/extension.json new file mode 100644 index 000000000..a0b869f08 --- /dev/null +++ b/modules/lo_event/xapi/extension.json @@ -0,0 +1,1344 @@ +[ + { + "id": 381, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/apm", + "createdAt": "2015-04-10T15:53:00.000Z", + "updatedAt": "2015-04-10T15:53:00.000Z", + "metadata": { + "id": 381, + "metadata": { + "name": { + "en-US": "Actions Per Minute" + }, + "description": { + "en-US": "A result extension used in the Tetris prototype at http://tincanapi.com/prototypes.\n\nActions per minute (APM) is a commonly reported on statistic in gaming as one mechanism for measuring the player's skill. See http://en.wikipedia.org/wiki/Actions_per_minute " + } + }, + "uriId": 381, + "editNotes": "", + "createdAt": "2015-04-10T15:53:00.000Z", + "updatedAt": "2015-04-10T15:53:00.000Z" + } + }, + { + "id": 478, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/assessment-type", + "createdAt": "2016-08-01T16:18:21.000Z", + "updatedAt": "2016-08-01T16:18:21.000Z", + "metadata": { + "id": 478, + "metadata": { + "name": { + "en-US": "Assessment Type" + }, + "description": { + "en-US": "A value representing the type of assessment like formative, summative, pretest, posttest, etc." + } + }, + "uriId": 478, + "editNotes": "", + "createdAt": "2016-08-01T16:18:21.000Z", + "updatedAt": "2016-08-01T16:18:21.000Z" + } + }, + { + "id": 385, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/attempt-id", + "createdAt": "2015-04-10T16:25:23.000Z", + "updatedAt": "2015-04-10T16:25:23.000Z", + "metadata": { + "id": 385, + "metadata": { + "name": { + "en-US": "Attempt ID" + }, + "description": { + "en-US": "Used to differentiate between attempts within a given registration. This extension is especially useful for games, for example in the Tetris prototype at http://tincanapi.com/prototypes this extension is used as an identifier for each new game of Tetris. " + } + }, + "uriId": 385, + "editNotes": "", + "createdAt": "2015-04-10T16:25:23.000Z", + "updatedAt": "2015-04-10T16:25:23.000Z" + } + }, + { + "id": 279, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/browser-info", + "createdAt": "2014-03-27T18:05:22.000Z", + "updatedAt": "2014-03-27T18:05:22.000Z", + "metadata": { + "id": 279, + "metadata": { + "name": { + "en-US": "browser information" + }, + "description": { + "en-US": "Value is an object containing key/value pairs describing various elements of a web browser.\n\nAn example value:\n\n{\n \"code_name\": \"Mozilla\",\n \"name\": \"Netscape\",\n \"version\": \"5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.152 Safari/537.36\",\n \"platform\": \"MacIntel\",\n \"user-agent-header\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.152 Safari/537.36\",\n \"cookies-enabled\": true\n}" + } + }, + "uriId": 279, + "editNotes": "", + "createdAt": "2014-03-27T18:05:22.000Z", + "updatedAt": "2014-03-27T18:05:22.000Z" + } + }, + { + "id": 409, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/cmi-interaction-weighting", + "createdAt": "2015-12-08T18:53:44.000Z", + "updatedAt": "2015-12-08T18:53:44.000Z", + "metadata": { + "id": 409, + "metadata": { + "name": { + "en-US": "cmi interaction weighting" + }, + "description": { + "en-US": " A number that indicates the importance of this interaction relative to other interactions. Corresponds to 'cmi.interactions[x].weighting'." + } + }, + "uriId": 409, + "editNotes": "", + "createdAt": "2015-12-08T18:53:44.000Z", + "updatedAt": "2015-12-08T18:53:44.000Z" + } + }, + { + "id": 469, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/collection-type", + "createdAt": "2016-08-01T16:13:03.000Z", + "updatedAt": "2016-08-01T16:13:03.000Z", + "metadata": { + "id": 469, + "metadata": { + "name": { + "en-US": "collection type" + }, + "description": { + "en-US": "Represents the activity type of the objects of a simple collection." + } + }, + "uriId": 469, + "editNotes": "", + "createdAt": "2016-08-01T16:13:03.000Z", + "updatedAt": "2016-08-01T16:13:03.000Z" + } + }, + { + "id": 188, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/color", + "createdAt": "2013-08-27T20:07:35.000Z", + "updatedAt": "2013-08-27T20:07:35.000Z", + "metadata": { + "id": 188, + "metadata": { + "name": { + "en-US": "Color" + }, + "description": { + "en-US": "A value representing a specific color as defined via a provided color model. Because the representation of a color depends on the model in which it was defined the value used should be an object that specifies two (at least) properties, specifically the 'model' used as well as the 'value' for the specific color within the color space. For example:\n\n{\n model: \"RGB\",\n value: \"#FFFFFF\"\n}" + } + }, + "uriId": 188, + "editNotes": "", + "createdAt": "2013-08-27T20:07:35.000Z", + "updatedAt": "2013-08-27T20:07:35.000Z" + } + }, + { + "id": 466, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/condition-type", + "createdAt": "2016-08-01T16:11:48.000Z", + "updatedAt": "2016-08-01T16:11:48.000Z", + "metadata": { + "id": 466, + "metadata": { + "name": { + "en-US": "condition type" + }, + "description": { + "en-US": "Represents the type of condition that the actor needs to achieve to do what the verb in the statement expresses." + } + }, + "uriId": 466, + "editNotes": "", + "createdAt": "2016-08-01T16:11:48.000Z", + "updatedAt": "2016-08-01T16:11:48.000Z" + } + }, + { + "id": 467, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/condition-value", + "createdAt": "2016-08-01T16:11:56.000Z", + "updatedAt": "2016-08-01T16:11:56.000Z", + "metadata": { + "id": 467, + "metadata": { + "name": { + "en-US": "condition value" + }, + "description": { + "en-US": "Represents the value (if necessary) to accomplish the condition. For instance, if the condition is a time limit, the condition value is the time expressed in the ISO8601 format, the same used in the result duration property." + } + }, + "uriId": 467, + "editNotes": "", + "createdAt": "2016-08-01T16:11:56.000Z", + "updatedAt": "2016-08-01T16:11:56.000Z" + } + }, + { + "id": 410, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/data-uri", + "createdAt": "2016-01-04T15:54:43.000Z", + "updatedAt": "2016-01-04T15:54:43.000Z", + "metadata": { + "id": 410, + "metadata": { + "name": { + "en-US": "Data URI" + }, + "description": { + "en-US": "Extension whose value is a string that follows the Data URI scheme as defined by RFC 2397." + } + }, + "uriId": 410, + "editNotes": "", + "createdAt": "2016-01-04T15:54:43.000Z", + "updatedAt": "2016-01-04T15:54:43.000Z" + } + }, + { + "id": 184, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/date", + "createdAt": "2013-08-27T20:05:35.000Z", + "updatedAt": "2013-08-27T20:05:35.000Z", + "metadata": { + "id": 184, + "metadata": { + "name": { + "en-US": "Date" + }, + "description": { + "en-US": "Value representing a calendar date, such as 2013-08-27. Value should be a string formatted as an ISO8601 date to match the rest of the specification values." + } + }, + "uriId": 184, + "editNotes": "", + "createdAt": "2013-08-27T20:05:35.000Z", + "updatedAt": "2013-08-27T20:05:35.000Z" + } + }, + { + "id": 186, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/datetime", + "createdAt": "2013-08-27T20:06:38.000Z", + "updatedAt": "2013-08-27T20:06:38.000Z", + "metadata": { + "id": 186, + "metadata": { + "name": { + "en-US": "DateTime" + }, + "description": { + "en-US": "Value representing a calendar date and time, such as 2013-08-27 09:26:45.001. Value should be a string formatted as an ISO8601 date and time to match the rest of the specification values." + } + }, + "uriId": 186, + "editNotes": "", + "createdAt": "2013-08-27T20:06:38.000Z", + "updatedAt": "2013-08-27T20:06:38.000Z" + } + }, + { + "id": 309, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/drop-down", + "createdAt": "2014-10-21T17:53:36.000Z", + "updatedAt": "2014-10-21T17:53:36.000Z", + "metadata": { + "id": 309, + "metadata": { + "name": { + "en-US": "drop down" + }, + "description": { + "en-US": "It contains an interaction component for a non standard question type. Non-standard question type used in e-learning: any number of drop-down on a graphic. Each drop-down is a subquestion.\n\nThe extension's value is an array containing objects representing each of the dropdowns/subquestions with \"id\", \"description\" and \"options\" properties. \"options\" contains an array of objects representing each possible value for the parent dropdown, with an \"id\" and \"description\" for each option/value.\n\nFor example:\n\n[\n {\n \"id\": \"1\",\n \"description\": {\n \"en-US\": \"DDOG1\"\n },\n \"options\": [\n {\n \"id\": \"1\",\n \"description\": {\n \"en-US\": \"True\"\n }\n },\n {\n \"id\": \"2\",\n \"description\": {\n \"en-US\": \"False\"\n }\n }\n ]\n },\n {\n \"id\": \"2\",\n \"description\": {\n \"en-US\": \"DDOG2\"\n },\n \"options\": [\n {\n \"id\": \"1\",\n \"description\": {\n \"en-US\": \"True\"\n }\n },\n {\n \"id\": \"2\",\n \"description\": {\n \"en-US\": \"False\"\n }\n }\n ]\n }\n]" + } + }, + "uriId": 309, + "editNotes": "", + "createdAt": "2014-10-21T17:53:36.000Z", + "updatedAt": "2014-10-21T17:53:36.000Z" + } + }, + { + "id": 288, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/duration", + "createdAt": "2014-05-06T18:43:38.000Z", + "updatedAt": "2014-05-06T18:43:38.000Z", + "metadata": { + "id": 288, + "metadata": { + "name": { + "en-US": "duration" + }, + "description": { + "en-US": "Value representing a length of time, for example the length of a video. Value should be either a string formatted as an ISO8601 duration to match the Result.duration property or a float that uses the same units as expected with correlating information (other extensions). This extension will generally be used within an Activity Definition to indicate a length of an Activity as compared to the Result.duration which captures the length of time for a specific event. For example a video may be 5 minutes long (this Extension), but an actor may have only watched 30 seconds of it (the Result.duration)." + } + }, + "uriId": 288, + "editNotes": "", + "createdAt": "2014-05-06T18:43:39.000Z", + "updatedAt": "2014-05-06T18:43:39.000Z" + } + }, + { + "id": 181, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/ending-point", + "createdAt": "2013-08-27T20:04:11.000Z", + "updatedAt": "2013-08-27T20:04:11.000Z", + "metadata": { + "id": 181, + "metadata": { + "name": { + "en-US": "Ending Point" + }, + "description": { + "en-US": "The final point at which an actor ceases an activity. For example stopping the playing of a video at a specific position either manually or automatically. Goes along with \"Starting Point\". Can be used with types of media and/or activities other than video." + } + }, + "uriId": 181, + "editNotes": "", + "createdAt": "2013-08-27T20:04:11.000Z", + "updatedAt": "2013-08-27T20:04:11.000Z" + } + }, + { + "id": 183, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/ending-position", + "createdAt": "2013-08-27T20:05:21.000Z", + "updatedAt": "2013-08-27T20:05:21.000Z", + "metadata": { + "id": 183, + "metadata": { + "name": { + "en-US": "Ending Position" + }, + "description": { + "en-US": "Final position within an ordinal set of numbers. Can also be thought of as a \"rank\". For example the ending position of a car or runner in a race. To be used with \"Starting Position\"." + } + }, + "uriId": 183, + "editNotes": "", + "createdAt": "2013-08-27T20:05:21.000Z", + "updatedAt": "2013-08-27T20:05:21.000Z" + } + }, + { + "id": 244, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/feedback", + "createdAt": "2013-10-09T17:53:57.000Z", + "updatedAt": "2013-10-09T17:53:57.000Z", + "metadata": { + "id": 244, + "metadata": { + "name": { + "en-US": "feedback" + }, + "description": { + "en-US": "A value representing a piece of feedback relating to a statement. The feedback should be a string. " + } + }, + "uriId": 244, + "editNotes": "", + "createdAt": "2013-10-09T17:53:57.000Z", + "updatedAt": "2013-10-09T17:53:57.000Z" + } + }, + { + "id": 190, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/geojson", + "createdAt": "2013-08-27T20:08:29.000Z", + "updatedAt": "2013-08-27T20:08:29.000Z", + "metadata": { + "id": 190, + "metadata": { + "name": { + "en-US": "GeoJSON" + }, + "description": { + "en-US": "Value should be a GeoJSON object as defined by the GeoJSON specification. GeoJSON can be used to represent GPS coordinates, as well as other geometrical entities. See http://www.geojson.org/ for more information." + } + }, + "uriId": 190, + "editNotes": "", + "createdAt": "2013-08-27T20:08:29.000Z", + "updatedAt": "2013-08-27T20:08:29.000Z" + } + }, + { + "id": 440, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/invitee", + "createdAt": "2016-08-01T15:58:57.000Z", + "updatedAt": "2016-08-01T15:58:57.000Z", + "metadata": { + "id": 440, + "metadata": { + "name": { + "en-US": "invitee" + }, + "description": { + "en-US": "To be used in the context. Contains a single object representing the actor which is being invited to the experience. For example the group on a social learning site. When using this extension, it is recommended to use the same actor objects that are used in other statements." + } + }, + "uriId": 440, + "editNotes": "", + "createdAt": "2016-08-01T15:58:57.000Z", + "updatedAt": "2016-08-01T15:58:57.000Z" + } + }, + { + "id": 310, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/ip-address", + "createdAt": "2014-12-15T15:37:18.000Z", + "updatedAt": "2014-12-15T15:37:18.000Z", + "metadata": { + "id": 310, + "metadata": { + "name": { + "en-US": "IP Address" + }, + "description": { + "en-US": "Value is a string representing an Internet Protocol address (IP address) in either IPv4 or IPv6 format. An example usage may be to help identify the client's real address location on internet as a Context extension. Another example may be to include relevant information about the http://activitystrea.ms/schema/1.0/page Activity type.\n\nIPv4 Address\nA string in decimal-dot notation, consisting of four decimal integers in the inclusive range 0-255, separated by dots (e.g. 192.168.0.1). Each integer represents an octet (byte) in the address. Leading zeroes are tolerated only for values less then 8 (as there is no ambiguity between the decimal and octal interpretations of such strings).\n\nIPv6 Address\nA string consisting of eight groups of four hexadecimal digits, each group representing 16 bits. The groups are separated by colons. This describes an exploded (longhand) notation. The string can also be compressed (shorthand notation) by various means. See RFC 4291 for details. For example, \"0000:0000:0000:0000:0000:0abc:0007:0def\" can be compressed to \"::abc:7:def\"." + } + }, + "uriId": 310, + "editNotes": "", + "createdAt": "2014-12-15T15:37:18.000Z", + "updatedAt": "2014-12-15T15:37:18.000Z" + } + }, + { + "id": 187, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/irl", + "createdAt": "2013-08-27T20:06:57.000Z", + "updatedAt": "2013-08-27T20:06:57.000Z", + "metadata": { + "id": 187, + "metadata": { + "name": { + "en-US": "IRL" + }, + "description": { + "en-US": "Value should be a fully qualified IRL that is resolvable. In so far as the IRL space contains all possible URLs this is provided in place of a more specific URL to match the expectation of the specification for using IRI/IRL." + } + }, + "uriId": 187, + "editNotes": "", + "createdAt": "2013-08-27T20:06:57.000Z", + "updatedAt": "2013-08-27T20:06:57.000Z" + } + }, + { + "id": 191, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/isbn", + "createdAt": "2013-08-27T20:08:47.000Z", + "updatedAt": "2013-08-27T20:08:47.000Z", + "metadata": { + "id": 191, + "metadata": { + "name": { + "en-US": "ISBN (International Standard Book Number)" + }, + "description": { + "en-US": "Value should be either a 10 digit ISBN or 13 digit ISBN string. Either value is acceptable as implementing systems can easily distinguish the two based on the length of the value. For more information see ISO 2108." + } + }, + "uriId": 191, + "editNotes": "", + "createdAt": "2013-08-27T20:08:47.000Z", + "updatedAt": "2013-08-27T20:08:47.000Z" + } + }, + { + "id": 416, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/jws-certificate-location", + "createdAt": "2016-02-16T12:56:14.000Z", + "updatedAt": "2016-02-16T12:56:14.000Z", + "metadata": { + "id": 416, + "metadata": { + "name": { + "en-US": "JWS Certificate Location" + }, + "description": { + "en-US": "Context extension containing the URL of a public certificate that can be used to verify the signature of the statement. E.g. https://example.com/cacert.pem\nBe aware that an attacker forging a statement could host their own certificate at a domain they control so in addition to verifying the statement signature\nyou should also verify that the certificate is hosted at a location you trust. \n\nThis extension is optional. If it is not included, another mechanism for the party verifying the statement signature to receive the public certificate will\nneed to be used." + } + }, + "uriId": 416, + "editNotes": "", + "createdAt": "2016-02-16T12:56:14.000Z", + "updatedAt": "2016-02-16T12:56:14.000Z" + } + }, + { + "id": 45, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/latitude", + "createdAt": "2013-08-01T13:20:15.000Z", + "updatedAt": "2013-08-01T13:20:15.000Z", + "metadata": { + "id": 45, + "metadata": { + "name": { + "en-US": "Latitude" + }, + "description": { + "en-US": "A geographic coordinate that specifies the north-south position of a point on the Earth's surface." + } + }, + "uriId": 45, + "editNotes": "", + "createdAt": "2013-08-01T13:20:15.000Z", + "updatedAt": "2013-08-01T13:20:15.000Z" + } + }, + { + "id": 269, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/location", + "createdAt": "2014-03-13T18:55:42.000Z", + "updatedAt": "2014-03-13T18:55:42.000Z", + "metadata": { + "id": 269, + "metadata": { + "name": { + "en-US": "location" + }, + "description": { + "en-US": "A non-specific (as in format) string value representing a location in which an activity took place. May contain an address, but for formal addresses a more specific format should be used with accompanying Extension." + } + }, + "uriId": 269, + "editNotes": "", + "createdAt": "2014-03-13T18:55:42.000Z", + "updatedAt": "2014-03-13T18:55:42.000Z" + } + }, + { + "id": 46, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/longitude", + "createdAt": "2013-08-01T13:20:24.000Z", + "updatedAt": "2013-08-01T13:20:24.000Z", + "metadata": { + "id": 46, + "metadata": { + "name": { + "en-US": "Longitude" + }, + "description": { + "en-US": "A geographic coordinate that specifies the east-west position of a point on the Earth's surface." + } + }, + "uriId": 46, + "editNotes": "", + "createdAt": "2013-08-01T13:20:24.000Z", + "updatedAt": "2013-08-01T13:20:24.000Z" + } + }, + { + "id": 189, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/measurement", + "createdAt": "2013-08-27T20:08:09.000Z", + "updatedAt": "2013-08-27T20:08:09.000Z", + "metadata": { + "id": 189, + "metadata": { + "name": { + "en-US": "Measurement" + }, + "description": { + "en-US": "Value that represents a measured unit or physical quantity such as a distance or weight. For interoperability the value should be a string that follows the SI (International System of Units) recommendations for formatting and may include any value that can be described by its units. For additional information see the ISO 80000 standard." + } + }, + "uriId": 189, + "editNotes": "", + "createdAt": "2013-08-27T20:08:09.000Z", + "updatedAt": "2013-08-27T20:08:09.000Z" + } + }, + { + "id": 272, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/monetary-value", + "createdAt": "2014-03-14T13:04:41.000Z", + "updatedAt": "2014-03-14T13:04:41.000Z", + "metadata": { + "id": 272, + "metadata": { + "name": { + "en-US": "Monetary Value" + }, + "description": { + "en-US": "A value representing the currency and amount of money. The value is an object with two properties, 'amount' which is a float and 'currency' which should hold an ISO 4217 code or number code." + } + }, + "uriId": 272, + "editNotes": "", + "createdAt": "2014-03-14T13:04:41.000Z", + "updatedAt": "2014-03-14T13:04:41.000Z" + } + }, + { + "id": 393, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/observer", + "createdAt": "2015-05-07T17:10:08.000Z", + "updatedAt": "2015-05-07T17:10:08.000Z", + "metadata": { + "id": 393, + "metadata": { + "name": { + "en-US": "observer" + }, + "description": { + "en-US": "Context extension containing an Agent or Group object representing an agent or group who observed the experience." + } + }, + "uriId": 393, + "editNotes": "", + "createdAt": "2015-05-07T17:10:08.000Z", + "updatedAt": "2015-05-07T17:10:08.000Z" + } + }, + { + "id": 395, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/planned-duration", + "createdAt": "2015-05-07T17:10:35.000Z", + "updatedAt": "2015-05-07T17:10:35.000Z", + "metadata": { + "id": 395, + "metadata": { + "name": { + "en-US": "planned duration" + }, + "description": { + "en-US": "Context extension containing an ISO 8601 duration representing the planned duration of a scheduled or planned event." + } + }, + "uriId": 395, + "editNotes": "", + "createdAt": "2015-05-07T17:10:35.000Z", + "updatedAt": "2015-05-07T17:10:35.000Z" + } + }, + { + "id": 394, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/planned-start-time", + "createdAt": "2015-05-07T17:10:20.000Z", + "updatedAt": "2015-05-07T17:10:20.000Z", + "metadata": { + "id": 394, + "metadata": { + "name": { + "en-US": "planned start time" + }, + "description": { + "en-US": "Context extension containing an ISO 8601 timestamp representing the planned start time of a scheduled or planned event." + } + }, + "uriId": 394, + "editNotes": "", + "createdAt": "2015-05-07T17:10:20.000Z", + "updatedAt": "2015-05-07T17:10:20.000Z" + } + }, + { + "id": 464, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/position", + "createdAt": "2016-08-01T16:11:19.000Z", + "updatedAt": "2016-08-01T16:11:19.000Z", + "metadata": { + "id": 464, + "metadata": { + "name": { + "en-US": "position" + }, + "description": { + "en-US": "Represents the position of the object in a group or collection of elements. It is needed when the group of elements should be in order." + } + }, + "uriId": 464, + "editNotes": "", + "createdAt": "2016-08-01T16:11:19.000Z", + "updatedAt": "2016-08-01T16:11:19.000Z" + } + }, + { + "id": 12, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/powered-by", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 12, + "metadata": { + "name": { + "en-us": "Powered By" + }, + "description": { + "en-us": "Information about what software is used to run a system." + } + }, + "uriId": 12, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 477, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/private-area", + "createdAt": "2016-08-01T16:17:56.000Z", + "updatedAt": "2016-08-01T16:17:56.000Z", + "metadata": { + "id": 477, + "metadata": { + "name": { + "en-US": "Private Area" + }, + "description": { + "en-US": "An area, for instance within a Learning Management System (LMS), in which students and teachers can share pedagogical objects and interact privately." + } + }, + "uriId": 477, + "editNotes": "", + "createdAt": "2016-08-01T16:17:56.000Z", + "updatedAt": "2016-08-01T16:17:56.000Z" + } + }, + { + "id": 482, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/published", + "createdAt": "2016-08-01T16:21:37.000Z", + "updatedAt": "2016-08-01T16:21:37.000Z", + "metadata": { + "id": 482, + "metadata": { + "name": { + "en-US": "Published" + }, + "description": { + "en-US": "Activity definition extension. The date and time at which the activity was published. Corresponds to the Activity Streams 1.0 'published' property. " + } + }, + "uriId": 482, + "editNotes": "", + "createdAt": "2016-08-01T16:21:37.000Z", + "updatedAt": "2016-08-01T16:21:37.000Z" + } + }, + { + "id": 468, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/purpose", + "createdAt": "2016-08-01T16:12:38.000Z", + "updatedAt": "2016-08-01T16:12:38.000Z", + "metadata": { + "id": 468, + "metadata": { + "name": { + "en-US": "purpose" + }, + "description": { + "en-US": "Represents the purpose of the object or result, as a way of classification." + } + }, + "uriId": 468, + "editNotes": "", + "createdAt": "2016-08-01T16:12:41.000Z", + "updatedAt": "2016-08-01T16:12:41.000Z" + } + }, + { + "id": 287, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/quality-rating", + "createdAt": "2014-05-01T19:48:23.000Z", + "updatedAt": "2014-05-01T19:48:23.000Z", + "metadata": { + "id": 287, + "metadata": { + "name": { + "en-US": "quality rating" + }, + "description": { + "en-US": "Value is an object that is similar to the Result's \"score\" property in that it should include a 'raw' value as well as 'min' and 'max' range indicators. So that a phrase such as \"4 out of 5 stars\" can be indicated such as:\n\n{\n raw: 4,\n min: 1,\n max: 5\n}" + } + }, + "uriId": 287, + "editNotes": "", + "createdAt": "2014-05-01T19:48:23.000Z", + "updatedAt": "2014-05-01T19:48:23.000Z" + } + }, + { + "id": 259, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/referrer", + "createdAt": "2013-12-19T14:57:58.000Z", + "updatedAt": "2013-12-19T14:57:58.000Z", + "metadata": { + "id": 259, + "metadata": { + "name": { + "en-US": "referrer" + }, + "description": { + "en-US": "To be used in the context. Contains a single activity object representing the activity which referred the learner to the experience. For example the page of an e-learning course that sent the learner to their current location. When using this extension, it is recommended to also include the activity object as one of the 'other' contextActivities of the statement as well for tools that don't recognize this extension." + } + }, + "uriId": 259, + "editNotes": "", + "createdAt": "2013-12-19T14:57:59.000Z", + "updatedAt": "2013-12-19T14:57:59.000Z" + } + }, + { + "id": 465, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/reflection", + "createdAt": "2016-08-01T16:11:31.000Z", + "updatedAt": "2016-08-01T16:11:31.000Z", + "metadata": { + "id": 465, + "metadata": { + "name": { + "en-US": "reflection" + }, + "description": { + "en-US": "Represents a reflection of the actor about the object." + } + }, + "uriId": 465, + "editNotes": "", + "createdAt": "2016-08-01T16:11:31.000Z", + "updatedAt": "2016-08-01T16:11:31.000Z" + } + }, + { + "id": 295, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/severity", + "createdAt": "2014-05-28T15:16:19.000Z", + "updatedAt": "2014-05-28T15:16:19.000Z", + "metadata": { + "id": 295, + "metadata": { + "name": { + "en-US": "severity" + }, + "description": { + "en-US": "Indicates the associated level of an event. For example it could be used to indicate the level of an injury or incident." + } + }, + "uriId": 295, + "editNotes": "", + "createdAt": "2014-05-28T15:16:19.000Z", + "updatedAt": "2014-05-28T15:16:19.000Z" + } + }, + { + "id": 476, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/share-medium", + "createdAt": "2016-08-01T16:16:44.000Z", + "updatedAt": "2016-08-01T16:16:44.000Z", + "metadata": { + "id": 476, + "metadata": { + "name": { + "en-US": "Sharing Medium" + }, + "description": { + "en-US": "Context extension used with the verb http://adlnet.gov/expapi/verbs/shared. Indicates the medium that the object has been shared over. Contains a single word lowercase string representing the share medium e.g. \"email\", \"sms\", \"twitter\", \"facebook\"" + } + }, + "uriId": 476, + "editNotes": "", + "createdAt": "2016-08-01T16:16:44.000Z", + "updatedAt": "2016-08-01T16:16:44.000Z" + } + }, + { + "id": 180, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/starting-point", + "createdAt": "2013-08-27T17:27:29.000Z", + "updatedAt": "2013-08-27T17:27:29.000Z", + "metadata": { + "id": 180, + "metadata": { + "name": { + "en-US": "Starting Point" + }, + "description": { + "en-US": "The initial point from which an agent begins an activity. For example starting to play a video from a specific position in the video. Goes along with \"Ending Point\". Can be used with types of media and/or activities other than video." + } + }, + "uriId": 180, + "editNotes": "", + "createdAt": "2013-08-27T17:27:29.000Z", + "updatedAt": "2013-08-27T17:27:29.000Z" + } + }, + { + "id": 182, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/starting-position", + "createdAt": "2013-08-27T20:04:45.000Z", + "updatedAt": "2013-08-27T20:04:45.000Z", + "metadata": { + "id": 182, + "metadata": { + "name": { + "en-US": "Starting Position" + }, + "description": { + "en-US": "Initial position within an ordinal set of numbers. Can also be thought of as a \"rank\". For example the starting position of a car or runner in a race. To be used with \"Ending Position\"." + } + }, + "uriId": 182, + "editNotes": "", + "createdAt": "2013-08-27T20:04:45.000Z", + "updatedAt": "2013-08-27T20:04:45.000Z" + } + }, + { + "id": 243, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/tags", + "createdAt": "2013-10-09T17:53:13.000Z", + "updatedAt": "2013-10-09T17:53:13.000Z", + "metadata": { + "id": 243, + "metadata": { + "name": { + "en-US": "tags" + }, + "description": { + "en-US": "A list of arbitrary tags to associate with a statement. Value of the extension should be an array with each tag being a string value as an element of the array." + } + }, + "uriId": 243, + "editNotes": "", + "createdAt": "2013-10-09T17:53:13.000Z", + "updatedAt": "2013-10-09T17:53:13.000Z" + } + }, + { + "id": 493, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/target", + "createdAt": "2016-08-01T16:30:04.000Z", + "updatedAt": "2016-08-01T16:30:04.000Z", + "metadata": { + "id": 493, + "metadata": { + "name": { + "en-US": "Target" + }, + "description": { + "en-US": "Based on the Activity Streams target property. Contains the target of the statement e.g. Brian Shared 'statements deep dive' with Andrew - Andrew is the target. \n\nThe value of this extension can be anything that would be a legal value of the statement's object property. " + } + }, + "uriId": 493, + "editNotes": "", + "createdAt": "2016-08-01T16:30:04.000Z", + "updatedAt": "2016-08-01T16:30:04.000Z" + } + }, + { + "id": 384, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/tetris-lines", + "createdAt": "2015-04-10T16:25:07.000Z", + "updatedAt": "2015-04-10T16:25:07.000Z", + "metadata": { + "id": 384, + "metadata": { + "name": { + "en-US": "Tetris Lines" + }, + "description": { + "en-US": "The number of lines achieved in a game of Tetris or another game of a similar type. This extension is used by the Tetris game prototype at http://tincanapi.com/prototypes." + } + }, + "uriId": 384, + "editNotes": "", + "createdAt": "2015-04-10T16:25:07.000Z", + "updatedAt": "2015-04-10T16:25:07.000Z" + } + }, + { + "id": 185, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/time", + "createdAt": "2013-08-27T20:05:57.000Z", + "updatedAt": "2013-08-27T20:05:57.000Z", + "metadata": { + "id": 185, + "metadata": { + "name": { + "en-US": "Time" + }, + "description": { + "en-US": "Value representing a moment in time but not specific to a date, such as \"12:34:56.789\". Value should be a string formatted as an ISO8601 time to match the rest of the specification values." + } + }, + "uriId": 185, + "editNotes": "", + "createdAt": "2013-08-27T20:05:57.000Z", + "updatedAt": "2013-08-27T20:05:57.000Z" + } + }, + { + "id": 263, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/topic", + "createdAt": "2013-12-20T14:55:50.000Z", + "updatedAt": "2013-12-20T14:55:50.000Z", + "metadata": { + "id": 263, + "metadata": { + "name": { + "en-US": "topic" + }, + "description": { + "en-US": "A value that contains a topic for a statement." + } + }, + "uriId": 263, + "editNotes": "", + "createdAt": "2013-12-20T14:55:50.000Z", + "updatedAt": "2013-12-20T14:55:50.000Z" + } + }, + { + "id": 414, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/training-provider", + "createdAt": "2016-02-16T12:54:16.000Z", + "updatedAt": "2016-02-16T12:54:16.000Z", + "metadata": { + "id": 414, + "metadata": { + "name": { + "en-US": "Training Provider" + }, + "description": { + "en-US": "An agent or group representing the company or organization that offers a training session." + } + }, + "uriId": 414, + "editNotes": "", + "createdAt": "2016-02-16T12:54:16.000Z", + "updatedAt": "2016-02-16T12:54:16.000Z" + } + }, + { + "id": 192, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/tweet", + "createdAt": "2013-09-03T16:48:49.000Z", + "updatedAt": "2013-09-03T16:48:49.000Z", + "metadata": { + "id": 192, + "metadata": { + "name": { + "en-US": "tweet" + }, + "description": { + "en-US": "An ID for a tweet, such as 373445672076197889. It is advised to also supply the author of the tweet's handle and the text of the tweet as values with this extension." + } + }, + "uriId": 192, + "editNotes": "", + "createdAt": "2013-09-03T16:48:49.000Z", + "updatedAt": "2013-09-03T16:48:49.000Z" + } + }, + { + "id": 483, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/updated", + "createdAt": "2016-08-01T16:21:55.000Z", + "updatedAt": "2016-08-01T16:21:55.000Z", + "metadata": { + "id": 483, + "metadata": { + "name": { + "en-US": "Updated" + }, + "description": { + "en-US": "Activity definition extension. The date and time at which a previously published activity has been modified. Corresponds to the Activity Streams 1.0 'updated' property. " + } + }, + "uriId": 483, + "editNotes": "", + "createdAt": "2016-08-01T16:21:55.000Z", + "updatedAt": "2016-08-01T16:21:55.000Z" + } + }, + { + "id": 415, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/valid-until", + "createdAt": "2016-02-16T12:55:06.000Z", + "updatedAt": "2016-02-16T12:55:06.000Z", + "metadata": { + "id": 415, + "metadata": { + "name": { + "en-US": "Valid Until" + }, + "description": { + "en-US": "An extension on the Result object indicating for how long the completion of this training is considered valid, before the \"actor\" needs to re-certify. The type should be an ISO8601 timestamp." + } + }, + "uriId": 415, + "editNotes": "", + "createdAt": "2016-02-16T12:55:06.000Z", + "updatedAt": "2016-02-16T12:55:06.000Z" + } + }, + { + "id": 321, + "kind": "extension", + "uri": "http://id.tincanapi.com/extension/watershedlrs-organization-id", + "createdAt": "2014-12-15T15:41:48.000Z", + "updatedAt": "2014-12-15T15:41:48.000Z", + "metadata": { + "id": 321, + "metadata": { + "name": { + "en-US": "WatershedLRS Organization ID" + }, + "description": { + "en-US": "WatershedLRS Organization ID." + } + }, + "uriId": 321, + "editNotes": "", + "createdAt": "2014-12-15T15:41:48.000Z", + "updatedAt": "2014-12-15T15:41:48.000Z" + } + }, + { + "id": 378, + "kind": "extension", + "uri": "http://specification.openbadges.org/xapi/extensions/badgeassertion", + "createdAt": "2015-03-31T09:08:44.000Z", + "updatedAt": "2015-03-31T09:08:44.000Z", + "metadata": { + "id": 378, + "metadata": { + "name": { + "en-US": "Open Badge Assertion" + }, + "description": { + "en-US": "Result Extension containing an object with an @id property pointing to the IRI of a hosted Open Badge Assertion." + } + }, + "uriId": 378, + "editNotes": "", + "createdAt": "2015-03-31T09:08:44.000Z", + "updatedAt": "2015-03-31T09:08:44.000Z" + } + }, + { + "id": 377, + "kind": "extension", + "uri": "http://specification.openbadges.org/xapi/extensions/badgeclass", + "createdAt": "2015-03-31T09:08:38.000Z", + "updatedAt": "2015-03-31T09:08:38.000Z", + "metadata": { + "id": 377, + "metadata": { + "name": { + "en-US": "Open Badge Class" + }, + "description": { + "en-US": "Activity Definition Extension containing an object with an @id property pointing to the IRI of a hosted Open Badge Class definition." + } + }, + "uriId": 377, + "editNotes": "", + "createdAt": "2015-03-31T09:08:38.000Z", + "updatedAt": "2015-03-31T09:08:38.000Z" + } + }, + { + "id": 313, + "kind": "extension", + "uri": "http://www.risc-inc.com/annotator/extensions/color", + "createdAt": "2014-12-15T15:37:50.000Z", + "updatedAt": "2014-12-15T15:37:50.000Z", + "metadata": { + "id": 313, + "metadata": { + "name": { + "en-US": "PDF annotation highlight colour" + }, + "description": { + "en-US": "This extension is used to describe the RGB colour of a PDF annotation highlight, underline of typewriter annotation. The value of this extension is a string, for example #FFCC66. \n\nFor any use cases outside of PDF annotations, consider http://id.tincanapi.com/extension/color to record the colour of an activity. " + } + }, + "uriId": 313, + "editNotes": "", + "createdAt": "2014-12-15T15:37:50.000Z", + "updatedAt": "2014-12-15T15:37:50.000Z" + } + }, + { + "id": 312, + "kind": "extension", + "uri": "http://www.risc-inc.com/annotator/extensions/highlightedString", + "createdAt": "2014-12-15T15:37:45.000Z", + "updatedAt": "2014-12-15T15:37:45.000Z", + "metadata": { + "id": 312, + "metadata": { + "name": { + "en-US": "Highlighted string" + }, + "description": { + "en-US": "Activity definition extension used with activities of type 'http://www.risc-inc.com/annotator/activities/highlight' and 'http://www.risc-inc.com/annotator/activities/underline'. Stores the string of text that has been highlighted or underlined. \n\nFor example the end of one bullet point and the beginning of the next bullet might be represented as: \"Reliability Authority\\r\\n\u2022\\r\\nPersonnel Issues\".\n\n\n\n" + } + }, + "uriId": 312, + "editNotes": "", + "createdAt": "2014-12-15T15:37:45.000Z", + "updatedAt": "2014-12-15T15:37:45.000Z" + } + }, + { + "id": 315, + "kind": "extension", + "uri": "http://www.risc-inc.com/annotator/extensions/page", + "createdAt": "2014-12-15T15:38:00.000Z", + "updatedAt": "2014-12-15T15:38:00.000Z", + "metadata": { + "id": 315, + "metadata": { + "name": { + "en-US": "Page index" + }, + "description": { + "en-US": "Activity Definition extension used for activities representing entities on a page, for example highlights or notes on an annotated PDF. The value of this extension is an integer that represents the zero-based page index of the document that the activity is on. This document should be listed as the contextActivities parent. \n\nNote that the page index is not normally equal to the page number printed on the document. " + } + }, + "uriId": 315, + "editNotes": "", + "createdAt": "2014-12-15T15:38:00.000Z", + "updatedAt": "2014-12-15T15:38:00.000Z" + } + }, + { + "id": 314, + "kind": "extension", + "uri": "http://www.risc-inc.com/annotator/extensions/rects", + "createdAt": "2014-12-15T15:37:55.000Z", + "updatedAt": "2014-12-15T15:37:55.000Z", + "metadata": { + "id": 314, + "metadata": { + "name": { + "en-US": "PDF Rectangle map" + }, + "description": { + "en-US": "A collection of rectangles marking an area within a PDF document. This is used to denote the location of an element on a page such as a highlight or annotation on a PDF document. Multiple rectangles may represent a single element. \n\nThe value of the extension is an array of rectangle objects. Each rectangle object has x, y, width and height properties. The value of each of these properties is a number measured in PDF Units. The X and Y coordinates are taken from the bottom left of the page. \n\nNote that in some implementations, this the value of this extension has been a string containing a JSON encoded array of rectangle objects. This is not recommended, but tools reading statements using this extension may wish to additionally accept this JSON encoded format. " + } + }, + "uriId": 314, + "editNotes": "", + "createdAt": "2014-12-15T15:37:55.000Z", + "updatedAt": "2014-12-15T15:37:55.000Z" + } + }, + { + "id": 405, + "kind": "extension", + "uri": "http://www.tincanapi.co.uk/extensions/result/classification", + "createdAt": "2015-09-28T15:07:20.000Z", + "updatedAt": "2015-09-28T15:07:20.000Z", + "metadata": { + "id": 405, + "metadata": { + "name": { + "en-US": "Classification" + }, + "description": { + "en-US": "A result extension used to store the grade awarded as a result of the experience. The value of this extension is an activity object representing the grade earned. This activity object should have an activity type of http://www.tincanapi.co.uk/activitytypes/grade_classification. The name of the activity should contain the value of the grade (e.g. \u201cA\u201d).\n\nAn activity object is used rather than a single letter string as grade letters in different contexts, for example a grade A for a nationally recognized qualification has very different meaning to an A awarded by a teacher to a small child for a drawing of a cat. The activity used should represent the grade within the context it is awarded." + } + }, + "uriId": 405, + "editNotes": "", + "createdAt": "2015-09-28T15:07:20.000Z", + "updatedAt": "2015-09-28T15:07:20.000Z" + } + }, + { + "id": 505, + "kind": "extension", + "uri": "https://xapi.gowithfloat.net/extension/host", + "createdAt": "2017-11-06T21:23:19.000Z", + "updatedAt": "2017-11-06T21:23:19.000Z", + "metadata": { + "id": 505, + "metadata": { + "name": { + "en-US": "Activity Host" + }, + "description": { + "en-US": "An activity extension which allows the host of an activity to store information about itself. This allows other systems to know who is responsible for maintaining an activity. The value of the extension should be a JSON-encoded object containing a `homePage` for the host and a host-specific `id` for the activity." + } + }, + "uriId": 505, + "editNotes": "", + "createdAt": "2017-11-06T21:23:19.000Z", + "updatedAt": "2017-11-06T21:23:19.000Z" + } + } +] \ No newline at end of file diff --git a/modules/lo_event/xapi/profile.json b/modules/lo_event/xapi/profile.json new file mode 100644 index 000000000..65b302e25 --- /dev/null +++ b/modules/lo_event/xapi/profile.json @@ -0,0 +1,110 @@ +[ + { + "id": 27, + "label": "assessment", + "name": "Assessment", + "description": "Profile for capturing items related to assessments of employees, students, players, etc.", + "isPublic": true, + "createdAt": "2014-06-10T19:13:02.000Z", + "updatedAt": "2015-11-03T14:02:23.000Z" + }, + { + "id": 48, + "label": "attendance", + "name": "Attendance", + "description": "Profile for tracking attendance at events.", + "isPublic": true, + "createdAt": "2015-04-28T17:46:38.000Z", + "updatedAt": "2015-05-07T17:35:29.000Z" + }, + { + "id": 23, + "label": "bookmarklet", + "name": "Bookmarklet", + "description": "Profile tracking items used by a bookmarklet application.", + "isPublic": true, + "createdAt": "2014-05-01T14:07:37.000Z", + "updatedAt": "2014-07-21T19:16:36.000Z" + }, + { + "id": 20, + "label": "checklist", + "name": "Checklist", + "description": "Profile for capturing items related to checklists, such as performance observation, self assessed, etc.", + "isPublic": true, + "createdAt": "2014-03-12T17:33:47.000Z", + "updatedAt": "2014-06-13T12:31:16.000Z" + }, + { + "id": 61, + "label": "ECE", + "name": "ECE", + "description": "The mission of this Early Childhood Education (ECE) xAPI Profile is to define a shared vocabulary that allow the recording of the most common events that take place on a daily basis in an ECE school. This profile adds specific information for the collection of relevant data by tools that allow the tracking and monitoring of the evolution of children from ages 0 to 6. In this context, educators and parents or legal guardian have clearly distinct roles. The educator acts fundamentally as the recorder of events that take place in the classroom while the parents or legal guardian act as the consumers of that information. \n\nIn a more specific manner, the main goal of the ECE-xAPI Profile is to make the following representation easier:\n\n - Care interactions (e.g. change a diaper)\n\n - Educational interactions (e.g. participating in an activity)\n\n - Assessment of the progress and achievements reached by the child\n\n - Multimedia documentation of observations\n\n - Exchange of messages between educators and families\n\n", + "isPublic": true, + "createdAt": "2016-04-26T12:20:51.000Z", + "updatedAt": "2016-05-20T12:20:07.000Z" + }, + { + "id": 44, + "label": "openbadges", + "name": "Open Badges", + "description": "Tin Can statements realting to Open Badges", + "isPublic": true, + "createdAt": "2015-03-20T12:37:13.000Z", + "updatedAt": "2015-03-31T09:20:51.000Z" + }, + { + "id": 40, + "label": "scorm-to-tin-can", + "name": "SCORM to Tin Can", + "description": "Profile for managing Activities and Recipes related to recording legacy SCORM and AICC courses in a Tin Can data format.", + "isPublic": true, + "createdAt": "2015-01-09T15:45:26.000Z", + "updatedAt": "2015-01-21T22:24:08.000Z" + }, + { + "id": 60, + "label": "srl", + "name": "xAPI-SRL", + "description": "Profile for capturing key data about the implementation of Self-Regulated Learning (SRL) strategies by learners. This data can be used to facilitate self-monitoring and self-evaluation for learners, monitoring the learners\u2019 performance for teachers and to enable measuring learners\u2019 adoption of SRL strategies.\n\n\n", + "isPublic": true, + "createdAt": "2016-03-03T15:17:33.000Z", + "updatedAt": "2016-03-08T13:40:34.000Z" + }, + { + "id": 22, + "label": "tags", + "name": "Tags", + "description": "An open profile where an activity can always be created because they are restricted to a single word with a single word name.\n\nA \"tag\" as a single word means that no matter what tag someone creates anyone can use that tag as well. Normally an Activity is only shared when it will have the same meaning to everyone, as a single word (English or not) a tag naturally has the same meaning.\n\nTo create an Activity under this profile simply use the format: `http://id.tincanapi.com/activity/tags/`. Then use the same word in the Activity Definition 'name' property with the 'und' (undefined) language map code for the language. The corresponding Activity looks like this:\n\n```json\n{\n \"id\": \"http://id.tincanapi.com/activity/tags/\",\n \"definition\": {\n \"name\": {\n \"und\": \"\"\n }\n },\n \"objectType\": \"Activity\"\n}\n```", + "isPublic": true, + "createdAt": "2014-05-01T14:06:28.000Z", + "updatedAt": "2014-07-21T15:47:34.000Z" + }, + { + "id": 17, + "label": "tincan-prototypes", + "name": "Tin Can Prototypes", + "description": "Profile to capture the various activities, verbs, etc. that are used in the Tin Can Prototypes. This profile serves the following purposes:\n\n* Serves as a sample profile for other profiles to follow.\n* Acts as a quick reference for the statement structure used by the prototypes without having to dissect the code.\n* Gives examples to follow for implementers with products in the areas covered by the prototypes until community led profiles for these use cases emerge. \n* Provides details for reporting tools looking to create reports for the prototypes and other similar implementations.", + "isPublic": true, + "createdAt": "2013-10-14T18:29:45.000Z", + "updatedAt": "2015-04-20T16:25:08.000Z" + }, + { + "id": 19, + "label": "video", + "name": "Video", + "description": "Profile capturing items related to video experiences.", + "isPublic": true, + "createdAt": "2014-03-12T17:33:45.000Z", + "updatedAt": "2014-06-13T12:31:40.000Z" + }, + { + "id": 50, + "label": "virtualpatient", + "name": "Medbiquitous Virtual Patient", + "description": "Working with Medbiq xAPI Interest Group and Medbiq Virtual Patient standard to create a Profile and Recipe to describe virtual patient activities", + "isPublic": true, + "createdAt": "2015-07-03T15:22:22.000Z", + "updatedAt": "2015-07-04T19:53:53.000Z" + } +] \ No newline at end of file diff --git a/modules/lo_event/xapi/verb.json b/modules/lo_event/xapi/verb.json new file mode 100644 index 000000000..aefd49cdf --- /dev/null +++ b/modules/lo_event/xapi/verb.json @@ -0,0 +1,4006 @@ +[ + { + "id": 80, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/accept", + "createdAt": "2013-08-07T22:26:32.000Z", + "updatedAt": "2013-08-07T22:26:32.000Z", + "metadata": { + "id": 80, + "metadata": { + "name": { + "en-US": "accepted" + }, + "description": { + "en-US": "Indicates that that the actor has accepted the object. For instance, a person accepting an award, or accepting an assignment." + } + }, + "uriId": 80, + "editNotes": "", + "createdAt": "2013-08-07T22:26:32.000Z", + "updatedAt": "2013-08-07T22:26:32.000Z" + } + }, + { + "id": 81, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/access", + "createdAt": "2013-08-07T22:26:43.000Z", + "updatedAt": "2013-08-07T22:26:43.000Z", + "metadata": { + "id": 81, + "metadata": { + "name": { + "en-US": "accessed" + }, + "description": { + "en-US": "Indicates that the actor has accessed the object. For instance, a person accessing a room, or accessing a file." + } + }, + "uriId": 81, + "editNotes": "", + "createdAt": "2013-08-07T22:26:43.000Z", + "updatedAt": "2013-08-07T22:26:43.000Z" + } + }, + { + "id": 82, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/acknowledge", + "createdAt": "2013-08-07T22:26:54.000Z", + "updatedAt": "2013-08-07T22:26:54.000Z", + "metadata": { + "id": 82, + "metadata": { + "name": { + "en-US": "acknowledged" + }, + "description": { + "en-US": "Indicates that the actor has acknowledged the object. This effectively signals that the actor is aware of the object's existence." + } + }, + "uriId": 82, + "editNotes": "", + "createdAt": "2013-08-07T22:26:54.000Z", + "updatedAt": "2013-08-07T22:26:54.000Z" + } + }, + { + "id": 83, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/add", + "createdAt": "2013-08-07T22:27:20.000Z", + "updatedAt": "2013-08-07T22:27:20.000Z", + "metadata": { + "id": 83, + "metadata": { + "name": { + "en-US": "added" + }, + "description": { + "en-US": "Indicates that the actor has added the object to the target. For instance, adding a photo to an album." + } + }, + "uriId": 83, + "editNotes": "", + "createdAt": "2013-08-07T22:27:20.000Z", + "updatedAt": "2013-08-07T22:27:20.000Z" + } + }, + { + "id": 84, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/agree", + "createdAt": "2013-08-07T22:27:34.000Z", + "updatedAt": "2013-08-07T22:27:34.000Z", + "metadata": { + "id": 84, + "metadata": { + "name": { + "en-US": "agreed" + }, + "description": { + "en-US": "Indicates that the actor agrees with the object. For example, a person agreeing with an argument, or expressing agreement with a particular issue." + } + }, + "uriId": 84, + "editNotes": "", + "createdAt": "2013-08-07T22:27:34.000Z", + "updatedAt": "2013-08-07T22:27:34.000Z" + } + }, + { + "id": 85, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/append", + "createdAt": "2013-08-07T22:27:45.000Z", + "updatedAt": "2013-08-07T22:27:45.000Z", + "metadata": { + "id": 85, + "metadata": { + "name": { + "en-US": "appended" + }, + "description": { + "en-US": "Indicates that the actor has appended the object to the target. For instance, a person appending a new record to a database." + } + }, + "uriId": 85, + "editNotes": "", + "createdAt": "2013-08-07T22:27:45.000Z", + "updatedAt": "2013-08-07T22:27:45.000Z" + } + }, + { + "id": 86, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/approve", + "createdAt": "2013-08-07T22:27:54.000Z", + "updatedAt": "2013-08-07T22:27:54.000Z", + "metadata": { + "id": 86, + "metadata": { + "name": { + "en-US": "approved" + }, + "description": { + "en-US": "Indicates that the actor has approved the object. For instance, a manager might approve a travel request." + } + }, + "uriId": 86, + "editNotes": "", + "createdAt": "2013-08-07T22:27:54.000Z", + "updatedAt": "2013-08-07T22:27:54.000Z" + } + }, + { + "id": 87, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/archive", + "createdAt": "2013-08-07T22:28:03.000Z", + "updatedAt": "2013-08-07T22:28:03.000Z", + "metadata": { + "id": 87, + "metadata": { + "name": { + "en-US": "archived" + }, + "description": { + "en-US": "Indicates that the actor has archived the object." + } + }, + "uriId": 87, + "editNotes": "", + "createdAt": "2013-08-07T22:28:03.000Z", + "updatedAt": "2013-08-07T22:28:03.000Z" + } + }, + { + "id": 88, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/assign", + "createdAt": "2013-08-07T22:28:39.000Z", + "updatedAt": "2013-08-07T22:28:39.000Z", + "metadata": { + "id": 88, + "metadata": { + "name": { + "en-US": "assigned" + }, + "description": { + "en-US": "Indicates that the actor has assigned the object to the target." + } + }, + "uriId": 88, + "editNotes": "", + "createdAt": "2013-08-07T22:28:39.000Z", + "updatedAt": "2013-08-07T22:28:39.000Z" + } + }, + { + "id": 89, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/at", + "createdAt": "2013-08-07T22:28:50.000Z", + "updatedAt": "2013-08-07T22:28:50.000Z", + "metadata": { + "id": 89, + "metadata": { + "name": { + "en-US": "was at" + }, + "description": { + "en-US": "Indicates that the actor was located at the object. For instance, a person being at a specific physical location." + } + }, + "uriId": 89, + "editNotes": "", + "createdAt": "2013-08-07T22:28:50.000Z", + "updatedAt": "2013-08-07T22:28:50.000Z" + } + }, + { + "id": 90, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/attach", + "createdAt": "2013-08-07T22:29:14.000Z", + "updatedAt": "2013-08-07T22:29:14.000Z", + "metadata": { + "id": 90, + "metadata": { + "name": { + "en-US": "attached" + }, + "description": { + "en-US": "Indicates that the actor has attached the object to the target. For instance, a person attaching a file to a wiki page or an email." + } + }, + "uriId": 90, + "editNotes": "", + "createdAt": "2013-08-07T22:29:14.000Z", + "updatedAt": "2013-08-07T22:29:14.000Z" + } + }, + { + "id": 91, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/attend", + "createdAt": "2013-08-07T22:29:25.000Z", + "updatedAt": "2013-08-07T22:29:25.000Z", + "metadata": { + "id": 91, + "metadata": { + "name": { + "en-US": "attended" + }, + "description": { + "en-US": "Indicates that the actor has attended the object. For instance, a person attending a meeting." + } + }, + "uriId": 91, + "editNotes": "", + "createdAt": "2013-08-07T22:29:25.000Z", + "updatedAt": "2013-08-07T22:29:25.000Z" + } + }, + { + "id": 92, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/author", + "createdAt": "2013-08-07T22:29:33.000Z", + "updatedAt": "2013-08-07T22:29:33.000Z", + "metadata": { + "id": 92, + "metadata": { + "name": { + "en-US": "authored" + }, + "description": { + "en-US": "Indicates that the actor has authored the object. Note that this is a more specific form of the verb \"create\"." + } + }, + "uriId": 92, + "editNotes": "", + "createdAt": "2013-08-07T22:29:33.000Z", + "updatedAt": "2013-08-07T22:29:33.000Z" + } + }, + { + "id": 93, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/authorize", + "createdAt": "2013-08-07T22:30:05.000Z", + "updatedAt": "2013-08-07T22:30:05.000Z", + "metadata": { + "id": 93, + "metadata": { + "name": { + "en-US": "authorized" + }, + "description": { + "en-US": "Indicates that the actor has authorized the object. If a target is specified, it means that the authorization is specifically in regards to the target. For instance, a service can authorize a person to access a given application; in which case the actor is the service, the object is the person, and the target is the application. In contrast, a person can authorize a request; in which case the actor is the person and the object is the request and there might be no explicit target." + } + }, + "uriId": 93, + "editNotes": "", + "createdAt": "2013-08-07T22:30:05.000Z", + "updatedAt": "2013-08-07T22:30:05.000Z" + } + }, + { + "id": 94, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/borrow", + "createdAt": "2013-08-07T22:30:27.000Z", + "updatedAt": "2013-08-07T22:30:27.000Z", + "metadata": { + "id": 94, + "metadata": { + "name": { + "en-US": "borrowed" + }, + "description": { + "en-US": "Indicates that the actor has borrowed the object. If a target is specified, it identifies the entity from which the object was borrowed. For instance, if a person borrows a book from a library, the person is the actor, the book is the object and the library is the target." + } + }, + "uriId": 94, + "editNotes": "", + "createdAt": "2013-08-07T22:30:27.000Z", + "updatedAt": "2013-08-07T22:30:27.000Z" + } + }, + { + "id": 95, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/build", + "createdAt": "2013-08-07T22:30:42.000Z", + "updatedAt": "2013-08-07T22:30:42.000Z", + "metadata": { + "id": 95, + "metadata": { + "name": { + "en-US": "built" + }, + "description": { + "en-US": "Indicates that the actor has built the object. For example, if a person builds a model or compiles code." + } + }, + "uriId": 95, + "editNotes": "", + "createdAt": "2013-08-07T22:30:42.000Z", + "updatedAt": "2013-08-07T22:30:42.000Z" + } + }, + { + "id": 96, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/cancel", + "createdAt": "2013-08-07T22:31:43.000Z", + "updatedAt": "2013-08-07T22:31:43.000Z", + "metadata": { + "id": 96, + "metadata": { + "name": { + "en-US": "canceled" + }, + "description": { + "en-US": "Indicates that the actor has canceled the object. For instance, canceling a calendar event." + } + }, + "uriId": 96, + "editNotes": "", + "createdAt": "2013-08-07T22:31:43.000Z", + "updatedAt": "2013-08-07T22:31:43.000Z" + } + }, + { + "id": 101, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/checkin", + "createdAt": "2013-08-07T22:33:18.000Z", + "updatedAt": "2013-08-07T22:33:18.000Z", + "metadata": { + "id": 101, + "metadata": { + "name": { + "en-US": "checked in" + }, + "description": { + "en-US": "Indicates that the actor has checked-in to the object. For instance, a person checking-in to a place." + } + }, + "uriId": 101, + "editNotes": "", + "createdAt": "2013-08-07T22:33:18.000Z", + "updatedAt": "2013-08-07T22:33:18.000Z" + } + }, + { + "id": 97, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/close", + "createdAt": "2013-08-07T22:32:01.000Z", + "updatedAt": "2013-08-07T22:32:01.000Z", + "metadata": { + "id": 97, + "metadata": { + "name": { + "en-US": "closed" + }, + "description": { + "en-US": "Indicates that the actor has closed the object. For instance, the object could represent a ticket being tracked in an issue management system." + } + }, + "uriId": 97, + "editNotes": "", + "createdAt": "2013-08-07T22:32:01.000Z", + "updatedAt": "2013-08-07T22:32:01.000Z" + } + }, + { + "id": 98, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/complete", + "createdAt": "2013-08-07T22:32:24.000Z", + "updatedAt": "2013-08-07T22:32:24.000Z", + "metadata": { + "id": 98, + "metadata": { + "name": { + "en-US": "completed" + }, + "description": { + "en-US": "Indicates that the actor has completed the object." + } + }, + "uriId": 98, + "editNotes": "", + "createdAt": "2013-08-07T22:32:24.000Z", + "updatedAt": "2013-08-07T22:32:24.000Z" + } + }, + { + "id": 99, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/confirm", + "createdAt": "2013-08-07T22:32:31.000Z", + "updatedAt": "2013-08-07T22:32:31.000Z", + "metadata": { + "id": 99, + "metadata": { + "name": { + "en-US": "confirmed" + }, + "description": { + "en-US": "Indicates that the actor has confirmed or agrees with the object. For instance, a software developer might confirm an issue reported against a product." + } + }, + "uriId": 99, + "editNotes": "", + "createdAt": "2013-08-07T22:32:31.000Z", + "updatedAt": "2013-08-07T22:32:31.000Z" + } + }, + { + "id": 100, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/consume", + "createdAt": "2013-08-07T22:32:45.000Z", + "updatedAt": "2013-08-07T22:32:45.000Z", + "metadata": { + "id": 100, + "metadata": { + "name": { + "en-US": "consumed" + }, + "description": { + "en-US": "Indicates that the actor has consumed the object. The specific meaning is dependent largely on the object's type. For instance, an actor may \"consume\" an audio object, indicating that the actor has listened to it; or an actor may \"consume\" a book, indicating that the book has been read. As such, the \"consume\" verb is a more generic form of other more specific verbs such as \"read\" and \"play\"." + } + }, + "uriId": 100, + "editNotes": "", + "createdAt": "2013-08-07T22:32:46.000Z", + "updatedAt": "2013-08-07T22:32:46.000Z" + } + }, + { + "id": 102, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/create", + "createdAt": "2013-08-07T22:33:30.000Z", + "updatedAt": "2013-08-07T22:33:30.000Z", + "metadata": { + "id": 102, + "metadata": { + "name": { + "en-US": "created" + }, + "description": { + "en-US": "Indicates that the actor has created the object." + } + }, + "uriId": 102, + "editNotes": "", + "createdAt": "2013-08-07T22:33:30.000Z", + "updatedAt": "2013-08-07T22:33:30.000Z" + } + }, + { + "id": 103, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/delete", + "createdAt": "2013-08-07T22:33:40.000Z", + "updatedAt": "2013-08-07T22:33:40.000Z", + "metadata": { + "id": 103, + "metadata": { + "name": { + "en-US": "deleted" + }, + "description": { + "en-US": "Indicates that the actor has deleted the object. This implies, but does not require, the permanent destruction of the object." + } + }, + "uriId": 103, + "editNotes": "", + "createdAt": "2013-08-07T22:33:40.000Z", + "updatedAt": "2013-08-07T22:33:40.000Z" + } + }, + { + "id": 104, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/deliver", + "createdAt": "2013-08-07T22:34:08.000Z", + "updatedAt": "2013-08-07T22:34:08.000Z", + "metadata": { + "id": 104, + "metadata": { + "name": { + "en-US": "delivered" + }, + "description": { + "en-US": "Indicates that the actor has delivered the object. For example, delivering a package." + } + }, + "uriId": 104, + "editNotes": "", + "createdAt": "2013-08-07T22:34:08.000Z", + "updatedAt": "2013-08-07T22:34:08.000Z" + } + }, + { + "id": 105, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/deny", + "createdAt": "2013-08-07T22:34:27.000Z", + "updatedAt": "2013-08-07T22:34:27.000Z", + "metadata": { + "id": 105, + "metadata": { + "name": { + "en-US": "denied" + }, + "description": { + "en-US": "Indicates that the actor has denied the object. For example, a manager may deny a travel request." + } + }, + "uriId": 105, + "editNotes": "", + "createdAt": "2013-08-07T22:34:27.000Z", + "updatedAt": "2013-08-07T22:34:27.000Z" + } + }, + { + "id": 106, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/disagree", + "createdAt": "2013-08-07T22:34:52.000Z", + "updatedAt": "2013-08-07T22:34:52.000Z", + "metadata": { + "id": 106, + "metadata": { + "name": { + "en-US": "disagreed" + }, + "description": { + "en-US": "Indicates that the actor disagrees with the object." + } + }, + "uriId": 106, + "editNotes": "", + "createdAt": "2013-08-07T22:34:52.000Z", + "updatedAt": "2013-08-07T22:34:52.000Z" + } + }, + { + "id": 107, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/dislike", + "createdAt": "2013-08-07T22:35:18.000Z", + "updatedAt": "2013-08-07T22:35:18.000Z", + "metadata": { + "id": 107, + "metadata": { + "name": { + "en-US": "disliked" + }, + "description": { + "en-US": "Indicates that the actor dislikes the object. Note that the \"dislike\" verb is distinct from the \"unlike\" verb which assumes that the object had been previously \"liked\"." + } + }, + "uriId": 107, + "editNotes": "", + "createdAt": "2013-08-07T22:35:18.000Z", + "updatedAt": "2013-08-07T22:35:18.000Z" + } + }, + { + "id": 108, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/experience", + "createdAt": "2013-08-07T22:35:28.000Z", + "updatedAt": "2013-08-07T22:35:28.000Z", + "metadata": { + "id": 108, + "metadata": { + "name": { + "en-US": "experienced" + }, + "description": { + "en-US": "Indicates that the actor has experienced the object in some manner. Note that, depending on the specific object types used for both the actor and object, the meaning of this verb can overlap that of the \"consume\" and \"play\" verbs. For instance, a person might \"experience\" a movie; or \"play\" the movie; or \"consume\" the movie. The \"experience\" verb can be considered a more generic form of other more specific verbs as \"consume\", \"play\", \"watch\", \"listen\", and \"read\"" + } + }, + "uriId": 108, + "editNotes": "", + "createdAt": "2013-08-07T22:35:28.000Z", + "updatedAt": "2013-08-07T22:35:28.000Z" + } + }, + { + "id": 109, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/favorite", + "createdAt": "2013-08-07T22:35:36.000Z", + "updatedAt": "2013-08-07T22:35:36.000Z", + "metadata": { + "id": 109, + "metadata": { + "name": { + "en-US": "favorited" + }, + "description": { + "en-US": "Indicates that the actor marked the object as an item of special interest." + } + }, + "uriId": 109, + "editNotes": "", + "createdAt": "2013-08-07T22:35:36.000Z", + "updatedAt": "2013-08-07T22:35:36.000Z" + } + }, + { + "id": 110, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/find", + "createdAt": "2013-08-07T22:35:44.000Z", + "updatedAt": "2013-08-07T22:35:44.000Z", + "metadata": { + "id": 110, + "metadata": { + "name": { + "en-US": "found" + }, + "description": { + "en-US": "Indicates that the actor has found the object." + } + }, + "uriId": 110, + "editNotes": "", + "createdAt": "2013-08-07T22:35:44.000Z", + "updatedAt": "2013-08-07T22:35:44.000Z" + } + }, + { + "id": 111, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/flag-as-inappropriate", + "createdAt": "2013-08-07T22:36:02.000Z", + "updatedAt": "2013-08-07T22:36:02.000Z", + "metadata": { + "id": 111, + "metadata": { + "name": { + "en-US": "flagged as inappropriate" + }, + "description": { + "en-US": "Indicates that the actor has flagged the object as being inappropriate for some reason. When using this verb, the context property, as specified within Section 4.1 can be used to provide additional detail about why the object has been flagged." + } + }, + "uriId": 111, + "editNotes": "", + "createdAt": "2013-08-07T22:36:02.000Z", + "updatedAt": "2013-08-07T22:36:02.000Z" + } + }, + { + "id": 112, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/follow", + "createdAt": "2013-08-07T22:36:15.000Z", + "updatedAt": "2013-08-07T22:36:15.000Z", + "metadata": { + "id": 112, + "metadata": { + "name": { + "en-US": "followed" + }, + "description": { + "en-US": "Indicates that the actor began following the activity of the object. In most cases, the objectType will be a \"person\", but it can potentially be of any type that can sensibly generate activity. Processors MAY ignore (silently drop) successive identical \"follow\" activities." + } + }, + "uriId": 112, + "editNotes": "", + "createdAt": "2013-08-07T22:36:15.000Z", + "updatedAt": "2013-08-07T22:36:15.000Z" + } + }, + { + "id": 113, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/give", + "createdAt": "2013-08-07T22:36:36.000Z", + "updatedAt": "2013-08-07T22:36:36.000Z", + "metadata": { + "id": 113, + "metadata": { + "name": { + "en-US": "gave" + }, + "description": { + "en-US": "Indicates that the actor is giving an object to the target. Examples include one person giving a badge object to another person. The object identifies the object being given. The target identifies the receiver." + } + }, + "uriId": 113, + "editNotes": "", + "createdAt": "2013-08-07T22:36:36.000Z", + "updatedAt": "2013-08-07T22:36:36.000Z" + } + }, + { + "id": 114, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/host", + "createdAt": "2013-08-07T22:36:49.000Z", + "updatedAt": "2013-08-07T22:36:49.000Z", + "metadata": { + "id": 114, + "metadata": { + "name": { + "en-US": "hosted" + }, + "description": { + "en-US": "Indicates that the actor is hosting the object. As in hosting an event, or hosting a service." + } + }, + "uriId": 114, + "editNotes": "", + "createdAt": "2013-08-07T22:36:49.000Z", + "updatedAt": "2013-08-07T22:36:49.000Z" + } + }, + { + "id": 115, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/ignore", + "createdAt": "2013-08-07T22:36:56.000Z", + "updatedAt": "2013-08-07T22:36:56.000Z", + "metadata": { + "id": 115, + "metadata": { + "name": { + "en-US": "ignored" + }, + "description": { + "en-US": "Indicates that the actor has ignored the object. For instance, this verb may be used when an actor has ignored a friend request, in which case the object may be the request-friend activity." + } + }, + "uriId": 115, + "editNotes": "", + "createdAt": "2013-08-07T22:36:56.000Z", + "updatedAt": "2013-08-07T22:36:56.000Z" + } + }, + { + "id": 116, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/insert", + "createdAt": "2013-08-07T22:37:16.000Z", + "updatedAt": "2013-08-07T22:37:16.000Z", + "metadata": { + "id": 116, + "metadata": { + "name": { + "en-US": "inserted" + }, + "description": { + "en-US": "Indicates that the actor has inserted the object into the target." + } + }, + "uriId": 116, + "editNotes": "", + "createdAt": "2013-08-07T22:37:16.000Z", + "updatedAt": "2013-08-07T22:37:16.000Z" + } + }, + { + "id": 117, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/install", + "createdAt": "2013-08-07T22:37:34.000Z", + "updatedAt": "2013-08-07T22:37:34.000Z", + "metadata": { + "id": 117, + "metadata": { + "name": { + "en-US": "installed" + }, + "description": { + "en-US": "Indicates that the actor has installed the object, as in installing an application." + } + }, + "uriId": 117, + "editNotes": "", + "createdAt": "2013-08-07T22:37:34.000Z", + "updatedAt": "2013-08-07T22:37:34.000Z" + } + }, + { + "id": 118, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/interact", + "createdAt": "2013-08-07T22:37:44.000Z", + "updatedAt": "2013-08-07T22:37:44.000Z", + "metadata": { + "id": 118, + "metadata": { + "name": { + "en-US": "interacted" + }, + "description": { + "en-US": "Indicates that the actor has interacted with the object. For instance, when one person interacts with another." + } + }, + "uriId": 118, + "editNotes": "", + "createdAt": "2013-08-07T22:37:44.000Z", + "updatedAt": "2013-08-07T22:37:44.000Z" + } + }, + { + "id": 119, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/invite", + "createdAt": "2013-08-07T22:37:51.000Z", + "updatedAt": "2013-08-07T22:37:51.000Z", + "metadata": { + "id": 119, + "metadata": { + "name": { + "en-US": "invited" + }, + "description": { + "en-US": "Indicates that the actor has invited the object, typically a person object, to join or participate in the object described by the target. The target could, for instance, be an event, group or a service." + } + }, + "uriId": 119, + "editNotes": "", + "createdAt": "2013-08-07T22:37:51.000Z", + "updatedAt": "2013-08-07T22:37:51.000Z" + } + }, + { + "id": 120, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/join", + "createdAt": "2013-08-07T22:38:04.000Z", + "updatedAt": "2013-08-07T22:38:04.000Z", + "metadata": { + "id": 120, + "metadata": { + "name": { + "en-US": "joined" + }, + "description": { + "en-US": "Indicates that the actor has become a member of the object. This specification only defines the meaning of this verb when the object of the Activity has an objectType of group, though implementors need to be prepared to handle other types of objects." + } + }, + "uriId": 120, + "editNotes": "", + "createdAt": "2013-08-07T22:38:04.000Z", + "updatedAt": "2013-08-07T22:38:04.000Z" + } + }, + { + "id": 121, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/leave", + "createdAt": "2013-08-07T22:38:20.000Z", + "updatedAt": "2013-08-07T22:38:20.000Z", + "metadata": { + "id": 121, + "metadata": { + "name": { + "en-US": "left" + }, + "description": { + "en-US": "Indicates that the actor has left the object. For instance, a Person leaving a Group or checking-out of a Place." + } + }, + "uriId": 121, + "editNotes": "", + "createdAt": "2013-08-07T22:38:20.000Z", + "updatedAt": "2013-08-07T22:38:20.000Z" + } + }, + { + "id": 122, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/like", + "createdAt": "2013-08-07T22:39:14.000Z", + "updatedAt": "2013-08-07T22:39:14.000Z", + "metadata": { + "id": 122, + "metadata": { + "name": { + "en-US": "liked" + }, + "description": { + "en-US": "Indicates that the actor marked the object as an item of special interest. The \"like\" verb is considered to be an alias of \"favorite\". The two verb are semantically identical." + } + }, + "uriId": 122, + "editNotes": "", + "createdAt": "2013-08-07T22:39:14.000Z", + "updatedAt": "2013-08-07T22:39:14.000Z" + } + }, + { + "id": 123, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/listen", + "createdAt": "2013-08-07T22:39:24.000Z", + "updatedAt": "2013-08-07T22:39:24.000Z", + "metadata": { + "id": 123, + "metadata": { + "name": { + "en-US": "listened" + }, + "description": { + "en-US": "Indicates that the actor has listened to the object. This is typically only applicable for objects representing audio content, such as music, an audio-book, or a radio broadcast. The \"listen\" verb is a more specific form of the \"consume\", \"experience\" and \"play\" verbs." + } + }, + "uriId": 123, + "editNotes": "", + "createdAt": "2013-08-07T22:39:24.000Z", + "updatedAt": "2013-08-07T22:39:24.000Z" + } + }, + { + "id": 124, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/lose", + "createdAt": "2013-08-07T22:39:45.000Z", + "updatedAt": "2013-08-07T22:39:45.000Z", + "metadata": { + "id": 124, + "metadata": { + "name": { + "en-US": "lost" + }, + "description": { + "en-US": "Indicates that the actor has lost the object. For instance, if a person loses a game." + } + }, + "uriId": 124, + "editNotes": "", + "createdAt": "2013-08-07T22:39:45.000Z", + "updatedAt": "2013-08-07T22:39:45.000Z" + } + }, + { + "id": 125, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/make-friend", + "createdAt": "2013-08-07T22:39:56.000Z", + "updatedAt": "2013-08-07T22:39:56.000Z", + "metadata": { + "id": 125, + "metadata": { + "name": { + "en-US": "made friend" + }, + "description": { + "en-US": "Indicates the creation of a friendship that is reciprocated by the object. Since this verb implies an activity on the part of its object, processors MUST NOT accept activities with this verb unless they are able to verify through some external means that there is in fact a reciprocated connection. For example, a processor may have received a guarantee from a particular publisher that the publisher will only use this Verb in cases where a reciprocal relationship exists." + } + }, + "uriId": 125, + "editNotes": "", + "createdAt": "2013-08-07T22:39:56.000Z", + "updatedAt": "2013-08-07T22:39:56.000Z" + } + }, + { + "id": 126, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/open", + "createdAt": "2013-08-07T22:40:45.000Z", + "updatedAt": "2013-08-07T22:40:45.000Z", + "metadata": { + "id": 126, + "metadata": { + "name": { + "en-US": "opened" + }, + "description": { + "en-US": "Indicates that the actor has opened the object. For instance, the object could represent a ticket being tracked in an issue management system." + } + }, + "uriId": 126, + "editNotes": "", + "createdAt": "2013-08-07T22:40:45.000Z", + "updatedAt": "2013-08-07T22:40:45.000Z" + } + }, + { + "id": 49, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/play", + "createdAt": "2013-08-05T13:46:06.000Z", + "updatedAt": "2013-08-05T13:46:06.000Z", + "metadata": { + "id": 49, + "metadata": { + "name": { + "en-US": "Played" + }, + "description": { + "en-US": "Indicates that the actor spent some time enjoying the object. For example, if the object is a video this indicates that the subject watched all or part of the video. The \"play\" verb is a more specific form of the \"consume\" verb." + } + }, + "uriId": 49, + "editNotes": "", + "createdAt": "2013-08-05T13:46:06.000Z", + "updatedAt": "2013-08-05T13:46:06.000Z" + } + }, + { + "id": 127, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/present", + "createdAt": "2013-08-07T22:41:03.000Z", + "updatedAt": "2013-08-07T22:41:03.000Z", + "metadata": { + "id": 127, + "metadata": { + "name": { + "en-US": "presented" + }, + "description": { + "en-US": "Indicates that the actor has presented the object. For instance, when a person gives a presentation at a conference." + } + }, + "uriId": 127, + "editNotes": "", + "createdAt": "2013-08-07T22:41:03.000Z", + "updatedAt": "2013-08-07T22:41:03.000Z" + } + }, + { + "id": 128, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/purchase", + "createdAt": "2013-08-07T22:41:47.000Z", + "updatedAt": "2013-08-07T22:41:47.000Z", + "metadata": { + "id": 128, + "metadata": { + "name": { + "en-US": "purchased" + }, + "description": { + "en-US": "Indicates that the actor has purchased the object. If a target is specified, in indicates the entity from which the object was purchased." + } + }, + "uriId": 128, + "editNotes": "", + "createdAt": "2013-08-07T22:41:47.000Z", + "updatedAt": "2013-08-07T22:41:47.000Z" + } + }, + { + "id": 129, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/qualify", + "createdAt": "2013-08-07T22:41:56.000Z", + "updatedAt": "2013-08-07T22:41:56.000Z", + "metadata": { + "id": 129, + "metadata": { + "name": { + "en-US": "qualified" + }, + "description": { + "en-US": "Indicates that the actor has qualified for the object. If a target is specified, it indicates the context within which the qualification applies." + } + }, + "uriId": 129, + "editNotes": "", + "createdAt": "2013-08-07T22:41:56.000Z", + "updatedAt": "2013-08-07T22:41:56.000Z" + } + }, + { + "id": 130, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/read", + "createdAt": "2013-08-07T22:42:33.000Z", + "updatedAt": "2013-08-07T22:42:33.000Z", + "metadata": { + "id": 130, + "metadata": { + "name": { + "en-US": "read" + }, + "description": { + "en-US": "Indicates that the actor read the object. This is typically only applicable for objects representing printed or written content, such as a book, a message or a comment. The \"read\" verb is a more specific form of the \"consume\", \"experience\" and \"play\" verbs." + } + }, + "uriId": 130, + "editNotes": "", + "createdAt": "2013-08-07T22:42:33.000Z", + "updatedAt": "2013-08-07T22:42:33.000Z" + } + }, + { + "id": 131, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/receive", + "createdAt": "2013-08-07T22:42:44.000Z", + "updatedAt": "2013-08-07T22:42:44.000Z", + "metadata": { + "id": 131, + "metadata": { + "name": { + "en-US": "received" + }, + "description": { + "en-US": "Indicates that the actor is receiving an object. Examples include a person receiving a badge object. The object identifies the object being received." + } + }, + "uriId": 131, + "editNotes": "", + "createdAt": "2013-08-07T22:42:44.000Z", + "updatedAt": "2013-08-07T22:42:44.000Z" + } + }, + { + "id": 132, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/reject", + "createdAt": "2013-08-07T22:42:53.000Z", + "updatedAt": "2013-08-07T22:42:53.000Z", + "metadata": { + "id": 132, + "metadata": { + "name": { + "en-US": "rejected" + }, + "description": { + "en-US": "Indicates that the actor has rejected the object." + } + }, + "uriId": 132, + "editNotes": "", + "createdAt": "2013-08-07T22:42:53.000Z", + "updatedAt": "2013-08-07T22:42:53.000Z" + } + }, + { + "id": 133, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/remove", + "createdAt": "2013-08-07T22:43:02.000Z", + "updatedAt": "2013-08-07T22:43:02.000Z", + "metadata": { + "id": 133, + "metadata": { + "name": { + "en-US": "removed" + }, + "description": { + "en-US": "Indicates that the actor has removed the object from the target." + } + }, + "uriId": 133, + "editNotes": "", + "createdAt": "2013-08-07T22:43:02.000Z", + "updatedAt": "2013-08-07T22:43:02.000Z" + } + }, + { + "id": 134, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/remove-friend", + "createdAt": "2013-08-07T22:43:10.000Z", + "updatedAt": "2013-08-07T22:43:10.000Z", + "metadata": { + "id": 134, + "metadata": { + "name": { + "en-US": "removed friend" + }, + "description": { + "en-US": "Indicates that the actor has removed the object from the collection of friends." + } + }, + "uriId": 134, + "editNotes": "", + "createdAt": "2013-08-07T22:43:10.000Z", + "updatedAt": "2013-08-07T22:43:10.000Z" + } + }, + { + "id": 135, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/replace", + "createdAt": "2013-08-07T22:44:17.000Z", + "updatedAt": "2013-08-07T22:44:17.000Z", + "metadata": { + "id": 135, + "metadata": { + "name": { + "en-US": "replaced" + }, + "description": { + "en-US": "Indicates that the actor has replaced the target with the object." + } + }, + "uriId": 135, + "editNotes": "", + "createdAt": "2013-08-07T22:44:17.000Z", + "updatedAt": "2013-08-07T22:44:17.000Z" + } + }, + { + "id": 136, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/request", + "createdAt": "2013-08-07T22:44:46.000Z", + "updatedAt": "2013-08-07T22:44:46.000Z", + "metadata": { + "id": 136, + "metadata": { + "name": { + "en-US": "requested" + }, + "description": { + "en-US": "Indicates that the actor has requested the object. If a target is specified, it indicates the entity from which the object is being requested." + } + }, + "uriId": 136, + "editNotes": "", + "createdAt": "2013-08-07T22:44:46.000Z", + "updatedAt": "2013-08-07T22:44:46.000Z" + } + }, + { + "id": 137, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/request-friend", + "createdAt": "2013-08-07T22:44:58.000Z", + "updatedAt": "2013-08-07T22:44:58.000Z", + "metadata": { + "id": 137, + "metadata": { + "name": { + "en-US": "requested friend" + }, + "description": { + "en-US": "Indicates the creation of a friendship that has not yet been reciprocated by the object." + } + }, + "uriId": 137, + "editNotes": "", + "createdAt": "2013-08-07T22:44:58.000Z", + "updatedAt": "2013-08-07T22:44:58.000Z" + } + }, + { + "id": 138, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/resolve", + "createdAt": "2013-08-07T22:45:15.000Z", + "updatedAt": "2013-08-07T22:45:15.000Z", + "metadata": { + "id": 138, + "metadata": { + "name": { + "en-US": "resolved" + }, + "description": { + "en-US": "Indicates that the actor has resolved the object. For instance, the object could represent a ticket being tracked in an issue management system." + } + }, + "uriId": 138, + "editNotes": "", + "createdAt": "2013-08-07T22:45:15.000Z", + "updatedAt": "2013-08-07T22:45:15.000Z" + } + }, + { + "id": 140, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/retract", + "createdAt": "2013-08-07T22:45:43.000Z", + "updatedAt": "2013-08-07T22:45:43.000Z", + "metadata": { + "id": 140, + "metadata": { + "name": { + "en-US": "retracted" + }, + "description": { + "en-US": "Indicates that the actor has retracted the object. For instance, if an actor wishes to retract a previously published activity, the object would be the previously published activity that is being retracted." + } + }, + "uriId": 140, + "editNotes": "", + "createdAt": "2013-08-07T22:45:43.000Z", + "updatedAt": "2013-08-07T22:45:43.000Z" + } + }, + { + "id": 139, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/return", + "createdAt": "2013-08-07T22:45:24.000Z", + "updatedAt": "2013-08-07T22:45:24.000Z", + "metadata": { + "id": 139, + "metadata": { + "name": { + "en-US": "returned" + }, + "description": { + "en-US": "Indicates that the actor has returned the object. If a target is specified, it indicates the entity to which the object was returned." + } + }, + "uriId": 139, + "editNotes": "", + "createdAt": "2013-08-07T22:45:24.000Z", + "updatedAt": "2013-08-07T22:45:24.000Z" + } + }, + { + "id": 163, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/rsvp-maybe", + "createdAt": "2013-08-08T13:38:25.000Z", + "updatedAt": "2013-08-08T13:38:25.000Z", + "metadata": { + "id": 163, + "metadata": { + "name": { + "en-US": "RSVPed maybe" + }, + "description": { + "en-US": "The \"possible RSVP\" verb indicates that the actor has made a possible RSVP for the object. This specification only defines the meaning of this verb when its object is an event (see Section 3.3), though implementors need to be prepared to handle other object types. The use of this verb is only appropriate when the RSVP was created by an explicit action by the actor. It is not appropriate to use this verb when a user has been added as an attendee by an event organizer or administrator.\n\nThis verb is included for data conversion with Activity Streams, it's not recommended for use in new Tin Can statements." + } + }, + "uriId": 163, + "editNotes": "", + "createdAt": "2013-08-08T13:38:25.000Z", + "updatedAt": "2013-08-08T13:38:25.000Z" + } + }, + { + "id": 164, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/rsvp-no", + "createdAt": "2013-08-08T13:38:32.000Z", + "updatedAt": "2013-08-08T13:38:32.000Z", + "metadata": { + "id": 164, + "metadata": { + "name": { + "en-US": "rsvped no" + }, + "description": { + "en-US": "The \"negative RSVP\" verb indicates that the actor has made a negative RSVP for the object. This specification only defines the meaning of this verb when its object is an event (see Section 3.3), though implementors need to be prepared to handle other object types. The use of this verb is only appropriate when the RSVP was created by an explicit action by the actor. It is not appropriate to use this verb when a user has been added as an attendee by an event organizer or administrator.\n\nThis verb is included for data conversion with Activity Streams, it's not recommended for use in new Tin Can statements." + } + }, + "uriId": 164, + "editNotes": "", + "createdAt": "2013-08-08T13:38:32.000Z", + "updatedAt": "2013-08-08T13:38:32.000Z" + } + }, + { + "id": 165, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/rsvp-yes", + "createdAt": "2013-08-08T13:38:44.000Z", + "updatedAt": "2013-08-08T13:38:44.000Z", + "metadata": { + "id": 165, + "metadata": { + "name": { + "en-US": "rsvped yes" + }, + "description": { + "en-US": "The \"positive RSVP\" verb indicates that the actor has made a positive RSVP for an object. This specification only defines the meaning of this verb when its object is an event (see Section 3.3), though implementors need to be prepared to handle other object types. The use of this verb is only appropriate when the RSVP was created by an explicit action by the actor. It is not appropriate to use this verb when a user has been added as an attendee by an event organizer or administrator.\n\nThis verb is included for data conversion with Activity Streams, it's not recommended for use in new Tin Can statements." + } + }, + "uriId": 165, + "editNotes": "", + "createdAt": "2013-08-08T13:38:44.000Z", + "updatedAt": "2013-08-08T13:38:44.000Z" + } + }, + { + "id": 141, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/satisfy", + "createdAt": "2013-08-07T22:46:32.000Z", + "updatedAt": "2013-08-07T22:46:32.000Z", + "metadata": { + "id": 141, + "metadata": { + "name": { + "en-US": "satisfied" + }, + "description": { + "en-US": "Indicates that the actor has satisfied the object. If a target is specified, it indicate the context within which the object was satisfied. For instance, if a person satisfies the requirements for a particular challenge, the person is the actor; the requirement is the object; and the challenge is the target." + } + }, + "uriId": 141, + "editNotes": "", + "createdAt": "2013-08-07T22:46:32.000Z", + "updatedAt": "2013-08-07T22:46:32.000Z" + } + }, + { + "id": 142, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/save", + "createdAt": "2013-08-07T22:48:21.000Z", + "updatedAt": "2013-08-07T22:48:21.000Z", + "metadata": { + "id": 142, + "metadata": { + "name": { + "en-US": "saved" + }, + "description": { + "en-US": "Indicates that the actor has called out the object as being of interest primarily to him- or herself. Though this action MAY be shared publicly, the implication is that the object has been saved primarily for the actor's own benefit rather than to show it to others as would be indicated by the \"share\" verb." + } + }, + "uriId": 142, + "editNotes": "", + "createdAt": "2013-08-07T22:48:21.000Z", + "updatedAt": "2013-08-07T22:48:21.000Z" + } + }, + { + "id": 143, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/schedule", + "createdAt": "2013-08-07T22:48:27.000Z", + "updatedAt": "2013-08-07T22:48:27.000Z", + "metadata": { + "id": 143, + "metadata": { + "name": { + "en-US": "scheduled" + }, + "description": { + "en-US": "Indicates that the actor has scheduled the object. For instance, scheduling a meeting." + } + }, + "uriId": 143, + "editNotes": "", + "createdAt": "2013-08-07T22:48:27.000Z", + "updatedAt": "2013-08-07T22:48:27.000Z" + } + }, + { + "id": 144, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/search", + "createdAt": "2013-08-07T22:48:32.000Z", + "updatedAt": "2013-08-07T22:48:32.000Z", + "metadata": { + "id": 144, + "metadata": { + "name": { + "en-US": "searched" + }, + "description": { + "en-US": "Indicates that the actor is or has searched for the object. If a target is specified, it indicates the context within which the search is or has been conducted." + } + }, + "uriId": 144, + "editNotes": "", + "createdAt": "2013-08-07T22:48:32.000Z", + "updatedAt": "2013-08-07T22:48:32.000Z" + } + }, + { + "id": 145, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/sell", + "createdAt": "2013-08-07T22:48:53.000Z", + "updatedAt": "2013-08-07T22:48:53.000Z", + "metadata": { + "id": 145, + "metadata": { + "name": { + "en-US": "sold" + }, + "description": { + "en-US": "Indicates that the actor has sold the object. If a target is specified, it indicates the entity to which the object was sold." + } + }, + "uriId": 145, + "editNotes": "", + "createdAt": "2013-08-07T22:48:53.000Z", + "updatedAt": "2013-08-07T22:48:53.000Z" + } + }, + { + "id": 146, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/send", + "createdAt": "2013-08-07T22:49:04.000Z", + "updatedAt": "2013-08-07T22:49:04.000Z", + "metadata": { + "id": 146, + "metadata": { + "name": { + "en-US": "sent" + }, + "description": { + "en-US": "Indicates that the actor has sent the object. If a target is specified, it indicates the entity to which the object was sent." + } + }, + "uriId": 146, + "editNotes": "", + "createdAt": "2013-08-07T22:49:04.000Z", + "updatedAt": "2013-08-07T22:49:04.000Z" + } + }, + { + "id": 147, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/share", + "createdAt": "2013-08-07T22:49:21.000Z", + "updatedAt": "2013-08-07T22:49:21.000Z", + "metadata": { + "id": 147, + "metadata": { + "name": { + "en-US": "shared" + }, + "description": { + "en-US": "Indicates that the actor has called out the object to readers. In most cases, the actor did not create the object being shared, but is instead drawing attention to it." + } + }, + "uriId": 147, + "editNotes": "", + "createdAt": "2013-08-07T22:49:21.000Z", + "updatedAt": "2013-08-07T22:49:21.000Z" + } + }, + { + "id": 148, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/sponsor", + "createdAt": "2013-08-07T22:49:29.000Z", + "updatedAt": "2013-08-07T22:49:29.000Z", + "metadata": { + "id": 148, + "metadata": { + "name": { + "en-US": "sponsored" + }, + "description": { + "en-US": "Indicates that the actor has sponsored the object. If a target is specified, it indicates the context within which the sponsorship is offered. For instance, a company can sponsor an event; or an individual can sponsor a project; etc." + } + }, + "uriId": 148, + "editNotes": "", + "createdAt": "2013-08-07T22:49:29.000Z", + "updatedAt": "2013-08-07T22:49:29.000Z" + } + }, + { + "id": 149, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/start", + "createdAt": "2013-08-07T22:50:16.000Z", + "updatedAt": "2013-08-07T22:50:16.000Z", + "metadata": { + "id": 149, + "metadata": { + "name": { + "en-US": "started" + }, + "description": { + "en-US": "Indicates that the actor has started the object. For instance, when a person starts a project." + } + }, + "uriId": 149, + "editNotes": "", + "createdAt": "2013-08-07T22:50:16.000Z", + "updatedAt": "2013-08-07T22:50:16.000Z" + } + }, + { + "id": 150, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/stop-following", + "createdAt": "2013-08-07T22:50:28.000Z", + "updatedAt": "2013-08-07T22:50:28.000Z", + "metadata": { + "id": 150, + "metadata": { + "name": { + "en-US": "stopped following" + }, + "description": { + "en-US": "Indicates that the actor has stopped following the object." + } + }, + "uriId": 150, + "editNotes": "", + "createdAt": "2013-08-07T22:50:28.000Z", + "updatedAt": "2013-08-07T22:50:28.000Z" + } + }, + { + "id": 151, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/submit", + "createdAt": "2013-08-07T22:50:49.000Z", + "updatedAt": "2013-08-07T22:50:49.000Z", + "metadata": { + "id": 151, + "metadata": { + "name": { + "en-US": "submitted" + }, + "description": { + "en-US": "Indicates that the actor has submitted the object. If a target is specified, it indicates the entity to which the object was submitted." + } + }, + "uriId": 151, + "editNotes": "", + "createdAt": "2013-08-07T22:50:49.000Z", + "updatedAt": "2013-08-07T22:50:49.000Z" + } + }, + { + "id": 152, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/tag", + "createdAt": "2013-08-07T22:51:46.000Z", + "updatedAt": "2013-08-07T22:51:46.000Z", + "metadata": { + "id": 152, + "metadata": { + "name": { + "en-US": "tagged" + }, + "description": { + "en-US": "Indicates that the actor has associated the object with the target. For example, if the actor specifies that a particular user appears in a photo. the object is the user and the target is the photo." + } + }, + "uriId": 152, + "editNotes": "", + "createdAt": "2013-08-07T22:51:46.000Z", + "updatedAt": "2013-08-07T22:51:46.000Z" + } + }, + { + "id": 153, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/terminate", + "createdAt": "2013-08-07T22:51:52.000Z", + "updatedAt": "2013-08-07T22:51:52.000Z", + "metadata": { + "id": 153, + "metadata": { + "name": { + "en-US": "terminated" + }, + "description": { + "en-US": "Indicates that the actor has terminated the object." + } + }, + "uriId": 153, + "editNotes": "", + "createdAt": "2013-08-07T22:51:52.000Z", + "updatedAt": "2013-08-07T22:51:52.000Z" + } + }, + { + "id": 154, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/tie", + "createdAt": "2013-08-07T22:52:01.000Z", + "updatedAt": "2013-08-07T22:52:01.000Z", + "metadata": { + "id": 154, + "metadata": { + "name": { + "en-US": "tied" + }, + "description": { + "en-US": "Indicates that the actor has neither won or lost the object. This verb is generally only applicable when the object represents some form of competition, such as a game." + } + }, + "uriId": 154, + "editNotes": "", + "createdAt": "2013-08-07T22:52:01.000Z", + "updatedAt": "2013-08-07T22:52:01.000Z" + } + }, + { + "id": 155, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/unfavorite", + "createdAt": "2013-08-07T22:52:10.000Z", + "updatedAt": "2013-08-07T22:52:10.000Z", + "metadata": { + "id": 155, + "metadata": { + "name": { + "en-US": "unfavorited" + }, + "description": { + "en-US": "Indicates that the actor has removed the object from the collection of favorited items." + } + }, + "uriId": 155, + "editNotes": "", + "createdAt": "2013-08-07T22:52:10.000Z", + "updatedAt": "2013-08-07T22:52:10.000Z" + } + }, + { + "id": 156, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/unlike", + "createdAt": "2013-08-07T22:52:17.000Z", + "updatedAt": "2013-08-07T22:52:17.000Z", + "metadata": { + "id": 156, + "metadata": { + "name": { + "en-US": "unliked" + }, + "description": { + "en-US": "Indicates that the actor has removed the object from the collection of liked items." + } + }, + "uriId": 156, + "editNotes": "", + "createdAt": "2013-08-07T22:52:17.000Z", + "updatedAt": "2013-08-07T22:52:17.000Z" + } + }, + { + "id": 157, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/unsatisfy", + "createdAt": "2013-08-07T22:52:27.000Z", + "updatedAt": "2013-08-07T22:52:27.000Z", + "metadata": { + "id": 157, + "metadata": { + "name": { + "en-US": "unsatisfied" + }, + "description": { + "en-US": "Indicates that the actor has not satisfied the object. If a target is specified, it indicates the context within which the object was not satisfied. For instance, if a person fails to satisfy the requirements of some particular challenge, the person is the actor; the requirement is the object and the challenge is the target." + } + }, + "uriId": 157, + "editNotes": "", + "createdAt": "2013-08-07T22:52:27.000Z", + "updatedAt": "2013-08-07T22:52:27.000Z" + } + }, + { + "id": 158, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/unsave", + "createdAt": "2013-08-07T22:52:35.000Z", + "updatedAt": "2013-08-07T22:52:35.000Z", + "metadata": { + "id": 158, + "metadata": { + "name": { + "en-US": "unsaved" + }, + "description": { + "en-US": "Indicates that the actor has removed the object from the collection of saved items." + } + }, + "uriId": 158, + "editNotes": "", + "createdAt": "2013-08-07T22:52:35.000Z", + "updatedAt": "2013-08-07T22:52:35.000Z" + } + }, + { + "id": 159, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/unshare", + "createdAt": "2013-08-07T22:52:41.000Z", + "updatedAt": "2013-08-07T22:52:41.000Z", + "metadata": { + "id": 159, + "metadata": { + "name": { + "en-US": "unshared" + }, + "description": { + "en-US": "Indicates that the actor is no longer sharing the object. If a target is specified, it indicates the entity with whom the object is no longer being shared." + } + }, + "uriId": 159, + "editNotes": "", + "createdAt": "2013-08-07T22:52:41.000Z", + "updatedAt": "2013-08-07T22:52:41.000Z" + } + }, + { + "id": 160, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/update", + "createdAt": "2013-08-07T22:52:49.000Z", + "updatedAt": "2013-08-07T22:52:49.000Z", + "metadata": { + "id": 160, + "metadata": { + "name": { + "en-US": "updated" + }, + "description": { + "en-US": "The \"update\" verb indicates that the actor has modified the object. Use of the \"update\" verb is generally reserved to indicate modifications to existing objects or data such as changing an existing user's profile information." + } + }, + "uriId": 160, + "editNotes": "", + "createdAt": "2013-08-07T22:52:49.000Z", + "updatedAt": "2013-08-07T22:52:49.000Z" + } + }, + { + "id": 161, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/use", + "createdAt": "2013-08-07T22:52:59.000Z", + "updatedAt": "2013-08-07T22:52:59.000Z", + "metadata": { + "id": 161, + "metadata": { + "name": { + "en-US": "used" + }, + "description": { + "en-US": "Indicates that the actor has used the object in some manner." + } + }, + "uriId": 161, + "editNotes": "", + "createdAt": "2013-08-07T22:52:59.000Z", + "updatedAt": "2013-08-07T22:52:59.000Z" + } + }, + { + "id": 50, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/watch", + "createdAt": "2013-08-05T13:46:13.000Z", + "updatedAt": "2013-08-05T13:46:13.000Z", + "metadata": { + "id": 50, + "metadata": { + "name": { + "en-US": "Watched" + }, + "description": { + "en-US": "Indicates that the actor has watched the object. This verb is typically applicable only when the object represents dynamic, visible content such as a movie, a television show or a public performance. This verb is a more specific form of the verbs \"experience\", \"play\" and \"consume\"." + } + }, + "uriId": 50, + "editNotes": "", + "createdAt": "2013-08-05T13:46:14.000Z", + "updatedAt": "2013-08-05T13:46:14.000Z" + } + }, + { + "id": 162, + "kind": "verb", + "uri": "http://activitystrea.ms/schema/1.0/win", + "createdAt": "2013-08-07T22:53:20.000Z", + "updatedAt": "2013-08-07T22:53:20.000Z", + "metadata": { + "id": 162, + "metadata": { + "name": { + "en-US": "won" + }, + "description": { + "en-US": "Indicates that the actor has won the object. This verb is typically applicable only when the object represents some form of competition, such as a game." + } + }, + "uriId": 162, + "editNotes": "", + "createdAt": "2013-08-07T22:53:20.000Z", + "updatedAt": "2013-08-07T22:53:20.000Z" + } + }, + { + "id": 21, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/answered", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 21, + "metadata": { + "name": { + "en-us": "answered" + }, + "description": { + "en-us": "Indicates the actor responded to a Question" + } + }, + "uriId": 21, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 37, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/asked", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 37, + "metadata": { + "name": { + "en-US": "asked" + }, + "description": { + "en-US": "Used to make an inquiry of an actor with the expectation of a response. May be used to ask a question, typically the system would be the primary actor, with the learner being the recipient of the question. The question could also be asked into a vacuum, with the eventual response (statement with verb responded) providing the actual context of the recipient. For example \"System asked Math quiz question 1 with result \"What is 2+2\"\" followed by \"Andy responded to quiz question 1 with result \"response=\"4\"\" would alleviate the need to identify the second actor." + } + }, + "uriId": 37, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 17, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/attempted", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 17, + "metadata": { + "name": { + "en-us": "attempted" + }, + "description": { + "en-us": "Used at the initiation of many \"experienced\" activities to mark the entry. Attempts without further verbs could be incomplete in some cases." + } + }, + "uriId": 17, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 16, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/attended", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 16, + "metadata": { + "name": { + "en-us": "attended" + }, + "description": { + "en-us": "The process of associating the location of an actor to some place (physical or virtual)." + } + }, + "uriId": 16, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 28, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/commented", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 28, + "metadata": { + "name": { + "en-US": "commented" + }, + "description": { + "en-US": "Offered an opinion or written experience of the activity. Can be used with the learner as the actor or a system as an actor. Comments can be sent from either party with the idea that the other will read and react to the content." + } + }, + "uriId": 28, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 18, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/completed", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 18, + "metadata": { + "name": { + "en-us": "completed" + }, + "description": { + "en-us": "To experience the activity in its entirety. Used to affirm the completion of content. This can be simply experiencing all the content, be tied to objectives or interactions, or determined in any other way. Any content that has been initialized, but not yet completed, should be considered incomplete. There is no verb to 'incomplete' an activity, one would void the statement which completes the activity." + } + }, + "uriId": 18, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 29, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/exited", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 29, + "metadata": { + "name": { + "en-US": "exited" + }, + "description": { + "en-US": "Used to leave an activity attempt with no intention of returning with the learner progress intact. The expectation is learner progress will be cleared. Should appear immediately before a statement with terminated. A statement with EITHER exited OR suspended should be used before one with terminated. Lack of the two implies the same as exited." + } + }, + "uriId": 29, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 15, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/experienced", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 15, + "metadata": { + "name": { + "en-us": "experienced" + }, + "description": { + "en-us": "A catch-all verb to say that someone viewed, listened to, read, etc. some form of content. There is no assumption of completion or success." + } + }, + "uriId": 15, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 20, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/failed", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 20, + "metadata": { + "name": { + "en-us": "failed" + }, + "description": { + "en-us": "Learner did not perform the activity to a level of pre-determined satisfaction. Used to affirm the lack of success a learner experienced within the learning content in relation to a threshold. If the user performed below the minimum to the level of this threshold, the content is 'failed'. The opposite of 'passed'." + } + }, + "uriId": 20, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 23, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/imported", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 23, + "metadata": { + "name": { + "en-us": "imported" + }, + "description": { + "en-us": "The act of moving an object into another location or system." + } + }, + "uriId": 23, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 26, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/initialized", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 26, + "metadata": { + "name": { + "en-US": "initialized" + }, + "description": { + "en-US": "Begins the formal tracking of learning content, any statements time stamped before a statement with an initialized verb are not formally tracked." + } + }, + "uriId": 26, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 22, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/interacted", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 22, + "metadata": { + "name": { + "en-us": "interacted" + }, + "description": { + "en-us": "A catch-all verb used to assert an actor's manipulation of an object, physical or digital, in some context." + } + }, + "uriId": 22, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 38, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/launched", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 38, + "metadata": { + "name": { + "en-US": "launched" + }, + "description": { + "en-US": "Starts the process of launching the next piece of learning content. There is no expectation if this is done by user or system and no expectation that the learning content is a \"SCO\". It is highly recommended that the display is used to mirror the behavior. If an activity is launched from another, then launched from may be better. If the activity is launched and then the statement is generated, launched or launched into may be more appropriate." + } + }, + "uriId": 38, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 39, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/mastered", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 39, + "metadata": { + "name": { + "en-US": "mastered" + }, + "description": { + "en-US": "Used to describe a level of competence achieved in the activity. The level should be within the range of a defined scale. This is not to be confused with \"progressed\", which shows how much content was experienced, whereas mastery has to do with level of expertise." + } + }, + "uriId": 39, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 19, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/passed", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 19, + "metadata": { + "name": { + "en-us": "passed" + }, + "description": { + "en-us": "Used to affirm the success a learner experienced within the learning content in relation to a threshold. If the user performed at a minimum to the level of this threshold, the content is 'passed'. The opposite of 'failed'." + } + }, + "uriId": 19, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 30, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/preferred", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 30, + "metadata": { + "name": { + "en-US": "preferred" + }, + "description": { + "en-US": "The user's preference, typically presented to the content or system in the form of a response to a question. A response to a personal question to the learner, typically resulting in a change in content or system behavior. For example, the system could ask a question if the learner preferred a voice over text option. The resulting statement could be Andy preferred on Civil War History with result response = 'no voiceover'. This distinction is made between statements with responded as the content/system is expected to change as a results of the learner response." + } + }, + "uriId": 30, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 31, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/progressed", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 31, + "metadata": { + "name": { + "en-US": "progressed" + }, + "description": { + "en-US": "A value, typically within a scale of progression, to how much of an activity has been accomplished. This is not to be confused with 'mastered', as the level of success or competency a user gained is not guaranteed by progress." + } + }, + "uriId": 31, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 32, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/registered", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 32, + "metadata": { + "name": { + "en-US": "registered" + }, + "description": { + "en-US": "Indicates the actor registered for a learning activity" + } + }, + "uriId": 32, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 36, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/responded", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 36, + "metadata": { + "name": { + "en-US": "responded" + }, + "description": { + "en-US": "Used to respond to a question. It could be either the actual answer to a question asked of the actor OR the correctness of an answer to a question asked of the actor. Must follow a statement with asked or another statement with a responded (the top statement with responded) must follow the \"asking\" statement. The response to the question can be the actual text (usually) response or the correctness of that response. For example, Andy responded to quiz question 1 with result 'response=4' and Andy responded to quiz question 1 with result success=true'. Typically both types of responded statements would follow a single question/interacton." + } + }, + "uriId": 36, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 33, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/resumed", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 33, + "metadata": { + "name": { + "en-US": "resumed" + }, + "description": { + "en-US": "Used to resume suspended attempts on an activity. Should immediately follow a statement with initialized if the attempt is indeed to be resumed. The absence of a resumed statement implies a fresh attempt on the activity. Can only be used on an activity that used a suspended statement." + } + }, + "uriId": 33, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 34, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/scored", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 34, + "metadata": { + "name": { + "en-US": "scored" + }, + "description": { + "en-US": "A measure related to the learner\u00e2\u20ac\u2122s performance, typically between either 0 and 1 or 0 and 100, which corresponds to a learner's performance on an activity. It is expected the context property provides guidance to the allowed values of the result field." + } + }, + "uriId": 34, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 24, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/shared", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 24, + "metadata": { + "name": { + "en-us": "shared" + }, + "description": { + "en-us": "Generic term indicating the intent to exchange an item of interest or the explicit changing of privacy \u00e2\u20ac\u201c largely derived from context." + } + }, + "uriId": 24, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 35, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/suspended", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 35, + "metadata": { + "name": { + "en-US": "suspended" + }, + "description": { + "en-US": "Used to suspend an activity with the intention of returning to it later, but not losing progress. Should appear immediately before a statement with terminated. A statement with EITHER exited OR suspended should be used before one with terminated. Lack of the two implies the same as exited. Beginning the suspended activity will always result in a resumed activity." + } + }, + "uriId": 35, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 27, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/terminated", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z", + "metadata": { + "id": 27, + "metadata": { + "name": { + "en-US": "terminated" + }, + "description": { + "en-US": "Ends the formal tracking of learning content, any statements time stamped after a statement with a terminated verb are not formally tracked." + } + }, + "uriId": 27, + "editNotes": "", + "createdAt": "2013-05-02T18:19:27.000Z", + "updatedAt": "2013-05-02T18:19:27.000Z" + } + }, + { + "id": 14, + "kind": "verb", + "uri": "http://adlnet.gov/expapi/verbs/voided", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z", + "metadata": { + "id": 14, + "metadata": { + "name": { + "en-us": "voided" + }, + "description": { + "en-us": "A special LRS-reserved verb. Used by the LRS to declare that an activity statement is to be voided from record." + } + }, + "uriId": 14, + "editNotes": "", + "createdAt": "2013-04-30T16:39:52.000Z", + "updatedAt": "2013-04-30T16:39:52.000Z" + } + }, + { + "id": 354, + "kind": "verb", + "uri": "http://curatr3.com/define/verb/edited", + "createdAt": "2015-01-06T19:17:12.000Z", + "updatedAt": "2015-01-06T19:17:12.000Z", + "metadata": { + "id": 354, + "metadata": { + "name": { + "en-US": "Edited" + }, + "description": { + "en-US": "Indicates that the actor edited an object, for example a user editing their account profile. " + } + }, + "uriId": 354, + "editNotes": "", + "createdAt": "2015-01-06T19:17:12.000Z", + "updatedAt": "2015-01-06T19:17:12.000Z" + } + }, + { + "id": 355, + "kind": "verb", + "uri": "http://curatr3.com/define/verb/voted-down", + "createdAt": "2015-01-06T19:17:21.000Z", + "updatedAt": "2015-01-06T19:17:21.000Z", + "metadata": { + "id": 355, + "metadata": { + "name": { + "en-US": "Voted down (with reason)" + }, + "description": { + "en-US": "Indicates that the actor has voted down a discussion post and given a reason. The reason is stored in the Result Response and can be \"Rude or Inappropriate\" or some other value. \n\nConsider using http://id.tincanapi.com/verb/voted-down for down votes that do not fit this pattern. " + } + }, + "uriId": 355, + "editNotes": "", + "createdAt": "2015-01-06T19:17:21.000Z", + "updatedAt": "2015-01-06T19:17:21.000Z" + } + }, + { + "id": 356, + "kind": "verb", + "uri": "http://curatr3.com/define/verb/voted-up", + "createdAt": "2015-01-06T19:17:33.000Z", + "updatedAt": "2015-01-06T19:17:33.000Z", + "metadata": { + "id": 356, + "metadata": { + "name": { + "en-US": "Voted up (with reason)" + }, + "description": { + "en-US": "Indicates that the actor has voted up a discussion post and given a reason. The reason is stored in the Result Response and can be \"Started Discussion\", \"Developed Discussion\", \"Resolved Discussion\" or some other value. \n\nConsider using http://id.tincanapi.com/verb/voted-up for up votes that do not fit this pattern." + } + }, + "uriId": 356, + "editNotes": "", + "createdAt": "2015-01-06T19:17:33.000Z", + "updatedAt": "2015-01-06T19:17:33.000Z" + } + }, + { + "id": 305, + "kind": "verb", + "uri": "http://future-learning.info/xAPI/verb/pressed", + "createdAt": "2014-08-06T13:47:47.000Z", + "updatedAt": "2014-08-06T13:47:47.000Z", + "metadata": { + "id": 305, + "metadata": { + "name": { + "en-US": "pressed" + }, + "description": { + "en-US": "Indicates that the actor has pressed the object. For instance, a person pressing a key of a keyboard." + } + }, + "uriId": 305, + "editNotes": "", + "createdAt": "2014-08-06T13:47:47.000Z", + "updatedAt": "2014-08-06T13:47:47.000Z" + } + }, + { + "id": 304, + "kind": "verb", + "uri": "http://future-learning.info/xAPI/verb/released", + "createdAt": "2014-08-06T13:47:42.000Z", + "updatedAt": "2014-08-06T13:47:42.000Z", + "metadata": { + "id": 304, + "metadata": { + "name": { + "en-US": "released" + }, + "description": { + "en-US": "Indicates that the actor has released the object. For instance, a person releasing a key of a keyboard." + } + }, + "uriId": 304, + "editNotes": "", + "createdAt": "2014-08-06T13:47:42.000Z", + "updatedAt": "2014-08-06T13:47:42.000Z" + } + }, + { + "id": 392, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/adjourned", + "createdAt": "2015-05-07T17:09:55.000Z", + "updatedAt": "2015-05-07T17:09:55.000Z", + "metadata": { + "id": 392, + "metadata": { + "name": { + "en-US": "adjourned" + }, + "description": { + "en-US": "Indicates the actor temporarily ended an event (e.g. a meeting). It is expected (but not required) that the event will be resumed at a future point in time. The actor of the statement should be somebody who has authority to adjourn the event, for example the event organizer." + } + }, + "uriId": 392, + "editNotes": "", + "createdAt": "2015-05-07T17:09:55.000Z", + "updatedAt": "2015-05-07T17:09:55.000Z" + } + }, + { + "id": 492, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/applauded", + "createdAt": "2016-08-01T16:26:15.000Z", + "updatedAt": "2016-08-01T16:26:15.000Z", + "metadata": { + "id": 492, + "metadata": { + "name": { + "en-US": "Applauded" + }, + "description": { + "en-US": "indicates that the actor approves of the content or message. Analogous to praising." + } + }, + "uriId": 492, + "editNotes": "", + "createdAt": "2016-08-01T16:26:15.000Z", + "updatedAt": "2016-08-01T16:26:15.000Z" + } + }, + { + "id": 447, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/arranged", + "createdAt": "2016-08-01T16:05:26.000Z", + "updatedAt": "2016-08-01T16:05:26.000Z", + "metadata": { + "id": 447, + "metadata": { + "name": { + "en-US": "arranged" + }, + "description": { + "en-US": "Indicates that the actor arranged the object within a collection or set of elements. The extension http://id.tincanapi.com/extension/position should be used to indicate the new position." + } + }, + "uriId": 447, + "editNotes": "", + "createdAt": "2016-08-01T16:05:26.000Z", + "updatedAt": "2016-08-01T16:05:26.000Z" + } + }, + { + "id": 274, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/bookmarked", + "createdAt": "2014-03-21T21:30:24.000Z", + "updatedAt": "2014-03-21T21:30:24.000Z", + "metadata": { + "id": 274, + "metadata": { + "name": { + "en-US": "bookmarked" + }, + "description": { + "en-US": "Indicates the user determined the content was important enough to keep a reference to it for later. A different verb should be used for tracking the location in a set of text that a reader has reached, as in a physical bookmark." + } + }, + "uriId": 274, + "editNotes": "", + "createdAt": "2014-03-21T21:30:24.000Z", + "updatedAt": "2014-03-21T21:30:24.000Z" + } + }, + { + "id": 245, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/called", + "createdAt": "2013-10-09T17:56:34.000Z", + "updatedAt": "2013-10-09T17:56:34.000Z", + "metadata": { + "id": 245, + "metadata": { + "name": { + "en-US": "called" + }, + "description": { + "en-US": "Indicates that the actor placed a phone call to the object." + } + }, + "uriId": 245, + "editNotes": "", + "createdAt": "2013-10-09T17:56:34.000Z", + "updatedAt": "2013-10-09T17:56:34.000Z" + } + }, + { + "id": 250, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/closed-sale", + "createdAt": "2013-10-14T14:56:50.000Z", + "updatedAt": "2013-10-14T14:56:50.000Z", + "metadata": { + "id": 250, + "metadata": { + "name": { + "en-US": "closed sale" + }, + "description": { + "en-US": "Indicates that the actor has closed a sale." + } + }, + "uriId": 250, + "editNotes": "", + "createdAt": "2013-10-14T14:56:50.000Z", + "updatedAt": "2013-10-14T14:56:50.000Z" + } + }, + { + "id": 248, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/created-opportunity", + "createdAt": "2013-10-14T14:56:13.000Z", + "updatedAt": "2013-10-14T14:56:13.000Z", + "metadata": { + "id": 248, + "metadata": { + "name": { + "en-US": "created opportunity" + }, + "description": { + "en-US": "Indicates that the actor has created a new opportunity, such as one might do in a CRM tool. " + } + }, + "uriId": 248, + "editNotes": "", + "createdAt": "2013-10-14T14:56:13.000Z", + "updatedAt": "2013-10-14T14:56:13.000Z" + } + }, + { + "id": 443, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/defined", + "createdAt": "2016-08-01T16:04:03.000Z", + "updatedAt": "2016-08-01T16:04:03.000Z", + "metadata": { + "id": 443, + "metadata": { + "name": { + "en-US": "defined" + }, + "description": { + "en-US": "Indicates that the actor has defined the object. Note that this is a more specific form of the verb \u201ccreate\u201d. For instance, a learner defining a goal." + } + }, + "uriId": 443, + "editNotes": "", + "createdAt": "2016-08-01T16:04:03.000Z", + "updatedAt": "2016-08-01T16:04:03.000Z" + } + }, + { + "id": 451, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/disabled", + "createdAt": "2016-08-01T16:06:15.000Z", + "updatedAt": "2016-08-01T16:06:15.000Z", + "metadata": { + "id": 451, + "metadata": { + "name": { + "en-US": "disabled" + }, + "description": { + "en-US": "Indicates that the actor turned off a particular part or feature of the system." + } + }, + "uriId": 451, + "editNotes": "", + "createdAt": "2016-08-01T16:06:15.000Z", + "updatedAt": "2016-08-01T16:06:15.000Z" + } + }, + { + "id": 449, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/discarded", + "createdAt": "2016-08-01T16:05:55.000Z", + "updatedAt": "2016-08-01T16:05:55.000Z", + "metadata": { + "id": 449, + "metadata": { + "name": { + "en-US": "discarded" + }, + "description": { + "en-US": "Indicates that the actor discarded a previous selection. This verb works with the verb 'selected'. " + } + }, + "uriId": 449, + "editNotes": "", + "createdAt": "2016-08-01T16:05:55.000Z", + "updatedAt": "2016-08-01T16:05:55.000Z" + } + }, + { + "id": 475, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/downloaded", + "createdAt": "2016-08-01T16:15:22.000Z", + "updatedAt": "2016-08-01T16:15:22.000Z", + "metadata": { + "id": 475, + "metadata": { + "name": { + "en-US": "Downloaded" + }, + "description": { + "en-US": "Indicates that the actor downloaded (rather than accessed or opened) a file or document. " + } + }, + "uriId": 475, + "editNotes": "", + "createdAt": "2016-08-01T16:15:22.000Z", + "updatedAt": "2016-08-01T16:15:22.000Z" + } + }, + { + "id": 441, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/earned", + "createdAt": "2016-08-01T16:03:16.000Z", + "updatedAt": "2016-08-01T16:03:16.000Z", + "metadata": { + "id": 441, + "metadata": { + "name": { + "en-US": "earned" + }, + "description": { + "en-US": "Indicates that the actor has earned or has been awarded the object." + } + }, + "uriId": 441, + "editNotes": "", + "createdAt": "2016-08-01T16:03:17.000Z", + "updatedAt": "2016-08-01T16:03:17.000Z" + } + }, + { + "id": 450, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/enabled", + "createdAt": "2016-08-01T16:06:08.000Z", + "updatedAt": "2016-08-01T16:06:08.000Z", + "metadata": { + "id": 450, + "metadata": { + "name": { + "en-US": "enabled" + }, + "description": { + "en-US": "Indicates that the actor turned on a particular part or feature of the system. It works with the verb disabled." + } + }, + "uriId": 450, + "editNotes": "", + "createdAt": "2016-08-01T16:06:08.000Z", + "updatedAt": "2016-08-01T16:06:08.000Z" + } + }, + { + "id": 444, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/estimated-duration", + "createdAt": "2016-08-01T16:04:26.000Z", + "updatedAt": "2016-08-01T16:04:26.000Z", + "metadata": { + "id": 444, + "metadata": { + "name": { + "en-US": "estimated the duration" + }, + "description": { + "en-US": "Indicates that the actor has estimated the duration of the object. For instance, a learner estimating the duration of a task. " + } + }, + "uriId": 444, + "editNotes": "", + "createdAt": "2016-08-01T16:04:26.000Z", + "updatedAt": "2016-08-01T16:04:26.000Z" + } + }, + { + "id": 446, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/expected", + "createdAt": "2016-08-01T16:05:07.000Z", + "updatedAt": "2016-08-01T16:05:07.000Z", + "metadata": { + "id": 446, + "metadata": { + "name": { + "en-US": "expected" + }, + "description": { + "en-US": "Indicates that the actor expected particular results from the object. The expected results should be recorded in the results field." + } + }, + "uriId": 446, + "editNotes": "", + "createdAt": "2016-08-01T16:05:07.000Z", + "updatedAt": "2016-08-01T16:05:07.000Z" + } + }, + { + "id": 484, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/expired", + "createdAt": "2016-08-01T16:22:05.000Z", + "updatedAt": "2016-08-01T16:22:05.000Z", + "metadata": { + "id": 484, + "metadata": { + "name": { + "en-US": "Expired" + }, + "description": { + "en-US": "Indicates that the object (a competency or certification) has expired for the actor. " + } + }, + "uriId": 484, + "editNotes": "", + "createdAt": "2016-08-01T16:22:05.000Z", + "updatedAt": "2016-08-01T16:22:05.000Z" + } + }, + { + "id": 278, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/focused", + "createdAt": "2014-03-27T17:33:11.000Z", + "updatedAt": "2014-03-27T17:33:11.000Z", + "metadata": { + "id": 278, + "metadata": { + "name": { + "en-US": "focused" + }, + "description": { + "en-US": "Indicates that a user has focused on a target object. This is the opposite of 'unfocused'. For example, it indicates that the user has clicked to focus or regain focus on the application, content or activity." + } + }, + "uriId": 278, + "editNotes": "", + "createdAt": "2014-03-27T17:33:11.000Z", + "updatedAt": "2014-03-27T17:33:11.000Z" + } + }, + { + "id": 276, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/frame/entered", + "createdAt": "2014-03-27T17:26:38.000Z", + "updatedAt": "2014-03-27T17:26:38.000Z", + "metadata": { + "id": 276, + "metadata": { + "name": { + "en-US": "entered frame" + }, + "description": { + "en-US": "Indicates that the actor has entered the frame of a camera or viewing device." + } + }, + "uriId": 276, + "editNotes": "", + "createdAt": "2014-03-27T17:26:38.000Z", + "updatedAt": "2014-03-27T17:26:38.000Z" + } + }, + { + "id": 275, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/frame/exited", + "createdAt": "2014-03-27T17:26:33.000Z", + "updatedAt": "2014-03-27T17:26:33.000Z", + "metadata": { + "id": 275, + "metadata": { + "name": { + "en-US": "exited frame" + }, + "description": { + "en-US": "Indicates that the actor has exited the frame of a camera or viewing device." + } + }, + "uriId": 275, + "editNotes": "", + "createdAt": "2014-03-27T17:26:33.000Z", + "updatedAt": "2014-03-27T17:26:33.000Z" + } + }, + { + "id": 196, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/hired", + "createdAt": "2013-09-12T17:32:58.000Z", + "updatedAt": "2013-09-12T17:32:58.000Z", + "metadata": { + "id": 196, + "metadata": { + "name": { + "en-US": "Hired" + }, + "description": { + "en-US": "An offer of employment that has been made by an agent and accepted by another. " + } + }, + "uriId": 196, + "editNotes": "", + "createdAt": "2013-09-12T17:32:58.000Z", + "updatedAt": "2013-09-12T17:32:58.000Z" + } + }, + { + "id": 197, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/interviewed", + "createdAt": "2013-09-12T17:34:29.000Z", + "updatedAt": "2013-09-12T17:34:29.000Z", + "metadata": { + "id": 197, + "metadata": { + "name": { + "en-US": "Interviewed" + }, + "description": { + "en-US": "For use when one agent or group interviews another agent or group. It could be used for the purposes of hiring, creating news articles, shows, research, etc." + } + }, + "uriId": 197, + "editNotes": "", + "createdAt": "2013-09-12T17:34:29.000Z", + "updatedAt": "2013-09-12T17:34:29.000Z" + } + }, + { + "id": 494, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/laughed", + "createdAt": "2016-08-01T16:31:33.000Z", + "updatedAt": "2016-08-01T16:31:33.000Z", + "metadata": { + "id": 494, + "metadata": { + "name": { + "en-US": "Laughed" + }, + "description": { + "en-US": "Indicates that the actor found the content funny and enjoyable. May be used with an \"Ending Point\" extension (see http://id.tincanapi.com/extension/ending-point) value capturing the point in time within the Activity." + } + }, + "uriId": 494, + "editNotes": "", + "createdAt": "2016-08-01T16:31:33.000Z", + "updatedAt": "2016-08-01T16:31:33.000Z" + } + }, + { + "id": 490, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/marked-unread", + "createdAt": "2016-08-01T16:24:09.000Z", + "updatedAt": "2016-08-01T16:24:09.000Z", + "metadata": { + "id": 490, + "metadata": { + "name": { + "en-US": "Unread" + }, + "description": { + "en-US": "The object was marked as unread. " + } + }, + "uriId": 490, + "editNotes": "", + "createdAt": "2016-08-01T16:24:09.000Z", + "updatedAt": "2016-08-01T16:24:09.000Z" + } + }, + { + "id": 488, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/mentioned", + "createdAt": "2016-08-01T16:23:20.000Z", + "updatedAt": "2016-08-01T16:23:20.000Z", + "metadata": { + "id": 488, + "metadata": { + "name": { + "en-US": "Mentioned" + }, + "description": { + "en-US": "Indicates that the actor mentioned the object, for example in a tweet. " + } + }, + "uriId": 488, + "editNotes": "", + "createdAt": "2016-08-01T16:23:20.000Z", + "updatedAt": "2016-08-01T16:23:20.000Z" + } + }, + { + "id": 246, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/mentored", + "createdAt": "2013-10-14T14:48:25.000Z", + "updatedAt": "2013-10-14T14:48:25.000Z", + "metadata": { + "id": 246, + "metadata": { + "name": { + "en-US": "mentored" + }, + "description": { + "en-US": "Indicates that that the actor has mentored the object. For instance, a manager mentoring an employee, or a teacher mentoring a student. " + } + }, + "uriId": 246, + "editNotes": "", + "createdAt": "2013-10-14T14:48:25.000Z", + "updatedAt": "2013-10-14T14:48:25.000Z" + } + }, + { + "id": 47, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/paused", + "createdAt": "2013-08-05T13:41:29.000Z", + "updatedAt": "2013-08-05T13:41:29.000Z", + "metadata": { + "id": 47, + "metadata": { + "name": { + "en-US": "paused" + }, + "description": { + "en-US": "To indicate an actor has ceased or suspended an activity temporarily." + } + }, + "uriId": 47, + "editNotes": "", + "createdAt": "2013-08-05T13:41:29.000Z", + "updatedAt": "2013-08-05T13:41:29.000Z" + } + }, + { + "id": 445, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/performed-offline", + "createdAt": "2016-08-01T16:04:48.000Z", + "updatedAt": "2016-08-01T16:04:48.000Z", + "metadata": { + "id": 445, + "metadata": { + "name": { + "en-US": "performed" + }, + "description": { + "en-US": "Indicates that the actor has performed the object offline for a period of time (episode). For instance, a learner performed task X, which is an offline task like reading a book, for 30 minutes. This is used to record the time spent on offline activities." + } + }, + "uriId": 445, + "editNotes": "", + "createdAt": "2016-08-01T16:04:48.000Z", + "updatedAt": "2016-08-01T16:04:48.000Z" + } + }, + { + "id": 452, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/personalized", + "createdAt": "2016-08-01T16:06:24.000Z", + "updatedAt": "2016-08-01T16:06:24.000Z", + "metadata": { + "id": 452, + "metadata": { + "name": { + "en-US": "personalized" + }, + "description": { + "en-US": "Indicates that the actor personalized the object. The idea is that the actor personalizes an object created by a third party to adapt it for his/her personal use. This can be used for personalizing a strategy, method, a cooking recipe, etc." + } + }, + "uriId": 452, + "editNotes": "", + "createdAt": "2016-08-01T16:06:24.000Z", + "updatedAt": "2016-08-01T16:06:24.000Z" + } + }, + { + "id": 364, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/previewed", + "createdAt": "2015-01-22T14:42:14.000Z", + "updatedAt": "2015-01-22T14:42:14.000Z", + "metadata": { + "id": 364, + "metadata": { + "name": { + "en-US": "previewed" + }, + "description": { + "en-US": "Used to indicate that an actor has taken a first glance at a piece of content that they plan to return to for a more in depth experience later. For instance someone may come across a webpage that they don't have enough time to read at that time, but plan to come back to and read fully." + } + }, + "uriId": 364, + "editNotes": "", + "createdAt": "2015-01-22T14:42:14.000Z", + "updatedAt": "2015-01-22T14:42:14.000Z" + } + }, + { + "id": 489, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/promoted", + "createdAt": "2016-08-01T16:23:39.000Z", + "updatedAt": "2016-08-01T16:23:39.000Z", + "metadata": { + "id": 489, + "metadata": { + "name": { + "en-US": "Promoted" + }, + "description": { + "en-US": "The act of promoting a content item such that it appears more highly in search results or is promoted to users in some other way. " + } + }, + "uriId": 489, + "editNotes": "", + "createdAt": "2016-08-01T16:23:39.000Z", + "updatedAt": "2016-08-01T16:23:39.000Z" + } + }, + { + "id": 306, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/rated", + "createdAt": "2014-08-06T16:15:47.000Z", + "updatedAt": "2014-08-06T16:15:47.000Z", + "metadata": { + "id": 306, + "metadata": { + "name": { + "en-US": "rated" + }, + "description": { + "en-US": "Action of giving a rating to an object. Should only be used when the action is the rating itself, as opposed to another action such as \"reading\" where a rating can be applied to the object as part of that action. In general the rating should be included in the Result with a Score object." + } + }, + "uriId": 306, + "editNotes": "", + "createdAt": "2014-08-06T16:15:47.000Z", + "updatedAt": "2014-08-06T16:15:47.000Z" + } + }, + { + "id": 487, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/replied", + "createdAt": "2016-08-01T16:23:12.000Z", + "updatedAt": "2016-08-01T16:23:12.000Z", + "metadata": { + "id": 487, + "metadata": { + "name": { + "en-US": "Replied" + }, + "description": { + "en-US": "The actor posted a reply to a forum, comment thread or discussion. " + } + }, + "uriId": 487, + "editNotes": "", + "createdAt": "2016-08-01T16:23:12.000Z", + "updatedAt": "2016-08-01T16:23:12.000Z" + } + }, + { + "id": 195, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/replied-to-tweet", + "createdAt": "2013-09-03T16:57:15.000Z", + "updatedAt": "2013-09-03T16:57:15.000Z", + "metadata": { + "id": 195, + "metadata": { + "name": { + "en-US": "replied to tweet" + }, + "description": { + "en-US": "This is an extension of the tweeted verb for the specific case of a tweet replying to another. This can be used to track group discussions experience. As with Retweeted we expect to find the original tweet id in the context as well as the person's handle to which the reply is addressed using the tweet extension URI http://id.tincanapi.com/extension/tweet" + } + }, + "uriId": 195, + "editNotes": "", + "createdAt": "2013-09-03T16:57:15.000Z", + "updatedAt": "2013-09-03T16:57:15.000Z" + } + }, + { + "id": 273, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/requested-attention", + "createdAt": "2014-03-18T01:21:09.000Z", + "updatedAt": "2014-03-18T01:21:09.000Z", + "metadata": { + "id": 273, + "metadata": { + "name": { + "en-US": "requested attention" + }, + "description": { + "en-US": "Indicates that the actor is requesting the attention of an instructor, presenter or moderator. " + } + }, + "uriId": 273, + "editNotes": "", + "createdAt": "2014-03-18T01:21:09.000Z", + "updatedAt": "2014-03-18T01:21:09.000Z" + } + }, + { + "id": 194, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/retweeted", + "createdAt": "2013-09-03T16:54:08.000Z", + "updatedAt": "2013-09-03T16:54:08.000Z", + "metadata": { + "id": 194, + "metadata": { + "name": { + "en-US": "retweeted" + }, + "description": { + "en-US": "Used when an agent repeats a tweet written by another user. Usage in a statement is similar to tweeted but we expect to find the URI to the original tweet in the context of the statement, as well as the username of the original author as a context extension. The extension URI used for this should be http://id.tincanapi.com/extension/tweet" + } + }, + "uriId": 194, + "editNotes": "", + "createdAt": "2013-09-03T16:54:08.000Z", + "updatedAt": "2013-09-03T16:54:08.000Z" + } + }, + { + "id": 247, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/reviewed", + "createdAt": "2013-10-14T14:53:24.000Z", + "updatedAt": "2013-10-14T14:53:24.000Z", + "metadata": { + "id": 247, + "metadata": { + "name": { + "en-US": "reviewed" + }, + "description": { + "en-US": "Indicates that the actor has reviewed the object. For instance, a person reviewing an employee or a person reviewing an owner's manual." + } + }, + "uriId": 247, + "editNotes": "", + "createdAt": "2013-10-14T14:53:24.000Z", + "updatedAt": "2013-10-14T14:53:24.000Z" + } + }, + { + "id": 417, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/secured", + "createdAt": "2016-02-16T12:59:13.000Z", + "updatedAt": "2016-02-16T12:59:13.000Z", + "metadata": { + "id": 417, + "metadata": { + "name": { + "en-US": "Secured" + }, + "description": { + "en-US": "Indicates that the actor secured the object. The object used with this verb might be a device, piece of software, location, etc." + } + }, + "uriId": 417, + "editNotes": "", + "createdAt": "2016-02-16T12:59:13.000Z", + "updatedAt": "2016-02-16T12:59:13.000Z" + } + }, + { + "id": 448, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/selected", + "createdAt": "2016-08-01T16:05:39.000Z", + "updatedAt": "2016-08-01T16:05:39.000Z", + "metadata": { + "id": 448, + "metadata": { + "name": { + "en-US": "selected" + }, + "description": { + "en-US": "Indicates that the actor selects an object from a collection or set to use it in an activity. The collection would be the context parent element." + } + }, + "uriId": 448, + "editNotes": "", + "createdAt": "2016-08-01T16:05:39.000Z", + "updatedAt": "2016-08-01T16:05:39.000Z" + } + }, + { + "id": 48, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/skipped", + "createdAt": "2013-08-05T13:42:09.000Z", + "updatedAt": "2013-08-05T13:42:09.000Z", + "metadata": { + "id": 48, + "metadata": { + "name": { + "en-US": "skipped" + }, + "description": { + "en-US": "To indicate an actor has passed over or omitted an interval, screen, segment, item, or step." + } + }, + "uriId": 48, + "editNotes": "", + "createdAt": "2013-08-05T13:42:09.000Z", + "updatedAt": "2013-08-05T13:42:09.000Z" + } + }, + { + "id": 418, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/talked-with", + "createdAt": "2016-02-16T12:59:56.000Z", + "updatedAt": "2016-02-16T12:59:56.000Z", + "metadata": { + "id": 418, + "metadata": { + "name": { + "en-US": "talked" + }, + "description": { + "en-US": "Indicates that the actor talked to another agent or group. The object of statements using this verb should be an agent or group, for example a teacher, an NPC in a simulation, a group of colleagues. This verb is intended to be used where one actor initiates and leads a conversation, rather than an equal discussion between two parties." + } + }, + "uriId": 418, + "editNotes": "", + "createdAt": "2016-02-16T12:59:56.000Z", + "updatedAt": "2016-02-16T12:59:56.000Z" + } + }, + { + "id": 502, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/terminated-employment-with", + "createdAt": "2017-11-06T21:22:07.000Z", + "updatedAt": "2017-11-06T21:22:07.000Z", + "metadata": { + "id": 502, + "metadata": { + "name": { + "en-US": "Terminated employment with" + }, + "description": { + "en-US": "Indicates that the actor's employment with the organization represented by the object of the statement has been terminated for some reason. Use of this verb does not imply any particular reason for the termination. The actor may have been fired, quit, died etc." + } + }, + "uriId": 502, + "editNotes": "", + "createdAt": "2017-11-06T21:22:07.000Z", + "updatedAt": "2017-11-06T21:22:07.000Z" + } + }, + { + "id": 193, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/tweeted", + "createdAt": "2013-09-03T16:52:25.000Z", + "updatedAt": "2013-09-03T16:52:25.000Z", + "metadata": { + "id": 193, + "metadata": { + "name": { + "en-US": "tweeted" + }, + "description": { + "en-US": "Use this verb when an agent tweets on Twitter. It is open for use also for other short messages (microblogging services) based on the URI as the activityId.\nWe expect activityId to be a URI to the tweet." + } + }, + "uriId": 193, + "editNotes": "", + "createdAt": "2013-09-03T16:52:25.000Z", + "updatedAt": "2013-09-03T16:52:25.000Z" + } + }, + { + "id": 277, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/unfocused", + "createdAt": "2014-03-27T17:31:46.000Z", + "updatedAt": "2014-03-27T17:31:46.000Z", + "metadata": { + "id": 277, + "metadata": { + "name": { + "en-US": "unfocused" + }, + "description": { + "en-US": "Indicates that the user has lost focus of the target object. For example, this could be used when the user clicks outside a given application, window or activity." + } + }, + "uriId": 277, + "editNotes": "", + "createdAt": "2014-03-27T17:31:46.000Z", + "updatedAt": "2014-03-27T17:31:46.000Z" + } + }, + { + "id": 376, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/unregistered", + "createdAt": "2015-03-27T15:04:53.000Z", + "updatedAt": "2015-03-27T15:04:53.000Z", + "metadata": { + "id": 376, + "metadata": { + "name": { + "en-US": "unregistered" + }, + "description": { + "en-US": "Indicates the actor unregistered for a learning activity. This verb is used in combination with http://adlnet.gov/expapi/verbs/registered for the registering and unregistering of learners." + } + }, + "uriId": 376, + "editNotes": "", + "createdAt": "2015-03-27T15:04:53.000Z", + "updatedAt": "2015-03-27T15:04:53.000Z" + } + }, + { + "id": 292, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/viewed", + "createdAt": "2014-05-27T15:43:54.000Z", + "updatedAt": "2014-05-27T15:43:54.000Z", + "metadata": { + "id": 292, + "metadata": { + "name": { + "en-US": "viewed" + }, + "description": { + "en-US": "Indicates that the actor has viewed the object." + } + }, + "uriId": 292, + "editNotes": "", + "createdAt": "2014-05-27T15:43:54.000Z", + "updatedAt": "2014-05-27T15:43:54.000Z" + } + }, + { + "id": 301, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/voted-down", + "createdAt": "2014-07-17T15:19:37.000Z", + "updatedAt": "2014-07-17T15:19:37.000Z", + "metadata": { + "id": 301, + "metadata": { + "name": { + "en-US": "down voted" + }, + "description": { + "en-US": "Indicates that the actor has voted down for a specific object. This is analogous to giving a thumbs down." + } + }, + "uriId": 301, + "editNotes": "", + "createdAt": "2014-07-17T15:19:37.000Z", + "updatedAt": "2014-07-17T15:19:37.000Z" + } + }, + { + "id": 300, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/voted-up", + "createdAt": "2014-07-17T15:19:21.000Z", + "updatedAt": "2014-07-17T15:19:21.000Z", + "metadata": { + "id": 300, + "metadata": { + "name": { + "en-US": "up voted" + }, + "description": { + "en-US": "Indicates that the actor has voted up for a specific object. This is analogous to giving a thumbs up." + } + }, + "uriId": 300, + "editNotes": "", + "createdAt": "2014-07-17T15:19:21.000Z", + "updatedAt": "2014-07-17T15:19:21.000Z" + } + }, + { + "id": 501, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/was-assigned-job-title", + "createdAt": "2017-11-06T21:21:35.000Z", + "updatedAt": "2017-11-06T21:21:35.000Z", + "metadata": { + "id": 501, + "metadata": { + "name": { + "en-US": "Was assigned job title" + }, + "description": { + "en-US": "Indicates that the actor was assigned the job title represented by the object of the statement. This object should use the activity type http://id.tincanapi.com/activitytype/job-title. This verb is used any time when the person's job title changes, for example when they are first hired and any time they are promoted, demoted or otherwise change job. " + } + }, + "uriId": 501, + "editNotes": "", + "createdAt": "2017-11-06T21:21:35.000Z", + "updatedAt": "2017-11-06T21:21:35.000Z" + } + }, + { + "id": 503, + "kind": "verb", + "uri": "http://id.tincanapi.com/verb/was-hired-by", + "createdAt": "2017-11-06T21:22:22.000Z", + "updatedAt": "2017-11-06T21:22:22.000Z", + "metadata": { + "id": 503, + "metadata": { + "name": { + "en-US": "was hired by" + }, + "description": { + "en-US": "Indicates that the actor was hired by the organization represented by the object of the statement. " + } + }, + "uriId": 503, + "editNotes": "", + "createdAt": "2017-11-06T21:22:22.000Z", + "updatedAt": "2017-11-06T21:22:22.000Z" + } + }, + { + "id": 320, + "kind": "verb", + "uri": "http://risc-inc.com/annotator/verbs/annotated", + "createdAt": "2014-12-15T15:38:23.000Z", + "updatedAt": "2014-12-15T15:38:23.000Z", + "metadata": { + "id": 320, + "metadata": { + "name": { + "en-US": "Annotated" + }, + "description": { + "en-US": "Indicates a new annotation has been added to a document. This verb may be used with PDFs, images, assignment submissions or any other type of document which may be annotated. " + } + }, + "uriId": 320, + "editNotes": "", + "createdAt": "2014-12-15T15:38:23.000Z", + "updatedAt": "2014-12-15T15:38:23.000Z" + } + }, + { + "id": 319, + "kind": "verb", + "uri": "http://risc-inc.com/annotator/verbs/modified", + "createdAt": "2014-12-15T15:38:19.000Z", + "updatedAt": "2014-12-15T15:38:19.000Z", + "metadata": { + "id": 319, + "metadata": { + "name": { + "en-US": "Modified annotation" + }, + "description": { + "en-US": "This verb is used on annotations created with the http://risc-inc.com/annotator/verbs/annotated verb. It indicates that an existing annotation has been modified, for example editing the text of a note annotation or adjusting the position of a underline or highlight. " + } + }, + "uriId": 319, + "editNotes": "", + "createdAt": "2014-12-15T15:38:19.000Z", + "updatedAt": "2014-12-15T15:38:19.000Z" + } + }, + { + "id": 380, + "kind": "verb", + "uri": "http://specification.openbadges.org/xapi/verbs/earned", + "createdAt": "2015-03-31T09:08:54.000Z", + "updatedAt": "2015-03-31T09:08:54.000Z", + "metadata": { + "id": 380, + "metadata": { + "name": { + "en-US": "Earned an Open Badge" + }, + "description": { + "en-US": "Indicates that the actor has been recognized by an Open Badge issuer for an achievement. The actor may claim the badge referenced as the object and use it as a verifiable credential, wherever Open Badges are accepted." + } + }, + "uriId": 380, + "editNotes": "", + "createdAt": "2015-03-31T09:08:54.000Z", + "updatedAt": "2015-03-31T09:08:54.000Z" + } + }, + { + "id": 308, + "kind": "verb", + "uri": "http://www.digital-knowledge.co.jp/tincanapi/verbs/drew", + "createdAt": "2014-09-24T14:47:27.000Z", + "updatedAt": "2014-09-24T14:47:27.000Z", + "metadata": { + "id": 308, + "metadata": { + "name": { + "en-US": "drew" + }, + "description": { + "en-US": "Indicates that the actor has created a picture of something using a physical drawing utensil or a digital input device." + } + }, + "uriId": 308, + "editNotes": "", + "createdAt": "2014-09-24T14:47:27.000Z", + "updatedAt": "2014-09-24T14:47:27.000Z" + } + }, + { + "id": 179, + "kind": "verb", + "uri": "http://www.tincanapi.co.uk/pages/verbs.html#cancelled_planned_learning", + "createdAt": "2013-08-24T18:53:42.000Z", + "updatedAt": "2013-08-24T18:53:42.000Z", + "metadata": { + "id": 179, + "metadata": { + "name": { + "en-US": "cancelled planned learning" + }, + "description": { + "en-US": "This verb is a weaker version of \"http://adlnet.gov/expapi/verbs/voided\" and is used to let a Learning Planning System know that this particular plan has been superseded and is no longer relevant. It implies a change of plans, whereas voiding the statement would indicate that the plan had never actually been made in the first place. \n\nThe object of this verb should always be a statement reference pointed to the id of statement using either the \"http://www.tincanapi.co.uk/verbs/planned_learning\" or \"http://www.tincanapi.co.uk/verbs/enrolled_onto_learning_plan\" verbs.\n\nWhere a whole learning plan is cancelled, every item in that plan is considered to be cancelled. \"http://adlnet.gov/expapi/verbs/voided\" would suggest that the plan was never made in the first place." + } + }, + "uriId": 179, + "editNotes": "", + "createdAt": "2013-08-24T18:53:42.000Z", + "updatedAt": "2013-08-24T18:53:42.000Z" + } + }, + { + "id": 178, + "kind": "verb", + "uri": "http://www.tincanapi.co.uk/pages/verbs.html#planned_learning", + "createdAt": "2013-08-24T18:53:34.000Z", + "updatedAt": "2013-08-24T18:53:34.000Z", + "metadata": { + "id": 178, + "metadata": { + "name": { + "en-US": "Planned" + }, + "description": { + "en-US": "Used to assert an intention to undertake a learning experience or activity. The object of the statement using this verb should always be a subStatement. See http://tincanapi.co.uk/pages/I_Want_This.html and http://tincanapi.co.uk/pages/The_Learning_Plan_Framework.html#Planned for more details. " + } + }, + "uriId": 178, + "editNotes": "", + "createdAt": "2013-08-24T18:53:34.000Z", + "updatedAt": "2013-08-24T18:53:34.000Z" + } + }, + { + "id": 177, + "kind": "verb", + "uri": "http://www.tincanapi.co.uk/verbs/enrolled_onto_learning_plan", + "createdAt": "2013-08-24T18:53:23.000Z", + "updatedAt": "2013-08-24T18:53:23.000Z", + "metadata": { + "id": 177, + "metadata": { + "name": { + "en-US": "enrolled onto learning plan" + }, + "description": { + "en-US": "This verb is used to add additional learners to an existing learning plan, or a new plan if one does not exist. \n\nThe actor of this statement is the person who is being enrolled onto the plan. Where the enrolment is being assigned by a 3rd party (or the plan was created by a 3rd party), the context instructor property may be used. \n\nThe object of statements using this verb will always be an activity representing the learning plan. \n\nSee http://tincanapi.co.uk/pages/The_Learning_Plan_Framework.html for more details." + } + }, + "uriId": 177, + "editNotes": "", + "createdAt": "2013-08-24T18:53:23.000Z", + "updatedAt": "2013-08-24T18:53:23.000Z" + } + }, + { + "id": 176, + "kind": "verb", + "uri": "http://www.tincanapi.co.uk/verbs/evaluated", + "createdAt": "2013-08-24T18:53:04.000Z", + "updatedAt": "2013-08-24T18:53:04.000Z", + "metadata": { + "id": 176, + "metadata": { + "name": { + "en-US": "evaluated" + }, + "description": { + "en-US": "Verb used for evaluating a previous learning experience. The object of the statement should normally be a StatementRef pointing to an existing statement about the experience being evaluated. The actual evaluation should be provided in the result as either a score, response or both. \n\nSee http://tincanapi.co.uk/pages/Tin_Can_Learning_Evaluator.html#Evaluating_and_Reflecting for further details and examples. " + } + }, + "uriId": 176, + "editNotes": "", + "createdAt": "2013-08-24T18:53:04.000Z", + "updatedAt": "2013-08-24T18:53:04.000Z" + } + }, + { + "id": 285, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/added/", + "createdAt": "2014-04-29T19:01:23.000Z", + "updatedAt": "2014-04-29T19:01:23.000Z", + "metadata": { + "id": 285, + "metadata": { + "name": { + "en-US": "added" + }, + "description": { + "en-US": "Added one or more items to a collection" + } + }, + "uriId": 285, + "editNotes": "", + "createdAt": "2014-04-29T19:01:23.000Z", + "updatedAt": "2014-04-29T19:01:23.000Z" + } + }, + { + "id": 284, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/loggedin/", + "createdAt": "2014-04-29T19:01:20.000Z", + "updatedAt": "2014-04-29T19:01:20.000Z", + "metadata": { + "id": 284, + "metadata": { + "name": { + "en-US": "Log In" + }, + "description": { + "en-US": "Logged in to some service" + } + }, + "uriId": 284, + "editNotes": "", + "createdAt": "2014-04-29T19:01:20.000Z", + "updatedAt": "2014-04-29T19:01:20.000Z" + } + }, + { + "id": 398, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/loggedout/", + "createdAt": "2015-05-28T16:49:13.000Z", + "updatedAt": "2015-05-28T16:49:13.000Z", + "metadata": { + "id": 398, + "metadata": { + "name": { + "en-US": "Log Out" + }, + "description": { + "en-US": "Logged out of some service." + } + }, + "uriId": 398, + "editNotes": "", + "createdAt": "2015-05-28T16:49:13.000Z", + "updatedAt": "2015-05-28T16:49:13.000Z" + } + }, + { + "id": 262, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/ran/", + "createdAt": "2013-12-20T14:55:29.000Z", + "updatedAt": "2013-12-20T14:55:29.000Z", + "metadata": { + "id": 262, + "metadata": { + "name": { + "en-US": "ran" + }, + "description": { + "en-US": "Indicates that the actor ran a distance indicated by the activity" + } + }, + "uriId": 262, + "editNotes": "", + "createdAt": "2013-12-20T14:55:29.000Z", + "updatedAt": "2013-12-20T14:55:29.000Z" + } + }, + { + "id": 286, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/removed/", + "createdAt": "2014-04-29T19:01:28.000Z", + "updatedAt": "2014-04-29T19:01:28.000Z", + "metadata": { + "id": 286, + "metadata": { + "name": { + "en-US": "removed" + }, + "description": { + "en-US": "Removed one or more items from a collection" + } + }, + "uriId": 286, + "editNotes": "", + "createdAt": "2014-04-29T19:01:28.000Z", + "updatedAt": "2014-04-29T19:01:28.000Z" + } + }, + { + "id": 260, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/reviewed/", + "createdAt": "2013-12-20T14:55:21.000Z", + "updatedAt": "2013-12-20T14:55:21.000Z", + "metadata": { + "id": 260, + "metadata": { + "name": { + "en-US": "reviewed" + }, + "description": { + "en-US": "Indicates that the actor returned to and reviewed the activity" + } + }, + "uriId": 260, + "editNotes": "", + "createdAt": "2013-12-20T14:55:21.000Z", + "updatedAt": "2013-12-20T14:55:21.000Z" + } + }, + { + "id": 261, + "kind": "verb", + "uri": "https://brindlewaye.com/xAPITerms/verbs/walked/", + "createdAt": "2013-12-20T14:55:25.000Z", + "updatedAt": "2013-12-20T14:55:25.000Z", + "metadata": { + "id": 261, + "metadata": { + "name": { + "en-US": "walked" + }, + "description": { + "en-US": "Indicates that the actor walked a distance indicated by the activity" + } + }, + "uriId": 261, + "editNotes": "", + "createdAt": "2013-12-20T14:55:25.000Z", + "updatedAt": "2013-12-20T14:55:25.000Z" + } + } +] \ No newline at end of file diff --git a/modules/lo_gpt/README.md b/modules/lo_gpt/README.md new file mode 100644 index 000000000..34aebb103 --- /dev/null +++ b/modules/lo_gpt/README.md @@ -0,0 +1,34 @@ +# Learning Observer GPT + +This module allows users make queries to a GPT model. + +This code is abstracted directly from the `wo_bulk_essay_analysis` module and should be treated as scaffolding as we determine the appropriate way to use query GPT responders. + +## Long-term goals / Future work + +### General Design + +Currently the GPT responders are using an Object Oriented approach which clashes with much of Learning Observer's design. We ought to change this from an OO approach to a Functional Programming approach. + +### Querying Responders + +This package currently initializes a single GPT responder for use. In practice, we need to keep track of multiple responders depending on the context/use case. The different responders ought to be defined and queried via PMSS like + +```css +.wo .group_a { + gpt_responder: openai; + model: gpt-4o; + temperature: 0.5; +} + +.dynamic-assessment { + gpt_responder: ollama; + ollama_server: http://my-ollama-server.learning-observer.org/ +} + +[school=ncsu] { + open_ai_creds: [NCSU-billed account] +} + +// ... and so on +``` diff --git a/modules/lo_gpt/lo_gpt/__init__.py b/modules/lo_gpt/lo_gpt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_gpt/lo_gpt/gpt.py b/modules/lo_gpt/lo_gpt/gpt.py new file mode 100644 index 000000000..b3eac5085 --- /dev/null +++ b/modules/lo_gpt/lo_gpt/gpt.py @@ -0,0 +1,251 @@ +''' +This file defines connections to various GPT providers. Each provider +defines a chat completion method. Supported large-language proviers: + +- OpenAI ChatGPT +- Ollama + +TODO OpenAI is built to provide multiple choices. The response from their +API looks like `response['choices'][0]['message']['content']`. The Ollama +API only provides a single completion so the response looks like +`response['message']['content']`. Much of the code is shared between both +APIs so the code can be abstracted to the parent class. +''' +import aiohttp +import asyncio +import json +import loremipsum +import os +import random + +from learning_observer.log_event import debug_log +import learning_observer.prestartup +import learning_observer.settings + +gpt_responder = None +SYSTEM_PROMPT_DEFAULT = 'You are a helper agent, please help fulfill user requests.' +LLM_SEMAPHOR = {} + + +class GPTAPI: + def chat_completion(self, prompt, system_prompt): + ''' + Respond to user prompt + ''' + raise NotImplementedError + + +class GPTInitializationError(Exception): + '''Raise when GPT fails to initialize. + This usually happens when the users forgets to + provide information in the `creds.yaml` file. + ''' + + +class GPTRequestErorr(Exception): + '''Raise when the GPT chat completion raises + an exception + ''' + + +class OpenAIGPT(GPTAPI): + def __init__(self, **kwargs): + ''' + kwargs: + - `model`: the GPT model we should use, defaults to `gpt-3.5-turbo-16k` + - `api_key`: OpenAI api key, defaults to the `OPENAI_API_KEY` environment variable. + ''' + super().__init__() + self.model = kwargs.get('model', 'gpt-3.5-turbo-16k') + self.api_key = kwargs.get('api_key', os.getenv('OPENAI_API_KEY')) + if self.api_key is None: + exception_text = 'Error while starting openai:\n'\ + 'Please ensure that the API Key is correctly configured in '\ + '`creds.yaml` under `modules.writing_observer.gpt_responders.openai.api_key`, '\ + 'or alternatively, set it as the `OPENAI_API_KEY` environment '\ + 'variable.' + raise GPTInitializationError(exception_text) + + async def chat_completion(self, prompt, system_prompt): + url = 'https://api.openai.com/v1/chat/completions' + headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {self.api_key}'} + messages = [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': prompt} + ] + content = {'model': self.model, 'messages': messages} + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=content) as resp: + json_resp = await resp.json() + if resp.status == 200: + return json_resp['choices'][0]['message']['content'] + error = 'Error occured while making OpenAI request' + if 'error' in json_resp: + error += f"\n{json_resp['error']['message']}" + raise GPTRequestErorr(error) + + +class OllamaGPT(GPTAPI): + '''GPT responder for handling request to the Ollama API + ''' + def __init__(self, **kwargs): + ''' + kwargs + - `model`: the GPT model we should use, defaults to `llama2` + - `host`: Ollama server to connect to - the Ollama client will + default to `localhost:11434`. + ''' + super().__init__() + self.model = kwargs.get('model', 'llama2') + # the Ollama client checks for the `OLLAMA_HOST` env variable + # or defaults to `localhost:11434`. We provide a warning when + # a specific host is not found. + self.ollama_host = kwargs.get('host', os.getenv('OLLAMA_HOST', None)) + if self.ollama_host is None: + debug_log('WARNING:: Ollama host not specified. Defaulting to '\ + '`localhost:11434`.\nTo set a specific host, set '\ + '`modules.writing_observer.gpt_responders.ollama.host` '\ + 'in `creds.yaml` or set the `OLLAMA_HOST` environment '\ + 'variable.\n'\ + 'If you wish to install Ollama and download a model, '\ + 'run the following commands:\n'\ + '```bash\ncurl https://ollama.ai/install.sh | sh\n'\ + 'ollama run \n```') + self.ollama_host = 'http://localhost:11434' + + global LLM_SEMAPHOR + if 'ollama' not in LLM_SEMAPHOR: + LLM_SEMAPHOR['ollama'] = asyncio.Semaphore(os.getenv('OLLAMA_NUM_PARALLEL', 1)) + + async def chat_completion(self, prompt, system_prompt): + '''Ollama only returns a single item compared to GPT returning a list + ''' + url = f'{self.ollama_host}/api/chat' + messages = [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': prompt} + ] + content = {'model': self.model, 'messages': messages, 'stream': False} + async with LLM_SEMAPHOR['ollama']: + async with aiohttp.ClientSession() as session: + async with session.post(url, json=content) as resp: + json_resp = await resp.json(content_type=None) + if resp.status == 200: + return json_resp['message']['content'] + error = 'Error occured while making Ollama request' + if 'error' in json_resp: + error += f"\n{json_resp['error']['message']}" + raise GPTRequestErorr(error) + + +class StubGPT(GPTAPI): + '''GPT responder for handling stub requests + ''' + def __init__(self, **kwargs): + super().__init__() + + global LLM_SEMAPHOR + if 'stub' not in LLM_SEMAPHOR: + LLM_SEMAPHOR['stub'] = asyncio.Semaphore(2) + + async def chat_completion(self, prompt, system_prompt): + async with LLM_SEMAPHOR['stub']: + await asyncio.sleep(random.randint(5, 15)) + return "\n".join(loremipsum.get_paragraphs(1)) + + +GPT_RESPONDERS = { + 'openai': OpenAIGPT, + 'ollama': OllamaGPT, + 'stub': StubGPT +} + + +@learning_observer.prestartup.register_startup_check +def initialize_gpt_responder(): + '''Iterate over the gpt_responders listed in `creds.yaml` + and attempt to initialize it. On successful initialization + of a responder, exit the this startup check. Otherwise, + try the next one. + ''' + global gpt_responder + # TODO change this to use settings.module_settings() instead + # that method now uses pmss which doesn't support lists and + # dictionaries yet. + # TODO think through how we might use different gpt responders + # with different PMSS groups. We may have mutliple responders + # running on the same system for different modules or schools. + responders = learning_observer.settings.settings['modules']['writing_observer'].get('gpt_responders', {}) + exceptions = [] + for key in responders: + if key not in GPT_RESPONDERS: + exceptions.append(KeyError( + f'GPT Responder `{key}` is not yet configured on this system.\n'\ + f'The available responders are [{", ".join(GPT_RESPONDERS.keys())}].' + )) + continue + try: + gpt_responder = GPT_RESPONDERS[key](**responders[key]) + debug_log(f'INFO:: Using GPT responder `{key}` with model `{responders[key]["model"]}`') + return True + except GPTInitializationError as e: + exceptions.append(e) + debug_log(f'WARNING:: Unable to initialize GPT responder `{key}:`.\n{e}') + gpt_responder = None + no_responders = 'No GPT responders found in `creds.yaml`. To add a responder, add either'\ + '`openai` or `ollama` along with any subsettings to `modules.writing_observer.gpt_responders`.\n'\ + 'Example:\n```\ngpt_responders:\n ollama:\n model: llama2\n```' + exception_strings = '\n'.join(str(e) for e in exceptions) if len(exceptions) > 0 else no_responders + exception_text = 'Unable to initialize a GPT responder. Encountered the following errors:\n'\ + f'{exception_strings}' + raise learning_observer.prestartup.StartupCheck("GPT: " + exception_text) + + +async def api_chat_completion(request): + '''This function drieclty interacts with the gpt_responder + chat completion interface. + + Expected Input (Request): + ------------------------- + - Method: POST + - Content-Type: application/json + - JSON Body: + { + "prompt": "", # Required + "system_prompt": "" # Optional, default `You are a helper agent, please help fulfill user requests.` + } + + Expected Output (Response): + --------------------------- + - Status: 200 OK + - Content-Type: application/json + - JSON Body: + { + "response": "" + } + ''' + # TODO add error handling + global gpt_responder + try: + data = await request.json() + except json.JSONDecodeError: + return aiohttp.web.json_response({"error": "Invalid JSON"}, status=400) + + prompt = data['prompt'] + system_prompt = data.get('system_prompt', SYSTEM_PROMPT_DEFAULT) + response_data = {'response': await gpt_responder.chat_completion(prompt, system_prompt)} + return aiohttp.web.json_response(response_data) + + +async def test_responder(): + responder = OllamaGPT('llama2') + response = await responder.chat_completion('Why is the sky blue?') + print('Response:', response) + + +if __name__ == '__main__': + import asyncio + loop = asyncio.get_event_loop() + asyncio.ensure_future(test_responder()) + loop.run_forever() + loop.close() diff --git a/modules/lo_gpt/lo_gpt/module.py b/modules/lo_gpt/lo_gpt/module.py new file mode 100644 index 000000000..46799d74d --- /dev/null +++ b/modules/lo_gpt/lo_gpt/module.py @@ -0,0 +1,26 @@ +''' +Learning Observer GPT +''' +import learning_observer.downloads as d +import learning_observer.communication_protocol.query as q +from learning_observer.dash_integration import thirdparty_url, static_url +from learning_observer.stream_analytics.helpers import KeyField, Scope + +import lo_gpt.gpt + +# Name for the module +NAME = 'Learning Observer GPT' + +# TODO Create a really simple dashboard for interfacing with the GPT api. +# This should be created in NextJS. +# An input for each api parameter, a submit button, and the json output. + +''' +Additional API calls we can define, this one returns the colors of the rainbow +''' +EXTRA_VIEWS = [{ + 'name': 'GPT Chat Completion', + 'suburl': 'api/llm', + 'method': 'POST', + 'handler': lo_gpt.gpt.api_chat_completion +}] diff --git a/modules/lo_gpt/setup.cfg b/modules/lo_gpt/setup.cfg new file mode 100644 index 000000000..7ca78a5ad --- /dev/null +++ b/modules/lo_gpt/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = Learning Observer GPT +description = Use this to iterface with GPT models. + +[options] +packages = lo_gpt + +[options.entry_points] +lo_modules = + lo_gpt = lo_gpt.module diff --git a/modules/lo_gpt/setup.py b/modules/lo_gpt/setup.py new file mode 100644 index 000000000..46795a41f --- /dev/null +++ b/modules/lo_gpt/setup.py @@ -0,0 +1,14 @@ +''' +Install script. Everything is handled in setup.cfg + +To set up locally for development, run `python setup.py develop`, in a +virtualenv, preferably. +''' +from setuptools import setup + +setup( + name="lo_gpt", + package_data={ + 'lo_gpt': ['assets/*'], + } +) diff --git a/modules/lo_lti_grade_demo/README.md b/modules/lo_lti_grade_demo/README.md new file mode 100644 index 000000000..6e838cb73 --- /dev/null +++ b/modules/lo_lti_grade_demo/README.md @@ -0,0 +1,18 @@ +# LTI Grade Demo + +A minimal example dashboard that uses the Assignment & Grade Service (AGS) proxy +routes registered during an LTI launch. It surfaces routes under +`/views/wo_lti_grade_demo/`: + +- `/lti-grade-demo/` renders a simple HTML dashboard for experimenting with grade + submissions. +- `/session-summary/` dumps the current LTI launch metadata (provider, course + context, and active user). +- `/line-items/` lists cleaned AGS line items for the current course (or a + specified courseId) to help pick a target assignment. +- `/submit-score/` proxies an AGS score payload to the LMS for the current LTI + session. + +The module depends on an active LTI session with `canvas_routes` (or the +relevant LMS routes) enabled so the AGS endpoints are registered in +`learning_observer.integrations.INTEGRATIONS`. diff --git a/modules/lo_lti_grade_demo/lo_lti_grade_demo/__init__.py b/modules/lo_lti_grade_demo/lo_lti_grade_demo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_lti_grade_demo/lo_lti_grade_demo/module.py b/modules/lo_lti_grade_demo/lo_lti_grade_demo/module.py new file mode 100644 index 000000000..d3ec4a965 --- /dev/null +++ b/modules/lo_lti_grade_demo/lo_lti_grade_demo/module.py @@ -0,0 +1,48 @@ +""" +Basic LTI grade submission demo module. + +This module exposes a minimal HTML dashboard (served via the `EXTRA_VIEWS` +mechanism) that reads the active LTI session and posts an AGS score using the +existing Canvas proxy wiring. It is intended as a copy/paste example for new +integrations rather than a production UI. +""" + +from . import views + +NAME = "LTI Grade Demo" + +EXTRA_VIEWS = [ + { + "name": "LTI Grade Demo Dashboard", + "suburl": "lti-grade-demo", + "method": "GET", + "handler": views.render_dashboard, + }, + { + "name": "LTI session summary", + "suburl": "session-summary", + "method": "GET", + "handler": views.session_summary, + }, + { + "name": "Course line items", + "suburl": "line-items", + "method": "GET", + "handler": views.course_line_items, + }, + { + "name": "Submit LTI grade", + "suburl": "submit-score", + "method": "POST", + "handler": views.submit_score, + }, +] + +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/views/lo_lti_grade_demo/lti-grade-demo/", + "icon": { + "type": "fas", + "icon": "fa-star" + } +}] diff --git a/modules/lo_lti_grade_demo/lo_lti_grade_demo/views.py b/modules/lo_lti_grade_demo/lo_lti_grade_demo/views.py new file mode 100644 index 000000000..ae382a3b9 --- /dev/null +++ b/modules/lo_lti_grade_demo/lo_lti_grade_demo/views.py @@ -0,0 +1,315 @@ +import datetime + +import aiohttp_session +from aiohttp import web + +import learning_observer.constants as constants +import learning_observer.integrations +import learning_observer.runtime + + +DASHBOARD_HTML = """ + + + + + LTI Grade Demo + + + +

LTI Grade Demo

+

This sample page reads the LTI launch context from your session and posts a score through the Assignment & Grade Service (AGS) proxy routes.

+ +
+

Session info

+

Loading...

+

+  
+ +
+

Submit a grade

+

Fill the fields and submit. Defaults use the current LTI user and course.

+
+
+ + +
+
+ + + + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

+ +
+ +
+

Last response

+
No submission yet.
+
+ + + + +""" + + +def _current_user_and_context(session): + user = session.get(constants.USER, {}) + context = user.get("lti_context", {}) if isinstance(user, dict) else {} + return user, context + + +def _validate_lti_session(session): + user, context = _current_user_and_context(session) + if not user: + raise web.HTTPUnauthorized(text="No active LTI session") + if constants.AUTH_HEADERS not in session: + raise web.HTTPUnauthorized(text="Missing LTI authorization headers") + if not context: + raise web.HTTPBadRequest(text="Missing LTI launch context") + provider = context.get("provider") + if not provider: + raise web.HTTPBadRequest(text="Missing LTI provider on session") + return user, context, provider + + +async def render_dashboard(request): + await aiohttp_session.get_session(request) # ensure session cookie exists + return web.Response(text=DASHBOARD_HTML, content_type="text/html") + + +async def session_summary(request): + session = await aiohttp_session.get_session(request) + try: + user, context, provider = _validate_lti_session(session) + except web.HTTPException as exc: + raise exc + + response = { + "userId": user.get(constants.USER_ID), + "email": user.get("email"), + "name": user.get("name"), + "provider": provider, + "ltiContext": context, + "hasAuthHeaders": constants.AUTH_HEADERS in session, + } + return web.json_response(response) + + +async def course_line_items(request): + session = await aiohttp_session.get_session(request) + _, context, provider = _validate_lti_session(session) + + integrations = learning_observer.integrations.INTEGRATIONS.get(provider) + if not integrations or "assignments" not in integrations: + raise web.HTTPServiceUnavailable(text="Line item endpoint is not registered for this provider") + + course_id = request.query.get("courseId") or context.get("api_id") + if not course_id: + raise web.HTTPBadRequest(text="courseId is required") + + runtime = learning_observer.runtime.Runtime(request) + line_items = await integrations["assignments"](runtime, courseId=str(course_id)) + return web.json_response(line_items) + + +async def submit_score(request): + session = await aiohttp_session.get_session(request) + user, context, provider = _validate_lti_session(session) + + integrations = learning_observer.integrations.INTEGRATIONS.get(provider) + if not integrations or "raw_lineitem_scores" not in integrations: + raise web.HTTPServiceUnavailable(text="Grade submission endpoint is not registered for this provider") + + try: + data = await request.json() + except Exception as exc: # pragma: no cover - aiohttp provides clear message already + raise web.HTTPBadRequest(text=f"Invalid JSON body: {exc}") + + line_item_id = data.get("lineItemId") + if not line_item_id: + raise web.HTTPBadRequest(text="lineItemId is required") + + course_id = data.get("courseId") or context.get("api_id") + if not course_id: + raise web.HTTPBadRequest(text="courseId is required") + + timestamp = data.get("timestamp") or datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat() + + score_payload = { + "userId": data.get("userId") or user.get(constants.USER_ID), + "scoreGiven": data.get("scoreGiven"), + "scoreMaximum": data.get("scoreMaximum"), + "activityProgress": data.get("activityProgress", "Completed"), + "gradingProgress": data.get("gradingProgress", "FullyGraded"), + "timestamp": timestamp, + } + + runtime = learning_observer.runtime.Runtime(request) + result = await integrations["raw_lineitem_scores"]( + runtime, + courseId=str(course_id), + lineItemId=str(line_item_id), + json_body={k: v for k, v in score_payload.items() if v is not None}, + ) + + return web.json_response({ + "provider": provider, + "courseId": str(course_id), + "lineItemId": str(line_item_id), + "submittedPayload": score_payload, + "lmsResponse": result, + }) diff --git a/modules/lo_lti_grade_demo/pyproject.toml b/modules/lo_lti_grade_demo/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/lo_lti_grade_demo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/lo_lti_grade_demo/setup.cfg b/modules/lo_lti_grade_demo/setup.cfg new file mode 100644 index 000000000..c38499e9e --- /dev/null +++ b/modules/lo_lti_grade_demo/setup.cfg @@ -0,0 +1,12 @@ +[metadata] +name = LTI Grade Demo +version = 0.1 +description = Basic dashboard to inspect LTI context and post AGS scores + +[options] +packages = find: +include_package_data = true + +[options.entry_points] +lo_modules = + lo_lti_grade_demo = lo_lti_grade_demo.module diff --git a/modules/lo_template_module/README.md b/modules/lo_template_module/README.md new file mode 100644 index 000000000..8fe47c057 --- /dev/null +++ b/modules/lo_template_module/README.md @@ -0,0 +1,27 @@ +# Learning Observer Template Module + +This cookiecutter modules should act as a template Learning Obervser module. + +## Create a new module + +To create a new module, run + +```bash +pip install cookiecutter # if not already installed +cd modules/ +cookiecutter lo_template_module/ +``` + +Cookiecutter will prompt you for the necessary information. + +## Install new module + +To install your new module, run + +```bash +pip install -e modules/learning_observer_template/ +``` + +## Helper functions + +This is where I would describe the script that will look for changes, and rebuild/re-install automatically, if I had one. diff --git a/modules/lo_template_module/cookiecutter.json b/modules/lo_template_module/cookiecutter.json new file mode 100644 index 000000000..f6cf702b5 --- /dev/null +++ b/modules/lo_template_module/cookiecutter.json @@ -0,0 +1,7 @@ +{ + "project_name": "Learning Observer Template", + "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}", + "project_hyphenated": "{{ cookiecutter.project_slug.replace('_', '-') }}", + "project_short_description": "My Learning Observer Module.", + "reducer": "student_event_counter" +} diff --git a/modules/lo_template_module/test.sh b/modules/lo_template_module/test.sh new file mode 100755 index 000000000..49f18907c --- /dev/null +++ b/modules/lo_template_module/test.sh @@ -0,0 +1,17 @@ +# This is a small script which builds and installs a test of this template. +# +# It is helpful for development. +# +# Be careful to run it from this directory. + +current_directory=$(pwd) +if [ ! -f "$current_directory/test.sh" ] || [ ! -f "$current_directory/test_module.json" ]; then + echo "test.sh should be run from the directory where it (and test_module.json) are located (e.g. modules/lo_template_module)" + exit 1 +fi + +cd .. +rm -Rf test_template_module +cookiecutter lo_template_module/ --replay-file lo_template_module/test_module.json +cd test_template_module +pip install -e . diff --git a/modules/lo_template_module/test_module.json b/modules/lo_template_module/test_module.json new file mode 100644 index 000000000..3e2f892d0 --- /dev/null +++ b/modules/lo_template_module/test_module.json @@ -0,0 +1,19 @@ +{ + "cookiecutter": { + "project_name": "Test Template Module", + "project_slug": "test_template_module", + "project_hyphenated": "test-template-module", + "project_short_description": "Learning Observer Cookiecutter Test Module.", + "reducer": "student_event_counter", + "_template": "lo_template_module", + "_repo_dir": "lo_template_module", + "_checkout": null + }, + "_cookiecutter": { + "project_name": "Learning Observer Cookiecutter Test Module.", + "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}", + "project_hyphenated": "{{ cookiecutter.project_slug.replace('_', '-') }}", + "project_short_description": "Learning Observer Cookiecutter Test Module.", + "reducer": "student_event_counter" + } +} diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/MANIFEST.in b/modules/lo_template_module/{{ cookiecutter.project_slug }}/MANIFEST.in new file mode 100644 index 000000000..b7ece30a1 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/MANIFEST.in @@ -0,0 +1 @@ +include {{ cookiecutter.project_slug }}/assets/* diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/README.md b/modules/lo_template_module/{{ cookiecutter.project_slug }}/README.md new file mode 100644 index 000000000..de913072c --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/README.md @@ -0,0 +1,146 @@ +# Learning Observer Example Module + +Welcome to the Learning Observer (LO) example module. This document +will detail everything need to create a module for the LO. + +## packaage structure + +```bash +module/ + {{ cookiecutter.project_slug }}/ + assets/ + ... + module.py + reducers.py + dash_dashboards.py + utils.py + tests/ + test_utils.py + MANIFEST.in + setup.cfg + pyproject.toml + VERSION +``` + +### setup.cfg + +Notice we include the following items in our `setup.cfg` file. + +```cfg +[options.entry_points] +lo_modules = + {{ cookiecutter.project_slug }} = {{ cookiecutter.project_slug }}.module + +[options.package_data] +{{ cookiecutter.project_slug }} = helpers/* +``` + +The `lo_modules` entry point tells Learning Observer to treat `{{ cookiecutter.project_slug }}.module` as a pluggable application. + +The package data section is where we include additional directories we want included in the build. + +### pyproject.toml + +The `pyproject.toml` file specifies the build system, which in this case is `setuptools`. It works alongside the `setup.cfg` file to provide metadata for the installation process. + +### MANIFEST.in + +The manifest specifies which files to include during Python packaging. This specifies the additional non-python files we want included. If you do not have additional files needed, this file is unnecessary. + +For modules with Dash-made dashboards, this will typically include a relative path to the assets folder. + +### VERSION + +The VERSION file specifies the version of the package. Each one defaults to `0.1.0`. + +### module.py + +This file defines everything about the module. See the dedicated section below. + +## Defining a module (module.py) + +Modules can include a variety items. This will cover each item and its purpose on the system. + +### NAME + +This one is pretty self explanatory. Give the module a short name to refer to it by. + +### EXECUTION_DAG + +The execution directed acyclic graph (DAG) is how we interact with the communication protocol. + +See `{{ cookiecutter.project_slug }}/module.py:EXECUTION_DAG` for a detailed example. + +### REDUCERS + +Reducers to define on the system. These are functions that will run over incoming events from students. + +See `{{ cookiecutter.project_slug }}/module.py:REDUCERS` for a detailed example. + +### DASH_PAGES + +Dashboards built using the Dash framework should be defined here. + +See `{{ cookiecutter.project_slug }}/module.py:DASH_PAGES` for a detailed example. + +### COURSE_DASHBOARDS + +The registered course dashboards are provided to the users for navigating around dashboards, such as on their Home screen. + +See `{{ cookiecutter.project_slug }}/module.py:COURSE_DASHBOARDS` for a detailed example. + +Note that the student counterpart, `STUDENT_DASHBOARDS`, exists. + +### THIRD_PARTY + +The third party items are downloaded and included when serving items from the module. This is usually used for including extra Javascript or CSS files. + +```python +THIRD_PARTY = { + 'name_of_item': { + 'url': 'url_to_third_party_tool', + 'hash': 'hash_of_download_OR_dict_of_versions_and_hashes' + } +} +``` + +### STATIC_FILE_GIT_REPOS + +We're still figuring this out, but we'd like to support hosting static files from the git repo of the module. +This allows us to have a Merkle-tree style record of which version is deployed in our log files. + +A common use case for this is serving static `.html` and `.js` files for your module. + +```python +STATIC_FILE_GIT_REPOS = { + 'repo_name': { + 'url': 'url_to_repo', + 'prefix': 'relative/path/to/directory', + # Branches we serve. This can either be a whitelist (e.g. which ones + # are available) or a blacklist (e.g. which ones are blocked) + 'whitelist': ['master'] + } +} +``` + +### EXTRA_VIEWS + +These are extra views to publish to the user. Currently, we only support `.json` files. + +```python +EXTRA_VIEWS = [{ + 'name': 'Name of view', + 'suburl': 'view-suburl', + 'static_json': python_dictionary_to_return +}] +``` + +## Creating a reducer (reducers.py) + +Reducers are ran over incoming student events. They can be defined using a decorator in the `learning_observer.stream_analytics` module. + +Each reducer should take the incoming `event` and the previous `internal_state` as parameters and return 2 new state objects. + +## Creating dashboards with Dash (dash_dashboard.py) + +Dash pages consist of a layout and callback functions. See `dash_dashboard.py` for a more detailed overview. diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/VERSION b/modules/lo_template_module/{{ cookiecutter.project_slug }}/VERSION new file mode 100644 index 000000000..cbbd3281b --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/VERSION @@ -0,0 +1 @@ +0.1.0+2025.07.16T17.39.45.896Z.a3b5acf4.workshop.updates diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/pyproject.toml b/modules/lo_template_module/{{ cookiecutter.project_slug }}/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/setup.cfg b/modules/lo_template_module/{{ cookiecutter.project_slug }}/setup.cfg new file mode 100644 index 000000000..42ff5a2ef --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/setup.cfg @@ -0,0 +1,14 @@ +[metadata] +name = {{ cookiecutter.project_name }} +version = file:VERSION +description = Use this as a base template for creating new modules on the Learning Observer. + +[options] +packages = {{ cookiecutter.project_slug }} + +[options.package_data] +{{ cookiecutter.project_slug }} = assets/* + +[options.entry_points] +lo_modules = + {{ cookiecutter.project_slug }} = {{ cookiecutter.project_slug }}.module diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/test.sh b/modules/lo_template_module/{{ cookiecutter.project_slug }}/test.sh new file mode 100755 index 000000000..888cd1278 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/test.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# modules/{{ cookiecutter.project_slug }}/test.sh +echo "=================================================" +echo "Running tests for {{ cookiecutter.project_name }}" +echo "=================================================" + +# Modify the commands below to fit your testing needs +echo "Running traditional pytests" +pytest tests/ +echo "Running doctests" +pytest --doctest-modules diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/tests/test_utils.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/tests/test_utils.py new file mode 100644 index 000000000..bc0bef163 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/tests/test_utils.py @@ -0,0 +1,6 @@ +import {{ cookiecutter.project_slug }}.utils as unit + +def test_increment(): + n = 1 + result = unit.increment(n) + assert result == n + 1 diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/assets/scripts.js b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/assets/scripts.js new file mode 100644 index 000000000..5733e08c1 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/assets/scripts.js @@ -0,0 +1,79 @@ +/** + * Javascript callbacks to be used with the LO Example dashboard + */ + +// Initialize the `dash_clientside` object if it doesn't exist +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +window.dash_clientside.{{ cookiecutter.project_slug }} = { + /** + * Send updated queries to the communication protocol. + * @param {object} wsReadyState LOConnection status object + * @param {string} urlHash query string from hash for determining course id + * @returns stringified json object that is sent to the communication protocl + */ + sendToLOConnection: async function (wsReadyState, urlHash) { + if (wsReadyState === undefined) { + return window.dash_clientside.no_update + } + if (wsReadyState.readyState === 1) { + if (urlHash.length === 0) { return window.dash_clientside.no_update } + const decodedParams = decode_string_dict(urlHash.slice(1)) + if (!decodedParams.course_id) { return window.dash_clientside.no_update } + const outgoingMessage = { + {{ cookiecutter.project_slug }}_query: { + execution_dag: '{{ cookiecutter.project_slug }}', + target_exports: ['{{ cookiecutter.reducer }}_export'], + kwargs: decodedParams + } + }; + return JSON.stringify(outgoingMessage); + } + return window.dash_clientside.no_update; + }, + + /** + * Build the student UI components based on the stored websocket data + * @param {*} wsStorageData information stored in the websocket store + * @returns Dash object to be displayed on page + */ + populateOutput: function (wsStorageData) { + if (!wsStorageData?.students) { + return 'No students'; + } + let output = [] + // Iterate over students and create UI items for each + for (const [student, value] of Object.entries(wsStorageData.students)) { + // We define Dash components in JS via a dictionary + // of where the component lives, what it is, and any + // parameters we want to pass along to it. + // - `namespace`: the module the component is in + // - `type`: the component to use + // - `props`: any parameters the component expects + // The following produces a LONameTag and Span wrapped in a Div + const studentBadge = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [{ + namespace: 'dash_html_components', + props: { + children: student + }, + type: 'Span' + }, { + namespace: 'dash_html_components', + props: { + children: ` - ${value.count} events` + }, + type: 'Span' + }] + } + } + output = output.concat(studentBadge); + } + return output; + } +}; diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/dash_dashboard.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/dash_dashboard.py new file mode 100644 index 000000000..e82bf3cf2 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/dash_dashboard.py @@ -0,0 +1,66 @@ +''' +This file will detail how to build a dashboard using +the Dash framework. + +If you are unfamiliar with Dash, it compiles python code +to react and serves it via a Flask server. You can register +callbacks to run when specific states change. Normal callbacks +execute Python code server side, but Clientside callbacks +execute Javascript code client side. Clientside functions are +preferred as it cuts down server and network resources. + +This file contains the hard stuff. You'll need to understand +this if you want to build dynamic, interactive dashboards. For +most simple dashboards, we tossed everything you need into +my_layout. +''' +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + +from .my_layout import my_layout, my_data_layout + +_prefix = '{{ cookiecutter.project_hyphenated }}' +_namespace = '{{ cookiecutter.project_slug }}' +_websocket = f'{_prefix}-websocket' +_output = f'{_prefix}-output' + +def layout(): + ''' + Function to define the page's layout. + ''' + return my_layout(_websocket, _output) + +# Send the initial state based on the url hash to LO. +# If this is not included, nothing will be returned from +# the communication protocol. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), + Output(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash') +) + +# Build the UI based on what we've received from the +# communicaton protocol +# This clientside callback and the serverside callback below are +# the same +# clientside_callback( +# ClientsideFunction(namespace=_namespace, function_name='populateOutput'), +# Output(_output, 'children'), +# Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), +# ) + + +@callback( + Output(_output, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), +) +def populate_output(data): + '''This method creates UI components for each student found + in the websocket's storage. + + This will use more network traffic and server resources + than using the equivalent clientside callback, `populateOutput`. + ''' + return my_data_layout(data) diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/module.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/module.py new file mode 100644 index 000000000..4ca427f67 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/module.py @@ -0,0 +1,97 @@ +''' +{{ cookiecutter.project_name }} + +{{ cookiecutter.project_short_description }} +''' +import learning_observer.downloads as d +import learning_observer.communication_protocol.util +from learning_observer.dash_integration import thirdparty_url, static_url +from learning_observer.stream_analytics.helpers import KeyField, Scope + +import {{ cookiecutter.project_slug }}.reducers +import {{ cookiecutter.project_slug }}.dash_dashboard + +# Name for the module +NAME = '{{ cookiecutter.project_name }}' + +''' +Define execution DAGs for this module. We provide a default DAG +for fetching information from the provided reducer. The internal +structure looks like: + +`execution_dag`: defined directed acyclic graph (DAG) for querying data + : q.select() # or some other communication protocol query +`exports`: fetchable nodes from the execution dag + : { + "returns": , + "parameters": ["list", "of", "parameters", "needed"] + } + +NOTE interfacing with the communication protocol may change, +the current flow is the first iteration. We will mark where things +ought to be improved. +''' +EXECUTION_DAG = learning_observer.communication_protocol.util.generate_base_dag_for_student_reducer('{{ cookiecutter.reducer }}', '{{ cookiecutter.project_slug }}') + +''' +Add reducers to the module. + +`context`: TODO +`scope`: the granularity of event (by student, by student + document, etc) +`function`: the reducer function to run +`default` (optional): initial value to start with +''' +REDUCERS = [ + { + 'context': 'org.mitros.writing_analytics', + # TODO scope is defined as a decorator on the function, why is + # is also defined here? + 'scope': Scope([KeyField.STUDENT]), + 'function': {{ cookiecutter.project_slug }}.reducers.{{ cookiecutter.reducer }}, + 'default': {'count': 0} + } +] + +''' +Define pages created with Dash. +''' +DASH_PAGES = [ + { + 'MODULE': {{ cookiecutter.project_slug }}.dash_dashboard, + 'LAYOUT': {{ cookiecutter.project_slug }}.dash_dashboard.layout, + 'ASSETS': 'assets', + 'TITLE': '{{ cookiecutter.project_name }}', + 'DESCRIPTION': '{{ cookiecutter.project_short_description }}', + 'SUBPATH': '{{ cookiecutter.project_hyphenated }}', + 'CSS': [ + thirdparty_url("css/fontawesome_all.css") + ], + 'SCRIPTS': [ + static_url("liblo.js") + ] + } +] + +''' +Additional files we want included that come from a third part. +''' +THIRD_PARTY = { + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/{{ cookiecutter.project_slug }}/dash/{{ cookiecutter.project_hyphenated }}", + "icon": { + "type": "fas", + "icon": "fa-play-circle" + } +}] diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/my_layout.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/my_layout.py new file mode 100644 index 000000000..f33b39111 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/my_layout.py @@ -0,0 +1,36 @@ +from dash import html, dcc +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + +def my_layout(_websocket, _output): + ''' + This is the layout for the static part of your dashboard which + is loaded when the page first loads. + + * The data would be populated in a div with id _output. + * We pass the _websocket so we can render a component letting us know when things updated + ''' + page_layout = html.Div(children=[ + html.H1(children='{{ cookiecutter.project_name }}'), + dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), + ]), + html.H2('Output from reducers'), + html.Div(id=_output) + ]) + return page_layout + + +def my_data_layout(data): + ''' + This is the layout for the changing part of your dashboard + populated from the data. + ''' + if not data or len(data.get('students', {})) == 0: + return 'No students' + output = [html.Div([ + k, + html.Span(f' - {v["count"]} events') + ]) for k, v in data.get('students', {}).items()] + return output diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/reducers.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/reducers.py new file mode 100644 index 000000000..070567bd1 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/reducers.py @@ -0,0 +1,16 @@ +''' +This file defines reducers we wish to add to the incoming event +pipeline. The `learning_observer.stream_analytics` package includes +helper functions for Scoping the and setting the null state. +''' +from learning_observer.stream_analytics.helpers import student_event_reducer + + +@student_event_reducer(null_state={"count": 0}) +async def {{ cookiecutter.reducer }}(event, internal_state): + ''' + An example of a per-student event counter + ''' + state = {"count": internal_state.get('count', 0) + 1} + + return state, state diff --git a/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/utils.py b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/utils.py new file mode 100644 index 000000000..ee1d72674 --- /dev/null +++ b/modules/lo_template_module/{{ cookiecutter.project_slug }}/{{ cookiecutter.project_slug }}/utils.py @@ -0,0 +1,28 @@ +''' +This file contains functions that are common across your package. +''' + +def increment(i): + ''' + Increment passed in variable `i`. Raise an exception if it fails. + + Simple use case + >>> increment(1) + 2 + + Raise an exception on increment error. + >>> increment('abc') + Traceback (most recent call last): + ... + Exception: Unable to increment: invalid literal for int() with base 10: 'abc' + ''' + try: + return int(i) + 1 + except Exception as e: + raise Exception(f'Unable to increment: {e}') + + +if __name__ == "__main__": + # Run doctests + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) diff --git a/modules/lo_toy_sba/MANIFEST.in b/modules/lo_toy_sba/MANIFEST.in new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_toy_sba/README.md b/modules/lo_toy_sba/README.md new file mode 100644 index 000000000..377245633 --- /dev/null +++ b/modules/lo_toy_sba/README.md @@ -0,0 +1,8 @@ +# LO Toy SBA + +This module provides various functionality for using the Toy SBA code within the Learning Observer system. + +The included functionality + +1. Providing a stub reducer so the Toy SBA can save/fetch state - we need a reducer that matches the source of LO Event +1. Serve the Toy SBA built NextJS output - not yet implemented diff --git a/modules/lo_toy_sba/lo_toy_sba/__init__.py b/modules/lo_toy_sba/lo_toy_sba/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_toy_sba/lo_toy_sba/module.py b/modules/lo_toy_sba/lo_toy_sba/module.py new file mode 100644 index 000000000..6d26ffeaa --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/module.py @@ -0,0 +1,80 @@ +''' +Toy-SBA Module + +Toy-SBA Module +''' +import learning_observer.communication_protocol.util +from learning_observer.stream_analytics.helpers import KeyField, Scope + +import lo_toy_sba.reducers + +# Name for the module +NAME = 'Toy-SBA Module' + +''' +Define execution DAGs for this module. We provide a default DAG +for fetching information from the provided reducer. The internal +structure looks like: + +`execution_dag`: defined directed acyclic graph (DAG) for querying data + : q.select() # or some other communication protocol query +`exports`: fetchable nodes from the execution dag + : { + "returns": , + "parameters": ["list", "of", "parameters", "needed"] + } + +NOTE interfacing with the communication protocol may change, +the current flow is the first iteration. We will mark where things +ought to be improved. +''' +EXECUTION_DAG = learning_observer.communication_protocol.util.generate_base_dag_for_student_reducer('student_event_counter', 'lo_toy_sba') + +''' +This is a simple reducer we use to ensure events are +passed into the event pipeline to save/fetch state. +We need a reducer whose context matches the source of +a page using LO Event. +''' +REDUCERS = [ + { + 'context': 'org.ets.sba', + 'scope': Scope([KeyField.STUDENT]), + 'function': lo_toy_sba.reducers.student_event_counter, + 'default': {'count': 0} + } +] + +''' +Which pages to link on the home page. +''' +COURSE_DASHBOARDS = [ + # { + # 'name': NAME, + # 'url': "/lo_toy_sba/toy-sba/", + # "icon": { + # "type": "fas", + # "icon": "fa-play-circle" + # } + # } +] + + +''' +Additional API calls we can define, this one returns the colors of the rainbow +''' +EXTRA_VIEWS = [ + # { + # 'name': 'Colors of the Rainbow', + # 'suburl': 'api/llm', + # 'method': 'POST', + # 'handler': function_to_call + # } +] + +''' +Built NextJS pages we want to serve. +''' +NEXTJS_PAGES = [ + # {'path': 'toy_sba/'} +] diff --git a/modules/lo_toy_sba/lo_toy_sba/reducers.py b/modules/lo_toy_sba/lo_toy_sba/reducers.py new file mode 100644 index 000000000..f34958ac7 --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/reducers.py @@ -0,0 +1,16 @@ +''' +This file defines reducers we wish to add to the incoming event +pipeline. The `learning_observer.stream_analytics` package includes +helper functions for Scoping the and setting the null state. +''' +from learning_observer.stream_analytics.helpers import student_event_reducer + + +@student_event_reducer(null_state={"count": 0}) +async def student_event_counter(event, internal_state): + ''' + An example of a per-student event counter + ''' + state = {"count": internal_state.get('count', 0) + 1} + + return state, state diff --git a/modules/lo_toy_sba/pyproject.toml b/modules/lo_toy_sba/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/lo_toy_sba/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/lo_toy_sba/setup.cfg b/modules/lo_toy_sba/setup.cfg new file mode 100644 index 000000000..9c06f7fa1 --- /dev/null +++ b/modules/lo_toy_sba/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = Toy-SBA Module +description = Module for serving the Toy SBA work. + +[options] +packages = lo_toy_sba + +[options.entry_points] +lo_modules = + lo_toy_sba = lo_toy_sba.module diff --git a/modules/toy-assess/README.md b/modules/toy-assess/README.md new file mode 100644 index 000000000..1b05a7098 --- /dev/null +++ b/modules/toy-assess/README.md @@ -0,0 +1,63 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Setup +If you haven't yet, run: +npm install next@latest + +This may require also running +npm audit fix + +You will also need to install writing_observer and package up lo_event to use inside the project. +Place to clone from: https://github.com/ETS-Next-Gen/writing_observer +cd to cloned directory, type npm install + +Then, inside writing_observer/modules/lo_event: +npm install redux +npm install redux-thunk + +Once you have writing observer setup, go back to the toysba directory and do something like the following: + +npm install ../writing_observer/modules/lo_event +npm i @azure/openai@1.0.0-beta.7 +npm install formdata-node + +then set the following environment variables: +OPENAI_API_RESOURCE +OPENAI_DEPLOYMENT_ID +OPENAI_API_KEY + +This assumes that writing_observer is installed in the same directory as toy-sba. + +## Getting Started +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/modules/toy-assess/jsconfig.json b/modules/toy-assess/jsconfig.json new file mode 100644 index 000000000..b8d6842d7 --- /dev/null +++ b/modules/toy-assess/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/modules/toy-assess/lo_event.sh b/modules/toy-assess/lo_event.sh new file mode 100755 index 000000000..4efe395b5 --- /dev/null +++ b/modules/toy-assess/lo_event.sh @@ -0,0 +1,14 @@ +# `lo_event` is the Learning Observer event library (and also `lo_assess` within it is the Learning Observer activity library, which should be factored out eventually). + +# * It's a library, and there's no practical stand-alone way to do more extensive development without some use-cases. This is a good set of use cases. +# * As we develop those, we often make changes to `lo_event` and `lo_assess`. Quite a lot, actually. + +# This script packages `lo_event` into a node package, wipes the `next.js` cache (which often contains relics before changes), and installs it. + +# Without this, development of `lo_event` is painful. Even with this, in an ideal case, we would rerun this whenever there were changes to `lo_event` automatically with some kind of watch daemon. +rm -Rf .next/cache/ +pushd ../lo_event/ +npm run prebuild +npm pack +popd +npm install ../lo_event/lo_event-0.0.3.tgz --no-save diff --git a/modules/toy-assess/next.config.js b/modules/toy-assess/next.config.js new file mode 100644 index 000000000..f7e13b93a --- /dev/null +++ b/modules/toy-assess/next.config.js @@ -0,0 +1,36 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: ( + config, + { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack } + ) => { + // Important: return the modified config + config.externals.unshift( + { + sqlite3: 'sqlite3', + crypto: 'crypto', + ws: 'ws', + 'indexeddb-js': 'indexeddb-js' + }); + config.module.rules.push({ + test: /\.jsx?$/, // This regex matches both .js and .jsx files + include: [/node_modules\/lo_event/], // Include only the lo_event module + use: { + loader: 'babel-loader', + options: { + // Add your babel presets here (e.g., @babel/preset-react) + presets: ['@babel/preset-react'] // Assuming you use React + }, + }, + }); + return config; + }, + eslint: { + ignoreDuringBuilds: true, + }, + output: 'export', + // TODO this is only needed when building for use within LO + // basePath: '/_next/learning_observer_template/toy_assess' +}; + +module.exports = nextConfig; diff --git a/modules/toy-assess/package.json b/modules/toy-assess/package.json new file mode 100644 index 000000000..3d749bb6d --- /dev/null +++ b/modules/toy-assess/package.json @@ -0,0 +1,45 @@ +{ + "name": "toy-assess", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@azure/openai": "^1.0.0-beta.7", + "@fortawesome/free-solid-svg-icons": "^6.5.0", + "@fortawesome/react-fontawesome": "^0.2.0", + "amdefine": "^1.0.1", + "bufferutil": "^4.0.8", + "indexeddb-js": "^0.0.14", + "jasmine": "^5.1.0", + "lo_event": "file:../lo_event/lo_event-0.0.3.tgz", + "mapbox": "^1.0.0-beta10", + "mock-aws-s3": "^4.0.2", + "next": "13.5.5", + "react": "^18", + "react-dom": "^18", + "react-markdown": "^9.0.1", + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "utf-8-validate": "^6.0.3" + }, + "devDependencies": { + "@babel/preset-react": "^7.24.7", + "@mapbox/node-pre-gyp": "^1.0.11", + "autoprefixer": "^10", + "babel-loader": "^9.1.3", + "better-react-mathjax": "^2.0.3", + "eslint": "^8", + "eslint-config-next": "13.5.5", + "mathjs": "^12.3.0", + "postcss": "^8", + "sqlite3": "^5.1.6", + "tailwindcss": "^3", + "ws": "^8.14.2" + } +} diff --git a/modules/toy-assess/postcss.config.js b/modules/toy-assess/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/modules/toy-assess/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/modules/toy-assess/src/app/api/llm/route.js b/modules/toy-assess/src/app/api/llm/route.js new file mode 100644 index 000000000..48ac4217c --- /dev/null +++ b/modules/toy-assess/src/app/api/llm/route.js @@ -0,0 +1,64 @@ +/* + This is an API for calling LLMs. +*/ + +import { NextResponse, NextRequest } from 'next/server'; +import * as openai from '../../lib/azureInterface'; +import * as stub from '../../lib/stubInterface'; + +const listChatCompletions = openai.listChatCompletions; + +const default_messages = [ + { role: "system", content: "I am your writing coach. How can I help you?" }, + { role: "user", content: "Hi, how are you?"}, +]; + +async function processPrompt(prompt) { + return await listChatCompletions( + [ + { role: "system", content: "I am your writing coach. How can I help you?" }, + { role: "user", content: prompt }, + ], + {} + ); +} + +export async function GET(request) { + console.log('GET request called'); + const prompt = request.nextUrl.searchParams.get('prompt') || "How are you?"; + const jsonResponse = await processPrompt(prompt); + return NextResponse.json({'response': jsonResponse}); +} + +// Handles POST requests +export async function POST(request) { + console.log('POST request called'); + const req = await request.json(); + + const prompt = req?.prompt || "How are you?"; + const jsonResponse = await processPrompt(prompt); + return NextResponse.json({'response': jsonResponse}); +} + +/*export async function GET(request) { + const messages = request.nextUrl.searchParams.get('messages'); + const temperature = request.nextUrl.searchParams.get('temperature'); + console.log(messages, temperature); + const jsonResponse = await listChatCompletions(messages, {temperature}); + return NextResponse.json(jsonResponse); +} + +// Handles POST requests +export async function POST(request) { + const req = await request.json(); + const messages = req?.messages || default_messages; + const temperature = req?.temperature || default_temperature; + console.log(messages, temperature); + + const jsonResponse = await listChatCompletions( + messages, + {temperature} + ); + return NextResponse.json(jsonResponse); +} +*/ diff --git a/modules/toy-assess/src/app/base-style.css b/modules/toy-assess/src/app/base-style.css new file mode 100644 index 000000000..d86044e45 --- /dev/null +++ b/modules/toy-assess/src/app/base-style.css @@ -0,0 +1,6 @@ +body { + font-family: 'Arial', sans-serif; + background-color: #f8f9fa; + margin: 0; + padding: 0; +} diff --git a/modules/toy-assess/src/app/base_components.js b/modules/toy-assess/src/app/base_components.js new file mode 100644 index 000000000..f42882e6b --- /dev/null +++ b/modules/toy-assess/src/app/base_components.js @@ -0,0 +1,22 @@ +// Basic components. This should depend on no other component files. + +import React from 'react'; + +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faEdit, faAngleDown, faAngleRight, faExclamationTriangle, faQuestionCircle, fasQuestionCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle, faTimesCircle, faDotCircle } from '@fortawesome/free-solid-svg-icons'; + +import * as lo_event from 'lo_event'; +import * as reduxLogger from 'lo_event/lo_event/reduxLogger.js'; + +import { useComponentSelector, useSettingSelector } from './utils.js'; +import { library } from '@fortawesome/fontawesome-svg-core'; + +// Debug log function. This should perhaps go away / change / DRY eventually. +const DEBUG = false; +const dclog = (...args) => {if(DEBUG) {console.log.apply(console, Array.from(args));} }; + +library.add(faCheckCircle, faDotCircle, faTimesCircle, faQuestionCircle); diff --git a/modules/toy-assess/src/app/changer/page.js b/modules/toy-assess/src/app/changer/page.js new file mode 100644 index 000000000..e5c6e7a0a --- /dev/null +++ b/modules/toy-assess/src/app/changer/page.js @@ -0,0 +1,68 @@ +// This is a little demo that retypes text as different characters. + +'use client'; +// @refresh reset + +// We need to import this to call init() for now +import { } from '../components.js'; + +import { ActionButton, Element, LLMAction, SideBarPanel, MainPane, TextInput, LLMFeedback } from 'lo_event/lo_event/lo_assess/components/components.jsx'; + +export default function Home( { children } ) { + return ( + + +

Write a text

+

+ Put it in the box provided. After you get feedback and look at examples, + feel free to revise your answer. +

+ +
+ +
+
+

Rewrite / audience / style

+ + Please rewrite this in simple English for a first grader: student_essay + First Grader Text + +
+ + Please rewrite this in contorted social sciences academic English: student_essay + SSR + +
+ + Please rewrite this in business English, with a marketing focus: student_essay + Business + +
+ + Please rewrite this in first person: student_essay + First person + +
+ + You're a world-class comedian. Please rewrite this to be hillarious, and with an edge. You can make stuff up (hey, it's comedy), but there should be real jokes (not just flippancy): student_essay + Comedian + +
+ + Please rewrite this using a Chinese communication style, organization, and argumentation structure (e.g. as described by Hofstede or Meyer), but in English: student_essay + Chinese American + +
+ + Please rewrite this text in legal English for a legal filing student_essay + Lawyer + +
+ + Please rewrite this text as a lower-to-mid-level middle schooler. Please include middle-school level spelling errors, grammar errors, and style issues; we'll want middle-school students to find and correct issues: student_essay + Middle Schooler + +
+
+ ); +}; diff --git a/modules/toy-assess/src/app/components.js b/modules/toy-assess/src/app/components.js new file mode 100644 index 000000000..9164f34c4 --- /dev/null +++ b/modules/toy-assess/src/app/components.js @@ -0,0 +1,37 @@ +/* + * This file contains generic components and most of the machinery + * behind the system. The goal is to gradually make the main page file + * simple enough for a relatively non-technical person to edit, and then + * to abstract much of it into data files. + * + * To do that, we are happy to have extra complexity here. + */ + +import React from 'react'; + +import * as lo_event from 'lo_event'; +import { reduxLogger } from 'lo_event/lo_event/reduxLogger.js'; +import { consoleLogger } from 'lo_event/lo_event/consoleLogger.js'; +import * as reducers from 'lo_event/lo_event/lo_assess/reducers.js'; +import * as debug from 'lo_event/lo_event/debugLog.js'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faQuestionCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; + +import './base-style.css'; +import './sidebar-panel.css'; +import './globals.css'; + +lo_event.init( + "org.ets.sba", + "0.0.1", + [consoleLogger(), reduxLogger([], {})], + { + debugLevel: debug.LEVEL.EXTENDED, + debugDest: [debug.LOG_OUTPUT.CONSOLE], + useDisabler: false, + queueType: lo_event.QueueType.IN_MEMORY + } +); + +lo_event.go(); diff --git a/modules/toy-assess/src/app/globals.css b/modules/toy-assess/src/app/globals.css new file mode 100644 index 000000000..a90f0749c --- /dev/null +++ b/modules/toy-assess/src/app/globals.css @@ -0,0 +1,4 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + diff --git a/modules/toy-assess/src/app/input.css b/modules/toy-assess/src/app/input.css new file mode 100644 index 000000000..9d030696e --- /dev/null +++ b/modules/toy-assess/src/app/input.css @@ -0,0 +1,11 @@ +textarea.large-input { + width: 100%; + height: 150px; + margin-bottom: 10px; +} + +textarea.compact-input { + width: 100%; + height: 75px; + margin-bottom: 10px; +} diff --git a/modules/toy-assess/src/app/input_types.js b/modules/toy-assess/src/app/input_types.js new file mode 100644 index 000000000..05a333493 --- /dev/null +++ b/modules/toy-assess/src/app/input_types.js @@ -0,0 +1,156 @@ +import React from 'react'; + +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { MathJax, MathJaxContext } from 'better-react-mathjax'; +import { parse, format } from "mathjs"; + +import * as lo_event from 'lo_event'; +import { useComponentSelector, useSettingSelector } from './utils.js'; + +// Debug log function. This should perhaps go away / change / DRY eventually. +const DEBUG = false; +const dclog = (...args) => {if(DEBUG) {console.log.apply(console, Array.from(args));} }; + + +export const UPDATE_INPUT = 'UPDATE_INPUT'; + +function handleInputChange(id) { + return event => { + if(DEBUG) { + console.log("Dispatching", id, event.target.value); + } + + lo_event.logEvent( + UPDATE_INPUT, { + id, + value: event.target.value, + selectionStart: event.target.selectionStart, //retrieved by the useEffect method below to reset cursor location + selectionEnd: event.target.selectionEnd, //stored for completeness, not absolutely neccesary + }); + }; +} + +function fixCursor(id, selectionStart, selectionEnd) { + return () => { + //This will fire after the textarea is rendered and value has changed + //Without this code, the cursor in the textarea will jump to the end of the + //text, making editing the text in the middle difficult. + const input = document.getElementsByName(id); + if (input) { + input[0].setSelectionRange(selectionStart, selectionEnd); + } + }; +} + +export function TextInput({id, className, children}) { + let value = useComponentSelector(id, s => s?.value ?? ''); + let selectionStart = useComponentSelector(id, s => s?.selectionStart ?? 1); + let selectionEnd = useComponentSelector(id, s => s?.selectionEnd ?? 1); + + useEffect(fixCursor(id, selectionStart, selectionEnd), [value]); + + return ( + <> + { children } + + + ); +} + +export function RenderEquation( { id }) { + let value = useComponentSelector(id, s => s?.value) ?? ''; + let equation='', raw_equation=''; + try { + console.log("v>>>", value); + let p = parse(value); + console.log("p>>>", p); + raw_equation = p.toTex(); + console.log("e>>>", raw_equation); + equation = "\\("+raw_equation+"\\)"; + console.log("f>>>", equation); + } catch { + equation = value; + } + //equation = "\\(\\frac{10}{4x} \\approx 2^{12}\\)"; + console.log("mj>>>", equation); + return <> { equation } + { raw_equation } + ; +} + +export function LineInput( { defaultvalue, prompt, id, children } ) { + prompt = prompt || "Answer: "; + + let value = useComponentSelector(id, s => s?.value) ?? defaultvalue ?? ''; + + return ( +
+ { prompt } + + {children} +
+ ); +} + + +export function NumericInput( { defaultvalue, prompt, id, children } ) { + prompt = prompt || "Answer: "; + + let value = useComponentSelector(id, s => s?.value) ?? defaultvalue ?? ''; + + return ( +
+ { prompt } + + {children} +
+ ); +} + +export function UnitInput( { id, children, prompt, units=["cm", "cm²"] } ) { + const number_id = id+".number"; + const units_id = id+".units"; + + let value = useComponentSelector(number_id, s => s?.value ?? ''); + let unit = useComponentSelector(units_id, s => s?.value ?? units[0]); + + prompt = prompt || "Answer: "; + const handleNumberChange = handleInputChange(number_id); + const handleUnitChange = handleInputChange(units_id); + + return ( +
+ { prompt } + + {children} +
+ ); +} + diff --git a/modules/toy-assess/src/app/layout.js b/modules/toy-assess/src/app/layout.js new file mode 100644 index 000000000..c55b09ac7 --- /dev/null +++ b/modules/toy-assess/src/app/layout.js @@ -0,0 +1,20 @@ +import { Inter } from 'next/font/google'; + +import StoreWrapper from './storeWrapper.js'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'Demo of lo_assess', + description: 'By Piotr Mitros and Paul Deane', +} + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ) +} diff --git a/modules/toy-assess/src/app/lib/azureInterface.js b/modules/toy-assess/src/app/lib/azureInterface.js new file mode 100644 index 000000000..33e317cd7 --- /dev/null +++ b/modules/toy-assess/src/app/lib/azureInterface.js @@ -0,0 +1,25 @@ +//This setup uses model="gpt-3.5-turbo" & apiVersion="2023-05-15"; + +const { OpenAIClient, AzureKeyCredential } = require("@azure/openai"); + +const resource=await process.env.OPENAI_URL; +const deploymentID=process.env.OPENAI_DEPLOYMENT_ID; +const key = await process.env.OPENAI_API_KEY || 'EMPTY'; + +console.log("In azureInterface"); +console.log("key: " + key); +const client = new OpenAIClient(resource, new AzureKeyCredential(key)); +console.log("client created"); + +export async function listChatCompletions( + messages, + { + maxTokens=128, + temperature=0.8 // Currently ignored + }) { + console.log("azureInterface.listChatCompletions called"); + console.log(`Messages: ${messages.map((m) => m.content).join("\n")}`); + console.log(client); + const events = await client.getChatCompletions(deploymentID, messages, { maxTokens }); + return events.choices[0].message.content; +} diff --git a/modules/toy-assess/src/app/lib/stubInterface.js b/modules/toy-assess/src/app/lib/stubInterface.js new file mode 100644 index 000000000..d3eda1f43 --- /dev/null +++ b/modules/toy-assess/src/app/lib/stubInterface.js @@ -0,0 +1,3 @@ +export async function listChatCompletions() { + return "Great job! I have no more feedback."; +} diff --git a/modules/toy-assess/src/app/llm_components.js b/modules/toy-assess/src/app/llm_components.js new file mode 100644 index 000000000..a5ab1699f --- /dev/null +++ b/modules/toy-assess/src/app/llm_components.js @@ -0,0 +1,69 @@ +import React from 'react'; + +import { useState } from 'react'; // For debugging / dev. Should never be used in final code. +import { useDispatch } from 'react-redux'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faQuestionCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; + +import * as lo_event from 'lo_event'; + +import { useComponentSelector, extractChildrenText } from './utils.js'; + +import { Spinner, Button } from './base_components'; + +export const UPDATE_LLM_RESPONSE = 'UPDATE_LLM_RESPONSE'; + +const LLMDialog = ({ children, id, onCloseDialog }) => { + return ( + +
+

Prompt Title

+ + + +
+
+ {children} +
+
+ ); +} + + +// This will go in a CSS file later. For dev. +const styles = { + questionMark: { + fontSize: '0.75rem', + color: 'blue', + cursor: 'pointer', + marginRight: '0.75rem', + }, + dialog: { + position: "fixed", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + boxShadow: "0 0 5px rgba(0, 0, 0, 0.2)", + borderRadius: "5px", + padding: "20px", + backgroundColor: "white", + zIndex: "999", + }, + dialogHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "10px", + }, + dialogTitle: { + margin: "0", + }, + dialogClose: { + cursor: "pointer", + }, + dialogContent: { + marginBottom: "20px", + }, +}; + diff --git a/modules/toy-assess/src/app/pulsefeedback.css b/modules/toy-assess/src/app/pulsefeedback.css new file mode 100644 index 000000000..bc072ffc9 --- /dev/null +++ b/modules/toy-assess/src/app/pulsefeedback.css @@ -0,0 +1,100 @@ +/* + * This is a pulse animation used to subtly highlight feedback. It can + * flash red for multiple incorrect answers. + */ + +@keyframes flash-red-1 { + 0% { + background-color: white; + } + 50% { + background-color: hsl(347, 90%, 96%); + } + 100% { + background-color: white; + } +} + +@keyframes flash-red-2 { + 0% { + background-color: white; + } + 50% { + background-color: hsl(347, 90%, 96%); + } + 100% { + background-color: white; + } +} + +@keyframes flash-green-1 { + 0% { + background-color: white; + } + 50% { + background-color: hsl(142, 52%, 96%); + } + 100% { + background-color: white; + } +} + +@keyframes flash-green-2 { + 0% { + background-color: white; + } + 50% { + background-color: hsl(142, 52%, 96%); + } + 100% { + background-color: white; + } +} + +@keyframes flash-yellow-1 { + 0% { + background-color: white; + } + 50% { + background-color: hsl(48, 100%, 96%); + } + 100% { + background-color: white; + } +} + +@keyframes flash-yellow-2 { + 0% { + background-color: white; + } + 50% { + background-color: hsl(48, 100%, 96%); + } + 100% { + background-color: white; + } +} + +.pulse-incorrect-1 { + animation:flash-red-1 0.25s; +} + +.pulse-incorrect-2 { + animation:flash-red-2 0.25s; +} + +.pulse-correct-1 { + animation:flash-green-1 0.25s; +} + +.pulse-correct-2 { + animation:flash-green-2 0.25s; +} + +.pulse-invalid-1 { + animation:flash-yellow-1 0.25s; +} + +.pulse-invalid-2 { + animation:flash-yellow-2 0.25s; +} diff --git a/modules/toy-assess/src/app/sidebar-panel.css b/modules/toy-assess/src/app/sidebar-panel.css new file mode 100644 index 000000000..c075702a0 --- /dev/null +++ b/modules/toy-assess/src/app/sidebar-panel.css @@ -0,0 +1,35 @@ +.pane-with-sidebar { + display: flex; + margin-top: 20px; +} + +.main-pane { + flex-grow: 1; + padding: 15px; +} + +.sidebar { + width: 300px; + background-color: #e9ecef; + padding: 15px; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1); +} + +.sidebar-card { + padding: 10px; + background-color: #fff; + border: 1px solid #d1d1d1; + margin-bottom: 10px; +} + +textarea.large-input { + width: 100%; + height: 150px; + margin-bottom: 10px; +} + +textarea.compact-input { + width: 33%; + height: 75px; + margin-bottom: 10px; +} diff --git a/modules/toy-assess/src/app/spinner.css b/modules/toy-assess/src/app/spinner.css new file mode 100644 index 000000000..71c3c35b5 --- /dev/null +++ b/modules/toy-assess/src/app/spinner.css @@ -0,0 +1,42 @@ +.spinner { + display: inline-block; + position: relative; + width: 80px; + height: 80px; + /* We added this to center. This might be teased out as an option + * (e.g. spinner align=Center) */ + left: 50%; + transform: translateX(-50%); +} +.spinner div { + display: inline-block; + position: absolute; + left: 8px; + width: 16px; + animation: spinner 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; + /* We added color appropriate for a white background. This might be + a parameter. */ + background: lightgrey; +} +.spinner div:nth-child(1) { + left: 8px; + animation-delay: -0.24s; +} +.spinner div:nth-child(2) { + left: 32px; + animation-delay: -0.12s; +} +.spinner div:nth-child(3) { + left: 56px; + animation-delay: 0; +} +@keyframes spinner { + 0% { + top: 8px; + height: 64px; + } + 50%, 100% { + top: 24px; + height: 32px; + } +} diff --git a/modules/toy-assess/src/app/storeWrapper.js b/modules/toy-assess/src/app/storeWrapper.js new file mode 100644 index 000000000..ec731c681 --- /dev/null +++ b/modules/toy-assess/src/app/storeWrapper.js @@ -0,0 +1,13 @@ +'use client'; +import React from 'react'; + +import { Provider } from 'react-redux'; +import * as reduxLogger from 'lo_event/lo_event/reduxLogger.js'; + +const StoreWrapper = ({ children }) => ( + + {children} + +); + +export default StoreWrapper; diff --git a/modules/toy-assess/src/app/utils.js b/modules/toy-assess/src/app/utils.js new file mode 100644 index 000000000..56140184d --- /dev/null +++ b/modules/toy-assess/src/app/utils.js @@ -0,0 +1,148 @@ +/* + * Helper functions, mostly for extracting things from our store. + */ +import React from 'react'; + +import { useSelector } from 'react-redux'; +import { registerReducer } from 'lo_event/lo_event/reduxLogger.js'; + +import * as lo_event from 'lo_event'; + +// Debug log function. This should perhaps go away / change / DRY eventually. +const DEBUG = false; +const dclog = (...args) => {if(DEBUG) {console.log.apply(console, Array.from(args));} }; + +// The store contains a mixture of lo_event state and our own +// state. This abstracts out just our own state. This should likely +// be polished and merged into lo_event. +export function useApplicationSelector(selector = s => s) { + return useSelector(state => selector(state?.application_state)); +} + +// Get the state for any component, by ID. +export function useComponentSelector(id, selector = s => s) { + return useApplicationSelector( + s => selector(s?.component_state?.[id]) + ); +} + +export function useSettingSelector(setting) { + return useSelector(state => state?.settings?.[setting]); +} + +export const extractChildrenText = (element) => { + if (typeof element === "string") { + return element.trim(); + } + const extractElementText = (element) => { + if (typeof element === "string") { + return element.trim(); + } else if (React.isValidElement(element)) { + return element.type.eval(element); + } + return ""; + }; + + const { children } = element.props; + const extractedChildren = React.Children.map(children, (element) => extractElementText(element)); + return extractedChildren.join(""); +}; + +const initialState = { + component_state: {} +}; + +export const updateResponseReducer = (state = initialState, action) => { + const { id, ...rest } = action; + const new_state = { + ...state, + component_state: { + ...state.component_state, + [id]: {...state.component_state?.[id], ...rest} + } + }; + if(DEBUG) { + console.log("REGISTER REDUCER"); + console.log("Reducer action:", action); + console.log("Response reducer called"); + console.log("Old state", state); + console.log("Action", action); + console.log("New state", new_state); + } + return new_state; +} + +/* + Let's say we have: + Parameter 1 Parameter 2 + The goal is to be able to extract Parameter 1 by passing Foo's children and "Bar." + + This is helpful for longer parameters than fit in + + Untested + */ +function extractChildComponent(componentName, children) { + let component = null; + + React.Children.forEach(children, child => { + if (child && child.type && child.type.displayName === componentName) { + component = child; + } + }); + + return component ? React.cloneElement(component) : null; +} + +export const LOAD_DATA_EVENT = 'LOAD_DATA_EVENT'; +export const JSONTYPE = 'JSONTYPE'; // Better names? JSON conflicts with JSON.parse, etc. +export const TEXTTYPE = 'TEXTTYPE'; + +registerReducer( + [LOAD_DATA_EVENT], + updateResponseReducer +); + +/* + * Return data from a given URL, or false if not loaded yet. + * + * id: component ID + * key: Where to store data in component's redux store + * override: if available, do not do AJAX and return the + * override. This is commonly used if we want to be able have + * e.g. both subtitles and subtitles_src as available parameters + * type: JSONTYPE or TEXTTYPE + * modifier: allows us to tweak the data (e.g. grab a single field from an AJAX request) + * + * We might consider allowing this to also automate extractChildComponent in the future. + * + * Note that key and id are optional. For simple data loads, this will simply be stored using the URL as an ID. This should probably be namespaced somehow later. + */ +export function useData({id, key, url, override = false, type=JSONTYPE, modifier=(d) => d}) { + id = id ?? url; + key = key ?? url; + const data = useComponentSelector(id, s => s?.[key] ?? override); + + async function fetchData() { + const response = await fetch(url); + + const lookup = { + JSONTYPE: async () => await response.json(), + TEXTTYPE: async () => await response.text(), + }; + + const data = await lookup[type](); + + lo_event.logEvent(LOAD_DATA_EVENT, { + id, + [key]: modifier(data), + }); + } + + React.useEffect(() => { + if(!data) { + fetchData(); + } + }, [url]); + + return data; +} diff --git a/modules/toy-assess/src/app/websocket/page.js b/modules/toy-assess/src/app/websocket/page.js new file mode 100644 index 000000000..0453bee7d --- /dev/null +++ b/modules/toy-assess/src/app/websocket/page.js @@ -0,0 +1,56 @@ +// Demo to show the Websocket + +'use client'; +// @refresh reset + +// We need to import this to call init() for now +import { } from '../components.js'; + +import React, { useState, useEffect } from 'react'; +import { LOConnectionLastUpdated, useLOConnectionDataManager, LO_CONNECTION_STATUS, Button } from 'lo_event/lo_event/lo_assess/components/components.jsx'; + +export default function Home ({ children }) { + const decoded = {}; + decoded.course_id = '123456'; + const dataScope = { + wo: { + execution_dag: 'writing_observer', + target_exports: ['docs_with_nlp_annotations'], + kwargs: decoded + } + }; + + const { data, errors, connection } = useLOConnectionDataManager({ url: 'ws://localhost:8888/wsapi/communication_protocol', dataScope }); + return ( +
+

WebSocket Connection Page

+
+ +
+
+ + + +
+
+

User Data

+ {Object.keys(data).length > 0 + ? (
{JSON.stringify(data, null, 2)}
) + : (

No user data available.

) + } +
+ {Object.keys(errors).length > 0 && ( +
+

Errors

+
{JSON.stringify(errors, null, 2)}
+
+ )} +
+ ); +}; diff --git a/modules/toy-assess/src/pages/index.js b/modules/toy-assess/src/pages/index.js new file mode 100644 index 000000000..213ec0e93 --- /dev/null +++ b/modules/toy-assess/src/pages/index.js @@ -0,0 +1,59 @@ +import path from 'path'; + +// @refresh reset + +export default function Home( { pages, text } ) { + console.log(pages); + return ( + <> +

Pages

+

We have many more than committed here, including math problems, SBAs, etc. We are leaving one demo for now, since the rest are externally contributed and may have IP issues.

+ +
{ JSON.stringify(text) }
+ + ); +} + + +export async function getStaticProps() { + function fullPagePath(shortname) { + return path.join(directoryPath, shortname, 'page.js'); + } + function description(shortname) { + const fileContent = fs.readFileSync(fullPagePath(shortname), 'utf8'); + const firstLine = fileContent.split('\n')[0]; + const comment = firstLine.split("//").slice(1).join('//').trim(); + if(comment === "") { + return shortname; + } + return comment; + } + + const fs = require('fs'); + const directoryPath = path.join(process.cwd(), 'src/app'); + const allFiles = fs.readdirSync(directoryPath); + const directories = allFiles.filter(fileName => { + const fullPath = path.join(directoryPath, fileName); + return fs.statSync(fullPath).isDirectory(); + }); + const directoriesWithPageJs = directories.filter(directory => { + const filesInDirectory = fs.readdirSync(path.join(directoryPath, directory)); + return filesInDirectory.includes('page.js'); + }); + const pages = directoriesWithPageJs.map(url => ({ + title: description(url), + url: url + })); + + console.log(directoryPath); + //const pages = [{title: "Text style changer demo", url: "/changer"}]; + const text = pages; + return { + props: { + pages, + text + } + }; +} diff --git a/modules/toy-assess/tailwind.config.js b/modules/toy-assess/tailwind.config.js new file mode 100644 index 000000000..d53b2eaa0 --- /dev/null +++ b/modules/toy-assess/tailwind.config.js @@ -0,0 +1,18 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': + 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + }, + }, + }, + plugins: [], +} diff --git a/modules/websocket_debug/static/websocketdebug.html b/modules/websocket_debug/static/websocketdebug.html new file mode 100644 index 000000000..53b4e523f --- /dev/null +++ b/modules/websocket_debug/static/websocketdebug.html @@ -0,0 +1,37 @@ + + + + + +

Web socket debug test!

+

This is a simple app to test web sockets.

+ + +
+
+
+ + + diff --git a/modules/wo_bulk_essay_analysis/MANIFEST.in b/modules/wo_bulk_essay_analysis/MANIFEST.in new file mode 100644 index 000000000..320ab3ab6 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/MANIFEST.in @@ -0,0 +1 @@ +include wo_bulk_essay_analysis/assets/* diff --git a/modules/wo_bulk_essay_analysis/README.md b/modules/wo_bulk_essay_analysis/README.md new file mode 100644 index 000000000..867001cdc --- /dev/null +++ b/modules/wo_bulk_essay_analysis/README.md @@ -0,0 +1,71 @@ +# Writing Observer - Classroom AI Feedback Assistant + +**Last updated:** September 30th, 2025 + +![Bulk Essay Analysis dashboard overview](_images/bulk-essay-analysis-overview.png) + +The Classroom AI Feedback Assistant dashboard helps teachers generate AI-driven feedback for an entire class of student essays in a single workspace. It combines Writing Observer's learning data with configurable prompts so you can rapidly review submissions, capture high-level trends, and craft targeted responses for each student. + +> **Who is this for?** Educators who need fast, consistent formative feedback across a large set of essays or narrative responses. + +## What you can do with this dashboard + +* **Queue AI feedback for the whole class.** Select a document source and instantly request AI-generated feedback for every student submission at once. +* **Customize prompts with placeholders.** Build prompts that reference student-specific context (e.g., `{student_text}` or custom placeholders you upload) so the AI response stays grounded in your classroom language. +* **Fine-tune the AI's voice.** Provide a system prompt that frames the tone, rubric, or learning goals you want the AI to follow. +* **Track prompt history.** Quickly re-run or iterate on past prompts without rebuilding them from scratch. +* **Monitor progress in real time.** A loading panel shows how many students have been processed so you know when the batch is complete. +* **Dive into individual students.** Expand any student tile to review their essay, the applied prompt, and the returned feedback side-by-side. +* **Adjust the layout to match your workflow.** Change tile height, students per row, and visibility of profile headers for small-group reviews or projector-friendly displays. + +## Getting started + +1. **Open the dashboard.** From the main dashboard, choose **Classroom AI Feedback Assistant** in your course navigation. The URL ends with `/wo_bulk_essay_analysis/dash/bulk-essay-analysis`. +2. **Connect to your class.** The dashboard automatically reads the course and assignment from the page URL. Use the profile sidebar to confirm you are logged in and connected. +3. **Pick a document source.** In **Settings → Document Source**, choose where essays should come from (e.g., most recent document, document accessed at specific time, etc). Adjust any source-specific options if prompted. +4. **Draft your prompts.** + * **System prompt:** Sets expectations for the AI (tone, rubric, grading stance). + * **Query prompt:** Explain what you want the AI to produce. Use placeholders such as `{student_text}` to insert student work. Add your own placeholders via the **Add** button - paste text or upload `.txt`, `.md`, `.pdf`, or `.docx` files to reference rubrics or exemplars. +5. **Review placeholder tags.** Click a placeholder pill to insert it into your query. The tag manager prevents duplicate labels and shows warnings if required content is missing. +6. **Submit the batch.** Click **Submit**. The dashboard disables the button while processing and displays a progress bar with status updates. + +## Reading the results + +![Student tile and feedback panel](_images/bulk-essay-analysis-student-tile.png) + +Each student tile shows: + +* **Profile header** (optional) with avatar, name. +* **Process tags** summarizing analytics such as time on task or current activity status. +* **Student text panel** rendered with Writing Observer's rich text component. +* **Feedback card** that fills in once the AI response returns. Loading spinners indicate students still in progress. Errors surface in a dismissible alert with debug details (visible in development mode). + +Use the expand icon on any tile to open the **Individual Student** drawer for focused review, longer scrolling feedback, or to copy responses into LMS comments. + +## Managing prompts and history + +The **Prompt History** panel (right side) stores every submitted prompt for this browser session. Selecting an entry previews the exact text that was sent. + +## Advanced configuration + +Open the **Advanced** panel to: + +* **Change layout density.** Set students-per-row and tile height for flexible layouts (e.g., 1-up for conferencing, 3-up for scanning). +* **Hide/show student profiles.** Toggle headers off when projecting or sharing anonymized work samples. +* **Switch document sources on the fly.** Quickly pivot between document sources. +* **Select information overlays.** Enable additional metrics from the Classroom Text Highlighter module (e.g., time on task) to contextualize feedback. + +## Tips for effective use + +* Start with the provided sample prompts and iterate. Short, specific requests (3-5 bullet points) generate the most actionable feedback. +* Use the tag manager to maintain a consistent rubric voice. Uploading a rubric once lets you reuse it across prompts without copy/paste. +* Watch the progress bar before closing the tab—students remaining in queue continue to update, and the bar helps you gauge completion. +* If no text is available for a student, the tile will note that explicitly so you can follow up with the student. + +## Troubleshooting + +* **Nothing happens when I click Submit.** Ensure the course URL includes `course_id` and `assignment_id`, and verify at least one placeholder (`{student_text}`) is present in your prompt. +* **The alert banner appears.** Hover to read the message. In development environments, open the error JSON to share with your technical support team. +* **PDF/DOCX uploads fail.** Confirm the file size is manageable and the content is mostly text (scanned images are not supported for extraction). + +Once configured, the Classroom AI Feedback Assistant becomes your hub for consistent, high-quality formative feedback at scale. diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION new file mode 100644 index 000000000..696424a49 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -0,0 +1 @@ +0.1.0+2025.09.30T15.53.18.556Z.1c72d9e1.master diff --git a/modules/wo_bulk_essay_analysis/_images/bulk-essay-analysis-overview.png b/modules/wo_bulk_essay_analysis/_images/bulk-essay-analysis-overview.png new file mode 100644 index 000000000..b74085665 Binary files /dev/null and b/modules/wo_bulk_essay_analysis/_images/bulk-essay-analysis-overview.png differ diff --git a/modules/wo_bulk_essay_analysis/_images/bulk-essay-analysis-student-tile.png b/modules/wo_bulk_essay_analysis/_images/bulk-essay-analysis-student-tile.png new file mode 100644 index 000000000..13c6efca4 Binary files /dev/null and b/modules/wo_bulk_essay_analysis/_images/bulk-essay-analysis-student-tile.png differ diff --git a/modules/wo_bulk_essay_analysis/pyproject.toml b/modules/wo_bulk_essay_analysis/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/wo_bulk_essay_analysis/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/wo_bulk_essay_analysis/setup.cfg b/modules/wo_bulk_essay_analysis/setup.cfg new file mode 100644 index 000000000..9f5cfb10d --- /dev/null +++ b/modules/wo_bulk_essay_analysis/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +name = WO Bulk Essay Analysis +description = Dashboard for interfacing a classroom of essays with automated feedback +url = https://github.com/ETS-Next-Gen/writing_observer +version = file:VERSION + +[options] +packages = find: +include_package_data = true + +[options.package_data] +wo_bulk_essay_analysis = assets/* + +[options.entry_points] +lo_modules = + wo_bulk_essay_analysis = wo_bulk_essay_analysis.module diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/__init__.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js new file mode 100644 index 000000000..a055d73cb --- /dev/null +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -0,0 +1,625 @@ +/** + * General scripts used for the bulk essay analysis dashboard + */ + +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js'; + +const createStudentCard = async function (s, prompt, width, height, showName, selectedMetrics) { + const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; + const student = s.documents?.[selectedDocument] ?? {}; + const promptHash = await hashObject({ prompt }); + + const studentText = { + namespace: 'lo_dash_react_components', + type: 'WOAnnotatedText', + props: { text: student.text, breakpoints: [] } + }; + const studentTileChild = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + children: [ + createProcessTags({ ...student }, selectedMetrics), + studentText + ] + } + ); + const errorMessage = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: 'An error occurred while processing the text.' + } + }; + const feedbackMessage = { + namespace: DASH_CORE_COMPONENTS, + type: 'Markdown', + props: { + children: student?.feedback ? student.feedback : '', + className: student?.feedback ? 'p-1 overflow-auto' : '', + style: { whiteSpace: 'pre-line' } + } + }; + const feedbackLoading = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [{ + namespace: 'dash_bootstrap_components', + type: 'Spinner', + props: {} + }, { + namespace: 'dash_html_components', + type: 'Div', + props: { children: 'Waiting for a response.' } + }], + className: 'text-center' + } + }; + const feedback = promptHash === student.option_hash_gpt_bulk ? feedbackMessage : feedbackLoading; + const feedbackOrError = 'error' in student ? errorMessage : feedback; + const userId = student?.user_id; + if (!userId) { return {}; } + const studentTile = createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', + { + showName, + profile: student?.profile || {}, + selectedDocument, + childComponent: studentTileChild, + id: { type: 'WOAIAssistStudentTileText', index: userId }, + currentOptionHash: promptHash, + currentStudentHash: student.option_hash_gpt_bulk, + style: { height: `${height}px` }, + additionalButtons: createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + id: { type: 'WOAIAssistStudentTileExpand', index: userId }, + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-expand' }), + color: 'transparent' + } + ) + } + ); + const tileWrapper = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + className: 'position-relative mb-2', + children: [ + studentTile, + createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Card', + { children: feedbackOrError, body: true } + ), + ], + id: { type: 'WOAIAssistStudentTile', index: userId }, + style: { width: `${(100 - width) / width}%` } + } + ); + return tileWrapper; +}; + +/** + * Check for if we should trigger loading on a student or not. + * @param {*} s student + * @param {*} promptHash current hash of prompts + * @returns true if student's selected document's hash is the same as promptHash + */ +const checkForResponse = function (s, promptHash, options) { + if (!('documents' in s)) { return false; } + const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; + const student = s.documents[selectedDocument]; + return options.every(option => promptHash === student[`option_hash_${option}`]); +}; + +const charactersAfterChar = function (str, char) { + const commaIndex = str.indexOf(char); + if (commaIndex === -1) { + return ''; + } + return str.slice(commaIndex + 1).trim(); +}; + +// Helper functions for extracting text from files +const extractPDF = async function (base64String) { + const pdfData = atob(charactersAfterChar(base64String, ',')); + + // Use PDF.js to load and parse the PDF + const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise; + + const totalPages = pdf.numPages; + const allTextPromises = []; + + for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) { + const pageTextPromise = pdf.getPage(pageNumber).then(function (page) { + return page.getTextContent(); + }).then(function (textContent) { + return textContent.items.map(item => item.str).join(' '); + }); + + allTextPromises.push(pageTextPromise); + } + + const allTexts = await Promise.all(allTextPromises); + const allText = allTexts.join('\n'); + + return allText; +}; + +const extractTXT = async function (base64String) { + return atob(charactersAfterChar(base64String, ',')); +}; + +const extractMD = async function (base64String) { + return atob(charactersAfterChar(base64String, ',')); +}; + +const extractDOCX = async function (base64String) { + const arrayBuffer = Uint8Array.from(atob(charactersAfterChar(base64String, ',')), c => c.charCodeAt(0)).buffer; + const result = await mammoth.extractRawText({ arrayBuffer }); + return result.value; // The raw text +}; + +const fileTextExtractors = { + pdf: extractPDF, + txt: extractTXT, + md: extractMD, + docx: extractDOCX +}; + +const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity']; + +window.dash_clientside.bulk_essay_feedback = { + /** + * Sends data to server via websocket + */ + send_to_loconnection: async function (state, hash, clicks, docKwargs, query, systemPrompt, tags) { + if (state === undefined) { + return window.dash_clientside.no_update; + } + if (state.readyState === 1) { + if (hash.length === 0) { return window.dash_clientside.no_update; } + const decoded = decode_string_dict(hash.slice(1)); + if (!decoded.course_id) { return window.dash_clientside.no_update; } + + decoded.gpt_prompt = ''; + decoded.message_id = ''; + decoded.doc_source = docKwargs.src; + decoded.doc_source_kwargs = docKwargs.kwargs; + // TODO what is a reasonable time to wait inbetween subsequent calls for + // the same arguments + decoded.rerun_dag_delay = 120; + + const trig = window.dash_clientside.callback_context.triggered[0]; + if (trig.prop_id.includes('bulk-essay-analysis-submit-btn')) { + decoded.gpt_prompt = query; + decoded.system_prompt = systemPrompt; + decoded.tags = tags; + } + + const optionsHash = await hashObject({ prompt: decoded.gpt_prompt }); + decoded.option_hash = optionsHash; + + const message = { + wo: { + execution_dag: 'writing_observer', + target_exports: ['gpt_bulk', 'document_list', 'document_sources', 'time_on_task', 'activity'], + kwargs: decoded + } + }; + return JSON.stringify(message); + } + return window.dash_clientside.no_update; + }, + + toggleAdvanced: function (clicks, shown) { + if (!clicks) { + return window.dash_clientside.no_update; + } + const optionPrefix = 'bulk-essay-analysis-advanced-collapse'; + if (shown.includes(optionPrefix)) { + shown = shown.filter(item => item !== optionPrefix); + } else { + shown = shown.concat(optionPrefix); + } + return shown; + }, + + closeAdvanced: function (clicks, shown) { + if (!clicks) { return window.dash_clientside.no_update; } + shown = shown.filter(item => item !== 'bulk-essay-analysis-advanced-collapse'); + return shown; + }, + + /** + * adds submitted query to history and clear input + */ + update_input_history_on_query_submission: async function (clicks, query, history) { + if (clicks > 0) { + return history.concat(query); + } + return window.dash_clientside.no_update; + }, + + /** + * update history based on history browser storage + */ + update_history_list: function (history) { + const items = history.map((x) => { + return { + namespace: 'dash_html_components', + type: 'Li', + props: { children: x } + }; + }); + return { + namespace: 'dash_html_components', + type: 'Ol', + props: { children: items } + }; + }, + + /** + * update student cards based on new data in storage + */ + updateStudentGridOutput: async function (wsStorageData, history, width, height, showName, value, options) { + if (!wsStorageData) { + return 'No students'; + } + const currPrompt = history.length > 0 ? history[history.length - 1] : ''; + const selectedMetrics = fetchSelectedItemsFromOptions(value, options, 'metric'); + + let output = []; + for (const student in wsStorageData.students) { + output = output.concat(await createStudentCard(wsStorageData.students[student], currPrompt, width, height, showName, selectedMetrics)); + } + return output; + }, + + /** + * Uploads file content as str + */ + handleFileUploadToTextField: async function (contents, filename, timestamp) { + if (filename === undefined) { + return ''; + } + let data = ''; + try { + const filetype = charactersAfterChar(filename, '.'); + if (filetype in fileTextExtractors) { + data = await fileTextExtractors[filetype](contents); + } else { + console.error('Unsupported file type'); + } + } catch (error) { + console.error('Error extracting text from file:', error); + } + return data; + }, + + /** + * append tag in curly braces to input + */ + add_tag_to_input: function (clicks, curr, store) { + const trig = window.dash_clientside.callback_context.triggered[0]; + const trigProp = trig.prop_id; + const trigJSON = JSON.parse(trigProp.slice(0, trigProp.lastIndexOf('.'))); + if (trig.value > 0) { + return curr.concat(` {${trigJSON.index}}`); + } + return window.dash_clientside.no_update; + }, + + /** + * enable/disabled submit based on query + * makes sure there is a query and the tags are properly formatted + * + * updates the following components + * - submit query button disbaled status + * - helper text for why we disabled the submit query button + */ + disableQuerySubmitButton: function (query, loading, store) { + if (query.length === 0) { + return [true, 'Please create a request before submitting.']; + } + if (loading) { + return [true, 'Please wait until current query has finished before resubmitting.']; + } + const tags = Object.keys(store); + const queryTags = query.match(/[^{}]+(?=})/g) || []; + const diffs = queryTags.filter(x => !tags.includes(x)); + if (diffs.length > 0) { + return [true, `Unable to find [${diffs.join(',')}] within the tags. Please check that the spelling is correct or remove the extra tags.`]; + } else if (!queryTags.includes('student_text')) { + return [true, 'Submission requires the inclusion of {student_text} to run the request over the student essays.']; + } + return [false, '']; + }, + + /** + * enable/disable the save attachment button if tag is already in use/blank + * + * updates the following components + * - save button disbaled status + * - helper text for why we are disabled + */ + disableAttachmentSaveButton: function (label, content, currentTagStore, replacementId) { + const tags = Object.keys(currentTagStore); + if (label.length === 0 & content.length === 0) { + return [true, '']; + } else if (label.length === 0) { + return [true, 'Add a label for your content']; + } else if (content.length === 0) { + return [true, 'Add content for your label']; + } else if ((!replacementId | replacementId !== label) & tags.includes(label)) { + return [true, `Label ${label} is already in use.`]; + } + return [false, '']; + }, + + /** + * Opens the tag modal when users want to add a new one or edit an + * existing tag. + */ + openTagAddModal: function (clicks, editClicks, currentTagStore, ids) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + if (triggeredItem === 'bulk-essay-analysis-tags-add-open-btn') { + return [true, null, '', '']; + } + const id = triggeredItem.index; + const index = ids.findIndex(item => item.index === id); + if (editClicks[index]) { + return [true, id, id, currentTagStore[id]]; + } + return window.dash_clientside.no_update; + }, + + /** + * populate word bank of tags + */ + update_tag_buttons: function (tagStore) { + const tagLabels = Object.keys(tagStore); + const tags = tagLabels.map((val) => { + const isStudentText = val === 'student_text'; + const button = createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + children: val, + id: { type: 'bulk-essay-analysis-tags-tag', index: val }, + n_clicks: 0, + color: isStudentText ? 'warning' : 'info' + } + ); + const editButton = createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-edit' }), + id: { type: 'bulk-essay-analysis-tags-tag-edit', index: val }, + n_clicks: 0, + color: 'info' + } + ); + const deleteButton = createDashComponent( + DASH_CORE_COMPONENTS, 'ConfirmDialogProvider', + { + children: createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-trash' }), + color: 'info' + } + ), + id: { type: 'bulk-essay-analysis-tags-tag-delete', index: val }, + message: `Are you sure you want to delete the \`${val}\` placeholder?` + } + ); + const buttons = isStudentText ? [button] : [button, editButton, deleteButton]; + const buttonGroup = createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'ButtonGroup', + { + children: buttons, + class_name: `${isStudentText ? '' : 'prompt-variable-tag'} ms-1 mb-1` + } + ); + return buttonGroup; + }); + return tags; + }, + + /** + * Save placeholder to browser storage and close edit placeholder modal + */ + savePlaceholder: function (clicks, label, text, replacementId, tagStore) { + if (clicks > 0) { + const newStore = tagStore; + if (!!replacementId & replacementId !== label) { + delete newStore[replacementId]; + } + newStore[label] = text; + return [newStore, false]; + } + return window.dash_clientside.no_update; + }, + + /** + * Remove placeholder from store on confirm dialogue yes + */ + removePlaceholder: function (clicks, tagStore, ids) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + const id = triggeredItem.index; + const index = ids.findIndex(item => item.index === id); + if (clicks[index]) { + const newStore = tagStore; + delete newStore[id]; + return newStore; + } + return window.dash_clientside.no_update; + }, + + /** + * Check if we've received any errors and update + * the alert with the appropriate information + * + * returns an array which updates dash components + * - text to display on alert + * - show alert + * - JSON error data on the alert (only in debug) + */ + updateAlertWithError: function (error) { + if (Object.keys(error).length === 0) { + return ['', false, '']; + } + const text = 'Oops! Something went wrong ' + + "on our end. We've noted the " + + 'issue. Please try again later, or consider ' + + 'exploring a different dashboard for now. ' + + 'Thanks for your patience!'; + return [text, true, error]; + }, + + /** + * Iterate over students and figure out if any of them have not loaded + * yet. We hash the last history item to compare to. + */ + updateLoadingInformation: async function (wsStorageData, history) { + const noLoading = [false, 0, '']; + if (!wsStorageData) { + return noLoading; + } + const currentPrompt = history.length > 0 ? history[history.length - 1] : ''; + const promptHash = await hashObject({ prompt: currentPrompt }); + const returnedResponses = Object.values(wsStorageData.students).filter(student => checkForResponse(student, promptHash, AIAssistantLoadingQueries)).length; + const totalStudents = Object.keys(wsStorageData.students).length; + if (totalStudents === returnedResponses) { return noLoading; } + const loadingProgress = returnedResponses / totalStudents + 0.1; + const outputText = `Fetching responses from server. This will take a few minutes. (${returnedResponses}/${totalStudents} received)`; + return [true, loadingProgress, outputText]; + }, + + adjustTileSize: function (width, height, studentIds) { + const total = studentIds.length; + return [ + Array(total).fill({ width: `${(100 - width) / width}%` }), + Array(total).fill({ height: `${height}px` }) + ]; + }, + + selectStudentForExpansion: function (clicks, shownPanels, ids) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + let id = null; + if (triggeredItem?.type === 'WOAIAssistStudentTileExpand') { + id = triggeredItem?.index; + const index = ids.findIndex(item => item.index === id); + if (clicks[index]) { + shownPanels = shownPanels.concat('bulk-essay-analysis-expanded-student-panel'); + } else { + // No clicks occurred so we should keep the ID as it was + id = window.dash_clientside.no_update; + } + } else { + return window.dash_clientside.no_update; + } + return [id, shownPanels]; + }, + + expandSelectedStudent: async function (selectedStudent, wsData, showName, history, value, options) { + if (!selectedStudent | !(selectedStudent in (wsData.students || {}))) { + return window.dash_clientside.no_update; + } + const prompt = history.length > 0 ? history[history.length - 1] : ''; + const s = wsData.students[selectedStudent]; + const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; + const document = Object.keys(s.documents)[0]; + const student = s.documents[document]; + const promptHash = await hashObject({ prompt }); + const selectedMetrics = fetchSelectedItemsFromOptions(value, options, 'metric'); + + // TODO some of this can easily be abstracted + const studentText = { + namespace: 'lo_dash_react_components', + type: 'WOAnnotatedText', + props: { text: student.text, breakpoints: [] } + }; + const studentTileChild = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + children: [ + createProcessTags({ ...student }, selectedMetrics), + studentText + ] + } + ); + const errorMessage = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: 'An error occurred while processing the text.' + } + }; + const feedbackMessage = { + namespace: DASH_CORE_COMPONENTS, + type: 'Markdown', + props: { + children: student?.feedback ? student.feedback : '', + className: student?.feedback ? 'p-1' : '', + style: { whiteSpace: 'pre-line' } + } + }; + const feedbackLoading = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [{ + namespace: 'dash_bootstrap_components', + type: 'Spinner', + props: {} + }, { + namespace: 'dash_html_components', + type: 'Div', + props: { children: 'Waiting for a response.' } + }], + className: 'text-center' + } + }; + const feedback = promptHash === student.option_hash_gpt_bulk ? feedbackMessage : feedbackLoading; + const feedbackOrError = 'error' in student ? errorMessage : feedback; + const studentTile = createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', + { + showName, + profile: student?.profile || {}, + selectedDocument, + childComponent: studentTileChild, + id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, + currentOptionHash: promptHash, + currentStudentHash: student.option_hash_gpt_bulk + } + ); + const individualWrapper = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + className: '', + children: [ + studentTile, + createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Card', + { children: feedbackOrError, body: true } + ) + ] + } + ); + return individualWrapper; + }, + + closeExpandedStudent: function (clicks, shown) { + if (!clicks) { return window.dash_clientside.no_update; } + shown = shown.filter(item => item !== 'bulk-essay-analysis-expanded-student-panel'); + return shown; + } +}; diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css new file mode 100644 index 000000000..b134da7e3 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css @@ -0,0 +1,8 @@ +.prompt-variable-tag button:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.prompt-variable-tag>div:last-child { + display: inline; +} diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/__init__.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py new file mode 100644 index 000000000..59ae6ae3c --- /dev/null +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py @@ -0,0 +1,465 @@ +''' +Define layout for dashboard that allows teachers to interface +student essays with LLMs. +''' +import dash_bootstrap_components as dbc +from dash_renderjson import DashRenderjson +import lo_dash_react_components as lodrc +import random + +from dash import html, dcc, clientside_callback, ClientsideFunction, Output, Input, State, ALL + +import wo_classroom_text_highlighter.options + +# TODO pull this flag from settings +DEBUG_FLAG = True + +prefix = 'bulk-essay-analysis' +_websocket = f'{prefix}-websocket' +_namespace = 'bulk_essay_feedback' + +alert = f'{prefix}-alert' +alert_text = f'{prefix}-alert-text' +alert_error_dump = f'{prefix}-alert-error-dump' + +query_input = f'{prefix}-query-input' + +panel_layout = f'{prefix}-panel-layout' + +_advanced = f'{prefix}-advanced' +_advanced_doc_src = f'{_advanced}-document-source' +_advanced_toggle = f'{_advanced}-toggle' +_advanced_collapse = f'{_advanced}-collapse' +_advanced_close = f'{_advanced}-close' +_advanced_width = f'{_advanced}-width' +_advanced_height = f'{_advanced}-height' +_advanced_hide_header = f'{_advanced}-hide-header' +_advanced_text_information = f'{_advanced}-text-information' + +_system_input = f'{prefix}-system-prompt-input' +_system_input_tooltip = f'{_system_input}-tooltip' + +# placeholder DOM ids +_tags = f'{prefix}-tags' +placeholder_tooltip = f'{_tags}-placeholder-tooltip' +tag = f'{_tags}-tag' +_tag_edit = f'{tag}-edit' +_tag_delete = f'{tag}-delete' +tag_store = f'{_tags}-tags-store' +_tag_add = f'{_tags}-add' +_tag_replacement_id = f'{_tag_add}-replacement-id' +_tag_add_modal = f'{_tag_add}-modal' +_tag_add_open = f'{_tag_add}-open-btn' +_tag_add_label = f'{_tag_add}-label' +_tag_add_text = f'{_tag_add}-text' +_tag_add_upload = f'{_tag_add}-upload' +_tag_add_warning = f'{_tag_add}-warning' +_tag_add_save = f'{_tag_add}-save' +tag_modal = dbc.Modal([ + dbc.ModalHeader('Add Placeholder'), + dbc.ModalBody([ + dbc.Input(id=_tag_replacement_id, class_name='d-none'), + dbc.Label('Label'), + dbc.Input( + placeholder='Name your placeholder (e.g., "Narrative Grade 8 Rubric")', + id=_tag_add_label, + value='' + ), + dbc.Label('Contents'), + dbc.Textarea( + placeholder='Enter text here... Uploading a file replaces this content', + id=_tag_add_text, + style={'height': '300px'}, + value='' + ), + dbc.Button( + dcc.Upload( + [html.I(className='fas fa-plus me-1'), 'Upload'], + accept='.txt,.md,.pdf,.docx', + id=_tag_add_upload + ) + ) + ]), + dbc.ModalFooter([ + html.Small(id=_tag_add_warning, className='text-danger'), + dbc.Button('Save', class_name='ms-auto', id=_tag_add_save), + ]) +], id=_tag_add_modal, is_open=False) + +# prompt history DOM ids +history_body = f'{prefix}-history-body' +history_store = f'{prefix}-history-store' +favorite_store = f'{prefix}-favorite-store' + +# loading message/bar DOM ids +_loading_prefix = f'{prefix}-loading' +_loading_collapse = f'{_loading_prefix}-collapse' +_loading_progress = f'{_loading_prefix}-progress-bar' +_loading_information = f'{_loading_prefix}-information-text' + +submit = f'{prefix}-submit-btn' +submit_warning_message = f'{prefix}-submit-warning-msg' +_student_data_wrapper = f'{prefix}-student-data' +grid = f'{prefix}-essay-grid' + +# Expanded student +_expanded_student = f'{prefix}-expanded-student' +_expanded_student_selected = f'{_expanded_student}-selected' +_expanded_student_panel = f'{_expanded_student}-panel' +_expanded_student_child = f'{_expanded_student}-child' +_expanded_student_close = f'{_expanded_student}-close' +expanded_student_component = html.Div([ + html.Div([ + html.H3('Individual Student', className='d-inline-block'), + dbc.Button( + html.I(className='fas fa-close'), + className='float-end', id=_expanded_student_close, + color='transparent'), + ]), + dbc.Input(id=_expanded_student_selected, class_name='d-none'), + html.Div(id=_expanded_student_child) +], className='p-2') + +# default prompts +system_prompt = 'You are a helpful assistant for grade school teachers. Your task is to analyze '\ + 'student writing and provide clear, constructive, and age-appropriate feedback. '\ + 'Focus on key writing traits such as clarity, creativity, grammar, and organization. '\ + 'When summarizing, highlight the main ideas and key details. Always maintain a '\ + 'positive and encouraging tone to support student growth.' + +starting_prompt = [ + 'Provide 3 bullet points summarizing this text:\n{student_text}', + 'List 3 strengths in this student\'s writing. Use bullet points and focus on creativity or clear ideas:\n{student_text}', + 'Find 2-3 grammar or spelling errors in this text. For each, quote the sentence and suggest a fix:\n{student_text}', + 'Identify 1) Main theme 2) Best sentence 3) One area to improve. Use numbered responses:\n{student_text}', + 'Give one specific compliment and one gentle suggestion to improve this story:\n{student_text}' +] + + +def layout(): + ''' + Generic layout function to create dashboard + ''' + # advanced menu for system prompt + advanced = html.Div([ + html.Div([ + html.H3('Settings', className='d-inline-block'), + dbc.Button( + html.I(className='fas fa-close'), + className='float-end', id=_advanced_close, + color='transparent'), + ]), + lodrc.LODocumentSourceSelectorAIO(aio_id=_advanced_doc_src), + dbc.Card([ + dbc.CardHeader('View Options'), + dbc.CardBody([ + dbc.Label('Students per row'), + dbc.Input(type='number', min=1, max=10, value=2, step=1, id=_advanced_width), + dbc.Label('Height of student tile'), + dcc.Slider(min=100, max=800, marks=None, value=350, id=_advanced_height), + dbc.Label('Student profile'), + dbc.Switch(value=True, id=_advanced_hide_header, label='Show/Hide'), + ]) + ]), + dbc.Card([ + dbc.CardHeader('Information Options'), + dbc.CardBody(lodrc.WOSettings( + id=_advanced_text_information, + options=wo_classroom_text_highlighter.options.PROCESS_OPTIONS, + value=wo_classroom_text_highlighter.options.DEFAULT_VALUE, + className='table table-striped align-middle' + )) + ]) + ]) + + # history panel + history_favorite_panel = dbc.Card([ + dbc.CardHeader('Prompt History'), + dbc.CardBody([], id=history_body), + dcc.Store(id=history_store, data=[]) + ], class_name='h-100') + + # query creator panel + input_panel = dbc.Card([ + dbc.CardHeader('Prompt Input'), + dbc.CardBody([ + dbc.Label([ + 'System prompt', + html.I(className='fas fa-circle-question ms-1', id=_system_input_tooltip) + ]), + dbc.Tooltip( + "A system prompt guides the AI's responses. It sets the context for how the AI should analyze or summarize student text.", + target=_system_input_tooltip + ), + dbc.Textarea(id=_system_input, value=system_prompt, style={'minHeight': '120px'}), + dbc.Label('Query'), + dbc.Textarea(id=query_input, value=random.choice(starting_prompt), class_name='h-100', style={'minHeight': '150px'}), + html.Div([ + html.Span([ + 'Placeholders', + html.I(className='fas fa-circle-question ms-1', id=placeholder_tooltip) + ], className='me-1'), + html.Span([], id=_tags), + dbc.Button([html.I(className='fas fa-add me-1'), 'Add'], id=_tag_add_open, class_name='ms-1 mb-1') + ], className='mt-1'), + dbc.Tooltip( + 'Click a placeholder to insert it into your query. Upon submission, it will be replaced with the corresponding value.', + target=placeholder_tooltip + ), + tag_modal, + dcc.Store(id=tag_store, data={'student_text': ''}), + ]), + dbc.CardFooter([ + html.Small(id=submit_warning_message, className='text-secondary'), + dbc.Button('Submit', color='primary', id=submit, n_clicks=0, class_name='float-end') + ]) + ]) + + alert_component = dbc.Alert([ + html.Div(id=alert_text), + html.Div(DashRenderjson(id=alert_error_dump), className='' if DEBUG_FLAG else 'd-none') + ], id=alert, color='danger', is_open=False) + + loading_component = dbc.Collapse([ + html.Div(id=_loading_information), + dbc.Progress(id=_loading_progress, animated=True, striped=True, max=1.1) + ], id=_loading_collapse, is_open=False, class_name='mb-1 sticky-top bg-light') + + # overall container + cont = dbc.Container([ + html.H1('Writing Observer - Classroom AI Feedback Assistant'), + dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), + dbc.Button([html.I(className='fas fa-cog me-1'), 'Advanced'], id=_advanced_toggle), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), + ], class_name='mb-1'), + lodrc.LOPanelLayout( + input_panel, + panels=[ + {'children': history_favorite_panel, 'width': '30%', 'id': 'history-favorite'}, + ], + shown=['history-favorite'], + id=panel_layout + ), + alert_component, + html.H3('Student Text', className='mt-1'), + loading_component, + lodrc.LOPanelLayout( + html.Div(id=grid, className='d-flex justify-content-between flex-wrap'), + panels=[ + {'children': advanced, 'width': '30%', 'id': _advanced_collapse, 'side': 'left' }, + {'children': expanded_student_component, + 'width': '30%', 'id': _expanded_student_panel, + 'side': 'right'} + ], + id=_student_data_wrapper, shown=[] + ), + ], fluid=True) + return html.Div(cont) + + +# Toggle if the advanced menu collapse is open or not +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='toggleAdvanced'), + Output(_student_data_wrapper, 'shown', allow_duplicate=True), + Input(_advanced_toggle, 'n_clicks'), + State(_student_data_wrapper, 'shown'), + prevent_initial_call=True +) + +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='closeAdvanced'), + Output(_student_data_wrapper, 'shown', allow_duplicate=True), + Input(_advanced_close, 'n_clicks'), + State(_student_data_wrapper, 'shown'), + prevent_initial_call=True +) + +# send request on websocket +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='send_to_loconnection'), + Output(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash'), + Input(submit, 'n_clicks'), + Input(lodrc.LODocumentSourceSelectorAIO.ids.kwargs_store(_advanced_doc_src), 'data'), + State(query_input, 'value'), + State(_system_input, 'value'), + State(tag_store, 'data'), +) + +# enable/disabled submit based on query +# makes sure there is a query and the tags are properly formatted +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='disableQuerySubmitButton'), + Output(submit, 'disabled'), + Output(submit_warning_message, 'children'), + Input(query_input, 'value'), + Input(_loading_collapse, 'is_open'), + Input(tag_store, 'data') +) + +# add submitted query to history and clear input +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_input_history_on_query_submission'), + Output(history_store, 'data'), + Input(submit, 'n_clicks'), + State(query_input, 'value'), + State(history_store, 'data') +) + +# update history based on history browser storage +# TODO create a history component that can handle favorites +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_history_list'), + Output(history_body, 'children'), + Input(history_store, 'data') +) + +# Toggle if the add placeholder is open or not +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='openTagAddModal'), + Output(_tag_add_modal, 'is_open'), + Output(_tag_replacement_id, 'value'), + Output(_tag_add_label, 'value'), + Output(_tag_add_text, 'value'), + Input(_tag_add_open, 'n_clicks'), + Input({'type': _tag_edit, 'index': ALL}, 'n_clicks'), + State(tag_store, 'data'), + State({'type': _tag_edit, 'index': ALL}, 'id'), +) + +# show attachment panel upon uploading document and populate fields +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='handleFileUploadToTextField'), + Output(_tag_add_text, 'value', allow_duplicate=True), + Input(_tag_add_upload, 'contents'), + Input(_tag_add_upload, 'filename'), + Input(_tag_add_upload, 'last_modified'), + prevent_initial_call=True +) + +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateAlertWithError'), + Output(alert_text, 'children'), + Output(alert, 'is_open'), + Output(alert_error_dump, 'data'), + Input(lodrc.LOConnectionAIO.ids.error_store(_websocket), 'data') +) + +# update student cards based on new data in storage +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateStudentGridOutput'), + Output(grid, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(history_store, 'data'), + Input(_advanced_width, 'value'), + Input(_advanced_height, 'value'), + Input(_advanced_hide_header, 'value'), + Input(_advanced_text_information, 'value'), + State(_advanced_text_information, 'options') +) + +# append tag in curly braces to input +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='add_tag_to_input'), + Output(query_input, 'value', allow_duplicate=True), + Input({'type': tag, 'index': ALL}, 'n_clicks'), + State(query_input, 'value'), + State(tag_store, 'data'), + prevent_initial_call=True +) + +# enable/disable the save attachment button if tag is already in use/blank +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='disableAttachmentSaveButton'), + Output(_tag_add_save, 'disabled'), + Output(_tag_add_warning, 'children'), + Input(_tag_add_label, 'value'), + Input(_tag_add_text, 'value'), + State(tag_store, 'data'), + State(_tag_replacement_id, 'value') +) + +# populate word bank of tags +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_tag_buttons'), + Output(_tags, 'children'), + Input(tag_store, 'data') +) + +# save placeholder to storage +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='savePlaceholder'), + Output(tag_store, 'data'), + Output(_tag_add_modal, 'is_open', allow_duplicate=True), + Input(_tag_add_save, 'n_clicks'), + State(_tag_add_label, 'value'), + State(_tag_add_text, 'value'), + State(_tag_replacement_id, 'value'), + State(tag_store, 'data'), + prevent_initial_call=True +) + +# remove placeholder from storage +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='removePlaceholder'), + Output(tag_store, 'data', allow_duplicate=True), + Input({'type': _tag_delete, 'index': ALL}, 'submit_n_clicks'), + State(tag_store, 'data'), + State({'type': _tag_delete, 'index': ALL}, 'id'), + prevent_initial_call=True +) + +# update loading information +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateLoadingInformation'), + Output(_loading_collapse, 'is_open'), + Output(_loading_progress, 'value'), + Output(_loading_information, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(history_store, 'data') +) + +# Adjust student tile size +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='adjustTileSize'), + Output({'type': 'WOAIAssistStudentTile', 'index': ALL}, 'style', allow_duplicate=True), + Output({'type': 'WOAIAssistStudentTileText', 'index': ALL}, 'style', allow_duplicate=True), + Input(_advanced_width, 'value'), + Input(_advanced_height, 'value'), + State({'type': 'WOAIAssistStudentTile', 'index': ALL}, 'id'), + prevent_initial_call=True +) + +# Expand a single student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='selectStudentForExpansion'), + Output(_expanded_student_selected, 'value'), + Output(_student_data_wrapper, 'shown', allow_duplicate=True), + Input({'type': 'WOAIAssistStudentTileExpand', 'index': ALL}, 'n_clicks'), + State(_student_data_wrapper, 'shown'), + State({'type': 'WOAIAssistStudentTile', 'index': ALL}, 'id'), + prevent_initial_call=True +) + +# Update expanded children based on selected student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='expandSelectedStudent'), + Output(_expanded_student_child, 'children'), + Input(_expanded_student_selected, 'value'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(_advanced_hide_header, 'value'), + Input(history_store, 'data'), + Input(_advanced_text_information, 'value'), + State(_advanced_text_information, 'options') +) + +# Close expanded student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='closeExpandedStudent'), + Output(_student_data_wrapper, 'shown', allow_duplicate=True), + Input(_expanded_student_close, 'n_clicks'), + State(_student_data_wrapper, 'shown'), + prevent_initial_call=True +) diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py new file mode 100644 index 000000000..7562f9ef8 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py @@ -0,0 +1,47 @@ +import learning_observer.communication_protocol.integration +import learning_observer.cache +import learning_observer.prestartup +import learning_observer.settings + +import lo_gpt.gpt + +template = """[Task]\n{question}\n\n[Essay]\n{text}""" +rubric_template = """{task}\n\n[Rubric]\n{rubric}""" + + +@learning_observer.communication_protocol.integration.publish_function('wo_bulk_essay_analysis.gpt_essay_prompt') +async def process_student_essay(text, prompt, system_prompt, tags): + ''' + This method processes text with a prompt through GPT. + + We use a closure to allow the system to connect to the memoization KVS. + ''' + copy_tags = tags.copy() + + @learning_observer.cache.async_memoization() + async def gpt(gpt_prompt): + completion = await lo_gpt.gpt.gpt_responder.chat_completion(gpt_prompt, system_prompt) + return completion + + if len(prompt) == 0: + output = { + 'text': text, + 'feedback': 'No prompt provided yet.', + 'prompt': prompt + } + elif len(text) == 0: + output = { + 'text': text, + 'feedback': 'No text available for this student.', + 'prompt': prompt + } + else: + copy_tags['student_text'] = text + formatted_prompt = prompt.format(**copy_tags) + + output = { + 'text': text, + 'feedback': await gpt(formatted_prompt), + 'prompt': prompt + } + return output diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py new file mode 100644 index 000000000..0f180f754 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py @@ -0,0 +1,69 @@ +import learning_observer.communication_protocol.query as q +import learning_observer.downloads as d +from learning_observer.dash_integration import thirdparty_url + +import wo_bulk_essay_analysis.gpt +import wo_bulk_essay_analysis.dashboard.layout + + +NAME = "Writing Observer - Classroom AI Feedback Assistant" + +DASH_PAGES = [ + { + "MODULE": wo_bulk_essay_analysis.dashboard.layout, + "LAYOUT": wo_bulk_essay_analysis.dashboard.layout.layout, + "ASSETS": 'assets', + "TITLE": "Classroom AI Feedback Assistant", + "DESCRIPTION": "The Classroom AI Feedback Assistant is a robust educational tool that leverages AI to simultaneously analyze and provide feedback on large batches of essays, delivering comprehensive insights and constructive critiques for educators in diverse group settings.", + "SUBPATH": "bulk-essay-analysis", + "CSS": [ + thirdparty_url("css/bootstrap.min.css"), + thirdparty_url("css/fontawesome_all.css") + ], + "SCRIPTS": [ + thirdparty_url('pdf.js'), + thirdparty_url('pdf.worker.js'), + thirdparty_url('mammoth.js') + ] + } +] + + +THIRD_PARTY = { + # PDF parser for reading in files clientside + 'pdf.js': { + 'url': 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.9.179/pdf.min.js', + 'hash': { + '3.9.179': 'c1dd581c4d2fec33c43eecb394a4335f25da58dcda361efe422ddbb32e640' + 'e548547b75cb8e0db9ec0746480eb3d34a63c23c89296ea22a7ab22b6f37e726ef2' + } + }, + 'pdf.worker.js': { + 'url': 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.9.179/pdf.worker.min.js', + 'hash': { + '3.9.179': '0fc109e44fb5af718c31f1a15e1479850ef259efa42dcdd2cdd975387df3b' + '3ebb7dad9946bd3d00bdcd29527dc753fde4b950b2a7a052bd8f66ee643bb736767' + } + }, + # Docx parser for reading in files clientside + 'mammoth.js': { + 'url': 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.9.0/mammoth.browser.min.js', + 'hash': { + '1.9.0': '7e77162c6d0103528615896ba72fcca385ab2f64699cd06d744a6d740c16179' + '322e02e2d45adf1c4d8720f6c8ac7c54e19c6a061eb0814f2abb4b80738d8766a' + } + }, + "css/bootstrap.min.css": d.BOOTSTRAP_MIN_CSS, + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_bulk_essay_analysis/dash/bulk-essay-analysis", + "icon": { + "type": "fas", + "icon": "fa-lightbulb" + } +}] diff --git a/modules/wo_classroom_text_highlighter/MANIFEST.in b/modules/wo_classroom_text_highlighter/MANIFEST.in new file mode 100644 index 000000000..d1e41f771 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/MANIFEST.in @@ -0,0 +1 @@ +include wo_classroom_text_highlighter/assets/* diff --git a/modules/wo_classroom_text_highlighter/README.md b/modules/wo_classroom_text_highlighter/README.md new file mode 100644 index 000000000..f088d3bb1 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/README.md @@ -0,0 +1,58 @@ +# Writing Observer - Classroom Text Highlighter + +**Last updated:** September 30th, 2025 + +![Classroom Text Highlighter dashboard overview](_images/classroom-text-highlighter-overview.png) + +The Classroom Text Highlighter dashboard visualizes student writing with rich NLP-driven annotations. Use it to help students notice patterns in their drafts, guide whole-class discussions, or plan targeted mini-lessons based on the language skills your learners are using. + +> **Who is this for?** Teachers who want to explore student writing through highlights, time-on-task metrics, and reusable annotation presets. + +## What you can do with this dashboard + +* **Display student writing at a glance.** See every student's document in a responsive grid, with quick navigation between document sources. +* **Highlight key language features.** Turn on NLP indicators (parts of speech, sentence structures, tone, etc.) and instantly spot trends across the class. +* **Track writing behaviors.** Add process metrics such as time on task or status badges so you know who is still working versus finished. +* **Save and reuse presets.** Build highlight collections (e.g., "Argumentative Evidence" or "Narrative Voice") and recall them with one click. +* **Surface a legend for students.** Share the color key so learners understand what each highlight represents during conferences or gallery walks. +* **Zoom into individual students.** Expand any tile for a focused view, ideal for projecting on screen or printing annotated drafts. + +## Getting started + +1. **Open the dashboard.** From the primary dashboard, select **Classroom Text Highlighter**. The dashboard loads at `/wo_classroom_text_highlighter/dash`. +2. **Confirm the connection.** The WebSocket indicator in the toolbar should be green. If not, check your login status via the profile sidebar. +3. **Pick a document source.** In **Settings → Document Source**, choose where essays should come from (e.g., most recent document, document accessed at specific time, etc). Adjust any source-specific options if prompted. +4. **Select NLP options.** In the **Information Options** table, check the highlights and metrics you want to display. Group headers (e.g., *Text Information*, *Process Information*) expand to reveal individual features. +5. **Adjust the layout.** Use the view controls to set students per row, tile height, and whether student names appear. These settings make it easy to adapt the dashboard for stations, projector mode, or screen readers. + +## Working with highlights and presets + +![Highlight configuration panel](_images/classroom-text-highlighter-options.png) + +* **Legend button:** Shows the current highlight color key so you can share the meaning with students. +* **Presets panel:** Save combinations of options (e.g., "Sentence Structure") for future lessons. Name your preset, click **Add Preset**, and it stores the current configuration. Select a preset from the list to instantly apply it. +* **Deselect All preset:** Quickly clears every highlight and metric selection if you want to start fresh. +* **Custom colors:** Customize colors for each selected highlightable item. + +## Exploring student writing + +![Expanded student tile](_images/classroom-text-highlighter-student.png) + +* **Student tiles:** Each tile shows badges for selected metrics (time on task, status) followed by the highlighted text. Tiles resize automatically based on your layout settings. +* **Expand view:** Click the expand icon to open the **Individual Student** drawer. This view removes the grid so you can focus on one student, scroll without distractions, and discuss highlights during conferences. +* **Loading feedback:** A progress bar appears while new highlights are generated. The banner updates with counts so you know when all documents are ready. + +## Tips for classroom use + +* Begin mini-lessons by projecting the legend and a few anonymized tiles. Have students identify how the highlights connect to the day's learning target. +* Combine process metrics with highlights to spot students who spent little time on task but still produced strong structures—perfect for peer coaching pairs. +* Encourage student self-assessment by sharing individual tiles and asking learners to explain why certain phrases received highlights. + +## Troubleshooting + +* **No students appear.** Ensure the URL contains a `course_id` and that the selected document source has submissions. Try toggling the Options panel to refresh the selection. +* **Highlights don't change after I toggle options.** Wait for the loading banner to disappear; the dashboard batches NLP requests and updates tiles when processing finishes. +* **Colors are confusing.** Use the legend button to review the palette, or save a preset with fewer simultaneous highlights. +* **Error alert shows up.** Read the message for guidance. The dashboard records error details (visible to developers) that you can share with your support contact. + +With the Classroom Text Highlighter, you can transform raw student writing into an interactive, data-informed experience that keeps learners engaged and reflective. diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION new file mode 100644 index 000000000..696424a49 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -0,0 +1 @@ +0.1.0+2025.09.30T15.53.18.556Z.1c72d9e1.master diff --git a/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-options.png b/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-options.png new file mode 100644 index 000000000..0cce1f8cc Binary files /dev/null and b/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-options.png differ diff --git a/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-overview.png b/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-overview.png new file mode 100644 index 000000000..1a9380e3c Binary files /dev/null and b/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-overview.png differ diff --git a/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-student.png b/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-student.png new file mode 100644 index 000000000..b58d22cc9 Binary files /dev/null and b/modules/wo_classroom_text_highlighter/_images/classroom-text-highlighter-student.png differ diff --git a/modules/wo_classroom_text_highlighter/pyproject.toml b/modules/wo_classroom_text_highlighter/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/wo_classroom_text_highlighter/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/wo_classroom_text_highlighter/setup.cfg b/modules/wo_classroom_text_highlighter/setup.cfg new file mode 100644 index 000000000..5ad128574 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/setup.cfg @@ -0,0 +1,14 @@ +[metadata] +name = WO Classroom Text Highlighter +version = file:VERSION +description = Use this as a base template for creating new modules on the Learning Observer. + +[options] +packages = wo_classroom_text_highlighter + +[options.package_data] +wo_classroom_text_highlighter = assets/* + +[options.entry_points] +lo_modules = + wo_classroom_text_highlighter = wo_classroom_text_highlighter.module diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/__init__.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css new file mode 100644 index 000000000..9138b7239 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css @@ -0,0 +1,24 @@ +.loading-circle { + border: 4px solid #e0e0e0; + border-top: 4px solid var(--bs-primary); + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.preset button:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} \ No newline at end of file diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js new file mode 100644 index 000000000..7510b2d37 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -0,0 +1,354 @@ +/** + * Javascript callbacks to be used with the LO Example dashboard + */ + +// Initialize the `dash_clientside` object if it doesn't exist +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +const DASH_HTML_COMPONENTS = 'dash_html_components'; +const DASH_CORE_COMPONENTS = 'dash_core_components'; +const DASH_BOOTSTRAP_COMPONENTS = 'dash_bootstrap_components'; +const LO_DASH_REACT_COMPONENTS = 'lo_dash_react_components'; + +// TODO this ought to move to a more common place +function createDashComponent (namespace, type, props) { + return { namespace, type, props }; +} + +function determineSelectedNLPOptionsList (optionsObj) { + if (optionsObj === undefined | optionsObj === null) { return []; } + return Object.keys(optionsObj).filter(id => + optionsObj[id].highlight?.value === true || + optionsObj[id].metric?.value === true + ); +} + +// TODO this ought to move to a more common place like liblo.js +async function hashObject (obj) { + const jsonString = JSON.stringify(obj); + const encoder = new TextEncoder(); + const data = encoder.encode(jsonString); + + // Check if crypto.subtle is available + if (crypto && crypto.subtle) { + try { + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); + return hashHex; + } catch (error) { + console.warn('crypto.subtle.digest failed; falling back to simple hash.'); + } + } + + // Fallback to the simple hash if crypto.subtle is unavailable + return simpleHash(jsonString); +} + +function simpleHash (str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32-bit integer + } + return hash.toString(16); +} + +function formatStudentData (document, selectedHighlights) { + const breakpoints = selectedHighlights.reduce((acc, option) => { + const offsets = document[option.id]?.offsets || []; + if (offsets) { + const modifiedOffsets = offsets.map(offset => { + return { + id: '', + tooltip: option.label, + start: offset[0], + offset: offset[1], + style: { backgroundColor: option.highlight.color } + }; + }); + acc = acc.concat(modifiedOffsets); + } + return acc; + }, []); + const text = document.text; + return { text, breakpoints }; +} + +function styleStudentTile (width, height) { + return { width: `${(100 - width) / width}%`, height: `${height}px` }; +} + +function fetchSelectedItemsFromOptions (value, options, type) { + return options.reduce(function(filtered, option) { + if (value?.[option.id]?.[type]?.value) { + const selected = {...option, ...value[option.id]}; + filtered.push(selected); + } + return filtered; + }, []); +} + +function createProcessTags (document, metrics) { + const children = metrics.map(metric => { + switch (metric.id) { + case 'time_on_task': + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Badge', + { children: `${rendertime2(document[metric.id])} on task`, className: 'me-1' } + ); + case 'status': + const color = document[metric.id] === 'active' ? 'success' : 'warning'; + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Badge', + { children: document[metric.id], color } + ); + default: + break + } + }); + return createDashComponent(DASH_HTML_COMPONENTS, 'Div', { children, className: 'sticky-top' }) +} +const ClassroomTextHighlightLoadingQueries = ['docs_with_nlp_annotations', 'time_on_task', 'activity']; + +window.dash_clientside.wo_classroom_text_highlighter = { + /** + * Send updated queries to the communication protocol. + * @param {object} wsReadyState LOConnection status object + * @param {string} urlHash query string from hash for determining course id + * @returns stringified json object that is sent to the communication protocl + */ + sendToLOConnection: async function (wsReadyState, urlHash, docKwargs, nlpValue) { + if (wsReadyState === undefined) { + return window.dash_clientside.no_update; + } + if (wsReadyState.readyState === 1) { + if (urlHash.length === 0) { return window.dash_clientside.no_update; } + const decodedParams = decode_string_dict(urlHash.slice(1)); + if (!decodedParams.course_id) { return window.dash_clientside.no_update; } + + const optionsHash = await hashObject(nlpValue); + const nlpOptions = determineSelectedNLPOptionsList(nlpValue); + decodedParams.nlp_options = nlpOptions; + decodedParams.option_hash = optionsHash; + decodedParams.doc_source = docKwargs.src; + decodedParams.doc_source_kwargs = docKwargs.kwargs; + const outgoingMessage = { + wo_classroom_text_highlighter_query: { + execution_dag: 'writing_observer', + target_exports: ['docs_with_nlp_annotations', 'document_sources', 'document_list', 'time_on_task', 'activity'], + kwargs: decodedParams + } + }; + return JSON.stringify(outgoingMessage); + } + return window.dash_clientside.no_update; + }, + + // toggleOptions: function (clicks, isOpen) { + // if (!clicks) { + // return window.dash_clientside.no_update; + // } + // return !isOpen; + // }, + + toggleOptions: function (clicks, shown) { + if (!clicks) { + return window.dash_clientside.no_update; + } + const optionPrefix = 'wo-classroom-text-highlighter-options'; + if (shown.includes(optionPrefix)) { + shown = shown.filter(item => item !== optionPrefix); + } else { + shown = shown.concat(optionPrefix); + } + return shown; + }, + + closeOptions: function (clicks, shown) { + if (!clicks) { return window.dash_clientside.no_update; } + shown = shown.filter(item => item !== 'wo-classroom-text-highlighter-options'); + return shown; + }, + + adjustTileSize: function (width, height, studentIds) { + const total = studentIds.length; + return Array(total).fill(styleStudentTile(width, height)); + }, + + showHideHeader: function (show, ids) { + const total = ids.length; + return Array(total).fill(show ? 'd-none' : ''); + }, + + updateCurrentOptionHash: async function (value, ids) { + const optionHash = await hashObject(value); + const total = ids.length; + return Array(total).fill(optionHash); + }, + + /** + * Build the student UI components based on the stored websocket data + * @param {*} wsStorageData information stored in the websocket store + * @returns Dash object to be displayed on page + */ + populateOutput: async function (wsStorageData, value, width, height, showName, options) { + // console.log('wsStorageData', wsStorageData); + if (!wsStorageData?.students) { + return 'No students'; + } + let output = []; + + const selectedHighlights = fetchSelectedItemsFromOptions(value, options, 'highlight'); + const selectedMetrics = fetchSelectedItemsFromOptions(value, options, 'metric'); + + const optionHash = await hashObject(value); + const students = wsStorageData.students; + for (const student in students) { + const selectedDocument = students[student].doc_id || Object.keys(students[student].documents || {})[0] || ''; + const studentTileChild = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + children: [ + createProcessTags({ ...students[student].documents[selectedDocument] }, selectedMetrics), + createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', + formatStudentData({ ...students[student].documents[selectedDocument] }, selectedHighlights) + ) + ] + } + ); + const studentTile = createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', + { + showName, + profile: students[student].documents[selectedDocument]?.profile || {}, + selectedDocument, + childComponent: studentTileChild, + id: { type: 'WOStudentTextTile', index: student }, + currentStudentHash: students[student].documents[selectedDocument]?.option_hash_docs_with_nlp_annotations, + currentOptionHash: optionHash, + className: 'h-100', + additionalButtons: createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + id: { type: 'WOStudentTileExpand', index: student }, + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-expand' }), + color: 'transparent' + } + ) + } + ); + const tileWrapper = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + className: 'mb-2', + children: [ + studentTile, + ], + id: { type: 'WOStudentTile', index: student }, + style: styleStudentTile(width, height) + } + ); + output = output.concat(tileWrapper); + } + return output; + }, + + updateAlertWithError: function (error) { + if (Object.keys(error).length === 0) { + return ['', false, '']; + } + const text = 'Oops! Something went wrong ' + + "on our end. We've noted the " + + 'issue. Please try again later, or consider ' + + 'exploring a different dashboard for now. ' + + 'Thanks for your patience!'; + return [text, true, error]; + }, + + addPreset: function (clicks, name, options, store) { + if (!clicks) { return store; } + const copy = { ...store }; + copy[name] = options; + return copy; + }, + + applyPreset: function (clicks, data) { + const preset = window.dash_clientside.callback_context?.triggered_id.index ?? null; + const itemsClicked = clicks.some(item => item !== undefined); + if (!preset | !itemsClicked) { return window.dash_clientside.no_update; } + return data[preset]; + }, + + updateLoadingInformation: async function (wsStorageData, nlpValue) { + const noLoading = [false, 0, '']; + if (!wsStorageData?.students) { + return noLoading; + } + const students = wsStorageData.students; + const promptHash = await hashObject(nlpValue); + const returnedResponses = Object.values(students).filter(student => checkForResponse(student, promptHash, ClassroomTextHighlightLoadingQueries)).length; + const totalStudents = Object.keys(students).length; + if (totalStudents === returnedResponses) { return noLoading; } + const loadingProgress = returnedResponses / totalStudents + 0.1; + const outputText = `Fetching responses from server. This will take a few minutes. (${returnedResponses}/${totalStudents} received)`; + return [true, loadingProgress, outputText]; + }, + + expandCurrentStudent: function (clicks, children, ids, shownPanels, currentChild) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + let child = ''; + let id = null; + if (triggeredItem?.type === 'WOStudentTile') { + if (!currentChild) { return window.dash_clientside.no_update; } + id = currentChild?.props.id.index; + } else if (triggeredItem?.type === 'WOStudentTileExpand') { + id = triggeredItem?.index; + shownPanels = shownPanels.concat('wo-classroom-text-highlighter-expanded-student-panel'); + } else { + return window.dash_clientside.no_update; + } + const index = ids.findIndex(item => item.index === id); + child = children[index][0]; + return [child, shownPanels]; + }, + + closeExpandedStudent: function (clicks, shown) { + if (!clicks) { return window.dash_clientside.no_update; } + shown = shown.filter(item => item !== 'wo-classroom-text-highlighter-expanded-student-panel'); + return shown; + }, + + updateLegend: function (value, options) { + const selectedHighlights = fetchSelectedItemsFromOptions(value, options, 'highlight'); + const selectedMetrics = fetchSelectedItemsFromOptions(value, options, 'metric'); + const total = selectedHighlights.length + selectedMetrics.length; + + if (selectedHighlights.length === 0) { + return ['No options selected. Click on the `Options` to select them.', total]; + } + let output = selectedHighlights.map(highlight => { + const color = highlight.highlight.color; + const legendItem = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + children: [ + createDashComponent( + DASH_HTML_COMPONENTS, 'Span', + { style: { width: '0.875rem', height: '0.875rem', backgroundColor: color, display: 'inline-block', marginRight: '0.5rem' } } + ), + highlight.label + ] + } + ); + return legendItem; + }); + output = output.concat('Note: words in the student text may have multiple highlights. Hover over a word for the full list of which options apply'); + return [output, total]; + } +}; diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py new file mode 100644 index 000000000..1ac0f18b0 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py @@ -0,0 +1,290 @@ +''' +This file creates the layout and defines any callbacks +for the classroom highlight dashboard. +''' +from dash import html, dcc, clientside_callback, ClientsideFunction, Output, Input, State, ALL +import dash_bootstrap_components as dbc +import dash_renderjson +import lo_dash_react_components as lodrc + +import learning_observer.settings +import wo_classroom_text_highlighter.options +import wo_classroom_text_highlighter.preset_component + +DEBUG_FLAG = learning_observer.settings.RUN_MODE == learning_observer.settings.RUN_MODES.DEV + +_prefix = 'wo-classroom-text-highlighter' +_namespace = 'wo_classroom_text_highlighter' +_websocket = f'{_prefix}-websocket' +_output = f'{_prefix}-output' + +# loading message/bar DOM ids +_loading_prefix = f'{_prefix}-loading' +_loading_collapse = f'{_loading_prefix}-collapse' +_loading_progress = f'{_loading_prefix}-progress-bar' +_loading_information = f'{_loading_prefix}-information-text' + +loading_component = dbc.Collapse([ + html.Div(id=_loading_information), + dbc.Progress(id=_loading_progress, animated=True, striped=True, max=1.1) +], id=_loading_collapse, is_open=False) + +# Option components +_options_toggle = f'{_prefix}-options-toggle' +_options_toggle_count = f'{_prefix}-options-toggle-count' +_options_collapse = f'{_prefix}-options-collapse' +_options_close = f'{_prefix}-options-close' +# TODO abstract these into a more generic options component +_options_prefix = f'{_prefix}-options' +_options_doc_src = f'{_options_prefix}-document-source' +_options_width = f'{_options_prefix}-width' +_options_height = f'{_options_prefix}-height' +_options_hide_header = f'{_options_prefix}-hide-names' +_options_text_information = f'{_options_prefix}-text-information' + +options_component = html.Div([ + html.Div([ + html.H3('Settings', className='d-inline-block'), + dbc.Button( + html.I(className='fas fa-close'), + className='float-end', id=_options_close, + color='transparent'), + ]), + lodrc.LODocumentSourceSelectorAIO(aio_id=_options_doc_src), + dbc.Card([ + dbc.CardHeader('View Options'), + dbc.CardBody([ + dbc.Label('Students per row'), + dbc.Input(type='number', min=1, max=10, value=2, step=1, id=_options_width), + dbc.Label('Height of student tile'), + dcc.Slider(min=100, max=800, marks=None, value=500, id=_options_height), + dbc.Label('Student profile'), + dbc.Switch(value=True, id=_options_hide_header, label='Show/Hide'), + ]) + ]), + dbc.Card([ + dbc.CardHeader('Information Options'), + dbc.CardBody([ + wo_classroom_text_highlighter.preset_component.create_layout(), + lodrc.WOSettings( + id=_options_text_information, + options=wo_classroom_text_highlighter.options.OPTIONS, + value=wo_classroom_text_highlighter.options.DEFAULT_VALUE, + className='table table-striped align-middle' + ) + ]) + ]) +], className='p-2') + +# Legend +_legend = f'{_prefix}-legend' +_legend_button = f'{_legend}-button' +_legend_children = f'{_legend}-children' + +# Expanded student +_expanded_student = f'{_prefix}-expanded-student' +_expanded_student_panel = f'{_expanded_student}-panel' +_expanded_student_child = f'{_expanded_student}-child' +_expanded_student_close = f'{_expanded_student}-close' +expanded_student_component = html.Div([ + html.Div([ + html.H3('Individual Student', className='d-inline-block'), + dbc.Button( + html.I(className='fas fa-close'), + className='float-end', id=_expanded_student_close, + color='transparent'), + ]), + html.Div(id=_expanded_student_child) +], className='p-2') + +# Alert Component +_alert = f'{_prefix}-alert' +_alert_text = f'{_prefix}-alert-text' +_alert_error_dump = f'{_prefix}-alert-error-dump' + +alert_component = dbc.Alert([ + html.Div(id=_alert_text), + html.Div(dash_renderjson.DashRenderjson(id=_alert_error_dump), className='' if DEBUG_FLAG else 'd-none') +], id=_alert, color='danger', is_open=False) + +# Settings buttons +input_group = dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), + dbc.Button([ + html.I(className='fas fa-cog me-1'), + 'Options (', + html.Span('0', id=_options_toggle_count), + ')' + ], id=_options_toggle), + dbc.Button( + 'Legend', + id=_legend_button, color='primary'), + dbc.Popover( + id=_legend_children, target=_legend_button, + trigger='focus', body=True, placement='bottom'), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), +], class_name='align-items-center') + + +def layout(): + ''' + Function to define the page's layout. + ''' + page_layout = html.Div([ + html.H1('Writing Observer - Classroom Text Highlighter'), + alert_component, + html.Div([ + html.Div(input_group, className='d-flex me-2'), + html.Div(loading_component, className='d-flex') + ], className='d-flex sticky-top pb-1 bg-light'), + lodrc.LOPanelLayout( + html.Div(id=_output, className='d-flex justify-content-between flex-wrap'), + panels=[ + {'children': options_component, 'width': '30%', 'id': _options_prefix, 'side': 'left' }, + {'children': expanded_student_component, + 'width': '30%', 'id': _expanded_student_panel, + 'side': 'right'} + ], + id=_options_collapse, shown=[] + ), + ]) + return page_layout + + +# Send the initial state based on the url hash to LO. +# If this is not included, nothing will be returned from +# the communication protocol. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), + Output(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash'), + Input(lodrc.LODocumentSourceSelectorAIO.ids.kwargs_store(_options_doc_src), 'data'), + Input(_options_text_information, 'value') +) + +# Build the UI based on what we've received from the +# communicaton protocol +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='populateOutput'), + Output(_output, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(_options_text_information, 'value'), + Input(_options_width, 'value'), + Input(_options_height, 'value'), + Input(_options_hide_header, 'value'), + State(_options_text_information, 'options'), +) + +# Toggle if the options collapse is open or not +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='toggleOptions'), + Output(_options_collapse, 'shown'), + Input(_options_toggle, 'n_clicks'), + State(_options_collapse, 'shown') +) + +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='closeOptions'), + Output(_options_collapse, 'shown', allow_duplicate=True), + Input(_options_close, 'n_clicks'), + State(_options_collapse, 'shown'), + prevent_initial_call=True +) + +# Adjust student tile size +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='adjustTileSize'), + Output({'type': 'WOStudentTile', 'index': ALL}, 'style'), + Input(_options_width, 'value'), + Input(_options_height, 'value'), + State({'type': 'WOStudentTile', 'index': ALL}, 'id'), +) + +# Handle showing or hiding the student tile header +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='showHideHeader'), + Output({'type': 'WOStudentTextTile', 'index': ALL}, 'showName'), + Input(_options_hide_header, 'value'), + State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), +) + +# When options change, update the current option hash for all students. +# When the option hash is different from the students internal option hash +# a loading class is applied to each student tile. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateCurrentOptionHash'), + Output({'type': 'WOStudentTextTile', 'index': ALL}, 'currentOptionHash'), + Input(_options_text_information, 'value'), + State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), +) + +# Expand a single student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='expandCurrentStudent'), + Output(_expanded_student_child, 'children'), + Output(_options_collapse, 'shown', allow_duplicate=True), + Input({'type': 'WOStudentTileExpand', 'index': ALL}, 'n_clicks'), + Input({'type': 'WOStudentTile', 'index': ALL}, 'children'), + State({'type': 'WOStudentTile', 'index': ALL}, 'id'), + State(_options_collapse, 'shown'), + State(_expanded_student_child, 'children'), + prevent_initial_call=True +) + +# Close expanded student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='closeExpandedStudent'), + Output(_options_collapse, 'shown', allow_duplicate=True), + Input(_expanded_student_close, 'n_clicks'), + State(_options_collapse, 'shown'), + prevent_initial_call=True +) + + +# Update the alert component with any errors that come through +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateAlertWithError'), + Output(_alert_text, 'children'), + Output(_alert, 'is_open'), + Output(_alert_error_dump, 'data'), + Input(lodrc.LOConnectionAIO.ids.error_store(_websocket), 'data') +) + +# Save options as preset +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='addPreset'), + Output(wo_classroom_text_highlighter.preset_component._store, 'data'), + Input(wo_classroom_text_highlighter.preset_component._add_button, 'n_clicks'), + State(wo_classroom_text_highlighter.preset_component._add_input, 'value'), + State(_options_text_information, 'value'), + State(wo_classroom_text_highlighter.preset_component._store, 'data') +) + +# Apply clicked preset +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='applyPreset'), + Output(_options_text_information, 'value'), + Input({'type': wo_classroom_text_highlighter.preset_component._set_item, 'index': ALL}, 'n_clicks'), + State(wo_classroom_text_highlighter.preset_component._store, 'data'), + prevent_initial_call=True +) + +# update loading information +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateLoadingInformation'), + Output(_loading_collapse, 'is_open'), + Output(_loading_progress, 'value'), + Output(_loading_information, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(_options_text_information, 'value') +) + +# Update legend +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateLegend'), + Output(_legend_children, 'children'), + Output(_options_toggle_count, 'children'), + Input(_options_text_information, 'value'), + State(_options_text_information, 'options') +) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py new file mode 100644 index 000000000..68b8af370 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py @@ -0,0 +1,56 @@ +''' +Writing Observer Classroom Text Highlighter + +Writing Observer dashboard for highlighting different attributes of text at the classroom level. +''' +import learning_observer.downloads as d +from learning_observer.dash_integration import thirdparty_url, static_url + +import wo_classroom_text_highlighter.dash_dashboard + +# Name for the module +NAME = 'Writing Observer - Classroom Text Highlighter' + +''' +Define pages created with Dash. +''' +DASH_PAGES = [ + { + 'MODULE': wo_classroom_text_highlighter.dash_dashboard, + 'LAYOUT': wo_classroom_text_highlighter.dash_dashboard.layout, + 'ASSETS': 'assets', + 'TITLE': 'Writing Observer Classroom Text Highlighter', + 'DESCRIPTION': 'Writing Observer dashboard for highlighting different attributes of text at the classroom level.', + 'SUBPATH': 'wo-classroom-text-highlighter', + 'CSS': [ + thirdparty_url("css/fontawesome_all.css") + ], + 'SCRIPTS': [ + static_url("liblo.js") + ] + } +] + +''' +Additional files we want included that come from a third part. +''' +THIRD_PARTY = { + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_classroom_text_highlighter/dash/wo-classroom-text-highlighter", + "icon": { + "type": "fas", + "icon": "fa-highlighter" + } +}] diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py new file mode 100644 index 000000000..07a9ab5aa --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py @@ -0,0 +1,72 @@ +import writing_observer.nlp_indicators +import writing_observer.languagetool_features + +PROCESS_OPTIONS = [ + {'id': 'process_information', 'label': 'Process Information', 'parent': ''}, + {'id': 'time_on_task', 'label': 'Time on Task', 'types': ['metric'], 'parent': 'process_information'}, + {'id': 'status', 'label': 'Status', 'types': ['metric'], 'parent': 'process_information'} +] +OPTIONS = PROCESS_OPTIONS + [ + {'id': indicator['id'], 'types': ['highlight'], 'label': indicator['name'], 'parent': indicator['category']} + for indicator in writing_observer.nlp_indicators.INDICATOR_JSONS +] +for category, label in writing_observer.nlp_indicators.INDICATOR_CATEGORIES.items(): + OPTIONS.append({'id': category, 'label': label, 'parent': 'text_information'}) +OPTIONS.append({'id': 'text_information', 'label': 'Text Information', 'parent': ''}) + +DEFAULT_VALUE = { + 'time_on_task': {'metric': {'value': True}}, + 'status': {'metric': {'value': True}} +} + +# Set of colors to use for highlighting with presets +HIGHLIGHTING_COLORS = [ + "#FFD700", # Golden Yellow + "#87CEEB", # Sky Blue + "#98FB98", # Pale Green + "#FFB6C1", # Light Pink + "#F0E68C", # Khaki + "#FF69B4", # Hot Pink + "#AFEEEE", # Pale Turquoise + "#FFA07A", # Light Salmon + "#D8BFD8", # Thistle + "#ADD8E6", # Light Blue + "#FFDEAD", # Navajo White + "#FA8072", # Salmon + "#E6E6FA", # Lavender + "#FFE4E1", # Misty Rose + "#F5DEB3" # Wheat +] + +# TODO these are used for creating the common presets +PRESETS_TO_CREATE = { + 'Narrative': ['direct_speech_verbs', 'indirect_speech', 'character_trait_words', 'in_past_tense', 'social_awareness'], + 'Argumentative': ['statements_of_opinion', 'statements_of_fact', 'information_sources', 'attributions', 'citations'], + 'Parts of Speech': ['adjectives', 'adverbs', 'nouns', 'proper_nouns', 'verbs', 'prepositions', 'coordinating_conjunction', 'subordinating_conjunction', 'auxiliary_verb', 'pronoun'], + 'Sentence Structure': ['simple_sentences', 'simple_with_complex_predicates', 'simple_with_compound_predicates', 'simple_with_compound_complex_predicates', 'compound_sentences', 'complex_sentences', 'compound_complex_sentences'], + 'Organization': ['main_idea_sentences', 'supporting_idea_sentences', 'supporting_detail_sentences'], + 'Tone': ['positive_tone', 'negative_tone', 'emotion_words', 'opinion_words'], + 'Vocabulary': ['academic_language', 'informal_language', 'latinate_words', 'polysyllabic_words', 'low_frequency_words'] +} + +deselect_all = 'Deselect All' +PRESETS = {deselect_all: OPTIONS} + + +def add_preset_to_presets(key, value): + '''This function creates a copy of the options and + sets each of the items in `value` to True along with + a highlighted color. This is for creating presets + from the `PRESETS_TO_CREATE` object. + ''' + color_index = 0 + preset = {} + for option in value: + preset[option] = {'highlight': {'value': True, 'color': HIGHLIGHTING_COLORS[color_index]}} + color_index += 1 + PRESETS[key] = preset + + +# Add each preset to PRESETS +for k, v in PRESETS_TO_CREATE.items(): + add_preset_to_presets(k, v) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py new file mode 100644 index 000000000..3dfeac035 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -0,0 +1,101 @@ +'''This creates the input and clickable badges for different +presets the user wants displayed. +TODO create a react component that does this +''' +from dash import html, dcc, clientside_callback, callback, Output, Input, State, ALL, exceptions, Patch, ctx +import dash_bootstrap_components as dbc + +import wo_classroom_text_highlighter.options + +_prefix = 'option-preset' +_store = f'{_prefix}-store' +_add_input = f'{_prefix}-add-input' +_add_help = f'{_prefix}-add-help' +_add_button = f'{_prefix}-add-button' +_tray = f'{_prefix}-tray' +_set_item = f'{_prefix}-set-item' +_remove_item = f'{_prefix}-remove-item' + + +def create_layout(): + add_preset = dbc.InputGroup([ + dbc.Input(id=_add_input, placeholder='Preset name', type='text', value=''), + dbc.InputGroupText(html.I(className='fas fa-circle-question'), id=_add_help), + dbc.Tooltip( + 'Save the current selected information as a preset for quick use in the future.', + target=_add_help + ), + dbc.Button([ + html.I(className='fas fa-plus me-1'), + 'Preset' + ], id=_add_button) + ], class_name='mb-1') + return html.Div([ + add_preset, + html.Div(id=_tray), + # TODO we ought to store the presets on the server instead of browser storage + # TODO we need to migrate the old options to new ones + dcc.Store(id=_store, data=wo_classroom_text_highlighter.options.PRESETS, storage_type='local') + ], id=_prefix) + + +# disabled add preset when name already exists +clientside_callback( + '''function (value, curr) { + if (value.length === 0) { return true; } + if (Object.keys(curr).includes(value)) { return true; } + return false; + }''', + Output(_add_button, 'disabled'), + Input(_add_input, 'value'), + State(_store, 'data') +) + +# clear input on add +clientside_callback( + '''function (clicks, curr) { + if (clicks) { return ''; } + return curr; + }''', + Output(_add_input, 'value'), + Input(_add_button, 'n_clicks'), + State(_add_input, 'value') +) + + +def create_tray_item(preset): + if preset == wo_classroom_text_highlighter.options.deselect_all: + return dbc.Button(preset, id={'type': _set_item, 'index': preset}, color='warning') + contents = dbc.ButtonGroup([ + dbc.Button(preset, id={'type': _set_item, 'index': preset}), + dcc.ConfirmDialogProvider( + dbc.Button(html.I(className='fas fa-trash fa-xs'), color='secondary'), + id={'type': _remove_item, 'index': preset}, + message=f'Are you sure you want to delete the `{preset}` preset?' + ) + ], class_name='preset') + return contents + + +@callback( + Output(_tray, 'children'), + Input(_store, 'modified_timestamp'), + State(_store, 'data') +) +def create_tray_items_from_store(ts, data): + if ts is None and data is None: + raise exceptions.PreventUpdate + return [html.Div(create_tray_item(preset), className='d-inline-block me-1 mb-1') for preset in reversed(data.keys())] + + +@callback( + Output(_store, 'data', allow_duplicate=True), + Input({'type': _remove_item, 'index': ALL}, 'submit_n_clicks'), + prevent_initial_call=True +) +def remove_item_from_store(clicks): + if not ctx.triggered_id or all(c is None for c in clicks): + raise exceptions.PreventUpdate + patched_store = Patch() + del patched_store[ctx.triggered_id['index']] + return patched_store diff --git a/modules/wo_common_student_errors/MANIFEST.in b/modules/wo_common_student_errors/MANIFEST.in new file mode 100644 index 000000000..b4051d0c4 --- /dev/null +++ b/modules/wo_common_student_errors/MANIFEST.in @@ -0,0 +1 @@ +include wo_common_student_errors/assets/* diff --git a/modules/wo_common_student_errors/setup.cfg b/modules/wo_common_student_errors/setup.cfg new file mode 100644 index 000000000..cec31a2ff --- /dev/null +++ b/modules/wo_common_student_errors/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +name = Writing Observer Common Student Errors +description = Dashboard for viewing errors across a classroom of students. +url = https://github.com/ETS-Next-Gen/writing_observer +version = 0.1 + +[options] +packages = find: +include_package_data = true + +[options.entry_points] +lo_modules = + wo_common_student_errors = wo_common_student_errors.module + +[options.package_data] +wo_common_student_errors = dashboard/* \ No newline at end of file diff --git a/modules/wo_common_student_errors/setup.py b/modules/wo_common_student_errors/setup.py new file mode 100644 index 000000000..38b5b5531 --- /dev/null +++ b/modules/wo_common_student_errors/setup.py @@ -0,0 +1,13 @@ +''' +Rather minimalistic install script. To install, run `python +setup.py develop` or just install via requirements.txt +''' + +from setuptools import setup, find_packages + +setup( + name="wo_common_student_errors", + package_data={ + 'wo_common_student_errors': ['assets/*'], + } +) diff --git a/modules/wo_common_student_errors/wo_common_student_errors/__init__.py b/modules/wo_common_student_errors/wo_common_student_errors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_common_student_errors/wo_common_student_errors/assets/aggregate.js b/modules/wo_common_student_errors/wo_common_student_errors/assets/aggregate.js new file mode 100644 index 000000000..c19de55f1 --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/assets/aggregate.js @@ -0,0 +1,97 @@ +/** + * This script defines functions for the aggregate information area + * of the common student errors dashboard. + */ +if (!window.dash_clientside) { + window.dash_clientside = {} +} + +/** + * Function for sorting an object of key-value pairs + * Returns two arrays, one with names and one with values + * which correspond to one another. + */ +const sortObject = function (obj) { + const entries = Object.entries(obj) + entries.sort((a, b) => b[1] - a[1]) + + const sortedKeys = entries.map(entry => entry[0]) + const sortedValues = entries.map(entry => entry[1]) + return [sortedKeys, sortedValues] +} + +/** + * Create the appropriate updates for the category and + * subcategory graphs + */ +const graphUpdates = function (data) { + const sorted = sortObject(data) + const colors = [] + const cleanedNames = [] + for (let i = 0; i < sorted[0].length; i++) { + // split the name to see if we have a subcategory or not + const split = sorted[0][i].split(':') + if (split.length > 1) { + cleanedNames.push(split[1]) + } else { + cleanedNames.push(split[0]) + } + colors.push(categoryColors[split[0]]) + } + const updates = [ + { x: [cleanedNames], y: [sorted[1]], 'marker.color': [colors] }, + [0], + sorted[0].length + ] + return updates +} + +/** + * Create sorted list of html list items + * This is used for listing student and error message aggregations + */ +const listUpdates = function (data) { + const sorted = sortObject(data) + const updates = [] + for (let i = 0; i < sorted[0].length; i++) { + updates.push({ + namespace: 'dash_html_components', + type: 'Li', + props: { children: `${sorted[0][i]} - ${sorted[1][i]}` } + }) + } + return updates +} + +window.dash_clientside.aggregate_common_errors = { + /** + * Parse the aggregate data for each of our 4 items when new data comes in OR + * when a new category or subcategory is clicked. + * 1. Overall category graph + * 2. Subcategory graph + * 3. Common error messages sum list + * 4. Student error sum list + */ + update_category_graph: function (data, catClick, subcatClick) { + const clickedCats = catClick?.points ? catClick.points.map(point => point.x) : Object.keys(categoryColors) + const clickedSubcats = subcatClick?.points ? subcatClick.points.map(point => point.x) : [] + const categoryCount = {} + const subcategoryCount = {} + const feedbackCount = {} + const studentCount = {} + let combined + data.forEach((student) => { + student.errors.forEach((error) => { + categoryCount[error.category] = (categoryCount[error.category] || 0) + 1 + if (clickedCats.includes(error.category) & (clickedSubcats.length === 0 | clickedSubcats.includes(error.subcategory))) { + combined = `${error.category}: ${error.subcategory}` + subcategoryCount[combined] = (subcategoryCount[combined] || 0) + 1 + feedbackCount[error.message] = (feedbackCount[error.message] || 0) + 1 + const name = student.student.profile.name.full_name; + studentCount[name] = (studentCount[name] || 0) + 1; + } + }) + }) + return [graphUpdates(categoryCount), graphUpdates(subcategoryCount), listUpdates(feedbackCount), listUpdates(studentCount)] + } +} diff --git a/modules/wo_common_student_errors/wo_common_student_errors/assets/hierarchical.js b/modules/wo_common_student_errors/wo_common_student_errors/assets/hierarchical.js new file mode 100644 index 000000000..a4094894e --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/assets/hierarchical.js @@ -0,0 +1,108 @@ +/** + * This script defines functions for the hierarchical information area + * of the common student errors dashboard. + */ +if (!window.dash_clientside) { + window.dash_clientside = {} +} + +window.dash_clientside.hierarchical_common_errors = { + /** + * Take the data returned from the LanguageTool and parse it + * into the hierarchical format needed for the 3 types of graphs + */ + populate_hierarchical_charts: function (data, toggleOrder) { + const categoryCount = {} + const subcategoryCount = {} + const names = [] + const ids = [] + const parents = [] + const values = [] + const colors = [] + const order = (toggleOrder === 'stud-sub') + for (let i = 0; i < data.length; i++) { + for (const [key, value] of Object.entries(data[i].category_counts)) { + categoryCount[key] = (categoryCount[key] || 0) + value + if (order & value > 0) { + names.push(data[i].profile.name.full_name) + ids.push(`${key} - ${data[i].user_id}`) + parents.push(key) + values.push(value) + colors.push('') + } + } + for (const [key, value] of Object.entries(data[i].subcategory_counts)) { + const keySplit = key.split(':') + subcategoryCount[key] = (subcategoryCount[key] || 0) + value + if (order & value > 0) { + names.push(keySplit[1].trim()) + ids.push(`${key} - - ${data[i].user_id}`) + parents.push(`${keySplit[0]} - ${data[i].user_id}`) + values.push(value) + colors.push('') + } else if (!order & value > 0) { + names.push(data[i].profile.name.full_name) + ids.push(`${data[i].user_id} - ${key}`) + parents.push(key) + values.push(value) + colors.push('') + } + } + } + let total = 0 + + const language = ['Grammar', 'Usage', 'Style', 'Semantics'] + const mechanics = ['Capitalization', 'Possible Typo', 'Punctuation', 'Spelling', 'Typography', 'Word Boundaries'] + + for (const [key, value] of Object.entries(categoryCount)) { + total = total + value + if (language.includes(key)) { + parents.push('Language') + } else { + parents.push('Mechanics') + } + names.push(key) + ids.push(key) + values.push(value) + colors.push(categoryColors[key]) + } + + names.push('Language') + ids.push('Language') + const languageSum = language.reduce((acc, key) => { + if (categoryCount.hasOwnProperty(key)) { + return acc + categoryCount[key] + } + return acc + }, 0) + values.push(languageSum) + colors.push('lightgray') + parents.push('') + + names.push('Mechanics') + ids.push('Mechanics') + const mechanicsSum = mechanics.reduce((acc, key) => { + if (categoryCount.hasOwnProperty(key)) { + return acc + categoryCount[key] + } + return acc + }, 0) + values.push(mechanicsSum) + colors.push('lightgray') + parents.push('') + + if (!order) { + for (const [key, value] of Object.entries(subcategoryCount)) { + const keySplit = key.split(':') + names.push(keySplit[1].trim()) + ids.push(key) + parents.push(keySplit[0]) + values.push(value) + colors.push('') + } + } + + const extendedData = [{ ids: [ids], labels: [names], parents: [parents], values: [values], 'marker.colors': [colors] }, [0], names.length] + return [extendedData, extendedData, extendedData] + } +} diff --git a/modules/wo_common_student_errors/wo_common_student_errors/assets/scripts.js b/modules/wo_common_student_errors/wo_common_student_errors/assets/scripts.js new file mode 100644 index 000000000..8dfcc8f2c --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/assets/scripts.js @@ -0,0 +1,361 @@ +/** + * General scripts used for the common student error dashboard + */ +if (!window.dash_clientside) { + window.dash_clientside = {} +} + +/** + * Colors corresponding to categories + * NOTE: these values are also defined in wo_common_student_errors/dashboard/colors.py + */ +const categoryColors = { + Error: 'white', // white + Capitalization: '#f3969a', // light pink + Grammar: '#56cc9d', // turquoise + 'Possible Typo': '#6cc3d5', // sky blue + Punctuation: '#ffce67', // yellow + Semantics: '#ff7851', // orange + Spelling: '#D9A9F5', // lilac purple + Style: '#9EF5D9', // mint green + Typography: '#87CEEB', // baby blue + Usage: '#FFB347', // soft orange + 'Word Boundaries': '#F5F0A9' // light yellow +} + +window.dash_clientside.common_student_errors = { + send_to_loconnection: function (state, hash, docSrc, docDate, docTime) { + /** + * When the hash of the URL changes, we send an updated query + * to the Learning Observer server. + */ + if (state === undefined) { + return [window.dash_clientside.no_update, 'individual-student-loading'] + } + if (state.readyState === 1) { + if (hash.length === 0) { return window.dash_clientside.no_update } + const decoded = decode_string_dict(hash.slice(1)) + // TODO handle no course id better + if (!decoded.course_id) { return window.dash_clientside.no_update } + + // the server expects a list of dictionary students, so we format the data that way + let loadingClass = 'individual-student-loaded' + if ('student_id' in decoded) { + decoded.student_id = [{ user_id: decoded.student_id }] + loadingClass = 'individual-student-loading' + } else { + decoded.student_id = [] + } + decoded.doc_source = docSrc; + decoded.requested_timestamp = new Date(`${docDate}T${docTime}`).getTime().toString(); + const message = { + wo: { + execution_dag: 'writing_observer', + target_exports: ['activity', 'single_student', 'overall_errors'], + kwargs: decoded + } + } + return [JSON.stringify(message), loadingClass] + } + return [window.dash_clientside.no_update, window.dash_clientside.no_update] + }, + + /** + * Catch any errors that come in from the server and + * update the error store for further usage. + */ + update_error_storage: function (message) { + const errors = {}; + for (const key in message.wo) { + if (message.wo[key].error !== undefined) { + errors[key] = message.wo[key]; + } + } + if (Object.keys(errors).length === 0) { + return window.dash_clientside.no_update; + } + console.error('Errors received from server', errors); + return errors; + }, + + /** + * Inform the user that we received an error + * + * returns an array which updates dash components + * - text to display on alert + * - show alert + * - JSON error data on the alert (only in debug) + */ + update_alert_with_error: function (error) { + if (!error) { + return ['', false, '']; + } + const text = 'Oops! Something went wrong ' + + "on our end. We've noted the " + + 'issue. Please try again later, or consider ' + + 'exploring a different dashboard for now. ' + + 'Thanks for your patience!'; + return [text, true, error]; + }, + + update_hash_via_graph: function (selected, message) { + /** + * Updated the selected student in the URL hash + */ + if (selected == null) { return window.dash_clientside.no_update } + const pt = selected.points[0] + const data = message.wo.lt_combined[pt.pointIndex] + const params = decode_string_dict(window.location.hash.slice(1)) + return `#course_id=${params.course_id};student_id=${data.user_id}` + }, + + /** + * Parse incoming data for the student activity chart + */ + receive_populate_activity: function (message) { + const data = message.wo.activity_combined || false; + if (!data) { + return ['No students', 'No students'] + } + const params = decode_string_dict(window.location.hash.slice(1)) + const output = {} + let badge = {} + for (let i = 0; i < data.length; i++) { + badge = { + namespace: 'dash_html_components', + type: 'A', + props: { + href: `#course_id=${params.course_id};student_id=${data[i].user_id}`, + className: 'activity-status-link', + children: { + namespace: 'lo_dash_react_components', + props: { + profile: data[i].profile, + className: 'student-name-tag', + id: `${data[i].user_id}-activity-img` + }, + type: 'LONameTag' + } + } + } + output[data[i].status] = output[data[i].status] === undefined ? [badge] : output[data[i].status].concat(badge) + } + return [output.inactive === undefined ? 'No students' : output.inactive, output.active === undefined ? 'No students' : output.active] + }, + + /** + * Populate the individual student error + * + * returns an array that updates dash components + * - Individual student header text (usually their name or "select a student") + * - Student text + * - List of breakpoints within student text + * - Extended data for the individual student sunburst chart + * - items being added to the graph (object where each key + * is a property of the graph and each value is a list of + * new values being added) + * - traces to update (always [0] in this case) + * - how many points to keep + * - className for determining if we are loading or loaded + */ + receive_populate_student_error: function (message, hash) { + let data = message.wo.single_lt_combined || false; + if (!data | data.length === 0 | data.error !== undefined) { + return ['Select a student', '', [], [{}, [0], 0], 'individual-student-loaded'] + } + data = data[0] + const decoded = decode_string_dict(hash.slice(1)) + if (decoded.student_id !== data.user_id) { + return [window.dash_clientside.no_update, window.dash_clientside.no_update, window.dash_clientside.no_update, window.dash_clientside.no_update, 'individual-student-loading'] + } + const student = { + namespace: 'lo_dash_react_components', + props: { + profile: data.profile, + className: 'student-name-tag', + id: `${data.user_id}-activity-img`, + includeName: true + }, + type: 'LONameTag' + } + const breakpoints = data.matches.map((x, index) => { + return { + id: index, + start: x.offset, + offset: x.length, + style: { textDecoration: `${categoryColors[x.label]} wavy underline` }, + tooltip: { + namespace: 'dash_bootstrap_components', + type: 'Card', + props: { + children: [ + { namespace: 'dash_bootstrap_components', type: 'CardHeader', props: { children: `${x.label}: ${x.detail}` } }, + { namespace: 'dash_bootstrap_components', type: 'CardBody', props: { children: x.message } } + ], + color: `${categoryColors[x.label]}80` + } + } + } + }) + let names = ['Errors'] + let ids = ['Errors'] + let parents = [''] + let values = [data.matches.length] + for (let key in data.category_counts) { + ids = ids.concat(key) + names = names.concat(key) + parents = parents.concat('Errors') + values = values.concat(data.category_counts[key]) + } + let category + for (let key in data.subcategory_counts) { + category = key.split(':')[0] + ids = ids.concat(key) + names = names.concat(key.split(':')[1].trim()) + parents = parents.concat(category) + values = values.concat(data.subcategory_counts[key]) + } + const extendedData = [{ ids: [ids], labels: [names], parents: [parents], values: [values] }, [0], names.length] + return [student, data.text, breakpoints, extendedData, 'individual-student-loaded'] + }, + + /** + * Parse the ws message and populate the errors versus text length graph + */ + receive_populate_error_graph: function (message) { + const data = message.wo.lt_combined || false; + if (!data) { + return window.dash_clientside.no_update + } + const len = data.length + let x = [] + let y = [] + let labels = [] + for (let i = 0; i < len; i++) { + x = x.concat(data[i].wordcounts.tokens) + y = y.concat(data[i].matches.length) + labels = labels.concat(`${data[i].profile.name.given_name.slice(0, 1)}${data[i].profile.name.family_name.slice(0, 1)}`) + } + return [ + { x: [x], y: [y], text: [labels] }, + [0], + len + ] + }, + + /** + * Update the hover information of the errors per text length graph + */ + update_graph_hover: function (hoverData, message) { + if (!hoverData) { + return [false, window.dash_clientside.no_update, ''] + } + const pt = hoverData.points[0] + const data = message.wo.lt_combined[pt.pointIndex] + const child = { + namespace: 'dash_html_components', + type: 'Div', + props: { + className: 'errors-per-word-tooltip', + children: [ + { + namespace: 'lo_dash_react_components', + props: { + profile: data.profile, + id: `${data.user_id}-activity-img`, + includeName: true + }, + type: 'LONameTag' + }, + { + namespace: 'dash_html_components', + type: 'P', + props: { + children: `Errors: ${data.matches.length}\nWords: ${data.wordcounts.tokens}` + } + } + ] + } + } + return [true, pt.bbox, child] + }, + + /** + * Populate the table of student category aggregation errors + */ + receive_populate_categorical_errors: function (message) { + const data = message.wo.lt_combined || false; + const rows = [] + if (!data | data.error !== undefined) { + return rows + } + + data.forEach(student => { + // TODO we could wrap this in a link component to adjust the side view + let columnData = [{ + namespace: 'dash_html_components', + type: 'Td', + props: { + children: { + namespace: 'lo_dash_react_components', + props: { + profile: student.profile, + id: `${student.user_id}-activity-img` + }, + type: 'LONameTag' + } + } + }] + for (let category in categoryColors) { + let value = '' + if (category === 'Error') { + value = student.matches.length + } else { + value = student.category_counts[category] + } + columnData.push({ + namespace: 'dash_html_components', + type: 'Td', + props: { children: value, style: { backgroundColor: `${categoryColors[category]}80` } } + }) + } + rows.push({ + namespace: 'dash_html_components', + type: 'Tr', + props: { + children: columnData + } + }) + }) + return rows + }, + + /** + * HACK + * This function flattens the data from all the language tool results + * for later use with the aggregate information. + * This hack was implemented since we have not yet determined the specifics + * of what we want returned from language tool/exactly how we will display it + */ + receive_populate_agg_info: function (message) { + const data = message.wo.lt_combined || false; + if (!data | data.error !== undefined) { + return [] + } + + const flatErrors = data.map((student) => { + return { + student: { user_id: student.user_id, profile: student.profile }, + errors: student.matches.map((match) => { + return { + category: match.label, + subcategory: match.detail, + shortMessage: match.shortMessage, + message: match.message + } + }) + } + }) + return flatErrors + } +} diff --git a/modules/wo_common_student_errors/wo_common_student_errors/assets/styles.css b/modules/wo_common_student_errors/wo_common_student_errors/assets/styles.css new file mode 100644 index 000000000..b145ec0e7 --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/assets/styles.css @@ -0,0 +1,47 @@ +/** Set the width/wrapping for the errors per text length hover information */ +.errors-per-word-tooltip { + width: 250px; + white-space: normal; +} + +/** CSS for displaying a spinner or not for the individual students */ +.individual-student-loading { + .loaded { + display: none; + } + + .loading { + display: block; + } +} + +.individual-student-loaded { + .loaded { + display: block; + } + + .loading { + display: none; + } +} + +/** Adjust the student activity name tag information */ +.student-activity-status-row { + .student-name-tag { + display: inline-block; + text-decoration: none; + color: var(--bs-body-color); + } + + .activity-status-link { + display: flex; + width: auto; + } +} + +/** Make the headers of the student table look nice */ +.categorical-table-headers { + writing-mode: vertical-rl; + text-orientation: mixed; + text-align: end; +} \ No newline at end of file diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/__init__.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/activity.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/activity.py new file mode 100644 index 000000000..d95a96cae --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/activity.py @@ -0,0 +1,30 @@ +''' +This file defines the components for the student activity information +''' +import dash_bootstrap_components as dbc + + +prefix = 'student-activity' +inactive = f'{prefix}-inactive' +active = f'{prefix}-active' + + +def create_activity_card(id, title): + ''' + Each activity (inactive/active) should look have the same look + ''' + return dbc.Col( + dbc.Card([ + dbc.CardHeader(title), + dbc.CardBody(dbc.Row(id=id, class_name='g-1 student-activity-status-row')) + ]), + sm=6 + ) + + +inactive_card = create_activity_card(inactive, 'Inactive') +active_card = create_activity_card(active, 'Active') +layout = dbc.Row([ + inactive_card, + active_card +], class_name='g-1') diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/aggregate_information.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/aggregate_information.py new file mode 100644 index 000000000..5bdfa3990 --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/aggregate_information.py @@ -0,0 +1,57 @@ +''' +This file creates the aggregate items for the category/subcategory graph. +Clicking on either of these continues to filter the aggregated data. +''' +from dash import clientside_callback, ClientsideFunction, Output, Input, State, html, dcc +import dash_bootstrap_components as dbc +import pandas as pd +import plotly.graph_objects as go + +from . import colors + +prefix = 'common-error-aggregate' +category_bar_chart = f'{prefix}-category-barchart' +subcategory_bar_chart = f'{prefix}-subcategory-barchart' +feedback_list = f'{prefix}-feedback-list' +student_list = f'{prefix}-student-list' +data_store = f'{prefix}-store' + +category_figure = go.Figure(go.Bar(x=[], y=[], marker_color=[])) +category_figure.update_layout(margin=dict(t=0, l=0, r=0, b=0), clickmode='event+select') +category_figure.update_yaxes(type='linear', rangemode='tozero') + +subcategory_figure = go.Figure(go.Bar(x=[], y=[], marker_color=[])) +subcategory_figure.update_layout(margin=dict(t=0, l=0, r=0, b=0), clickmode='event+select') +subcategory_figure.update_yaxes(type='linear', rangemode='tozero') + +feedback = dbc.Card([ + dbc.CardHeader('Feedback Items'), + dbc.CardBody(html.Ol([], id=feedback_list)) +]) + +students = dbc.Card([ + dbc.CardHeader('Students'), + dbc.CardBody(html.Ol([], id=student_list)) +]) + +layout = html.Div([ + html.H4('Aggregate Information'), + dbc.Row([ + dbc.Col(dcc.Graph(id=category_bar_chart, figure=category_figure), sm=6), + dbc.Col(dcc.Graph(id=subcategory_bar_chart, figure=subcategory_figure), sm=6), + dbc.Col(feedback, sm=6), + dbc.Col(students, sm=6) + ], class_name='g-0'), + dcc.Store(id=data_store, data=[]) +], id=prefix) + +clientside_callback( + ClientsideFunction(namespace='aggregate_common_errors', function_name='update_category_graph'), + Output(category_bar_chart, 'extendData'), + Output(subcategory_bar_chart, 'extendData'), + Output(feedback_list, 'children'), + Output(student_list, 'children'), + Input(data_store, 'data'), + Input(category_bar_chart, 'selectedData'), + Input(subcategory_bar_chart, 'selectedData') +) diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/colors.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/colors.py new file mode 100644 index 000000000..01db3cfac --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/colors.py @@ -0,0 +1,13 @@ +colors = { + 'Errors': 'white', # white + 'Capitalization': '#f3969a', # light pink + 'Grammar': '#56cc9d', # turquoise + 'Possible Typo': '#6cc3d5', # sky blue + 'Punctuation': '#ffce67', # yellow + 'Semantics': '#ff7851', # orange + 'Spelling': '#D9A9F5', # lilac purple + 'Style': '#9EF5D9', # mint green + 'Typography': '#87CEEB', # baby blue + 'Usage': '#FFB347', # soft orange + 'Word Boundaries': '#F5F0A9', # light yellow +} diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/hierarchical_information.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/hierarchical_information.py new file mode 100644 index 000000000..f9c15213e --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/hierarchical_information.py @@ -0,0 +1,73 @@ +''' +This file defines the hierarchical graphs +''' +from dash import html, dcc, clientside_callback, ClientsideFunction, Output, Input +import dash_bootstrap_components as dbc +import pandas as pd +import plotly.graph_objects as go + +from . import colors + +prefix = 'common-error-hierarchical' +order_toggle = f'{prefix}-hierarchy-order-toggle' +data_store = f'{prefix}-store' + +sunburst = f'{prefix}-sunburst' +sunburst_figure = go.Figure(go.Sunburst( + ids=[], + labels=[], + parents=[], + values=[], + marker_colors=[], + branchvalues='total' +)) +sunburst_figure.update_layout(margin=dict(t=0, l=0, r=0, b=0)) + +treemap = f'{prefix}-treemap' +treemap_figure = go.Figure(go.Treemap( + ids=[], + labels=[], + parents=[], + values=[], + marker_colors=[], + branchvalues='total' +)) +treemap_figure.update_layout(margin=dict(t=0, l=0, r=0, b=0)) + +icicle = f'{prefix}-icicle' +icicle_figure = go.Figure(go.Icicle( + ids=[], + labels=[], + parents=[], + values=[], + marker_colors=[], + branchvalues='total' +)) +icicle_figure.update_layout(margin=dict(t=5, l=0, r=0, b=5)) + +layout = html.Div([ + html.H4('Hierarchical Information'), + dbc.RadioItems( + options=[ + {'label': 'Category - Student - Subcategory', 'value': 'stud-sub'}, + {'label': 'Category - Subcategory - Student', 'value': 'sub-stud'} + ], + value='stud-sub', + id=order_toggle + ), + dbc.Tabs([ + dbc.Tab(dcc.Graph(id=sunburst, figure=sunburst_figure), label='Sunburst'), + dbc.Tab(dcc.Graph(id=treemap, figure=treemap_figure), label='Treemap'), + dbc.Tab(dcc.Graph(id=icicle, figure=icicle_figure), label='Icicle') + ]), + dcc.Store(id=data_store, data=[]) +], id=prefix) + +clientside_callback( + ClientsideFunction(namespace='hierarchical_common_errors', function_name='populate_hierarchical_charts'), + Output(sunburst, 'extendData'), + Output(treemap, 'extendData'), + Output(icicle, 'extendData'), + Input(data_store, 'data'), + Input(order_toggle, 'value') +) diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/individual.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/individual.py new file mode 100644 index 000000000..060d80488 --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/individual.py @@ -0,0 +1,38 @@ +''' +This file handles the individual student view on the right side +of the screen when a student is selected. +''' +from dash import html, dcc +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc +import plotly.graph_objects as go +import pandas as pd + +from . import colors + +prefix = 'individual-student-errors' +student = f'{prefix}-student' +text = f'{prefix}-text' +errors = f'{prefix}-errors' + +error_sunburst = f'{prefix}-errors-sunburst' +error_sunburst_figure = go.Figure(go.Sunburst( + ids=[], + labels=[], + parents=[], + values=[], + marker=dict( + colors=pd.Series(colors.colors) + ), + branchvalues='total' +)) +error_sunburst_figure.update_layout(margin=dict(t=0, l=0, r=0, b=0)) + +layout = html.Div([ + html.Div([ + html.H4(id=student), + lodrc.WOAnnotatedText(id=text), + dcc.Graph(id=error_sunburst, figure=error_sunburst_figure), + ], className='loaded'), + html.Div(dbc.Spinner(), className='loading') +], id=prefix) diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py new file mode 100644 index 000000000..6572a5956 --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py @@ -0,0 +1,246 @@ +''' +Define layout for common student errors +This layout is pretty messy as we are constantly prototyping +new ways of displaying information +''' +# TODO this module no longer works properly since switching +# the communication protocol to use an async generator. +error = f'The module WO Common student errors is not compatible with the communication protocol api.\n'\ + 'Please uninstall this module with `pip uninstall wo-common-student-errors`.' +raise RuntimeError(error) +# package imports +import dash_bootstrap_components as dbc +from dash_renderjson import DashRenderjson +import datetime +import lo_dash_react_components as lodrc +import plotly.express as px +import writing_observer.languagetool + +from dash import clientside_callback, ClientsideFunction, Output, Input, State, html, dcc + +# local imports +from . import activity, individual, aggregate_information, colors, hierarchical_information + +# TODO pull this flag from settings +DEBUG_FLAG = True + +prefix = 'common-student-errors' +websocket = f'{prefix}-websocket' +ws_store = f'{prefix}-ws-store' +error_store = f'{prefix}-error-store' + +alert = f'{prefix}-alert' +alert_text = f'{prefix}-alert-text' +alert_error_dump = f'{prefix}-alert-error-dump' + +# document source +doc_src = f'{prefix}-doc-src' +doc_src_date = f'{prefix}-doc-src-date' +doc_src_timestamp = f'{prefix}-doc-src-timestamp' + +# error per text length items +error_per_length = f'{prefix}-errors-per-length-graph' +error_per_length_tooltip = f'{prefix}-errors-per-length-tooltip' +error_per_length_figure = px.scatter( + x=[0], y=[0], text=[''], title='Number of errors compared to text length', + labels=dict(x='Text length (words)', y='Total Errors') +) +error_per_length_figure.update_layout(clickmode='event+select') +error_per_length_figure.update_traces(hoverinfo='none', hovertemplate=None, marker_size=24, textfont=dict(color='white'), textposition='middle center') + +categorical_errors = f'{prefix}-categorical-errors' + + +def layout(): + ''' + Generic layout function for the common errors dashboard + ''' + headers = ['Student'] + [headers.append(category) for category in colors.colors.keys()] + + tooltip = dcc.Tooltip(id=error_per_length_tooltip, direction='bottom') + overall_view = html.Div([ + html.Div([ + dbc.Label('Document Source'), + dbc.RadioItems(options=[ + {'label': 'Latest Document', 'value': 'latest' }, + {'label': 'Specific Time', 'value': 'ts'}, + ], value='latest', id=doc_src), + dbc.InputGroup([ + dcc.DatePickerSingle(id=doc_src_date, date=datetime.date.today()), + dbc.Input(type='time', id=doc_src_timestamp, value=datetime.datetime.now().strftime("%H:%M")) + ]) + ]), + activity.layout, + dcc.Graph( + id=error_per_length, + figure=error_per_length_figure, + clear_on_unhover=True + ), + tooltip, + hierarchical_information.layout, + aggregate_information.layout, + html.Div([ + html.H4('Table counts'), + dbc.Table([ + html.Thead((html.Tr([html.Th(h, className='categorical-table-headers') for h in headers]))), + html.Tbody(id=categorical_errors) + ], hover=True), + ]) + ], className='vh-100 overflow-auto') + + individal_view = html.Div([ + individual.layout + ], className='vh-100 overflow-auto') + + alert_component = dbc.Alert([ + html.Div(id=alert_text), + html.Div(DashRenderjson(id=alert_error_dump), className='' if DEBUG_FLAG else 'd-none') + ], id=alert, color='danger', is_open=False) + + cont = dbc.Container([ + html.H2('Prototype: Work in Progress'), + html.P( + 'This dashboard is a prototype displaying various features returned from LanguageTool. ' + 'LanguageTool is used to determine grammatical and syntax errors in text. ' + 'The dashboard is subject to change based on ongoing feedback from teachers.' + ), + alert_component, + lodrc.LOPanelLayout( + children=overall_view, + panels=[ + {'children': individal_view, 'width': '40%', 'id': 'individual'}, + ], + shown=['individual'], + id='panel' + ), + dcc.Store(ws_store, data={'wo': {}}), + dcc.Store(error_store, data=False if writing_observer.languagetool.lt_started else {'error': 'Language Tool is not running.'}), + lodrc.LOConnection(id=websocket), + ], fluid=True) + return dcc.Loading(cont) + + + +# disbale document date/time options +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='disable_doc_src_datetime'), + Output(doc_src_date, 'disabled'), + Output(doc_src_timestamp, 'disabled'), + Input(doc_src, 'value') +) + +# send request to LOConnection +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='send_to_loconnection'), + Output(websocket, 'send'), + Output(individual.prefix, 'className'), + Input(websocket, 'state'), # used for initial setup + Input('_pages_location', 'hash'), + Input(doc_src, 'value'), + Input(doc_src_date, 'date'), + Input(doc_src_timestamp, 'value'), +) + +# Update the url's hash based on errors per text length graph's selectedData +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='update_hash_via_graph'), + Output('_pages_location', 'hash'), + Input(error_per_length, 'selectedData'), + State(ws_store, 'data') +) + +# store message from LOConnection in storage for later use +clientside_callback( + ''' + function(message) { + const data = JSON.parse(message.data) + return data + } + ''', + Output(ws_store, 'data'), + Input(websocket, 'message'), + prevent_initial_call=True +) + +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='update_error_storage'), + Output(error_store, 'data'), + Input(ws_store, 'data') +) + +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='update_alert_with_error'), + Output(alert_text, 'children'), + Output(alert, 'is_open'), + Output(alert_error_dump, 'data'), + Input(error_store, 'data') +) + +# populate the activity/inactivity cards +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='receive_populate_activity'), + Output(activity.inactive, 'children'), + Output(activity.active, 'children'), + Input(ws_store, 'data') +) + +# update individual student panel based on LOConnection message +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='receive_populate_student_error'), + Output(individual.student, 'children'), + Output(individual.text, 'text'), + Output(individual.text, 'breakpoints'), + Output(individual.error_sunburst, 'extendData'), + Output(individual.prefix, 'className', allow_duplicate=True), + Input(ws_store, 'data'), + Input('_pages_location', 'hash'), + prevent_initial_call=True +) + +# handle errors per text length graph hover information +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='update_graph_hover'), + Output(error_per_length_tooltip, 'show'), + Output(error_per_length_tooltip, 'bbox'), + Output(error_per_length_tooltip, 'children'), + Input(error_per_length, 'hoverData'), + Input(ws_store, 'data') +) + +# populate errors per text length graph data +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='receive_populate_error_graph'), + Output(error_per_length, 'extendData'), + Input(ws_store, 'data'), +) + +# update categorical student aggregation table +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='receive_populate_categorical_errors'), + Output(categorical_errors, 'children'), + Input(ws_store, 'data') +) + +# parse the data from the LOConnection message for the aggregation information +# note this is different from the aggregation table +clientside_callback( + ClientsideFunction(namespace='common_student_errors', function_name='receive_populate_agg_info'), + Output(aggregate_information.data_store, 'data'), + Input(ws_store, 'data') +) + +# provide data to the hierarchical information layout +clientside_callback( + ''' + function(message) { + const data = message.wo.lt_combined + if (!data) { + return window.dash_clientside.no_update + } + return data + } + ''', + Output(hierarchical_information.data_store, 'data'), + Input(ws_store, 'data') +) diff --git a/modules/wo_common_student_errors/wo_common_student_errors/module.py b/modules/wo_common_student_errors/wo_common_student_errors/module.py new file mode 100644 index 000000000..769bf7064 --- /dev/null +++ b/modules/wo_common_student_errors/wo_common_student_errors/module.py @@ -0,0 +1,42 @@ +import learning_observer.downloads as d +from learning_observer.dash_integration import thirdparty_url, static_url + +import wo_common_student_errors.dashboard.layout + + +NAME = "Writing Observer - Common Student Errors" + +DASH_PAGES = [ + { + "MODULE": wo_common_student_errors.dashboard.layout, + "LAYOUT": wo_common_student_errors.dashboard.layout.layout, + "ASSETS": 'assets', + "TITLE": "Common student errors", + "DESCRIPTION": "Dashboard for viewing errors across a classroom of students.", + "SUBPATH": "common-errors", + "CSS": [ + thirdparty_url("css/bootstrap.min.css"), + thirdparty_url("css/fontawesome_all.css") + ], + "SCRIPTS": [ + static_url("liblo.js") + ] + } +] + +THIRD_PARTY = { + "css/bootstrap.min.css": d.BOOTSTRAP_MIN_CSS, + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + + +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_common_student_errors/dash/common-errors", + "icon": { + "type": "fas", + "icon": "fa-pen-nib" + } +}] diff --git a/modules/wo_document_list/MANIFEST.in b/modules/wo_document_list/MANIFEST.in new file mode 100644 index 000000000..e9fc2da90 --- /dev/null +++ b/modules/wo_document_list/MANIFEST.in @@ -0,0 +1 @@ +include wo_document_list/assets/* diff --git a/modules/wo_document_list/setup.cfg b/modules/wo_document_list/setup.cfg new file mode 100644 index 000000000..01be4a27e --- /dev/null +++ b/modules/wo_document_list/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +name = Writing Observer List of Documents +description = Dashboard for viewing documents from various sources for each student. +url = https://github.com/ETS-Next-Gen/writing_observer +version = 0.1 + +[options] +packages = find: +include_package_data = true + +[options.entry_points] +lo_modules = + wo_document_list = wo_document_list.module + +[options.package_data] +wo_document_list = dashboard/* \ No newline at end of file diff --git a/modules/wo_document_list/setup.py b/modules/wo_document_list/setup.py new file mode 100644 index 000000000..4c0a6a79d --- /dev/null +++ b/modules/wo_document_list/setup.py @@ -0,0 +1,13 @@ +''' +Rather minimalistic install script. To install, run `python +setup.py develop` or just install via requirements.txt +''' + +from setuptools import setup + +setup( + name="wo_document_list", + package_data={ + 'wo_document_list': ['assets/*'], + } +) diff --git a/modules/wo_document_list/wo_document_list/__init__.py b/modules/wo_document_list/wo_document_list/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_document_list/wo_document_list/assets/scripts.js b/modules/wo_document_list/wo_document_list/assets/scripts.js new file mode 100644 index 000000000..3260f9b7d --- /dev/null +++ b/modules/wo_document_list/wo_document_list/assets/scripts.js @@ -0,0 +1,162 @@ +/** + * General scripts used for the document list dashboard + */ + +if (!window.dash_clientside) { + window.dash_clientside = {} +} + +/** + * Create a list item with a link to the document + */ +const createDocLink = function (doc) { + return { + namespace: 'dash_html_components', + type: 'Li', + props: { + children: { + namespace: 'dash_html_components', + type: 'A', + props: { children: doc.title, href: `https://docs.google.com/document/d/${doc.id}/edit`, target: '_blank' } + } + } + } +} + +/** + * Create a student card that lists the various types of documents (latest, tagged, assignments) + */ +const createDocStudentCard = function (student) { + console.log(student) + const header = { + namespace: 'dash_bootstrap_components', + type: 'CardHeader', + props: { children: student.profile.name.full_name } + } + const latest = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [ + { namespace: 'dash_html_components', type: 'H6', props: { children: 'Latest doc' } }, + { namespace: 'dash_html_components', type: 'A', props: { children: student.latest ? createDocLink(student.latest) : {} } } + ] + } + } + const assignment = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [ + { namespace: 'dash_html_components', type: 'H6', props: { children: 'Assignment Docs' } }, + { namespace: 'dash_html_components', type: 'Ul', props: { children: student.assignment_docs?.map(function (doc) { return createDocLink(doc) }) || [] } } + ] + } + } + const tagged = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [ + { namespace: 'dash_html_components', type: 'H6', props: { children: 'Tagged docs' } }, + { namespace: 'dash_html_components', type: 'Ul', props: { children: student.tagged_docs?.map(function (doc) { return createDocLink(doc) }) || [] } } + ] + } + } + const card = { + namespace: 'dash_bootstrap_components', + type: 'Card', + props: { children: [header, latest, assignment, tagged] } + } + return { + namespace: 'dash_bootstrap_components', + type: 'Col', + props: { children: card, width: 4 } + } +} + +// These are the nodes we want on the communication protocol +const ENDPOINTS = ['latest_doc_ids', 'tagged_docs_per_student', 'assignment_docs'] + +window.dash_clientside.document_list = { + /** + * Fetch assignments for a given class and populate the radio items with them + */ + fetch_assignments: async function (hash) { + if (hash.length === 0) { return window.dash_clientside.no_update } + const decoded = decode_string_dict(hash.slice(1)) + if (!decoded.course_id) { return window.dash_clientside.no_update } + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/google/course_work/${decoded.course_id}`) + const data = await response.json() + const options = data.courseWork.map(function (item) { + return { label: item.title, value: item.id } + }) + return options + }, + + /** + * Send data to the communication protocol + */ + send_to_loconnection: async function (state, hash, tag, assignment) { + if (state === undefined) { + return window.dash_clientside.no_update + } + if (state.readyState === 1) { + if (hash.length === 0) { return window.dash_clientside.no_update } + const decoded = decode_string_dict(hash.slice(1)) + if (!decoded.course_id) { return window.dash_clientside.no_update } + + decoded.assignment_id = assignment || '' + + decoded.tag_path = tag ? `tags.${tag}` : 'tags' + + const message = { + wo: { + execution_dag: 'writing_observer', + target_exports: ENDPOINTS, // this needs to be latest doc, tag docs, and assignment docs + kwargs: decoded + } + } + return JSON.stringify(message) + } + return window.dash_clientside.no_update + }, + + /** + * Update the student grid based on the response from the websocket + * We iterate over each of the endpoint's results to create a new array + * where each item is a student and their corresponding documents of + * each type. + * Lastly, we feed this new array into into the createStudentDocCard. + */ + update_student_grid: function (data) { + const studentMap = new Map() + for (const key of ENDPOINTS) { + if (Array.isArray(data[key])) { + for (const student of data[key]) { + const id = student.user_id + if (!id) { continue } + if (!studentMap.has(id)) { + studentMap.set(id, { user_id: id, profile: student.profile }) + } + const studentData = studentMap.get(id) + if (key === 'latest_doc_ids') { + studentData.latest = student.doc_id ? { id: student.doc_id, title: student.doc_id } : {} + } else if (key === 'tagged_docs_per_student') { + studentData.tagged_docs = student.documents?.map(function (doc) { + return { title: doc.title, id: doc.id } + }) || [] + } else if (key === 'assignment_docs') { + studentData.assignment_docs = student.documents?.map(function (doc) { + return { title: doc.title, id: doc.id } + }) || [] + } + } + } + } + const gridObjects = Array.from(studentMap.values()).map(function (student) { + return createDocStudentCard(student) + }) + return gridObjects + } +} diff --git a/modules/wo_document_list/wo_document_list/dashboard/__init__.py b/modules/wo_document_list/wo_document_list/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_document_list/wo_document_list/dashboard/layout.py b/modules/wo_document_list/wo_document_list/dashboard/layout.py new file mode 100644 index 000000000..a7744a222 --- /dev/null +++ b/modules/wo_document_list/wo_document_list/dashboard/layout.py @@ -0,0 +1,91 @@ +''' +Define layout for per student list of documents +''' +# TODO this module no longer works properly since switching +# the communication protocol to use an async generator. +error = f'The module WO Document List is not compatible with the communication protocol api.\n'\ + 'Please uninstall this module with `pip uninstall wo-document-list`.' +raise RuntimeError(error) +# package imports +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + +from dash import clientside_callback, ClientsideFunction, Output, Input, State, html, dcc + +prefix = 'document-list' +websocket = f'{prefix}-ws' +ws_store = f'{prefix}-ws-store' +grid = f'{prefix}-student-grid' + +# option inputs +assignment_select_id = f'{prefix}-assignment-select' +tag_input_id = f'{prefix}-tag-input' + + +def layout(): + ''' + Function to define the page layout + ''' + assignment_select = html.Div([ + dbc.Label('Select an assignment from Google Classroom'), + dbc.RadioItems(id=assignment_select_id, options=[]) + ]) + tag_input = html.Div([ + dbc.Label('Search on tag'), + dbc.Input(id=tag_input_id, type='text') + ]) + cont = dbc.Container([ + html.H2('Prototype: Work in Progress'), + html.P( + 'This dashboard is a prototype displaying different sources of student documents. ' + 'This is currently used as an example to show we can obtain documents from different sources. ' + 'The dashboard is subject to change based on ongoing feedback from peers and teachers.' + ), + html.H2('Student Document List'), + dbc.Row([ + dbc.Col(assignment_select, width=6), + dbc.Col(tag_input, width=6) + ]), + dbc.Row(id=grid, class_name='g-2 mt-2'), + lodrc.LOConnection(id=websocket), + dcc.Store(id=ws_store, data={}) + ], fluid=True) + return cont + + +# send request to websocket +clientside_callback( + ClientsideFunction(namespace='document_list', function_name='send_to_loconnection'), + Output(websocket, 'send'), + Input(websocket, 'state'), # used for initial setup + Input('_pages_location', 'hash'), + Input(tag_input_id, 'value'), + Input(assignment_select_id, 'value'), +) + +# store message from LOConnection in storage for later use +clientside_callback( + ''' + function(message) { + const data = JSON.parse(message.data).wo + console.log(data) + return data + } + ''', + Output(ws_store, 'data'), + Input(websocket, 'message') +) + +# fetch assignment list for users to select which assignment they want to see +clientside_callback( + ClientsideFunction(namespace='document_list', function_name='fetch_assignments'), + Output(assignment_select_id, 'options'), + Input('_pages_location', 'hash'), +) + +# update the grid of students and their appropriate documents +clientside_callback( + ClientsideFunction(namespace='document_list', function_name='update_student_grid'), + Output(grid, 'children'), + Input(ws_store, 'data') +) diff --git a/modules/wo_document_list/wo_document_list/module.py b/modules/wo_document_list/wo_document_list/module.py new file mode 100644 index 000000000..910f2f278 --- /dev/null +++ b/modules/wo_document_list/wo_document_list/module.py @@ -0,0 +1,41 @@ +import learning_observer.downloads as d +from learning_observer.dash_integration import thirdparty_url, static_url + +import wo_document_list.dashboard.layout + + +NAME = "Writing Observer - Document List" + +DASH_PAGES = [ + { + "MODULE": wo_document_list.dashboard.layout, + "LAYOUT": wo_document_list.dashboard.layout.layout, + "ASSETS": 'assets', + "TITLE": "Student Document List", + "DESCRIPTION": "Dashboard for viewing documents from various sources for each student.", + "SUBPATH": "document-list", + "CSS": [ + thirdparty_url("css/bootstrap.min.css"), + thirdparty_url("css/fontawesome_all.css") + ], + "SCRIPTS": [ + static_url("liblo.js") + ] + } +] + +THIRD_PARTY = { + "css/bootstrap.min.css": d.BOOTSTRAP_MIN_CSS, + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_document_list/dash/document-list", + "icon": { + "type": "fas", + "icon": "fa-pen-nib" + } +}] diff --git a/modules/wo_highlight_dashboard/MANIFEST.in b/modules/wo_highlight_dashboard/MANIFEST.in new file mode 100644 index 000000000..6e4dd718d --- /dev/null +++ b/modules/wo_highlight_dashboard/MANIFEST.in @@ -0,0 +1 @@ +include wo_highlight_dashboard/assets/* diff --git a/modules/wo_highlight_dashboard/setup.cfg b/modules/wo_highlight_dashboard/setup.cfg new file mode 100644 index 000000000..617f176d5 --- /dev/null +++ b/modules/wo_highlight_dashboard/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +name = Dash Writing Observer Class Highlight Dashboard +description = Dashboard using Dash for the Writing Observer +url = https://github.com/ETS-Next-Gen/writing_observer +version = 0.1 + +[options] +packages = find: +include_package_data = true + +[options.entry_points] +lo_modules = + wo_highlight_dashboard = wo_highlight_dashboard.module + +[options.package_data] +wo_highlight_dashboard = dashboard/* \ No newline at end of file diff --git a/modules/wo_highlight_dashboard/setup.py b/modules/wo_highlight_dashboard/setup.py new file mode 100644 index 000000000..f63c1c5ac --- /dev/null +++ b/modules/wo_highlight_dashboard/setup.py @@ -0,0 +1,13 @@ +''' +Rather minimalistic install script. To install, run `python +setup.py develop` or just install via requirements.txt +''' + +from setuptools import setup, find_packages + +setup( + name="wo_highlight_dashboard", + package_data={ + 'wo_highlight_dashboard': ['assets/*'], + } +) diff --git a/modules/wo_highlight_dashboard/tests/conftest.py b/modules/wo_highlight_dashboard/tests/conftest.py new file mode 100644 index 000000000..49556839e --- /dev/null +++ b/modules/wo_highlight_dashboard/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest + +import writing_observer.nlp_indicators + + +def synthesize_student(id, course_id=None): + """ + Create a fake student under a given course_id + """ + if course_id is None: + course_id = '1234567890' + first = f'student-{id}' + family = 'last' + student = { + "course_id": course_id, + "user_id": id, + "profile": { + "id": id, + "name": { + "given_name": first, + "family_name": family, + "full_name": f'{first} {family}' + }, + "email_address": f"{first}@example.com", + "photo_url": "https://lh3.googleusercontent.com/photo.jpg" + } + } + return student + + +@pytest.fixture(scope='session') +def fetch_students(): + students = [synthesize_student(i) for i in range(10)] + return students + + +@pytest.fixture(scope='session') +def fetch_nlp_options(): + return writing_observer.nlp_indicators.INDICATOR_JSONS diff --git a/modules/wo_highlight_dashboard/tests/dashboard/test_settings_options.py b/modules/wo_highlight_dashboard/tests/dashboard/test_settings_options.py new file mode 100644 index 000000000..f0f1d8646 --- /dev/null +++ b/modules/wo_highlight_dashboard/tests/dashboard/test_settings_options.py @@ -0,0 +1,36 @@ +from dash import html +import dash_bootstrap_components as dbc + +import wo_highlight_dashboard.dashboard.settings_options as unit +import wo_highlight_dashboard.dashboard.settings_defaults as defaults + + +def test_create_metric_label(fetch_nlp_options): + for opt in fetch_nlp_options: + result = unit.create_metric_label(opt) + assert isinstance(result, dbc.Badge) + assert result.children == opt['name'] + assert result.color == 'info' + + +def test_create_highlight_label(fetch_nlp_options): + for opt in fetch_nlp_options: + result = unit.create_highlight_label(opt) + assert isinstance(result, html.Span) + assert result.children == opt['name'] + assert result.className == f"{opt.get('id')}_highlight" + + +def test_create_generic_label(fetch_nlp_options): + for opt in fetch_nlp_options: + result = unit.create_highlight_label(opt) + assert isinstance(result, html.Span) + assert result.children == opt['name'] + + +def test_create_checklist_options(fetch_nlp_options): + items = ['metrics', 'indicators', 'highlight'] + for i in items: + default = defaults.general[i]['selected'] + options = unit.create_checklist_options(default, fetch_nlp_options, i) + assert len(options) == len(default) diff --git a/modules/wo_highlight_dashboard/tests/dashboard/test_students.py b/modules/wo_highlight_dashboard/tests/dashboard/test_students.py new file mode 100644 index 000000000..d175688d5 --- /dev/null +++ b/modules/wo_highlight_dashboard/tests/dashboard/test_students.py @@ -0,0 +1,14 @@ +import wo_highlight_dashboard.dashboard.students as unit + + +def test_fill_in_settings(fetch_nlp_options): + settings = unit.fill_in_settings(1, 1, fetch_nlp_options, 'narrative') + keys = settings.keys() + assert len(keys) > 0 + for k in keys: + assert len(settings[k]) > 0 + + +def test_create_cards(fetch_students): + cards = unit.create_cards(fetch_students) + assert len(cards) == len(fetch_students) diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/__init__.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/app.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/app.py new file mode 100644 index 000000000..265fe003d --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/app.py @@ -0,0 +1,24 @@ +from aiohttp import web +from aiohttp_wsgi import WSGIHandler +import dash_bootstrap_components as dbc + +import learning_observer.dash_wrapper as dash +import writing_dashboard.dashboard.layout + +app = dash.Dash( + __name__, + external_stylesheets=[ + dbc.themes.MINTY, # bootstrap styling + dbc.icons.FONT_AWESOME, # font awesome icons + 'https://cdn.jsdelivr.net/gh/AnnMarieW/dash-bootstrap-templates@V1.0.6/dbc.min.css', # styling dcc components as Bootstrap + ], + title='Learning Observer', + suppress_callback_exceptions=True +) + +app.layout = writing_dashboard.dashboard.layout.layout + +wsgi_handler = WSGIHandler(app.server) +webapp = web.Application() +webapp.router.add_route("*", "/{path_info:.*}", wsgi_handler) +web.run_app(webapp) diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/favicon.ico b/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/favicon.ico new file mode 100644 index 000000000..aed40165a Binary files /dev/null and b/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/favicon.ico differ diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/scripts.js b/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/scripts.js new file mode 100644 index 000000000..32a8d713b --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/scripts.js @@ -0,0 +1,478 @@ +/* + Javascript functions + This file contains Javascript functions that will be + called through a clientside callback in the Python code. + An example of how to run the javascript function, see below + + Essentially its just a JSON that defines functions. + + You'll see `window.dash_clientside.no_update` appear often in the code. + This tells dash not to update the output component. + If we don't need to update it, we shouldn't! + + This is often used with initializing the array of students to return. + `Array(students).fill(window.dash_clientside.no_update);` + Where `students` is the total number of students +*/ +// initialize dash_clientside +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +function getRGBAValues(str) { + var vals = str.substring(str.indexOf('(') +1, str.length -1).split(', '); + return { + 'r': parseInt(vals[0]), + 'g': parseInt(vals[1]), + 'b': parseInt(vals[2]), + 'o': parseFloat(vals[3]) + }; +} + +// define functions we are calling +window.dash_clientside.clientside = { + + /** + * Update error information when we receive it from the + * websocket connection. + * + * returns an array which updates dash components + * - text to display on alert + * - show alert + * - JSON error data on the alert (only in debug) + */ + update_error_from_ws: function (msg) { + if (!msg) { + return ['', false, '']; + } + const data = JSON.parse(msg.data).docs_with_nlp.nlp_combined; + + if (data.error === undefined) { + return ['', false, '']; + } + console.error('ERROR:: Received error from server', data); + const text = 'Oops! Something went wrong ' + + "on our end. We've noted the " + + 'issue. Please try again later, or consider ' + + 'exploring a different dashboard for now. ' + + 'Thanks for your patience!'; + return [text, true, data]; + }, + + disable_doc_src_datetime: function (value) { + if (value === 'ts') { + return [false, false]; + } + return [true, true]; + }, + + change_sort_direction_icon: function(sort_check, sort_values) { + // updates UI elements, does not handle sorting + // based on the current sort, set the sort direction icon and sort text + + // Output(sort_icon, 'className'), + // Output(sort_label, 'children'), + // Input(sort_toggle, 'value'), + // Input(sort_by_checklist, 'value') + if (sort_check.includes('checked')) { + return ['fas fa-sort-down', 'Desc']; + } + return ['fas fa-sort-up', 'Asc']; + }, + + reset_sort_options: function(clicks) { + // resets the sort_by_checklist, this will trigger sort_students and change_sort_direction_icon + + // Output(sort_by_checklist, 'value'), + // Input(sort_reset, 'n_clicks') + if (clicks) { + return []; + } + return window.dash_clientside.no_update; + }, + + sort_students: function(values, direction, data, student_ids, options, students) { + // We sort students whenever one of the following occurs: + // 1. the checklist for sorting changes + // 2. the direction of sorting changes + // 3. the student's data changes + // We add the value of each indicator checked in the checklist to determine score for each student + // We then set the order style attribute of each student to their score + // Items with order=1 come before items with order=2 and so on. + // This will set students in ascending order (low scoring students first) + // We set a max and subtract from it to determine descending order (high scoring students first) + + // Output({'type': student_col, 'index': ALL}, 'style'), + // Input(settings.sort_by_checklist, 'value'), + // Input(settings.sort_toggle, 'value'), + // Input({'type': student_indicators, 'index': ALL}, 'data'), + // State(student_store, 'data'), + // State(settings.sort_by_checklist, 'options'), + // State(student_counter, 'data') + + let orders = Array(students).fill(window.dash_clientside.no_update); + if (values.length === 0) { + // default sort is alphabetical by student id + const sort_order = [...student_ids.keys()].sort((a, b) => student_ids[a].id - student_ids[b].id); + orders = sort_order.map(idx => {return {'order': (direction.includes('checked') ? student_ids.length - idx : idx)}}); + return orders; + } + let labels = options.map(obj => {return (values.includes(obj.value) ? obj.label : '')}); + labels = labels.filter(e => e); + for (let i = 0; i < data.length; i++) { + let score = 0; + values.forEach(function (item, index) { + score += data[i][`${item}_indicator`]['value']; + }); + let order = (direction.includes('checked') ? (100*values.length) - score : score); + orders[i] = {'order': order}; + } + return orders; + }, + + populate_student_data: function(msg, student_ids, prev_metrics, prev_text, prev_highlights, prev_indicators, students, msg_count) { + // Populates and updates students data from the websocket + // for each update, parse the data into the proper format + // Also return the current time + // TODO rewrite this function - the current state is still functions similar + // to the early prototype which was made for static data then adjusted to fit into + // the communication channel instead of being re-implemented properly. + // + // Output({'type': student_metrics, 'index': ALL}, 'data'), + // Output({'type': student_texthighlight, 'index': ALL}, 'text'), + // Output({'type': student_texthighlight, 'index': ALL}, 'highlight_breakpoints'), + // Output({'type': student_indicators, 'index': ALL}, 'data'), + // Output({'type': student_link, 'index': ALL}, 'href'), + // Output(last_updated, 'children'), + // Output(msg_counter, 'data'), + // Input(websocket, 'message'), + // State(student_store, 'data'), + // State({'type': student_metrics, 'index': ALL}, 'data'), + // State({'type': student_texthighlight, 'index': ALL}, 'text'), + // State({'type': student_texthighlight, 'index': ALL}, 'highlight_breakpoints'), + // State({'type': student_indicators, 'index': ALL}, 'data'), + // State(student_counter, 'data') + if (!msg) { + return [prev_metrics, prev_text, prev_highlights, prev_indicators, [], -1, 0]; + } + let updates = Array(students).fill(window.dash_clientside.no_update); + const data = JSON.parse(msg.data)['docs_with_nlp']['nlp_combined']; + for (let i = 0; i < data.length; i++) { + let curr_user = data[i].user_id; + let user_index = student_ids.findIndex(item => item.user_id === curr_user) + const last_document = data[i]?.student?.['writing_observer.writing_analysis.last_document']; + const link = (typeof last_document !== 'undefined') ? `https://docs.google.com/document/d/${last_document.document_id}/edit` : ''; + const text = (typeof data[i].text !== 'undefined') ? data[i].text : data[i].error.message; + updates[user_index] = { + 'id': curr_user, + 'text': { + "student_text": { + "id": "student_text", + "value": text, + "label": "Student text" + } + }, + 'highlight': {}, + 'metrics': {}, + 'indicators': {}, + 'link': link + } + for (const key in data[i]) { + const item = data[i][key]; + const sumType = (item.summary_type ? item.summary_type : ''); + // we set each id to be ${key}_{type} so we can select items by class name when highlighting + const metricLabel = (sumType === 'percent') ? `% ${item.label}` : item.label; + let metric; + if (item.metric === null) { + metric = 0; + } else if (sumType === 'counts') { + // Sum all values in the object + metric = Object.values(JSON.parse(item.metric)).reduce((sum, value) => sum + value, 0); + } else { + metric = item.metric; + } + updates[user_index]['metrics'][`${key}_metric`] = { + 'id': `${key}_metric`, + 'value': metric, + 'label': metricLabel + } + const indicatorLabel = (sumType === 'percent') ? `${item.label} (%)` : `${item.label} (${sumType})`; + updates[user_index]['indicators'][`${key}_indicator`] = { + 'id': `${key}_indicator`, + 'value': metric, + 'label': indicatorLabel + } + const offsets = (item.hasOwnProperty('offsets') ? item['offsets'] : ''); + if (offsets.length !== 0) { + updates[user_index]['highlight'][`${key}_highlight`] = { + 'id': `${key}_highlight`, + 'value': item['offsets'], + 'label': item['label'] + } + } + } + } + const timestamp = new Date(); + + // return the data to each each module + return [ + updates.map(function(d) { return d['metrics']; }), // metrics + updates.map(function(d) { + if (d.text) { + return d.text.student_text ? d.text.student_text.value : ''; + } + return ''; + }), // texthighlight text + updates.map(function(d) { return d['highlight']; }), // texthighlight highlighting + updates.map(function(d) { return d['indicators']; }), // indicators + updates.map(function(d) { return d['link']; }), // student doc links + timestamp, // current time + msg_count + 1 // set message count + ]; + }, + + update_last_updated_text: function(last_time, intervals) { + // Whenever we get a new message or 5 seconds have passed, update the last updated text + + // Output(last_updated_msg, 'children'), + // Input(last_updated, 'data'), + // Input(last_updated_interval, 'n_intervals') + if (last_time === -1) { + return 'Never'; + } + const curr_time = new Date(); + const sec_diff = (curr_time.getTime() - last_time.getTime())/1000 + if (sec_diff < 1) { + return 'just now' + } + const ms_since_last_message = rendertime2(sec_diff); + return `${ms_since_last_message} ago`; + }, + + open_settings: function(clicks, close, is_open, students) { + // Toggles the settings panel + // Based on if its open or not, we adjust the grid css classes of students and the panel itself + // this makes the student card remain the same size even if the settings panel is open. + // + // There are multiple ways to close the settings button (x button or click settings again). + // This means we have to determine which input fired and handle the possible cases. + + // Output(settings_collapse, 'is_open'), + // Output({'type': student_col, 'index': ALL}, 'class_name'), + // Output(student_grid, 'class_name'), + // Input(settings.open_btn, 'n_clicks'), + // Input(settings.close_settings, 'n_clicks'), + // State(settings_collapse, 'is_open'), + // State(student_counter, 'data') + + // determine which button caused this callback to trigger + const trig = dash_clientside.callback_context.triggered[0]; + if(!is_open & (typeof trig !== 'undefined')) { + if (trig.prop_id === 'teacher-dashboard-settings-show-hide-open-button.n_clicks') { + return [true, Array(students).fill('col-12 col-lg-6 col-xxl-4'), 'col-xxl-9 col-lg-8 col-md-6']; + } + } + return [false, Array(students).fill('col-12 col-md-6 col-lg-4 col-xxl-3'), '']; + }, + + update_students: async function(course_id) { + // Fetch the student information based on course id + + // Output(student_counter, 'data'), + // Output(student_store, 'data'), + // Input(course_store, 'data') + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/webapi/courseroster/${course_id}`); + const data = await response.json(); + return [data.length, data]; + }, + + fetch_assignment_info: async function(course_id, assignment_id) { + // Fetch assignment information from server based on course and assignment id + // Not yet implemented, TODO + // + // Output(assignment_name, 'children'), + // Output(assignment_desc, 'children'), + // Input(course_store, 'data'), + // Input(assignment_store, 'data') + return [`Assignment ${assignment_id}`, `This is assignment ${assignment_id} from course ${course_id}`] + }, + + fetch_nlp_options: async function(trigger) { + // Fetch possible NLP options from the server to later build the settings panel + // + // Output(nlp_options, 'data'), + // Input(prefix, 'className') + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/views/writing_observer/nlp-options/`); + const data = await response.json(); + return data; + }, + + update_course_assignment: function(url_hash) { + // Update the course and assignment info based on the hash query string + // + // Output(course_store, 'data'), + // Output(assignment_store, 'data'), + // Input('_pages_location', 'hash') + if (url_hash.length === 0) {return window.dash_clientside.no_update;} + const decoded = decode_string_dict(url_hash.slice(1)) + return [decoded.course_id, decoded.assignment_id] + }, + + highlight_text: function(overall_show, shown, data_trigger, options) { + // Highlights the text appropriately + // + // Output(settings.dummy, 'style'), + // Input(settings.checklist, 'value'), + // Input(settings.highlight_checklist, 'value'), + // Input({'type': student_card, 'index': ALL}, 'data'), + // State(settings.highlight_checklist, 'options') + + if (!overall_show.includes('highlight')) {return window.dash_clientside.no_update;} + const colors = [ + // Mints primary 4 colors with a 0.25 opacity + // 'rgba(86, 204, 157, 0.25)', 'rgba(108, 195, 213, 0.25)', + // 'rgba(255, 206, 103, 0.25)', 'rgba(255, 120, 81, 0.25)', + // Plotly's T10 with a 0.25 opacity applied + 'rgba(245, 133, 24, 0.25)', + 'rgba(114, 183, 178, 0.25)', 'rgba(228, 87, 86, 0.25)', + 'rgba(84, 162, 75, 0.25)', 'rgba(238, 202, 59, 0.25)', + 'rgba(178, 121, 162, 0.25)', 'rgba(255, 157, 166, 0.25)', + 'rgba(76, 120, 168, 0.25)', + ]; + let docs = []; + const shown_colors = {}; + // remove all highlighting and record current colors + options.forEach(item => { + docs = document.getElementsByClassName(`${item.value}_highlight`); + if (docs.length === 0) {return window.dash_clientside.no_update;} + if (shown.includes(item.value)) { + if (docs[0].style.backgroundColor.length > 0 & docs[0].style.backgroundColor !== 'transparent') { + shown_colors[item.value] = docs[0].style.backgroundColor; + } + } + for (var i = 0; i < docs.length; i++) { + docs[i].style.backgroundColor = 'transparent'; + } + }) + // highlight shown items + let high_color = ''; + shown.forEach(item => { + docs = document.getElementsByClassName(`${item}_highlight`); + // fetch current color or figure out a new one + if (shown_colors.hasOwnProperty(item)) { + high_color = shown_colors[item]; + } else { + let curr_colors = Object.values(shown_colors); + let remaining_colors = Array.from(new Set([...colors].filter(x => !curr_colors.includes(x)))); + high_color = (remaining_colors.length === 0 ? colors[Math.floor(Math.random()*colors.length)] : remaining_colors[0]) + shown_colors[item] = high_color; + } + + // add background color to highlighted elements + for (var i = 0; i < docs.length; i++) { + if (docs[i].style.backgroundColor.length > 0 & docs[i].style.backgroundColor !== 'transparent') { + let dc = getRGBAValues(docs[i].style.backgroundColor); + let hc = getRGBAValues(high_color); + let combined = `rgba(${parseInt((dc.r+hc.r)/2)}, ${parseInt((dc.g+hc.g)/2)}, ${parseInt((dc.b+hc.b)/2)}, ${hc.o+dc.o})`; + // console.log(dc, hc, combined); + docs[i].style.backgroundColor = combined; + } else { + docs[i].style.backgroundColor = high_color; + } + } + }) + }, + + set_status: function(status) { + // Set the websocket status icon/title + // + // Output(websocket_status, 'className'), + // Output(websocket_status, 'title'), + // Input(websocket, 'state') + if (status === undefined) { + return window.dash_clientside.no_update; + } + const icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger']; + const titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server']; + return [icons[status.readyState], titles[status.readyState]]; + }, + + show_hide_initialize_message: function(msg_count) { + // Show or hide the initialization message based on how many messages we've seen + // + // Output(initialize_alert, 'is_open'), + // Input(msg_counter, 'data') + if (msg_count > 0){ + return false; + } + return true; + }, + + send_options_to_server: function(types, metrics, highlights, indicators, sort_by, course_id, doc_src, doc_date, doc_time) { + // Send selected options to the server + // TODO work on protocol for communicating with the + // + // Output(websocket, 'send'), + // Input(settings.checklist, 'value'), + // Input(settings.metric_checklist, 'value'), + // Input(settings.highlight_checklist, 'value'), + // Input(settings.indicator_checklist, 'value') + // Input(settings.sort_by_checklist, 'value'), + // Input(course_store, 'data'), + // Input(settings.doc_src, 'value'), + // Input(settings.doc_src_date, 'date'), + // Input(settings.doc_src_timestamp, 'value') + const options = metrics.concat(highlights).concat(indicators).concat(sort_by); + const message = { + docs_with_nlp: { + execution_dag: 'writing_observer', + target_exports: ['docs_with_nlp_annotations'], + kwargs: { + course_id: course_id, + nlp_options: options, + doc_source: doc_src, + requested_timestamp: new Date(`${doc_date}T${doc_time}`).getTime().toString() + } + } + } + return JSON.stringify(message) + }, + + show_nlp_running_alert: function(msg_count, checklist, metrics, highlight, indicator, sort_by) { + // Show or hide the NLP running alert + // On new selections, show alert. + // When new data comes in, hide the alert + // + // Output({'type': alert_type, 'index': nlp_running_alert}, 'is_open'), + // Input(msg_counter, 'data'), + // Input(settings.checklist, 'value'), + // Input(settings.metric_checklist, 'value'), + // Input(settings.highlight_checklist, 'value'), + // Input(settings.indicator_checklist, 'value'), + // Input(settings.sort_by_checklist, 'value'), + const trig = dash_clientside.callback_context.triggered[0]; + if (trig.prop_id === 'teacher-dashboard-msg-counter.data') { + return false; + } + return true; + }, + + update_overall_alert: function(is_open, children) { + // Update the overall alert system, + // if only 1 alert exists, show its message, + // otherwise combine + // + // Output(overall_alert, 'label'), + // Output(overall_alert, 'class_name'), + // Input({'type': alert_type, 'index': ALL}, 'is_open'), + // Input({'type': alert_type, 'index': ALL}, 'children'), + const truth = is_open.filter(function(e) {return e}).length; + if (truth == 1) { + return [children[is_open.indexOf(true)], ''] + } + if (truth > 1) { + return [`Waiting on ${truth} items to finish`, '']; + } + return [window.dash_clientside.no_update, 'hidden-alert']; + } +} diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/styles.css b/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/styles.css new file mode 100644 index 000000000..41e8e3528 --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/assets/styles.css @@ -0,0 +1,132 @@ + +/* + Customize the scrollbar (Chrome only, but its okay cause they use Chromebooks) + Set size of scrollbar + Set track (slider track) + Set thumb (slider on track) + Set hover/active styles + */ + +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: rgb(179 177 177); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb { + background: rgb(136 136 136); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(100 100 100); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:active { + background: rgb(68 68 68); + border-radius: 5px; +} + +/* + Provides a shift and a shadow to student cards + I think it helps to focus in on one student, but the shadow itself could be improved upon + Try hovering over a student's card +*/ +.shadow-card:hover { + transition: all 0.2s ease-out; + box-shadow: 0 2px 8px var(--bs-gray-500); + border: 1px solid #ccc; + background-color: white; +} + +.shadow-card:hover::before { + transform: scale(2.15); +} + +/* Style the text element (the box of text) on student cards */ +.student-card-text { + max-height: 250px; + overflow: auto; + border: var(--bs-gray-100) solid 1px; + border-radius: 0.4rem; + margin: 1px; +} + +/* Larger font size helper class */ +.font-size-lg { font-size: 1.2rem; } + +/* + Add some background so you can see which option of a checklist you are hovering + Add darker background for nested-forms + Try hovering over an option in the Settings menu +*/ +.form-check:hover { + transition: all 0.2s ease-out; + background-color: var(--bs-gray-100); + box-shadow: 0 0 5px var(--bs-gray-100); + border-radius: 0.4rem; +} + +.nested-form:hover { + transition: all 0.2s ease-out; + background-color: var(--bs-gray-300); + box-shadow: 0 0 5px var(--bs-gray-300); + border-radius: 0.4rem; +} + +/* +Style dropdown menu component to be an outline btn +with appropriate colors +*/ +.dropdown-menu-outline-dark { + color: #343a40; + background-color: transparent; +} + +.dropdown-menu-outline-dark:hover { + color: white; + background-color: #343a40; +} + +.dropdown-item:focus, .dropdown-item:hover { + color: #212529; + background-color: var(--bs-gray-300); +} + +/* +Adjust styling for parent and children items +Parents are disabled and shift to the left +Children are indented to the right +*/ +.form-check-input:disabled { + display: none; +} + +.nested-form:has(.subchecklist-label) { + margin-left: 1.5em; +} + +.nested-form:has(.form-check-input:disabled) { + padding-left: 0; +} + +/* +Animation to hide the alert +Some items need a slight delay which is why we don't adjust opacity +until halfway through +*/ +@keyframes delay-hide { + 0% {opacity: 1;} + 50% {opacity: 1;} + 100% {opacity: 0;} +} + +.hidden-alert { + opacity: 0; + animation: delay-hide 2s linear; +} diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/__init__.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py new file mode 100644 index 000000000..4d0dd7b87 --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py @@ -0,0 +1,40 @@ +''' +Define layout for student dashboard view +''' +# TODO this module no longer works properly since switching +# the communication protocol to use an async generator. +# Additionally, this module has been re-written as `wo_classroom_text_highlighter` +error = f'The module WO Highlight Dashboard is not compatible with the communication protocol api.\n'\ + 'Please uninstall this module with `pip uninstall wo-highlight-dashboard`.' +raise RuntimeError(error) +# package imports +import learning_observer.dash_wrapper as dash +import dash_bootstrap_components as dbc + +# local imports +from .students import student_dashboard_view + + +# passing empty parameters will automatigically be used as query strings +# see: https://dash.plotly.com/urls#query-strings +def layout(course_id=None, assignment_id=None): + """ + Returns the layout of the student dashboard view wrapped in a Spinner component. + + Args: + - course_id (str or None): The ID of the course to display in the dashboard. Defaults to None. + - assignment_id (str or None): The ID of the assignment to display in the dashboard. Defaults to None. + + Returns: + - A Dash layout containing the student dashboard view wrapped in a Spinner component. + """ + layout = dbc.Spinner([ + dash.html.H2('Prototype: Work in Progress'), + dash.html.P( + 'This dashboard is a prototype displaying various natural language processing (NLP) features. ' + 'The NLP features include metrics and the ability to highlight specific attributes of the text. ' + 'The dashboard is subject to change based on ongoing feedback from teachers.' + ), + student_dashboard_view(course_id, assignment_id), + ], color='primary') + return layout diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py new file mode 100644 index 000000000..51ed1ba6f --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py @@ -0,0 +1,354 @@ +''' +Defines the settings panel used on the student overview dashbaord view +''' +# package imports +from learning_observer.dash_wrapper import html, dcc, clientside_callback, ClientsideFunction, Output, Input +import dash_bootstrap_components as dbc +import datetime + +prefix = 'teacher-dashboard-settings' +# ids related to opening/closing panel +open_btn = f'{prefix}-show-hide-open-button' # settings button +offcanvas = f'{prefix}-show-hide-offcanvcas' # setting wrapper +close_settings = f'{prefix}-close' # X on settings panel + +# document source +doc_src = f'{prefix}-doc-src' +doc_src_date = f'{prefix}-doc-src-date' +doc_src_timestamp = f'{prefix}-doc-src-timestamp' + +# essay type +essay_type = f'{prefix}-essay-type' + +# ids related to sorting +sort_by_checklist = f'{prefix}-sort-by-checklist' # options that can be included for sorting +sort_toggle = f'{prefix}-sort-by-toggle' # checkbox for determining sort direction +sort_icon = f'{prefix}-sort-by-icon' # icon for sort direction +sort_label = f'{prefix}-sort-by-label' # text for sort direction +sort_reset = f'{prefix}-sort-by-reset' # sort reset button +# ids relating to showing or hiding elements +checklist = f'{prefix}-show-hide-checklist' # parent checklist - determines which type of stuff to show +metric_collapse = f'{prefix}-show-hide-metric-collapse' # metric options wrapper +metric_checklist = f'{prefix}-show-hide-metric-checklist' # metric options +text_collapse = f'{prefix}-show-hide-text-collapse' # text options wrapper +text_radioitems = f'{prefix}-show-hide-text-radioitems' # text options +highlight_collapse = f'{prefix}-show-hide-highlight-collapse' # highlight options wrapper +highlight_checklist = f'{prefix}-show-hide-highlight-radioitems' # highlight options +indicator_collapse = f'{prefix}-show-hide-indicator-collapse' # indicator options wrapper +indicator_checklist = f'{prefix}-show-hide-indicator-checklist' # indicator wrapper +dummy = f'{prefix}-dummy' + +# settings panel itself +panel = dbc.Card( + [ + html.Div(id=dummy), + html.Div( + [ + # panel title + html.H4( + [ + html.I(className='fas fa-gear me-2'), # gear icon + 'Settings' + ], + # bootstrap styling to allow for the floating X button and remove lower margin + className='d-inline mb-0' + ), + # close settings X + dbc.Button( + # font awesome X icon + html.I(className='fas fa-xmark'), + color='white', + # bootstrap text styling + class_name='text-body', + id=close_settings + ) + ], + # create flex container so children can be positioned properly + className='m-2 d-flex align-items-center justify-content-between' + ), + # Each settings option is an accordion item + dbc.Accordion( + [ + # essay type + dbc.AccordionItem( + html.Div([ + dbc.RadioItems(options=[ + {'label': 'Latest Document', 'value': 'latest' }, + {'label': 'Specific Time', 'value': 'ts'}, + ], value='latest', id=doc_src), + dbc.InputGroup([ + dcc.DatePickerSingle(id=doc_src_date, date=datetime.date.today()), + dbc.Input(type='time', id=doc_src_timestamp, value=datetime.datetime.now().strftime("%H:%M")) + ]) + ]), title='Document Source' + ), + dbc.AccordionItem( + html.Div([ + dbc.RadioItems(options=[ + {'label': 'All', 'value': 'overall' }, + {'label': 'Argumentative', 'value': 'argumentative'}, + {'label': 'Narrative', 'value': 'narrative'} + ], value='overall', id=essay_type) + ]), title='Essay Type' + ), + # sort by + dbc.AccordionItem( + dbc.Card( + [ + dcc.Checklist( + options=[], + value=[], + id=sort_by_checklist, + labelClassName='form-check nested-form', # style dcc as bootstrap + inputClassName='form-check-input' # style dcc as bootstrap + ), + html.Div( + # button group for sort buttons + dbc.ButtonGroup( + [ + # change sort direction + dbc.Button( + dcc.Checklist( + options=[ + { + 'value': 'checked', + 'label': html.Span( # define Dash component as checklist option + [ + html.I(id=sort_icon), + html.Span( + 'None', + id=sort_label, + className='ms-1' + ) + ] + ) + } + ], + value=[], + id=sort_toggle, + inputClassName='d-none', # hide the checkbox, icon/text are clickable + className='d-inline', # needed to style for components as options + ), + outline=True, + color='primary', + title='Arrange students by attributes', + ), + # reset sort button + dbc.Button( + [ + html.I(className='fas fa-rotate me-1'), # font awesome rotate icon + 'Reset Sort' + ], + id=sort_reset, + outline=True, + color='primary' + ) + ], + size='sm', + class_name='float-end d-inline' # bootstrap keep button group to the right + ), + className='mt-1' # bootstrap top margin + ) + ], + class_name='border-0' # bootstrap remove borders + ), + title='Sort by' # hover text + ), + # show/hide elements + dbc.AccordionItem( + [ + dcc.Checklist( + options=[ + # metrics + { + 'label': html.Span( + [ + html.Span( + [ + html.I(className='fas fa-hashtag me-1'), + 'Metrics overview' + ], + className='font-size-lg' # make labels a little bigger + ), + dbc.Collapse( + dcc.Checklist( + # option for each possible metric + options=[], + value=[], # defaults + id=metric_checklist, + labelClassName='form-check nested-form', # style dcc as Bootstrap and add nested hover + inputClassName='form-check-input' # style dcc as Bootstrap + ), + id=metric_collapse, + ) + ], + ), + 'value': 'metrics' + }, + # text + # { + # 'label': html.Span( + # [ + # html.Span( + # [ + # html.I(className='fas fa-file me-1'), + # 'Text', + # ], + # className='font-size-lg' + # ), + # dbc.Collapse( + # dcc.RadioItems( + # # option for each possible text item + # # TODO pull this information from somewhere + # options=[], + # value=None, # default option + # id=text_radioitems, + # labelClassName='form-check nested-form', # style dcc as Bootstrap and add nested hover + # inputClassName='form-check-input' # style dcc as Bootstrap + # ), + # id=text_collapse, + # ) + # ], + # ), + # 'value': 'text' + # }, + # highlight + { + 'label': html.Span( + [ + html.Span( + [ + html.I(className='fas fa-highlighter fa-flip-horizontal me-1'), + 'Highlight', + ], + className='font-size-lg' + ), + dbc.Collapse( + dcc.Checklist( + # option for each possible highlightable item + # TODO pull this information from somewhere + options=[], + value=[], # default options + id=highlight_checklist, + labelClassName='form-check nested-form', # style dcc as Bootstrap and add nested hover + inputClassName='form-check-input' # style dcc as Bootstrap + ), + id=highlight_collapse, + ) + ], + ), + 'value': 'highlight' + }, + # indicators + { + 'label': html.Span( + [ + html.Span( + [ + html.I(className='fas fa-chart-bar me-1'), + 'Indicators overview', + ], + className='font-size-lg' + ), + dbc.Collapse( + # option for each possible indicator + # TODO pull this information from somewhere + dcc.Checklist( + options=[], + value=[], # default options + id=indicator_checklist, + labelClassName='form-check nested-form', # style dcc as Bootstrap and add nested hover + inputClassName='form-check-input' # style dcc as Bootstrap + ), + id=indicator_collapse, + ) + ] + ), + 'value': 'indicators' + } + ], + value=['text', 'highlight', 'indicators', 'metrics'], + id=checklist, + labelClassName='form-check', # style dcc as Bootstrap + inputClassName='form-check-input' # style dcc as Bootstrap + ), + ], + title='Student Card Options', + class_name='rounded-bottom' # bootstrap round bottom corners + ), + ], + # make both items visible from the start + active_item=['item-1', 'item-3'], + always_open=True, # keep accordionitems open when click on others + flush=True, # styles to take up width + class_name='border-top' # bootstrap border on top + ), + ], + id=offcanvas, + # TODO eventually we want sticky-top in the classname however + # if the screen height is short enough we won't be able to + # see all options available. + # need to add overflow to the last accordian item + + # bootstrap add right (e)nd and (b)ottom margins + class_name='me-2 mb-2' +) + +# disbale document date/time options +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='disable_doc_src_datetime'), + Output(doc_src_date, 'disabled'), + Output(doc_src_timestamp, 'disabled'), + Input(doc_src, 'value') +) + +# change the icon and label of the sort button +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='change_sort_direction_icon'), + Output(sort_icon, 'className'), + Output(sort_label, 'children'), + Input(sort_toggle, 'value'), + Input(sort_by_checklist, 'value') +) + +# reset the sort +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='reset_sort_options'), + Output(sort_by_checklist, 'value'), + Input(sort_reset, 'n_clicks') +) + +# settings checklist toggle +# if the option is selected, show its sub-options +# +# e.g. if metrics is chosen, show the options for time_on_task, adjectives, adverbs, etc. +# otherwise, don't shown those items + +toggle_checklist_visibility = ''' + function(values, students) {{ + if (values.includes('{id}')) {{ + return true; + }} + return false; + }} + ''' +clientside_callback( + toggle_checklist_visibility.format(id='indicators'), + Output(indicator_collapse, 'is_open'), + Input(checklist, 'value') +) +clientside_callback( + toggle_checklist_visibility.format(id='metrics'), + Output(metric_collapse, 'is_open'), + Input(checklist, 'value') +) +# clientside_callback( +# toggle_checklist_visibility.format(id='text'), +# Output(text_collapse, 'is_open'), +# Input(checklist, 'value') +# ) +clientside_callback( + toggle_checklist_visibility.format(id='highlight'), + Output(highlight_collapse, 'is_open'), + Input(checklist, 'value') +) diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings_defaults.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings_defaults.py new file mode 100644 index 000000000..cf6e054a0 --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings_defaults.py @@ -0,0 +1,155 @@ +''' +Each variable displays the defaults for each type of essay. +Ideally, users can select multiple (deep merge) to see both general +and argumentative for instance. + +In the future, we probably want the dashboard more flexible with +different types of modules being plugged in (metrics/highlighter/etc.). +This information will probably want to be handled a bit nicer once +we understand the full workflow of the plugability. +''' +import writing_observer.nlp_indicators + +all_options = writing_observer.nlp_indicators.INDICATORS.keys() +general = { + 'sort_by': { + 'options': [], + 'selected': [] + }, + 'metrics': { + 'options': ['sentences', 'paragraphs', 'pos_'], + 'selected': ['sentences', 'paragraphs'] + }, + 'highlight': { + 'options': [ + 'informal_language', 'transition_words', 'low_frequency_words', + 'positive_tone', 'negative_tone', + 'polysyllabic_words', 'academic_language' + ], + 'selected': [] + }, + 'indicators': { + 'options': [ + 'academic_language', 'informal_language', 'latinate_words', + 'polysyllabic_words', 'low_frequency_words' + ], + 'selected': ['academic_language', 'informal_language'] + } +} +argumentative = { + 'sort_by': { + 'options': [], + 'selected': [] + }, + 'metrics': { + 'options': [], + 'selected': [] + }, + 'highlight': { + 'options': [ + 'main_idea_sentences', 'supporting_idea_sentences', 'supporting_detail_sentences', + 'argument_words', 'explicit_argument', + 'statements_of_opinion', 'statements_of_fact', + 'explicit_claims', + ], + 'selected': ['main_idea_sentences', 'supporting_idea_sentences'] + }, + 'indicators': { + 'options': ['opinion_words', 'argument_words', 'information_sources', 'attributions', 'citations'], + 'selected': [] + } +} +narrative = { + 'sort_by': { + 'options': [], + 'selected': [] + }, + 'metrics': { + 'options': [], + 'selected': [] + }, + 'highlight': { + 'options': [ + 'direct_speech_verbs', 'indirect_speech', + 'in_past_tense', 'social_awareness', + 'character_trait_words', 'concrete_details', + ], + 'selected': ['character_trait_words', 'concrete_details'] + }, + 'indicators': { + 'options': ['emotion_words', 'character_trait_words'], + 'selected': ['character_trait_words'] + } +} +source_based = { + 'sort_by': { + 'options': [], + 'selected': [] + }, + 'metrics': { + 'options': [], + 'selected': [] + }, + 'highlight': { + 'options': ['information_sources', 'attributions', 'citations', 'quoted_words'], + 'selected': [] + }, + 'indicators': { + 'options': [], + 'selected': [] + } +} + + +def combine_dicts(dicts): + # Initialize a dictionary with the same structure as input dicts + combined = { + 'sort_by': { + 'options': [], + 'selected': [] + }, + 'metrics': { + 'options': [], + 'selected': [] + }, + 'highlight': { + 'options': [], + 'selected': [] + }, + 'indicators': { + 'options': [], + 'selected': [] + } + } + + # Iterate over input dicts + for d in dicts: + # Iterate over keys and subkeys of each input dict + for key, subdict in d.items(): + for subkey, value in subdict.items(): + # Append values to the corresponding list in the combined dict + combined[key][subkey].extend(value) + + return combined + + +overall = { + 'sort_by': { + 'options': all_options, + 'selected': [] + }, + 'metrics': { + 'options': all_options, + 'selected': [] + }, + 'highlight': { + 'options': all_options, + 'selected': [] + }, + 'indicators': { + 'options': all_options, + 'selected': [] + } +} +general_argumentative = combine_dicts([general, argumentative, source_based]) +general_narrative = combine_dicts([general, narrative, source_based]) diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings_options.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings_options.py new file mode 100644 index 000000000..f9bba8f4a --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings_options.py @@ -0,0 +1,101 @@ +''' +Define the options labels in the settings panel +''' +# package imports +from dash import html +import dash_bootstrap_components as dbc + + +def create_metric_label(opt, child=False): + """ + Creates a metric label component with the given option. + + Parameters: + - opt (dict): A dictionary that contains the options to create the metric label component. + - child (bool): Whether this label is a child label. + + Returns: + - A Badge component with the given options. + """ + return dbc.Badge( + opt.get('name'), + color='info', + title=opt.get('tooltip', ''), + class_name='subchecklist-label' if child else '' + ) + + +def create_highlight_label(opt, child=False): + """ + Creates a highlight label component with the given option. + + Parameters: + - opt (dict): A dictionary that contains the options to create the highlight label component. + - child (bool): Whether this label is a child label. + + Returns: + - A Span component with the given options. + """ + class_name = f"{opt.get('id')}_highlight" + return html.Span( + opt.get('name'), + title=opt.get('tooltip', ''), + className=f'subchecklist-label {class_name}' if child else class_name + ) + + +def create_generic_label(opt, child=False): + """ + Creates a generic label component with the given option. + + Parameters: + - opt (dict): A dictionary that contains the options to create the generic label component. + - child (bool): Whether this label is a child label. + + Returns: + - A Span component with the given options. + """ + return html.Span( + opt.get('name'), + title=opt.get('tooltip', ''), + className='subchecklist-label' if child else '' + ) + + +def create_checklist_options(user_options, options, selector_type): + """ + Creates a checklist with the given user options, options and selector type. + + Parameters: + - user_options (list): A list of user options. + - options (list): A list of options. + - selector_type (str): The type of selector to create. + + Returns: + - A list of options formatted for use in a checklist. + """ + if selector_type == 'metric': + label_maker = create_metric_label + elif selector_type == 'highlight': + label_maker = create_highlight_label + else: + label_maker = create_generic_label + ui_options = [] + for opt_id in user_options: + opt = next((o for o in options if o['id'] == opt_id), None) + if opt is None: + children = [o for o in options if o['parent'] == opt_id] + children_options = [ + { + 'label': label_maker(child, child=True), + 'value': child['id'] + } for child in children + ] + ui_options.append({'label': opt_id, 'value': opt_id, 'disabled': True}) + ui_options.extend(children_options) + else: + ui_options.append({ + 'label': label_maker(opt), + 'value': opt['id'] + }) + return ui_options diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/students.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/students.py new file mode 100644 index 000000000..0aa405fce --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/students.py @@ -0,0 +1,598 @@ +''' +Creates the grid of student cards +''' +# package imports +import learning_observer.constants as constants +from learning_observer.dash_wrapper import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, ALL, MATCH, exceptions as dash_e +import dash_bootstrap_components as dbc +from dash_renderjson import DashRenderjson +import lo_dash_react_components as lodrc +import writing_observer.aggregator + +# local imports +from . import settings, settings_defaults, settings_options as so + +# TODO pull this flag from settings +DEBUG_FLAG = True + +# define ids for the dashboard +# use a prefix to help ensure we don't double up on IDs (guess what happens if you double up? it breaks) +prefix = 'teacher-dashboard' + +# individual student items +student_col = f'{prefix}-student-col' # individual student card wrapper id +student_link = f'{prefix}-student-link' +student_metrics = f'{prefix}-student-metrics' +student_texthighlight = f'{prefix}-student-texthighlight' +student_indicators = f'{prefix}-student-indicators' + +student_row = f'{prefix}-student-row' # overall student row +student_grid = f'{prefix}-student-grid' # overall student grid wrapper id +websocket = f'{prefix}-websocket' # websocket to connect to the server (eventually) +student_counter = f'{prefix}-student-counter' # store item for quick access to the number of students +student_store = f'{prefix}-student-store' # store item for student information +course_store = f'{prefix}-course-store' # store item for course id +settings_collapse = f'{prefix}-settings-collapse' # settings menu wrapper +websocket_status = f'{prefix}-websocket-status' # websocket status icon +last_updated = f'{prefix}-last-updated' # data last updated id +last_updated_msg = f'{prefix}-last-updated-text' # data last updated id +last_updated_interval = f'{prefix}-last-updated-interval' + +alert_type = f'{prefix}-alert' +error_alert = f'{prefix}-error-alert' +error_alert_text = f'{prefix}-alert-text' +error_alert_dump = f'{prefix}-alert-error-dump' +initialize_alert = f'{prefix}-initialize-alert' +nlp_running_alert = f'{prefix}-nlp-running-alert' +overall_alert = f'{prefix}-navbar-alert' + +msg_counter = f'{prefix}-msg-counter' +nlp_options = f'{prefix}-nlp-options' +assignment_store = f'{prefix}-assignment-info_store' +assignment_name = f'{prefix}-assignment-name' +assignment_desc = f'{prefix}-assignment-description' + + +def student_dashboard_view(course_id, assignment_id): + """ + Create a student dashboard view for a given course and assignment. + + Args: + course_id (str): The ID of the course. + assignment_id (str): The ID of the assignment. + + Returns: + html.Div: A Dash component that displays a student dashboard view. + + The student dashboard view consists of a navigation bar at the top and a container + that contains the main content of the dashboard. The navigation bar displays the + title of the assignment, a progress bar indicating the status of data fetching, a + button to open the settings menu, and a button to log out. The container contains + the description of the assignment, a row of cards that display information about + each student, and several hidden stores and intervals that store data and update + the view periodically. + + """ + alert_component = dbc.Alert( + dcc.Markdown('The analysis features are not enabled on the server. '\ + 'The measures provided below are synthetic for testing and debugging. '\ + 'Set `modules.writing_observer.use_nlp: true` in the `creds.yaml` '\ + 'file to enable analysis tools.'), + color='danger', + is_open=(not writing_observer.aggregator.use_nlp) + ) + navbar = dbc.Navbar( + [ + # assignment title + html.H3( + [ + # document icon with a right bootstrap margin + html.I(className='fas fa-file-lines me-2'), + html.Span(id=assignment_name), + ], + className='d-inline' + ), + html.Div( + dbc.Progress( + value=100, striped=True, animated=True, + label='Fetching data...', + color='info', + id=overall_alert, + style={'height': '1.5rem'} + ), + className='w-25', + ), + # open settings button + html.Div( + [ + dbc.ButtonGroup( + [ + dbc.Button( + html.Small( + [ + html.I(id=websocket_status), + html.Span('Last Updated: ', className='ms-2'), + html.Span(id=last_updated_msg) + ] + ), + outline=True, + color='dark' + ), + dbc.DropdownMenu( + [ + dbc.DropdownMenuItem( + 'Settings', + id=settings.open_btn + ), + dbc.DropdownMenuItem( + 'Logout', + href='/auth/logout', + external_link=True + ), + ], + group=True, + align_end=True, + label='Menu', + color='dark', + toggle_class_name='dropdown-menu-outline-dark' + ) + ] + ) + ], + className='d-flex align-items-center float-end' + ) + ], + sticky='top', + class_name='justify-content-between align-items-center px-3' + ) + container = dbc.Container( + [ + alert_component, + # assignment description + html.P(id=assignment_desc), + dbc.Alert([ + html.Div(id=error_alert_text), + html.Div(DashRenderjson(id=error_alert_dump), className='' if DEBUG_FLAG else 'd-none') + ], id=error_alert, color='danger', is_open=False), + dbc.Alert( + 'Fetching initial data...', + is_open=False, + class_name='d-none', + id={ + 'type': alert_type, + 'index': initialize_alert + } + ), + dbc.Alert( + 'Running NLP...', + is_open=False, + class_name='d-none', + id={ + 'type': alert_type, + 'index': nlp_running_alert + } + ), + dbc.Row( + [ + # settings panel wrapper + dbc.Collapse( + dbc.Col( + settings.panel, + # bootstrap use 100% of (w)idth and (h)eight + class_name='w-100 h-100' + ), + id=settings_collapse, + # bootstrap collapse and grid sizing + class_name='collapse-horizontal col-xxl-3 col-lg-4 col-md-6', + # default open/close + is_open=False + ), + # overall student grid wrapp + dbc.Col( + dbc.Row( + id=student_row, + # bootstrap gutters-2 (little bit of space between cards) and w(idth)-100(%) + class_name='g-2 w-100' + ), + id=student_grid, + # classname set in callback, default classname should go in the callback + ) + ], + # no spacing between settings and students + # students already have some space on the sides + class_name='g-0' + ), + lodrc.LOConnection(id=websocket), + # stores for course and student info + student counter + dcc.Store(id=course_store), + dcc.Store(id=assignment_store), + dcc.Store( + id=student_store, + data=[] + ), + dcc.Store( + id=student_counter, + data=0 + ), + dcc.Store( + id=msg_counter, + data=0 + ), + dcc.Store( + id=nlp_options, + data=[] + ), + dcc.Store( + id=last_updated, + data=-1 + ), + dcc.Interval( + id=last_updated_interval, + interval=5000 + ) + ], + fluid=True + ) + return html.Div([navbar, container], id=prefix) + + +# set hash parameters +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='update_course_assignment'), + Output(course_store, 'data'), + Output(assignment_store, 'data'), + Input('_pages_location', 'hash') +) + +# fetch the nlp options from the server +# this will only fetch on the first page load since we never update the prefix's className +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='fetch_nlp_options'), + Output(nlp_options, 'data'), + Input(prefix, 'className') +) + +# set the websocket status icon +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='set_status'), + Output(websocket_status, 'className'), + Output(websocket_status, 'title'), + Input(websocket, 'state') +) + +# fetch student info for course +# TODO fix this to pull the roster information better +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='update_students'), + Output(student_counter, 'data'), + Output(student_store, 'data'), + Input(course_store, 'data') +) + +# fetch assignment information from server +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='fetch_assignment_info'), + Output(assignment_name, 'children'), + Output(assignment_desc, 'children'), + Input(course_store, 'data'), + Input(assignment_store, 'data') +) + +# open the settings menu +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='open_settings'), + Output(settings_collapse, 'is_open'), + Output({'type': student_col, 'index': ALL}, 'class_name'), + Output(student_grid, 'class_name'), + Input(settings.open_btn, 'n_clicks'), + Input(settings.close_settings, 'n_clicks'), + State(settings_collapse, 'is_open'), + State(student_counter, 'data') +) + +# Update data from websocket +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='populate_student_data'), + Output({'type': student_metrics, 'index': ALL}, 'data'), + Output({'type': student_texthighlight, 'index': ALL}, 'text'), + Output({'type': student_texthighlight, 'index': ALL}, 'highlight_breakpoints'), + Output({'type': student_indicators, 'index': ALL}, 'data'), + Output({'type': student_link, 'index': ALL}, 'href'), + Output(last_updated, 'data'), + Output(msg_counter, 'data'), + Input(websocket, 'message'), + State(student_store, 'data'), + State({'type': student_metrics, 'index': ALL}, 'data'), + State({'type': student_texthighlight, 'index': ALL}, 'text'), + State({'type': student_texthighlight, 'index': ALL}, 'highlight_breakpoints'), + State({'type': student_indicators, 'index': ALL}, 'data'), + State(student_counter, 'data'), + State(msg_counter, 'data'), +) + +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='update_error_from_ws'), + Output(error_alert_text, 'children'), + Output(error_alert, 'is_open'), + Output(error_alert_dump, 'data'), + Input(websocket, 'message'), +) + +# update the last updated text +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='update_last_updated_text'), + Output(last_updated_msg, 'children'), + Input(last_updated, 'data'), + Input(last_updated_interval, 'n_intervals') +) + +# send list of wanted nlp options to server +# data will be returned from the server in a separate callback +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='send_options_to_server'), + Output(websocket, 'send'), + Input(settings.checklist, 'value'), + Input(settings.metric_checklist, 'value'), + Input(settings.highlight_checklist, 'value'), + Input(settings.indicator_checklist, 'value'), + Input(settings.sort_by_checklist, 'value'), + Input(course_store, 'data'), + Input(settings.doc_src, 'value'), + Input(settings.doc_src_date, 'date'), + Input(settings.doc_src_timestamp, 'value') +) + +# show or hide the settings checklist for different components +show_hide_module = ''' + function(values, students) {{ + if (values.includes('{id}')) {{ + return Array(students).fill(''); + }} + return Array(students).fill('d-none'); + }} + ''' +clientside_callback( + show_hide_module.format(id='metrics'), + Output({'type': student_metrics, 'index': ALL}, 'class_name'), + Input(settings.checklist, 'value'), + State(student_counter, 'data') +) +clientside_callback( + show_hide_module.format(id='highlight'), + Output({'type': student_texthighlight, 'index': ALL}, 'class_name'), + Input(settings.checklist, 'value'), + State(student_counter, 'data') +) +clientside_callback( + show_hide_module.format(id='indicators'), + Output({'type': student_indicators, 'index': ALL}, 'class_name'), + Input(settings.checklist, 'value'), + State(student_counter, 'data') +) + +clientside_callback( + # TODO validate that the student link is shown when available + ''' + function(href) { + if (typeof href === 'undefined' || href.length === 0) { + return 'd-none'; + } + return ''; + } + ''', + Output({'type': student_link, 'index': MATCH}, 'class_name'), + Input({'type': student_link, 'index': MATCH}, 'href') +) + +# show or hide the components on all student cards +update_shown_items = ''' + function(values, students) {{ + return Array(students).fill(values.map(x => `${{x}}_{}`)); + }} +''' +clientside_callback( + update_shown_items.format('metric'), + Output({'type': student_metrics, 'index': ALL}, 'shown'), + Input(settings.metric_checklist, 'value'), + State(student_counter, 'data') +) +clientside_callback( + update_shown_items.format('highlight'), + Output({'type': student_texthighlight, 'index': ALL}, 'shown'), + Input(settings.highlight_checklist, 'value'), + State(student_counter, 'data') +) +clientside_callback( + update_shown_items.format('indicator'), + Output({'type': student_indicators, 'index': ALL}, 'shown'), + Input(settings.indicator_checklist, 'value'), + State(student_counter, 'data') +) + +# Show/hide the initialization alert +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='show_hide_initialize_message'), + Output({'type': alert_type, 'index': initialize_alert}, 'is_open'), + Input(msg_counter, 'data') +) + +# toggle the npl running alert +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='show_nlp_running_alert'), + Output({'type': alert_type, 'index': nlp_running_alert}, 'is_open'), + Input(msg_counter, 'data'), + Input(settings.checklist, 'value'), + Input(settings.metric_checklist, 'value'), + Input(settings.highlight_checklist, 'value'), + Input(settings.indicator_checklist, 'value'), + Input(settings.sort_by_checklist, 'value'), +) + +# update overall page alert based on all alerts +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='update_overall_alert'), + Output(overall_alert, 'label'), + Output(overall_alert, 'class_name'), + Input({'type': alert_type, 'index': ALL}, 'is_open'), + Input({'type': alert_type, 'index': ALL}, 'children'), +) + +# Sort students by indicator values +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='sort_students'), + Output({'type': student_col, 'index': ALL}, 'style'), + Input(settings.sort_by_checklist, 'value'), + Input(settings.sort_toggle, 'value'), + Input({'type': student_indicators, 'index': ALL}, 'data'), + State(student_store, 'data'), + State(settings.sort_by_checklist, 'options'), + State(student_counter, 'data') +) + +# highlight text +clientside_callback( + ClientsideFunction(namespace='clientside', function_name='highlight_text'), + Output(settings.dummy, 'style'), + Input(settings.checklist, 'value'), + Input(settings.highlight_checklist, 'value'), + Input({'type': student_texthighlight, 'index': ALL}, 'highlight_breakpoints'), + State(settings.highlight_checklist, 'options') +) + + +@callback( + output=dict( + sort_by_options=Output(settings.sort_by_checklist, 'options'), + metric_options=Output(settings.metric_checklist, 'options'), + metric_value=Output(settings.metric_checklist, 'value'), + # text_options=Output(settings.text_radioitems, 'options'), + # text_value=Output(settings.text_radioitems, 'value'), + highlight_options=Output(settings.highlight_checklist, 'options'), + highlight_value=Output(settings.highlight_checklist, 'value'), + indicator_options=Output(settings.indicator_checklist, 'options'), + indicator_value=Output(settings.indicator_checklist, 'value'), + ), + inputs=dict( + course=Input(course_store, 'data'), + assignment=Input(assignment_store, 'data'), + options=Input(nlp_options, 'data'), + essay_type=Input(settings.essay_type, 'value') + ) +) +def fill_in_settings(course, assignment, options, essay_type): + """ + Fill in the settings for the student performance dashboard based on the selected course and assignment, + as well as the NLP options. Returns a dictionary of settings that can be used to update the dashboard. + + Args: + course (dict): A dictionary containing information about the selected course. + assignment (dict): A dictionary containing information about the selected assignment. + options (list): A list of NLP options selected by the user. + + Returns: + dict: A dictionary containing the updated settings for the student performance dashboard. The dictionary has the following keys: + - sort_by_options: A list of checklist options for sorting the data. + - metric_options: A list of checklist options for selecting the metrics to display. + - metric_value: The default metric selected. + - highlight_options: A list of checklist options for highlighting the data. + - highlight_value: The default highlight selected. + - indicator_options: A list of checklist options for selecting the indicators to display. + - indicator_value: The default indicator selected. + """ + if len(options) == 0: + raise dash_e.PreventUpdate + # populate all settings based on assignment or default + + if essay_type == 'argumentative': + opt = settings_defaults.general_argumentative + elif essay_type == 'narrative': + opt = settings_defaults.general_narrative + elif essay_type == 'overall': + opt = settings_defaults.overall + else: + opt = settings_defaults.general + + ret = dict( + sort_by_options=so.create_checklist_options(opt['indicators']['options'], options, 'indicators'), # same as indicators + metric_options=so.create_checklist_options(opt['metrics']['options'], options, 'metric'), + metric_value=opt['metrics']['selected'], + # text_options=[so.text_options[o] for o in opt['text']['options']], + # text_value=opt['text']['selected'], + highlight_options=so.create_checklist_options(opt['highlight']['options'], options, 'highlight'), + highlight_value=opt['highlight']['selected'], + indicator_options=so.create_checklist_options(opt['indicators']['options'], options, 'indicators'), + indicator_value=opt['indicators']['selected'], + ) + return ret + + +@callback( + Output(student_row, 'children'), + Input(student_store, 'data') +) +def create_cards(students): + """ + Create a list of Dash Bootstrap Components (dbc) columns, where each column contains + a dbc Card for a student. + + Args: + students (list): A list of dictionaries representing the data for each student. + + Returns: + list: A list of dbc Columns, where each column contains a dbc Card for a student. + """ + cards = [ + dbc.Col( + [ + dbc.Card( + [ + html.H4(s['profile']['name']['full_name']), + dbc.ButtonGroup( + [ + dbc.Button( + html.I(className='text-body fas fa-up-right-from-square'), + title='Open document in new tab', + target='_blank', + color='white', + id={ + 'type': student_link, + 'index': s[constants.USER_ID] + } + ) + ], + className='position-absolute top-0 end-0' + ), + lodrc.WOMetrics( + id={ + 'type': student_metrics, + 'index': s[constants.USER_ID] + } + ), + html.Div( + lodrc.WOTextHighlight( + id={ + 'type': student_texthighlight, + 'index': s[constants.USER_ID] + } + ), + className='student-card-text' + ), + lodrc.WOIndicatorBars( + id={ + 'type': student_indicators, + 'index': s[constants.USER_ID] + } + ) + ], + body=True, + class_name='shadow-card' + ) + ], + # pattern matching callback + id={ + 'type': student_col, + 'index': s[constants.USER_ID] + }, + ) for s in students + ] + return cards diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/module.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/module.py new file mode 100644 index 000000000..cbf8b1f8e --- /dev/null +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/module.py @@ -0,0 +1,40 @@ +import learning_observer.downloads as d +from learning_observer.dash_integration import thirdparty_url, static_url + +import wo_highlight_dashboard.dashboard.layout + +NAME = "Writing Observer - Text Metric & Highlight Dashboard" + +DASH_PAGES = [ + { + "MODULE": wo_highlight_dashboard.dashboard.layout, + "LAYOUT": wo_highlight_dashboard.dashboard.layout.layout, + "ASSETS": 'assets', + "TITLE": "Metric & Highlight Dashboard", + "DESCRIPTION": "The Metric and Highlight dashboard provides in-depth natural language processing on student essays.", + "SUBPATH": "dashboard", + "CSS": [ + thirdparty_url("css/bootstrap.min.css"), + thirdparty_url("css/fontawesome_all.css") + ], + "SCRIPTS": [ + static_url("liblo.js") + ] + } +] + +THIRD_PARTY = { + "css/bootstrap.min.css": d.BOOTSTRAP_MIN_CSS, + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_highlight_dashboard/dash/dashboard/", + "icon": { + "type": "fas", + "icon": "fa-pen-nib" + } +}] diff --git a/modules/writing_observer/README.md b/modules/writing_observer/README.md new file mode 100644 index 000000000..1aa811ea4 --- /dev/null +++ b/modules/writing_observer/README.md @@ -0,0 +1,124 @@ +# Writing Observer + +**Last updated:** September 30th, 2025 + +The Writing Observer module wraps the NLP and feedback services that power Writing Observer features. It exposes a consistent interface for: + +* generating writing indicators from the Analytics for Writing Evaluation (AWE) stack, see [AWE Workbench](https://github.com/ETS-Next-Gen/AWE_Workbench) +* surfacing grammar and usage issues through LanguageTool +* forwarding communication protocol queries to GPT-based responders + +This document explains how to configure the module and reuse individual components inside another system. + +## Quick Start + +1. Ensure the module is installed in your environment `pip install modules/writing_observer`. +2. Configure module level settings in `creds.yaml` (see the next section). +3. Import the module into your project and call the service(s) you require. + +> The module is designed so that any feature can be toggled on or off. This lets you reuse only the pieces that are available in your deployment environment. + +## Configuration (`creds.yaml`) + +Below is a representative configuration block. Any unspecified key falls back to the documented default value. + +## Config (`creds.yaml`) + +```yaml +modules: + writing_observer: + use_nlp: false + use_google_documents: false + use_languagetool: false + languagetool_host: http://localhost + languagetool_port: 8081 + verbose: false + gpt_responders: + ollama: + model: llama2 + host: http://localhost:11434 + openai: + model: gpt-3.5-turbo-16k + api_key: your-secret-key +``` + +### Top-level flags + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `use_nlp` | bool | `false` | Enable AWE NLP indicators such as parts of speech, main idea detection, and academic language metrics. | +| `use_google_documents` | bool | `false` | Sync reconstructed reducer state with a Google Doc using the Google Docs API. | +| `use_languagetool` | bool | `false` | Run LanguageTool analysis on submitted text. | +| `verbose` | bool | `false` | Log reducer state whenever it updates (useful when debugging pipelines). | + +### LanguageTool options + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `languagetool_host` | str | `http://localhost` | Host URL for the LanguageTool server. | +| `languagetool_port` | int | `8081` | Port on which LanguageTool is available. | + +Set `use_languagetool` to `true` only when LanguageTool is reachable at the configured host and port. + +### GPT responders + +The `gpt_responders` object declares one or more providers. The system iterates over the responders in the order they appear and selects the first one that is correctly configured. This allows you to offer fallbacks (for example, try a local Ollama instance before hitting OpenAI). + +Currently supported responders: + +#### Ollama + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `model` | str | `llama2` | Model name served by the Ollama runtime. | +| `host` | str | `http://localhost:11434` | Base URL of the Ollama server. You can also supply this via the `OLLAMA_HOST` environment variable. | + +#### OpenAI + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `model` | str | `gpt-3.5-turbo-16k` | Chat completion model to call. | +| `api_key` | str | _(required)_ | Secret API key for your OpenAI account. You can also provide it through the `OPENAI_API_KEY` environment variable. | + +## Feature-by-feature guidance + +### NLP indicators (AWE) + +1. Set `use_nlp: true` in the configuration. +2. Ensure the AWE libraries are installed and accessible from the runtime environment. +3. Call the relevant reducers or service functions to compute indicators such as parts-of-speech counts or academic language percentages. + +These indicators are useful for automated writing evaluation and can be combined with LanguageTool or GPT feedback. + +### Google Docs synchronization + +Enable `use_google_documents` when you want the module to keep a Google Doc up to date with reconstructed reducer state. You must supply Google API credentials in the broader system configuration (outside the scope of this document). + +### LanguageTool error detection + +1. Run or connect to a LanguageTool server. +2. Configure `languagetool_host` and `languagetool_port`. +3. Set `use_languagetool: true`. + +The module will then enrich reducer output with spelling and usage error information, which can be displayed directly to writers or incorporated into other feedback flows. + +### GPT-based feedback + +1. Add one or more entries to `gpt_responders` (see tables above). +2. Provide any required credentials via configuration or environment variables. +3. Enable the module feature or reducer that consumes GPT responses (for instance, conversational feedback or revision suggestions). + +## Tips for integration + +* Toggle features gradually during development to keep dependencies manageable. +* Use the `verbose` flag while integrating new reducers to observe state transitions and diagnose issues. +* Keep secrets (such as API keys) out of version control by relying on environment variables or secret management tooling. +* When deploying in containers, expose the LanguageTool and Ollama ports to the module runtime. + +## Further resources + +* [LanguageTool server documentation](https://dev.languagetool.org/http-server) +* [Ollama documentation](https://docs.ollama.ai/) +* [OpenAI API reference](https://platform.openai.com/docs/) + +These resources provide additional setup instructions and troubleshooting guides for the third-party services referenced above. diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION new file mode 100644 index 000000000..0d5696b3a --- /dev/null +++ b/modules/writing_observer/VERSION @@ -0,0 +1 @@ +0.1.0+2026.02.02T20.54.51.168Z.c0f2a8f0.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/pyproject.toml b/modules/writing_observer/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/writing_observer/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/writing_observer/setup.cfg b/modules/writing_observer/setup.cfg new file mode 100644 index 000000000..57445aba3 --- /dev/null +++ b/modules/writing_observer/setup.cfg @@ -0,0 +1,20 @@ +[metadata] +name = Writing Observer +description = Writing Observer, a tool for monitoring student writing processes +url = http://www.ets.org +author_email = pmitros@ets.org +author = Piotr Mitros +version = file:VERSION + +[options] +packages = writing_observer +install_requires = + AWE_SpellCorrect @ git+https://github.com/ETS-Next-Gen/AWE_SpellCorrect.git + AWE_Components @ git+https://github.com/ETS-Next-Gen/AWE_Components.git + AWE_Lexica @ git+https://github.com/ETS-Next-Gen/AWE_Lexica.git + AWE_LanguageTool @ git+https://github.com/ETS-Next-Gen/AWE_LanguageTool.git + wikipedia + +[options.entry_points] +lo_modules = + wobserver = writing_observer.module diff --git a/modules/writing_observer/writing_observer/__init__.py b/modules/writing_observer/writing_observer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/writing_observer/writing_observer/aggregator.py b/modules/writing_observer/writing_observer/aggregator.py new file mode 100644 index 000000000..85b3809c5 --- /dev/null +++ b/modules/writing_observer/writing_observer/aggregator.py @@ -0,0 +1,445 @@ +''' +This file initially intended to handle any aggregators on the system. +We've kind of strayed from that purpose and jammed a bunch of other +code in. +TODO refractor the code to be more organized +''' +import pmss +import sys +import time + +import learning_observer.cache +import learning_observer.communication_protocol.integration +import learning_observer.constants as constants +import learning_observer.kvs +import learning_observer.settings +from learning_observer.stream_analytics.fields import KeyField, KeyStateType, EventField +import learning_observer.stream_analytics.helpers +# import traceback +import learning_observer.util + +pmss.register_field( + name='use_nlp', + description='Flag for loading in and using AWE Components. These are '\ + 'used to extract NLP metrics from text. When enabled, the '\ + 'server start-up time takes longer.', + type=pmss.pmsstypes.TYPES.boolean, + default=False +) +pmss.register_field( + name='use_google_documents', + description="Flag for whether we should fetch the ground truth of a "\ + "document's text from the Google API to fix any errors "\ + "in the reconstruction reducer.", + type=pmss.pmsstypes.TYPES.boolean, + default=False +) + +def excerpt_active_text( + text, cursor_position, + desired_length=103, cursor_target=2 / 3, max_overflow=10, + cursor_character="❙" +): + ''' + This function returns a short segment of student text, cutting in a + sensible way around word boundaries. This can be used for real-time + typing views. + + `desired_length` is how much text we want. + `cursor_target` is what fraction of the text should be before the cursor. + `max_overflow` is how much longer we're willing to go in order to land on + a word boundary. + `cursor_character` is what we insert at the boundary. Can be an empty + string, a nice bit of markup, etc. + ''' + character_count = len(text) + before = int(desired_length * 2 / 3) + # We step backwards and forwards from the cursor by the desired number of characters + start = max(0, int(cursor_position - before)) + end = min(character_count - 1, start + desired_length) + # And, if we don't have much text after the cursor, we adjust the beginning + # print(start, cursor_position, end) + start = max(0, end - desired_length) + # Split on a word boundary, if there's one close by + # print(start, cursor_position, end) + while end < character_count and end - start < desired_length + 10 and not text[end].isspace(): + end += 1 + + # print(start, cursor_position, end) + while start > 0 and end - start < desired_length + 10 and not text[start].isspace(): + start -= 1 + + clipped_text = text[start:max(cursor_position - 1, 0)] + cursor_character + text[max(cursor_position - 1, 0):end] + return clipped_text + + +def sanitize_and_shrink_per_student_data(student_data): + ''' + This function is run over the data for **each student**, one-by-one. + + We: + * Compute text length + * Cut down the text to just what the client needs to receive (we + don't want to send 30 full essays) + ''' + text = student_data.get('writing_observer.writing_analysis.reconstruct', {}).get('text', None) + if text is None: + student_data['writing_observer_compiled'] = { + "text": "[None]", + "character_count": 0 + } + return student_data + + character_count = len(text) + cursor_position = student_data['writing_observer.writing_analysis.reconstruct']['position'] + + # Yes, this does mutate the input. No, we should. No, it doesn't matter, since the + # code needs to move out of here. Shoo, shoo. + student_data['writing_observer_compiled'] = { + "text": excerpt_active_text(text, cursor_position), + "character_count": character_count + } + # Remove things which are too big to send back. Note: Not benchmarked, so perhaps not too big + del student_data['writing_observer.writing_analysis.reconstruct']['text'] + # We should downsample, rather than removing + del student_data['writing_observer.writing_analysis.reconstruct']['edit_metadata'] + return student_data + + +def aggregate_course_summary_stats(student_data): + ''' + Here, we compute summary stats across the entire course. This is + helpful so that the front end can know, for example, how to render + axes. + + Right now, this API is **evolving**. Ideally, we'd like to support: + + - Transforming summarized per-student data based on data from + other students + - Extract aggregates + + This API lets us do that, but it's a little too generic. We'd like + to be a little bit more semantic. + ''' + max_idle_time = 0 + max_time_on_task = 0 + max_character_count = 0 + for student in student_data: + max_character_count = max( + max_character_count, + student.get('writing_observer_compiled', {}).get('character_count', 0) + ) + max_time_on_task = max( + max_time_on_task, + student.get('writing_observer.writing_analysis.time_on_task', {}).get("total_time_on_task", 0) + ) + return { + "summary_stats": { + 'max_character_count': max_character_count, + 'max_time_on_task': max_time_on_task, + # TODO: Should we aggregate this in some way? If we run on multiple servers, + # this is susceptible to drift. That could be jarring; even a few seconds + # error could be an issue in some contexts. + 'current_time': time.time() + } + } + + +###### +# +# Everything from here on is a hack. +# We need to figure out proper abstractions. +# +###### + + +async def get_latest_student_documents(student_data): + ''' + This will retrieve the latest student documents from the database. It breaks + abstractions. + + It also involves some excess loops that are annoying but briefly we need to + determine which students actually *have* last writing data. Then we need to + go through and build keys for that data. Then we fetch the data itself. + Later on in this file we need to marry the information again. This builds + up a series of lists which are successively merged into a single dict with + the resulting data. + + Some of what is copied along is clearly duplicative and probably unneeded. + ''' + import learning_observer.kvs + + import writing_observer.writing_analysis + from learning_observer.stream_analytics.fields import KeyField, KeyStateType, EventField + + kvs = learning_observer.kvs.KVS() + + # Compile a list of the active students. + active_students = [s for s in student_data if 'writing_observer.writing_analysis.last_document' in s] + + # Now collect documents for all of the active students. + document_keys = ([ + learning_observer.stream_analytics.helpers.make_key( + writing_observer.writing_analysis.reconstruct, + { + KeyField.STUDENT: s[constants.USER_ID], + EventField('doc_id'): get_last_document_id(s) + }, + KeyStateType.INTERNAL + ) for s in active_students]) + + kvs_data = await kvs.multiget(keys=document_keys) + + # Return blank entries if no data, rather than None. This makes it possible + # to use item.get with defaults sanely. For the sake of later alignment + # we also zip up the items with the keys and users that they come from + # this hack allows us to align them after cleaning occurrs later. + # writing_data = [{} if item is None else item for item in writing_data] + writing_data = [] + for idx in range(len(document_keys)): + student = active_students[idx] + doc = kvs_data[idx] + + # If we have an empty item we simply return an empty dict with the + # student but an empty doc value. + if (doc is None): + doc = {} + + # Now insert the student data and pass it along. + doc['student'] = student + writing_data.append(doc) + + return writing_data + + +async def remove_extra_data(writing_data): + ''' + We don't want Deane graph data going to the client. We just do a bit of + a cleanup. This is in-place. + ''' + for item in writing_data: + if 'edit_metadata' in item: + del item['edit_metadata'] + return writing_data + + +async def merge_with_student_data(writing_data, student_data): + ''' + Add the student metadata to each text + ''' + + for item, student in zip(writing_data, student_data): + if 'edit_metadata' in item: + del item['edit_metadata'] + item['student'] = student + return writing_data + + +# TODO the use_nlp initialization code ought to live in a +# registered startup function +use_nlp = learning_observer.settings.module_setting('writing_observer', 'use_nlp') +if use_nlp: + try: + import writing_observer.awe_nlp + processor = writing_observer.awe_nlp.process_writings_with_caching + except ImportError as e: + print(e) + print('AWE Components is not installed. To install, please see https://github.com/ETS-Next-Gen/AWE_Components') + sys.exit(-1) +else: + import writing_observer.stub_nlp + processor = writing_observer.stub_nlp.process_texts + +processor = learning_observer.communication_protocol.integration.publish_function('writing_observer.process_texts')(processor) + + +@learning_observer.communication_protocol.integration.publish_function('writing_observer.update_reconstruct_with_google_api') +async def update_reconstruct_reducer_with_google_api(runtime, doc_ids): + """ + This method updates the reconstruct reducer every so often with the ground + truth directly from the Google API. This allows us to automatically fix + errors introduced by the reconstruction. If the current user does not have + access to the ground truth (e.g. permission), then we do not update the + reconstruct reducer. + + This method is intended for use within the communication protocol. + Since we already select reconstruct data from the KVS, this method just + updates the KVS and returns the data we input, `doc_ids`, without modification. + + We use a closure here to make use of memoization so we do not update the KVS + every time we call this method. + """ + + @learning_observer.cache.async_memoization() + async def fetch_doc_from_google(student, doc_id): + """ + This method performs the fetching of current document text and the + updating of the KVS. + """ + if student is None or doc_id is None or len(doc_id) == 0: + return None + import learning_observer.google + + kvs = learning_observer.kvs.KVS() + + text = await learning_observer.google.doctext(runtime, documentId=doc_id) + # Only update Redis is we have text available. If `text` is missing, then + # we likely encountered an error, usually related to document permissions. + if 'text' not in text: + return None + key = learning_observer.stream_analytics.helpers.make_key( + writing_observer.writing_analysis.reconstruct, + { + KeyField.STUDENT: student, + EventField('doc_id'): doc_id + }, + KeyStateType.INTERNAL + ) + await kvs.set(key, text) + return text + + fetch_from_google_documents = learning_observer.settings.module_setting('writing_observer', 'use_google_documents') + async for d in doc_ids: + if fetch_from_google_documents: + await fetch_doc_from_google( + learning_observer.util.get_nested_dict_value(d, 'provenance.provenance.STUDENT.value.user_id'), + learning_observer.util.get_nested_dict_value(d, 'doc_id') + ) + yield d + + +def get_last_document_id(s): + """ + Retrieves the ID of the latest document for a given student. + :param s: The student data. + :return: The ID of the latest document. + """ + return s.get('writing_observer.writing_analysis.last_document', {}).get('document_id', None) + + +async def update_reconstruct_data_with_google_api(runtime, student_data): + """ + This function updates the text reconstruction writing data from the extension with the + ground truth data from the Google Docs API. + :param runtime: The runtime for the application + :param student_data: A list of students + :return: A list of writing data, one for each student + """ + @learning_observer.cache.async_memoization() + async def fetch_doc_from_google(student): + """ + This function retrieves a single document text from Google based on the document ID. + :param student: A student object + :return: The text of the latest document + """ + import learning_observer.google + + kvs = learning_observer.kvs.KVS() + + docId = get_last_document_id(student) + # fetch text + text = await learning_observer.google.doctext(runtime, documentId=docId) + # set reconstruction data to ground truth + key = learning_observer.stream_analytics.helpers.make_key( + writing_observer.writing_analysis.reconstruct, + { + KeyField.STUDENT: student[constants.USER_ID], + EventField('doc_id'): docId + }, + KeyStateType.INTERNAL + ) + await kvs.set(key, text) + return text + + # For each student, retrieve the document text from Google and store it in a list + writing_data = [ + await fetch_doc_from_google(s) + if get_last_document_id(s) is not None else {} + for s in student_data + ] + return writing_data + + +# TODO This is old way of querying data from the system. +# The code should all still function, but the proper way to +# do this is using the Communication Protocol. +# This function and any references should be removed. +async def latest_data(runtime, student_data, options=None): + ''' + Retrieves the latest writing data for a set of students. + + + :param runtime: The runtime object from the server + # for annotated_text, single_doc in zip(annotated_texts, writing_data): + :param student_data: The student data. + # if annotated_text != "Error": + :param options: Additional options to pass to the text processing pipeline. + # single_doc.update(annotated_text) + :return: The latest writing data. + ''' + + # HACK we have a cache downstream that relies on redis_ephemeral being setup + # when that is resolved, we can remove the feature flag + # Update reconstruct data from KVS with ground truth from Google API + if learning_observer.settings.module_setting('writing_observer', 'use_google_documents'): + await update_reconstruct_data_with_google_api(runtime, student_data) + + # Get the latest documents with the students appended. + writing_data = await get_latest_student_documents(student_data) + + # Strip out the unnecessary extra data. + writing_data = await remove_extra_data(writing_data) + + # print(">>> WRITE DATA-premerge: {}".format(writing_data)) + + # This is the error. Skipping now. + writing_data_merge = await merge_with_student_data(writing_data, student_data) + # print(">>> WRITE DATA-postmerge: {}".format(writing_data_merge)) + + # #print(">>>> PRINT WRITE DATA: Merge") + # #print(writing_data) + + # just_the_text = [w.get("text", "") for w in writing_data] + + # annotated_texts = await writing_observer.awe_nlp.process_texts_parallel(just_the_text) + + # for annotated_text, single_doc in zip(annotated_texts, writing_data): + # if annotated_text != "Error": + # single_doc.update(annotated_text) + + writing_data = await merge_with_student_data(writing_data, student_data) + writing_data = await processor(writing_data, options) + + return {'latest_writing_data': writing_data} + + +@learning_observer.communication_protocol.integration.publish_function('google.fetch_assignment_docs') +async def fetch_assignment_docs(runtime, course_id, kwargs=None): + ''' + Invoke the Google API to retrieve a list of students, where each student possesses a + collection of documents associated with the specified assignment. + + I wasn't sure where to put this code, so I just tossed it here for now. + This entire file needs a bit of reworking, what's a little more? + ''' + if kwargs is None: + kwargs = {} + assignment_id = kwargs.get('assignment') + output = [] + if assignment_id: + output = await learning_observer.google.assigned_docs(runtime, courseId=course_id, courseWorkId=assignment_id) + async for student in learning_observer.util.ensure_async_generator(output): + s = {} + s['doc_id'] = student['documents'][0]['id'] + # HACK a piece above the source selector in the communication protocol + # expects all items returned to have the same provenance. This mirrors + # the provenance that will be returned by the other sources. + # TODO modify the source selector to handle the provenance + provenance = { + 'provenance': {'STUDENT': { + 'value': {'user_id': student['user_id']}, + 'user_id': student['user_id'] + }} + } + s['provenance'] = provenance + yield s diff --git a/modules/writing_observer/writing_observer/awe_nlp.py b/modules/writing_observer/writing_observer/awe_nlp.py new file mode 100644 index 000000000..4028d1265 --- /dev/null +++ b/modules/writing_observer/writing_observer/awe_nlp.py @@ -0,0 +1,424 @@ +''' +This is an interface to AWE_Workbench. +''' + +import asyncio +import enum +import functools +import multiprocessing +import os +import pmss +import time + +from concurrent.futures import ProcessPoolExecutor +from learning_observer.log_event import debug_log +from learning_observer.util import timestamp, timeparse + +import spacy +import coreferee +import spacytextblob.spacytextblob +import awe_components.components.lexicalFeatures +import awe_components.components.syntaxDiscourseFeats +import awe_components.components.viewpointFeatures +import awe_components.components.lexicalClusters +import awe_components.components.contentSegmentation +import json +import warnings + +import writing_observer.nlp_indicators +import learning_observer.kvs +import learning_observer.paths +import learning_observer.settings +import learning_observer.util + + +SPACY_PREFERENCE = { + 'require': spacy.require_gpu, + 'prefer': spacy.prefer_gpu, + 'none': lambda: None +} + +pmss.parser('spacy_gpu_preference', parent='string', choices=['require', 'prefer', 'none'], transform=None) +pmss.register_field( + name='spacy_gpu_preference', + type='spacy_gpu_preference', + description='Determine if we should use the GPU for Spacy or not.\n'\ + '`require`: use GPU for spacy operations, raises error if GPU is not preset.\n'\ + '`prefer`: uses GPU, if available, for spacy operations, otherwise use CPU.\n'\ + '`none`: use CPU for spacy operations.', + default='none' +) + +RUN_MODES = enum.Enum('RUN_MODES', 'MULTIPROCESSING SERIAL') + + +def init_nlp(): + ''' + Initialize the spacy pipeline with the AWE components. This takes a while + to run. + ''' + gpu_preference = learning_observer.settings.pmss_settings.spacy_gpu_preference() + debug_log(f'Spacy GPU preference set to {gpu_preference}.') + SPACY_PREFERENCE[gpu_preference]() + + warnings.filterwarnings('ignore', category=UserWarning, module='nltk') + try: + nlp = spacy.load("en_core_web_lg") + except OSError as e: + error_text = 'There was an issue loading `en_core_web_lg` from spacy. '\ + '`awe_components` requires various models to operate properly. '\ + f'Run `{learning_observer.paths.PYTHON_EXECUTABLE} -m awe_components.setup.data` to install all '\ + 'of the necessary models.' + + a = input('Spacy model `en_core_web_lg` not available. Would you like to download? (y/n)') + if a.strip().lower() not in ['y', 'yes']: + raise OSError(error_text) from e + import awe_components.setup.data + awe_components.setup.data.download_models() + nlp = spacy.load('en_core_web_lg') + + # Adding all of the components, since + # each of them turns out to be implicated in + # the demo list. I note below which ones can + # be loaded separately to support specific indicators. + nlp.add_pipe('coreferee') + nlp.add_pipe('spacytextblob') + nlp.add_pipe('lexicalfeatures') + nlp.add_pipe('syntaxdiscoursefeatures') + nlp.add_pipe('viewpointfeatures') + nlp.add_pipe('lexicalclusters') + nlp.add_pipe('contentsegmentation') + return nlp + + +nlp = init_nlp() + + +def outputIndicator(doc, indicatorName, itype, stype=None, text=None, added_filter=None): + ''' + A function to output three types of information: summary metrics, + lists of textual information selected by the indicator, and + the offset information for each word or span selected by the indicator + ''' + + indicator = {} + + if added_filter is None: + theFilter = [(indicatorName, [True]), ('is_alpha', [True])] + else: + theFilter = added_filter + theFilter.append(('is_alpha', [True])) + + indicator['metric'] =\ + doc._.AWE_Info(infoType=itype, + indicator=indicatorName, + filters=theFilter, + summaryType=stype) + + data = json.loads( + doc._.AWE_Info(infoType=itype, + indicator=indicatorName, + filters=theFilter)).values() + + indicator['offsets'] = \ + [[entry['offset'], entry['length']] for entry in data] + + if itype == 'Token': + indicator['text'] = \ + json.loads(doc._.AWE_Info(infoType=itype, + indicator=indicatorName, + filters=theFilter, + transformations=['lemma'], + summaryType='uniq')) + else: + indicator['text'] = [] + + for span in indicator['offsets']: + indicator['text'].append(text[int(span[0]):int(span[0]) + int(span[1])]) + + return indicator + + +def process_text(text, options=None): + ''' + This will extract a dictionary of metadata using Paul's AWE Workbench code. + ''' + doc = nlp(text) + results = {} + + if options is None: + # Do we want options to be everything initially or nothing? + options = writing_observer.nlp_indicators.INDICATORS.keys() + + for item in options: + if item not in writing_observer.nlp_indicators.INDICATORS: + continue + indicator = writing_observer.nlp_indicators.INDICATORS[item] + (id, label, infoType, select, filterInfo, summaryType, category) = indicator + results[id] = outputIndicator(doc, select, infoType, stype=summaryType, text=text, added_filter=filterInfo) + results[id].update({ + "label": label, + "type": infoType, + "name": id, + "summary_type": summaryType + }) + return results + + +async def process_texts_serial(texts, options=None): + ''' + Process a list of texts, in serial. + + For testing / debugging, this will process a single essay. Note that while + labeled async, it's not. If run on the server, it will lock up the main + Python process. + ''' + annotated = [] + for text in texts: + print(text) + annotations = process_text(text, options) + annotations['text'] = text + annotated.append(annotations) + + return annotated + + +executor = None + + +def run_in_fork(func): + ''' + This will run a function in a forked subproces, for isolation. + + I wanted to check if this would solve a bug. It didn't. + ''' + q = multiprocessing.Queue() + thread = os.fork() + if thread != 0: + print("Awaiting queue") + return q.get(block=True) + print("Awaited") + else: + print("Queuing") + q.put(func()) + print("Queued") + os._exit(0) + + +async def process_texts_parallel(texts, options=None): + ''' + This will spin up as many processes as we have cores, and process texts + in parallel. Note that we should confirm issues of thread safety. If + Python does this right, this should run in forked environments, and we + won't run into issues. Otherwise, we'd want to either fork ourselves, or + understand how well spacy, etc. do with parallelism. + ''' + global executor + if executor is None: + executor = ProcessPoolExecutor() + + loop = asyncio.get_running_loop() + result_futures = [] + for text in texts: + processor = functools.partial(process_text, text, options) + # forked_processor = functools.partial(run_in_fork, processor) + result_futures.append(loop.run_in_executor(executor, processor)) + + annotated = [] + for text, result_future in zip(texts, result_futures): + try: + annotations = await result_future + annotations['text'] = text + except Exception: + raise + annotations = "Error" + annotated.append(annotations) + + return annotated + + +async def get_latest_cache_data_for_text(cache, text_hash): + """ + Cache Helper Function: Returns latest cache for the text hash or initializes key-value pair for that hash if it does not already exist in the cache. + :param cache: The cache object. + :param text_hash: The hash of the text. + :return: The latest cache data for the text hash. + """ + text_cache_data = await cache[text_hash] + if text_cache_data is None: + text_cache_data = {} + return text_cache_data + + +async def check_available_features_in_cache(cache, text_hash, requested_features, writing): + """ + Cache Helper Function : Check if some options are a subset of features_available + :param cache: The cache object. + :param text_hash: The hash of the text. + :param requested_features: The set of requested features. + :param writing: The writing data. + :return: A tuple containing the found features and the updated writing data. + """ + features_available = 'features_available' + text_cache_data = await get_latest_cache_data_for_text(cache, text_hash) # Get latest cache + found_features = set() + text_cache_data.setdefault(features_available, dict()) + if len(text_cache_data[features_available]) > 0: + found_features = requested_features.intersection(text_cache_data[features_available].keys()) + writing.update(text_cache_data[features_available]) + return found_features, writing + + +async def check_and_wait_for_running_features(writing, requested_features, found_features, cache, sleep_interval, wait_time_for_running_features, text_hash): + """ + Check if some options are a subset of running_features: features that are needed but are already running + :param writing: The writing data. + :param requested_features: The set of requested features. + :param found_features: The found features. + :param cache: The cache object. + :param sleep_interval: The time interval in seconds to wait between recurring calls to cache. + :param wait_time_for_running_features: The time in seconds to wait for features already running. + :param text_hash: The hash of the text. + :return: A tuple containing the unfound features, found features, and the updated writing data. + """ + text_cache_data = await get_latest_cache_data_for_text(cache, text_hash) # Get latest cache + running_features = set(json.loads(text_cache_data['running_features'])) if 'running_features' in text_cache_data else set() + run_successful = False + unfound_features = requested_features - found_features + needed_running_features = set() # Features that are needed but are already processing + if running_features: + needed_running_features = unfound_features.intersection(running_features) + # Recursively check if features have finished processing at a regular interval of sleep_interval + if len(needed_running_features) > 0: + while True: + new_cache = await cache[text_hash] + if new_cache['stop_time'] != "running": + run_successful = True + break + if (timeparse(timestamp()) - timeparse(new_cache['start_time'])).total_seconds() > wait_time_for_running_features: + break + await asyncio.sleep(sleep_interval) + if run_successful: + # running_features will be available in features_available after they finish running. + writing.update(text_cache_data['features_available']) + found_features = found_features.union(needed_running_features) + return unfound_features, found_features, writing + + +async def process_and_cache_missing_features(unfound_features, found_features, requested_features, cache, text_hash, writing): + """ + Cache Helper: Add not found options to running_features and update cache. + :param unfound_features: The unfound features. + :param found_features: The found features. + :param requested_features: The set of requested features. + :param cache: The cache object. + :param text_hash: The hash of the text. + :param writing: The writing data. + :return: The updated writing data. + """ + unfound_features = requested_features - found_features + running_features = unfound_features + temp_cache_dict = {'running_features': json.dumps(list(running_features)), + 'start_time': timestamp(), + 'stop_time': "running"} + + text_cache_data = await get_latest_cache_data_for_text(cache, text_hash) # Get latest cache + text_cache_data.update(temp_cache_dict) + text_cache_data.setdefault('features_available', dict()) + await cache.set(text_hash, text_cache_data) + + annotated_text = process_text(writing.get("text", ""), list(unfound_features)) + text_cache_data['running_features'] = json.dumps([]) + text_cache_data['stop_time'] = timestamp() + text_cache_data['features_available'].update(annotated_text) + writing.update(annotated_text) + await cache.set(text_hash, text_cache_data) + return writing + + +async def process_writings_with_caching(writing_data, options=None, mode=RUN_MODES.MULTIPROCESSING, sleep_interval=1, wait_time_for_running_features=60): + ''' + Caching: + + 1. Create text hash. + 2. Check if hash exist in cache. + 3. Check if some options are a subset of features_available + * Yes: add the intersection of features_available and options to results + 4. Check if some options are a subset of running_features. + * Yes: + a. Wait for running_features to finish. + b. Update the cache + c. Add intersection of running_features and Options to results + 5. Check if additional features are required. + * Yes: + a. Collect options not covered till now and add to running_features. + b. Once finished, update cache and return results. + + param writing_data: The writing data. + :param options: The list of additional features (optional). + :param mode: The run mode (default: RUN_MODES.MULTIPROCESSING). + :param sleep_interval: Time in seconds to wait between recurring calls to cache to check if features have finished running, defaults to 1. + :param wait_time_for_running_features: The time in seconds to wait for features already running (default: 60). + :return: The results list. + ''' + results = [] + cache = learning_observer.kvs.KVS() + requested_features = set(options if options else []) + processor = { + RUN_MODES.MULTIPROCESSING: process_texts_parallel, + RUN_MODES.SERIAL: process_texts_serial + } + + async for writing in writing_data: + text = writing.get('text', '') + if len(text) == 0: + yield writing + continue + + # Creating text hash and setting defaults + text_hash = 'NLP_CACHE_' + learning_observer.util.secure_hash(text.encode('utf-8')) + + # Check if some options are a subset of features_available + found_features, writing = await check_available_features_in_cache(cache, text_hash, requested_features, writing) + # If all options were found + if found_features == requested_features: + yield writing + continue + + # Check if some options are a subset of running_features: features that are needed but are already running + unfound_features, found_features, writing = await check_and_wait_for_running_features(writing, requested_features, found_features, cache, sleep_interval, wait_time_for_running_features, text_hash) + # If all options are found + if found_features == requested_features: + yield writing + continue + + # Add not found options to running_features and update cache + yield await process_and_cache_missing_features(unfound_features, found_features, requested_features, cache, text_hash, writing) + + +if __name__ == '__main__': + import time + import writing_observer.sample_essays + # Run over a sample text + example_texts = writing_observer.sample_essays.SHORT_STORIES + t1 = time.time() + results = process_text(example_texts[0]) + t2 = time.time() + print(json.dumps(results, indent=2)) + + # If we want to save some test data, flip this to True + if False: + with open("results.json", "w") as fp: + json.dump(results, fp, indent=2) + print("==============") + results2 = asyncio.run(process_texts_parallel(example_texts[0:8])) + t3 = time.time() + results3 = asyncio.run(process_texts_serial(example_texts[0:8])) + t4 = time.time() + print(results2) + print("Single time", t2 - t1) + print("Parallel time", t3 - t2) + print("Serial time", t4 - t3) + print("Note that these results are imperfect -- ") + print("Errors", len([r for r in results2 if r == "Error"])) + print("Errors", [r if r == "Error" else "--" for r in results2]) \ No newline at end of file diff --git a/modules/writing_observer/writing_observer/document_timestamps.py b/modules/writing_observer/writing_observer/document_timestamps.py new file mode 100644 index 000000000..4f872655d --- /dev/null +++ b/modules/writing_observer/writing_observer/document_timestamps.py @@ -0,0 +1,49 @@ +import bisect + +import learning_observer.communication_protocol.integration + + +@learning_observer.communication_protocol.integration.publish_function('source_selector') +def select_source(sources, source): + ''' + Given a variety of sources, pick which source we should use. + + HACK With this as a function, the system will run every node + available in `sources`. If we created this as a dispatch type + within the protocol, we could make it so the system only runs + the requested source node. + TODO make this a dispatch type within the protocol + TODO add provenance at this layer. Each source might have a different + provenance structure. This should create one to use. + ''' + if source not in sources: + raise KeyError(f'Source, `{source}`, not found in available sources: {sources.keys()}') + return sources[source] + + +@learning_observer.communication_protocol.integration.publish_function('writing_observer.fetch_doc_at_timestamp') +async def fetch_doc_at_timestamp(overall_timestamps, kwargs=None): + ''' + Iterate over a list of students and determine their latest document + in reference to the `kwargs.requested_timestamp`. + + `requested_timestamp` should be a string of ms since unix epoch + ''' + if kwargs is None: + kwargs = {} + requested_timestamp = kwargs.get('requested_timestamp', None) + async for student in overall_timestamps: + timestamps = student.get('timestamps', {}) + student['doc_id'] = '' + if requested_timestamp is None: + # perhaps this should fetch the latest doc id instead + yield student + continue + sorted_ts = sorted(timestamps.keys()) + bisect_index = bisect.bisect_right(sorted_ts, requested_timestamp) - 1 + if bisect_index < 0: + yield student + continue + target_ts = sorted_ts[bisect_index] + student['doc_id'] = timestamps[target_ts] + yield student diff --git a/modules/writing_observer/writing_observer/languagetool.py b/modules/writing_observer/writing_observer/languagetool.py new file mode 100644 index 000000000..c9fdecd70 --- /dev/null +++ b/modules/writing_observer/writing_observer/languagetool.py @@ -0,0 +1,86 @@ +import pmss + +import learning_observer.cache +import learning_observer.communication_protocol.integration +import learning_observer.prestartup +import learning_observer.settings + +from learning_observer.log_event import debug_log + +from awe_languagetool import languagetoolClient + +client = None +DEFAULT_PORT = 8081 +lt_started = False +# TODO fill this in +STUB_LANGUAGETOOL_OUTPUT = { + 'languagetool_stub': 'This is stub output. It will probably break something until we clean this up.' +} + +pmss.register_field( + name='use_languagetool', + description='Flag for connecting to and using LT (LanguageTool). LT is'\ + 'used to find language and mechanical errors in text.', + type=pmss.pmsstypes.TYPES.boolean, + default=False +) +pmss.register_field( + name='languagetool_host', + description='Hostname of the system LanguageTool is running on.', + type=pmss.pmsstypes.TYPES.hostname, + default='localhost' +) +pmss.register_field( + name='languagetool_port', + description='Port of the system LanguageTool is running on.', + type=pmss.pmsstypes.TYPES.port, + default=DEFAULT_PORT +) + + +@learning_observer.prestartup.register_startup_check +def check_languagetool_running(): + ''' + We want to make sure that the Language Tool service is running on the server + before starting the rest of the Learning Observer platform. + + TODO create a stub function for language tool to return dummy data when testing. + See aggregator.py:214 for stubbing in the function + ''' + if learning_observer.settings.module_setting('writing_observer', 'use_languagetool'): + host = learning_observer.settings.module_setting('writing_observer', 'languagetool_host') + port = learning_observer.settings.module_setting('writing_observer', 'languagetool_port') + # TODO LanguageTool Client also accepts a full server url, we ought to fetch that from pmss + + global client, lt_started + try: + client = languagetoolClient.languagetoolClient(port=port, host=host) + lt_started = True + except RuntimeError as e: + raise learning_observer.prestartup.StartupCheck( + f'Unable to start LanguageTool Client.\n{e}' + ) from e + else: + debug_log('WARNING:: We are not configured to try and use to LanguageTool. '\ + 'Set `modules.writing_observer.use_languagetool: true` in `creds.yaml` '\ + 'to enable the usage of the LanguageTool client.') + + +@learning_observer.communication_protocol.integration.publish_function('writing_observer.languagetool') +async def process_texts(texts): + ''' + This method processes the text through Language Tool and returns + the output. + + We use a closure to allow the system to initialize the memoization KVS. + ''' + @learning_observer.cache.async_memoization() + async def process_text(text): + return await client.summarizeText(text) + + async for t in texts: + text = t.get('text', '') + text_data = await process_text(text) if lt_started else STUB_LANGUAGETOOL_OUTPUT + text_data['text'] = text + text_data['provenance'] = t['provenance'] + yield text_data diff --git a/modules/writing_observer/writing_observer/languagetool_features.py b/modules/writing_observer/writing_observer/languagetool_features.py new file mode 100644 index 000000000..a76350c16 --- /dev/null +++ b/modules/writing_observer/writing_observer/languagetool_features.py @@ -0,0 +1,128 @@ +'''This file lists all the options for LanguageTool (LT) +so we can query specific items. + +Note that all features are calculated at once when +querying LT. Listing all the features here allows +dashboards pull out specific items from the returned +query. + +TODO figure out the best data structure for storing +the available features +''' + +# Each tuple in AVAILABLE_ITEMS contains the full path of +# the categories. +AVAILABLE_ITEMS = [ + ('Capitalization', 'Unnecessary capitalization'), + ('Capitalization', 'Acronyms'), + ('Capitalization', 'Abbreviations'), + ('Capitalization', 'Hyphenated Letter Case'), + ('Capitalization', 'Sentence Start'), + ('Capitalization', 'Proper noun case'), + ('Capitalization', 'First person singular caps'), + ('Grammar', 'Extra function word'), + ('Grammar', 'Nonstandard copula'), + ('Grammar', 'Tag question error'), + ('Grammar', 'Missing function word'), + ('Grammar', 'Wrong verb tense'), + ('Grammar', 'Conjunction error'), + ('Grammar', 'Bare possessive'), + ('Grammar', 'Wrong word order'), + ('Grammar', 'Pronoun/antecedent agreement'), + ('Grammar', 'Plural error'), + ('Grammar', 'Missing content word'), + ('Grammar', 'Repeated words'), + ('Grammar', 'Pronoun case'), + ('Grammar', 'Article error'), + ('Grammar', 'Complement error'), + ('Grammar', 'Sentence fragment'), + ('Grammar', 'Comparative error'), + ('Grammar', 'Superlative error'), + ('Grammar', 'Tense error'), + ('Grammar', 'Wrong verb form'), + ('Grammar', 'Wrong part of speech'), + ('Grammar', 'Subject/verb agreement'), + ('Grammar', 'Wrong past participle'), + ('Grammar', 'Negation error'), + ('Possible Typo', 'Misplaced space'), + ('Possible Typo', 'Missing space'), + ('Possible Typo', 'Incorrect plural possessive'), + ('Possible Typo', 'Wrong case'), + ('Possible Typo', 'Missing hyphen'), + ('Possible Typo', 'Wrong contraction'), + ('Possible Typo', 'Possessive as contraction'), + ('Possible Typo', 'Wrong letter'), + ('Possible Typo', 'Terminology'), + ('Possible Typo', 'Reversed letters'), + ('Possible Typo', 'Possessive as plural'), + ('Possible Typo', 'Extra space'), + ('Possible Typo', 'Bare possessive'), + ('Possible Typo', 'Missing punctuation'), + ('Possible Typo', 'Extra letter'), + ('Possible Typo', 'Missing period'), + ('Possible Typo', 'Plural as possessive'), + ('Possible Typo', 'Repeated words'), + ('Possible Typo', 'Contraction without apostrophe'), + ('Possible Typo', 'Missing letter'), + ('Punctuation', 'Missing end punctuation'), + ('Punctuation', 'Oxford comma'), + ('Punctuation', 'Conjunction'), + ('Punctuation', 'Abbreviation'), + ('Punctuation', 'Comparison'), + ('Punctuation', 'Restrictive modifier'), + ('Punctuation', 'Compound sentence'), + ('Punctuation', 'Compound adjective'), + ('Punctuation', 'Sentence modifier'), + ('Punctuation', 'Vocative'), + ('Semantics', 'Date inconsistency'), + ('Semantics', 'Meaning mismatch'), + ('Spelling', 'Number in Name'), + ('Spelling', 'Misused idiom'), + ('Spelling', 'Missing letter'), + ('Spelling', 'Extra letter'), + # ('Spelling', 'Possible Typo'), + ('Spelling', 'Added or dropped t/ed'), + ('Spelling', 'Phonetic spelling'), + ('Spelling', 'Inconsistent Spelling'), + ('Spelling', 'Homonym'), + ('Spelling', 'Unknown word'), + ('Style', 'Formality'), + ('Style', 'Word choice'), + ('Style', 'Repetition'), + ('Style', 'Wikipedia'), + ('Style', 'British English'), + ('Style', 'Readability'), + ('Style', 'Profanity'), + ('Style', 'Awkward language'), + ('Style', 'Redundancy'), + ('Style', 'Informal'), + ('Style', 'Indian English Only'), + ('Typography', 'Roman Numerals'), + ('Typography', 'Math'), + ('Typography', 'Date format'), + ('Typography', 'Missing space'), + ('Typography', 'Repeated punctuation'), + ('Typography', 'Headings'), + ('Typography', 'Redundant punctuation'), + ('Typography', 'Extra whitespace'), + ('Typography', 'Paragraph Indent'), + ('Typography', 'Time format'), + ('Typography', 'Special character'), + ('Typography', 'Currency'), + ('Usage', 'Confused words'), + ('Usage', 'Missing preposition'), + ('Usage', 'Misused idiom'), + ('Usage', 'Wrong preposition'), + ('Usage', 'Wrong verb'), + ('Usage', 'Extra preposition'), + ('Word Boundaries', 'Missing hyphen'), + ('Word Boundaries', 'Extra space'), + ('Word Boundaries', 'Prefix as word'), + ('Word Boundaries', 'Missing space') +] + +CATEGORIES = [ + 'Grammar', 'Semantics', 'Style', 'Usage', + 'Capitalization', 'Possible Typo', 'Punctuation', + 'Spelling', 'Typography', 'Word Boundaries' +] diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py new file mode 100644 index 000000000..b23df8323 --- /dev/null +++ b/modules/writing_observer/writing_observer/module.py @@ -0,0 +1,375 @@ +''' +Module definition file + +This may be an examplar for building new modules too. +''' + +# Outgoing APIs +# +# Generically, these would usually serve JSON to dashboards written as JavaScript and +# HTML. These used to be called 'dashboards,' but we're now hosting those as static +# files. +import pmss + +import learning_observer.communication_protocol.query as q +import learning_observer.settings + +from learning_observer import downloads as d + +import writing_observer.aggregator +import writing_observer.writing_analysis +import writing_observer.languagetool +import writing_observer.tag_docs +import writing_observer.document_timestamps +from writing_observer.nlp_indicators import INDICATOR_JSONS + + +NAME = "The Writing Observer" + +# things that process data versus things that interact with the environment +# side-effects or not +# course_id or rosters.course_id to distinguish or provide a default - how to specify selector +course_roster = q.call('learning_observer.courseroster') +process_texts = q.call('writing_observer.process_texts') +determine_activity = q.call('writing_observer.activity_map') +languagetool = q.call('writing_observer.languagetool') +update_via_google = q.call('writing_observer.update_reconstruct_with_google_api') +assignment_documents = q.call('google.fetch_assignment_docs') + +unwind = q.call('unwind') +group_docs_by = q.call('writing_observer.group_docs_by') + +document_access_ts = q.call('writing_observer.fetch_doc_at_timestamp') + +source_selector = q.call('source_selector') + +# TODO each of these choices should come from an Enum +pmss.parser('nlp_source', parent='string', choices=['nlp', 'nlp_sep_proc'], transform=None) +pmss.register_field( + name='nlp_source', + type='nlp_source', + description='Process the NLP components at time of execution '\ + 'dag `nlp` or read results from reducer `nlp_sep_proc`.', + default='nlp' +) +pmss.parser('languagetool_source', parent='string', choices=['overall_lt', 'overall_lt_sep_proc'], transform=None) +pmss.register_field( + name='languagetool_source', + type='languagetool_source', + description='Process the NLP components at time of execution '\ + 'dag `overall_lt` or read results from reducer `overall_lt_sep_proc`.', + default='overall_lt' +) +pmss.parser('languagetool_individual_source', parent='string', choices=['single_student_lt', 'single_lt_sep_proc'], transform=None) +pmss.register_field( + name='languagetool_individual_source', + type='languagetool_individual_source', + description='Process the NLP components at time of execution '\ + 'dag `single_student_lt` or read results from reducer `single_lt_sep_proc`.', + default='single_student_lt' +) + +# TODO We have a lot of nodes to keep track of and their +# current names are not great. +# TODO think about a better approach to changing query DAGs. +# Currently, we set the node we want to fetch in a settings file. +# We do have this `source_selector`method floating to select a +# specific item from a provided dictionary mapping. +nlp_source = learning_observer.settings.module_setting('writing_observer', setting='nlp_source') +lt_single_source = learning_observer.settings.module_setting('writing_observer', setting='languagetool_individual_source') +lt_group_source = learning_observer.settings.module_setting('writing_observer', setting='languagetool_source') + +gpt_bulk_essay = q.call('wo_bulk_essay_analysis.gpt_essay_prompt') + +# Document sources +document_sources = source_selector( + sources={'timestamp': q.variable('docs_at_ts'), + 'latest': q.variable('doc_ids'), + 'assignment': q.variable('assignment_docs') + }, + source=q.parameter('doc_source', required=False, default='latest') +) + +EXECUTION_DAG = { + "execution_dag": { + "roster": course_roster(runtime=q.parameter("runtime"), course_id=q.parameter("course_id", required=True)), + "doc_ids": q.select(q.keys('writing_observer.last_document', STUDENTS=q.variable("roster"), STUDENTS_path='user_id'), fields={'document_id': 'doc_id'}), + 'update_docs': update_via_google(runtime=q.parameter("runtime"), doc_ids=q.variable('doc_sources')), + "docs": q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("update_docs"), RESOURCES_path='doc_id'), fields={'text': 'text'}), + "docs_combined": q.join(LEFT=q.variable("docs"), RIGHT=q.variable("roster"), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT_ON='user_id'), + 'nlp': process_texts(writing_data=q.variable('docs'), options=q.parameter('nlp_options', required=False, default=[])), + 'nlp_sep_proc': q.select(q.keys('writing_observer.nlp_components', STUDENTS=q.variable('roster'), STUDENTS_path='user_id', RESOURCES=q.variable("doc_ids"), RESOURCES_path='doc_id'), fields='All'), + 'nlp_combined': q.join(LEFT=q.variable(nlp_source), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('roster'), RIGHT_ON='user_id'), + # error dashboard activity map nodes + 'time_on_task': q.select(q.keys('writing_observer.time_on_task', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("doc_sources"), RESOURCES_path='doc_id'), fields={'saved_ts': 'last_ts', 'total_time_on_task': 'time_on_task'}), + 'activity_map': q.map(determine_activity, q.variable('time_on_task'), value_path='last_ts'), + # single student language tool nodes + 'single_student_latest_doc': q.select(q.keys('writing_observer.last_document', STUDENTS=q.parameter("student_id", required=True), STUDENTS_path='user_id'), fields={'document_id': 'doc_id'}), + 'single_timestamped_docs': q.select(q.keys('writing_observer.document_access_timestamps', STUDENTS=q.parameter("student_id", required=True), STUDENTS_path='user_id'), fields={'timestamps': 'timestamps'}), + 'single_docs_at_ts': document_access_ts(overall_timestamps=q.variable('timestamped_docs'), requested_timestamp=q.parameter('requested_timestamp')), + 'single_doc_sources': source_selector(sources={'ts': q.variable('single_docs_at_ts'), 'latest': q.variable('single_student_latest_doc')}, source=q.parameter('doc_source', required=False, default='latest')), + 'single_update_doc': update_via_google(runtime=q.parameter('runtime'), doc_ids=q.variable('single_doc_sources')), + 'single_student_doc': q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.parameter("student_id", required=True), STUDENTS_path='user_id', RESOURCES=q.variable("single_update_doc"), RESOURCES_path='doc_id'), fields={'text': 'text'}), + 'single_student_lt': languagetool(texts=q.variable('single_student_doc')), + 'single_lt_combined': q.join(LEFT=q.variable(lt_single_source), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('roster'), RIGHT_ON='user_id'), + 'single_lt_sep_proc': q.select(q.keys('writing_observer.languagetool_process', STUDENTS=q.parameter("student_id", required=True), STUDENTS_path='user_id', RESOURCES=q.variable("single_student_latest_doc"), RESOURCES_path='doc_id'), fields='All'), + # overall language tool nodes + 'overall_lt': languagetool(texts=q.variable('docs')), + 'overall_lt_sep_proc': q.select(q.keys('writing_observer.languagetool_process', STUDENTS=q.variable('roster'), STUDENTS_path='user_id', RESOURCES=q.variable("doc_ids"), RESOURCES_path='doc_id'), fields='All'), + 'lt_combined': q.join(LEFT=q.variable(lt_group_source), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('roster'), RIGHT_ON='user_id'), + + 'latest_doc_ids': q.join(LEFT=q.variable('roster'), RIGHT=q.variable('doc_ids'), LEFT_ON='user_id', RIGHT_ON='provenance.provenance.value.user_id'), + # the following nodes are used to fetch a set of documents' metadata based on a given tag + # HACK: this could be a lot fewer nodes with some form of filter functionality + # e.g. once we get the list of documents that match the inputted tag, we just filter + # the student document list on those + # instead we do some unwinding and joining to achieve filtering. this solution + # is a bit better suited for fetching document text which is how the system was + # initially built. + 'raw_tags': q.select(q.keys('writing_observer.document_tagging', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), fields={'tags': 'tags'}), + 'unwind_tags': unwind(objects=q.variable('raw_tags'), value_path=q.parameter('tag_path', required=True), new_name='doc_id', keys_to_keep=['provenance']), + 'doc_list': q.select(q.keys('writing_observer.document_list', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), fields={'docs': 'docs'}), + 'unwind_doc_list': unwind(objects=q.variable('doc_list'), value_path='docs', new_name='doc'), + 'tagged_doc_list': q.join(LEFT=q.variable('unwind_tags'), RIGHT=q.variable('unwind_doc_list'), LEFT_ON='doc_id', RIGHT_ON='doc.id'), + 'grouped_doc_list_by_student': group_docs_by(items=q.variable('tagged_doc_list'), value_path='provenance.provenance.value.user_id'), + 'tagged_docs_per_student': q.join(LEFT=q.variable('roster'), RIGHT=q.variable('grouped_doc_list_by_student'), LEFT_ON='user_id', RIGHT_ON='user_id'), + # Student document list + 'document_list': q.select(q.keys('writing_observer.document_list', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), fields={'docs': 'availableDocuments'}), + + # the following nodes just fetches docs related to an assignment on Google Classroom + 'assignment_docs': assignment_documents(runtime=q.parameter('runtime'), course_id=q.parameter('course_id', required=True), kwargs=q.parameter('doc_source_kwargs')), + + # fetch the doc less than or equal to a timestamp + 'timestamped_docs': q.select(q.keys('writing_observer.document_access_timestamps', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), fields={'timestamps': 'timestamps'}), + 'docs_at_ts': document_access_ts(overall_timestamps=q.variable('timestamped_docs'), kwargs=q.parameter('doc_source_kwargs')), + + # figure out where to source document ids from + # current options include `ts` for a given timestamp + # or `latest` for the most recently accessed + 'doc_sources': document_sources, + 'gpt_map': q.map( + gpt_bulk_essay, + values=q.variable('docs'), + value_path='text', + func_kwargs={'prompt': q.parameter('gpt_prompt'), 'system_prompt': q.parameter('system_prompt'), 'tags': q.parameter('tags', required=False, default={})}, + parallel=True + ), + 'gpt_bulk': q.join(LEFT=q.variable('gpt_map'), LEFT_ON='provenance.provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('roster'), RIGHT_ON='user_id'), + }, + "exports": { + "docs_with_roster": { + "returns": "docs_combined", + "parameters": ["course_id"], + "output": "" + }, + "roster": { + "returns": "roster", + "parameters": ["course_id"], + "output": "" + }, + "document_list": { + "returns": "document_list", + "parameters": ["course_id"], + "output": "" + }, + "document_sources": { + "returns": "doc_sources", + "parameters": ["course_id"], + "output": "" + }, + 'gpt_bulk': { + 'returns': 'gpt_bulk', + 'parameters': ['course_id', 'gpt_prompt', 'system_prompt'], + 'output': '' + }, + "docs_with_nlp_annotations": { + "returns": "nlp_combined", + "parameters": ["course_id", "nlp_options"], + "output": "" + }, + "activity": { + "returns": "activity_map", + "parameters": ["course_id"], + "output": "" + }, + "time_on_task": { + "returns": "time_on_task", + "parameters": ["course_id"], + "output": "" + }, + 'single_student': { + 'returns': 'single_lt_combined', + 'parameters': ['course_id', 'student_id'], + 'output': '' + }, + 'overall_errors': { + 'returns': 'lt_combined', + 'parameters': ['course_id'], + 'output': '' + }, + 'tagged_docs_per_student': { + 'returns': 'tagged_docs_per_student', + 'parameters': ['course_id', 'tag_path'] + }, + 'latest_doc_ids': { + 'returns': 'latest_doc_ids', + 'parameters': ['course_id'] + } + }, +} + +COURSE_AGGREGATORS = { + "writing_observer": { + "sources": [ # These are the reducers whose outputs we aggregate + writing_observer.writing_analysis.time_on_task, + writing_observer.writing_analysis.reconstruct + # TODO: "roster" + ], + # Then, we pass the per-student data through the cleaner, if provided. + "cleaner": writing_observer.aggregator.sanitize_and_shrink_per_student_data, + # And we pass an array of the output of that through the aggregator + "aggregator": writing_observer.aggregator.aggregate_course_summary_stats, + "name": "This is the main Writing Observer dashboard.", + # This is what we return for a student for whom we have no data + # (or if we have data, don't have these fields) + "default_data": { + 'writing_observer.writing_analysis.reconstruct': { + 'text': None, + 'position': 0, + 'edit_metadata': {'cursor': [2], 'length': [1]} + }, + 'writing_observer.writing_analysis.time_on_task': { + 'saved_ts': -1, + 'total_time_on_task': 0 + } + } + }, + "latest_data": { + "sources": [ + writing_observer.writing_analysis.last_document + ], + "name": "Show the latest student writing", + "aggregator": writing_observer.aggregator.latest_data + } +} + +STUDENT_AGGREGATORS = { +} + +# Incoming event APIs +REDUCERS = [ + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.gdoc_scope_time_on_task, + 'default': {'saved_ts': 0} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_tab_scope, + 'function': writing_observer.writing_analysis.gdoc_tab_scope_time_on_task, + 'default': {'saved_ts': 0} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.binned_time_on_task + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.reconstruct, + 'default': {'text': ''} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.student_scope, + 'function': writing_observer.writing_analysis.event_count + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.student_scope, + 'function': writing_observer.writing_analysis.document_list, + 'default': {'docs': []} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.tab_list, + 'default': {'tabs': {}} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.student_scope, + 'function': writing_observer.writing_analysis.last_document, + 'default': {'document_id': ''} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.student_scope, + 'function': writing_observer.writing_analysis.document_tagging, + 'default': {'tags': {}} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.student_scope, + 'function': writing_observer.writing_analysis.document_access_timestamps, + 'default': {'timestamps': {}} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.nlp_components, + 'default': {'text': ''} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.languagetool_process, + 'default': {'text': '', 'category_counts': {}, 'matches': [], 'subcategory_counts': {}, 'wordcounts': {}} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.student_scope, + 'function': writing_observer.writing_analysis.student_profile, + } +] + + +# Required client-side JavaScript downloads +THIRD_PARTY = { + "require.js": d.REQUIRE_JS, + "text.js": d.TEXT_JS, + "r.js": d.R_JS, + "bulma.min.css": d.BULMA_CSS, + "fontawesome.js": d.FONTAWESOME_JS, + "showdown.js": d.SHOWDOWN_JS, + "showdown.js.map": d.SHOWDOWN_JS_MAP, + "mustache.min.js": d.MUSTACHE_JS, + "d3.v5.min.js": d.D3_V5_JS, + "bulma-tooltip-min.css": d.BULMA_TOOLTIP_CSS +} + + +# We're still figuring this out, but we'd like to support hosting static files +# from the git repo of the module. +# +# This allows us to have a Merkle-tree style record of which version is deployed +# in our log files. +STATIC_FILE_GIT_REPOS = { + 'writing_observer': { + # Where we can grab a copy of the repo, if not already on the system + 'url': 'https://github.com/ETS-Next-Gen/writing_observer.git', + # Where the static files in the repo lie + 'prefix': 'modules/writing_observer/writing_observer/static', + # Branches we serve. This can either be a whitelist (e.g. which ones + # are available) or a blacklist (e.g. which ones are blocked) + 'whitelist': ['master'] + } +} + +EXTRA_VIEWS = [{ + 'name': 'NLP Options', + 'suburl': 'nlp-options', + 'static_json': INDICATOR_JSONS +}] diff --git a/modules/writing_observer/writing_observer/nlp_indicators.py b/modules/writing_observer/writing_observer/nlp_indicators.py new file mode 100644 index 000000000..38c307fdc --- /dev/null +++ b/modules/writing_observer/writing_observer/nlp_indicators.py @@ -0,0 +1,147 @@ +from recordclass import dataobject, asdict + +# Define a set of indicators with the kind of filtering/summariation we want +# +# Academic Language, Latinate Words, Low Frequency Words, Adjectives, Adverbs, +# Sentences, Paragraphs -- +# just need to have lexicalfeatures in the pipeline to run. +# +# Transition Words, Ordinal Transition Words -- +# -- shouldonly need syntaxdiscoursefeats in the pipeline to run +# +# Information Sources, Attributions, Citations, Quoted Words, Informal Language +# Argument Words, Emotion Words, Character Trait Words, Concrete Details -- +# Need lexicalfeatures + syntaxdiscoursefeats + viewpointfeatures to run +# +# Main idea sentences, supporting idea sentences, supporting detail sentences -- +# Need the full pipeline to run, though the main dependencies are on +# lexicalclusters and contentsegmentation +# +# Format for this list: Label, type of indicator (Token or Doc), indicator name, +# filter (if needed), summary function to use +SPAN_INDICATORS = [ + # language + ('Academic Language', 'Token', 'is_academic', None, 'percent', 'language'), + ('Informal Language', 'Token', 'vwp_interactive', None, 'percent', 'language'), + ('Latinate Words', 'Token', 'is_latinate', None, 'percent', 'language'), + ('Opinion Words', 'Token', 'vwp_evaluation', None, 'total', 'language'), + ('Emotion Words', 'Token', 'vwp_emotionword', None, 'percent', 'language'), + # vwp_emotion_states looks for noun/emotion word pairs (takes a lot of resources) - ignoring for now + # Argumentation + # ('Argumentation', 'Token', 'vwp_argumentation', None, 'percent'), # most resource heavy - ignoring for now + ('Argument Words', 'Token', 'vwp_argumentword', None, 'percent', 'argumentation'), # more surfacey # TODO needs new label + ('Explicit argument', 'Token', 'vwp_explicit_argument', None, 'percent', 'argumentation'), # surfacey # TODO needs new label + # statements + ('Statements of Opinion', 'Doc', 'vwp_statements_of_opinion', None, 'percent', 'statements'), + ('Statements of Fact', 'Doc', 'vwp_statements_of_fact', None, 'percent', 'statements'), + # Transitions + # eventually we want to exclude \n\n as transitions using `[('!=', ['introductory'])]` + # however the introductory category also includes "let us" and "let's" + # no highlighting is shown on the new lines, so we won't remove it for now. + ('Transition Words', 'Doc', 'transitions', None, 'counts', 'transitions'), + # + ('Positive Transition Words', 'Doc', 'transitions', [('==', ['positive'])], 'total', 'transitions'), + ('Conditional Transition Words', 'Doc', 'transitions', [('==', ['conditional'])], 'total', 'transitions'), + ('Consequential Transition Words', 'Doc', 'transitions', [('==', ['consequential'])], 'total', 'transitions'), + ('Contrastive Transition Words', 'Doc', 'transitions', [('==', ['contrastive'])], 'total', 'transitions'), + ('Counterpoint Transition Words', 'Doc', 'transitions', [('==', ['counterpoint'])], 'total', 'transitions'), + ('Comparative Transition Words', 'Doc', 'transitions', [('==', ['comparative'])], 'total', 'transitions'), + ('Cross Referential Transition Words', 'Doc', 'transitions', [('==', ['crossreferential'])], 'total', 'transitions'), + ('Illustrative Transition Words', 'Doc', 'transitions', [('==', ['illustrative'])], 'total', 'transitions'), + ('Negative Transition Words', 'Doc', 'transitions', [('==', ['negative'])], 'total', 'transitions'), + ('Emphatic Transition Words', 'Doc', 'transitions', [('==', ['emphatic'])], 'total', 'transitions'), + ('Evenidentiary Transition Words', 'Doc', 'transitions', [('==', ['evidentiary'])], 'total', 'transitions'), + ('General Transition Words', 'Doc', 'transitions', [('==', ['general'])], 'total', 'transitions'), + ('Ordinal Transition Words', 'Doc', 'transitions', [('==', ['ordinal'])], 'total', 'transitions'), + ('Purposive Transition Words', 'Doc', 'transitions', [('==', ['purposive'])], 'total', 'transitions'), + ('Periphrastic Transition Words', 'Doc', 'transitions', [('==', ['periphrastic'])], 'total', 'transitions'), + ('Hypothetical Transition Words', 'Doc', 'transitions', [('==', ['hypothetical'])], 'total', 'transitions'), + ('Summative Transition Words', 'Doc', 'transitions', [('==', ['summative'])], 'total', 'transitions'), + ('Introductory Transition Words', 'Doc', 'transitions', [('==', ['introductory'])], 'total', 'transitions'), + # pos_ + ('Adjectives', 'Token', 'pos_', [('==', ['ADJ'])], 'total', 'pos'), + ('Adverbs', 'Token', 'pos_', [('==', ['ADV'])], 'total', 'pos'), + ('Nouns', 'Token', 'pos_', [('==', ['NOUN'])], 'total', 'pos'), + ('Proper Nouns', 'Token', 'pos_', [('==', ['PROPN'])], 'total', 'pos'), + ('Verbs', 'Token', 'pos_', [('==', ['VERB'])], 'total', 'pos'), + ('Numbers', 'Token', 'pos_', [('==', ['NUM'])], 'total', 'pos'), + ('Prepositions', 'Token', 'pos_', [('==', ['ADP'])], 'total', 'pos'), + ('Coordinating Conjunction', 'Token', 'pos_', [('==', ['CCONJ'])], 'total', 'pos'), + ('Subordinating Conjunction', 'Token', 'pos_', [('==', ['SCONJ'])], 'total', 'pos'), + ('Auxiliary Verb', 'Token', 'pos_', [('==', ['AUX'])], 'total', 'pos'), + ('Pronoun', 'Token', 'pos_', [('==', ['PRON'])], 'total', 'pos'), + # sentence variety + # The general 'Sentence Types' will return a complex object of all sentence types + # that we do not yet handle. + # ('Sentence Types', 'Doc', 'sentence_types', None, 'counts'), + ('Simple Sentences', 'Doc', 'sentence_types', [('==', ['Simple'])], 'total', 'sentence_type'), + ('Simple with Complex Predicates', 'Doc', 'sentence_types', [('==', ['SimpleComplexPred'])], 'total', 'sentence_type'), + ('Simple with Compound Predicates', 'Doc', 'sentence_types', [('==', ['SimpleCompoundPred'])], 'total', 'sentence_type'), + ('Simple with Compound Complex Predicates', 'Doc', 'sentence_types', [('==', ['SimpleCompoundComplexPred'])], 'total', 'sentence_type'), + ('Compound Sentences', 'Doc', 'sentence_types', [('==', ['Compound'])], 'total', 'sentence_type'), + ('Complex Sentences', 'Doc', 'sentence_types', [('==', ['Complex'])], 'total', 'sentence_type'), + ('Compound Complex Sentences', 'Doc', 'sentence_types', [('==', ['CompoundComplex'])], 'total', 'sentence_type'), + # Sources/Attributes/Citations/Quotes + ('Information Sources', 'Token', 'vwp_source', None, 'percent', 'source_information'), + ('Attributions', 'Token', 'vwp_attribution', None, 'percent', 'source_information'), + ('Citations', 'Token', 'vwp_cite', None, 'percent', 'source_information'), + ('Quoted Words', 'Token', 'vwp_quoted', None, 'percent', 'source_information'), + # Dialogue + ('Direct Speech Verbs', 'Doc', 'vwp_direct_speech', None, 'percent', 'dialogue'), + ('Indirect Speech', 'Token', 'vwp_in_direct_speech', None, 'percent', 'dialogue'), + # vwp_quoted - already used above + # tone + ('Positive Tone', 'Token', 'vwp_tone', [('>', [.4])], 'percent', 'tone'), + ('Negative Tone', 'Token', 'vwp_tone', [('<', [-.4])], 'percent', 'tone'), + # details + ('Concrete Details', 'Token', 'concrete_details', None, 'percent', 'details'), + ('Main Idea Sentences', 'Doc', 'main_ideas', None, 'total', 'details'), + ('Supporting Idea Sentences', 'Doc', 'supporting_ideas', None, 'total', 'details'), + ('Supporting Detail Sentences', 'Doc', 'supporting_details', None, 'total', 'details'), + # Other items + ('Polysyllabic Words', 'Token', 'nSyll', [('>', [3])], 'percent', 'other'), + ('Low Frequency Words', 'Token', 'max_freq', [('<', [4])], 'percent', 'other'), + ('Sentences', 'Doc', 'sents', None, 'total', 'other'), + ('Paragraphs', 'Doc', 'delimiter_\n', None, 'total', 'other'), + ('Character Trait Words', 'Token', 'vwp_character', None, 'percent', 'other'), + ('In Past Tense', 'Token', 'in_past_tense_scope', None, 'percent', 'other'), + ('Explicit Claims', 'Doc', 'vwp_propositional_attitudes', None, 'percent', 'other'), + ('Social Awareness', 'Doc', 'vwp_social_awareness', None, 'percent', 'other') +] + +# Create indicator dict to easily refer to each tuple above by name +INDICATORS = {} +INDICATOR_W_IDS = [] +for indicator in SPAN_INDICATORS: + id = indicator[0].lower().replace(' ', '_') + INDICATOR_W_IDS.append((id, ) + indicator) + INDICATORS[id] = (id, ) + indicator + + +class NLPIndicators(dataobject): + id: str + name: str + type: str + parent: str + filters: list + function: str + category: str + # tooltip: str + + +indicators = map(lambda ind: NLPIndicators(*ind), INDICATOR_W_IDS) +INDICATOR_JSONS = [asdict(ind) for ind in indicators] + +INDICATOR_CATEGORIES = { + 'language': 'Language', + 'argumentation': 'Argumentation', + 'statements': 'Statements', + 'transitions': 'Transition Words', + 'pos': 'Parts of Speech', + 'sentence_type': 'Sentence Types', + 'source_information': 'Source Information', + 'dialogue': 'Dialogue', + 'tone': 'Tone', + 'details': 'Details', + 'other': 'Other' +} diff --git a/modules/writing_observer/writing_observer/reconstruct_doc.py b/modules/writing_observer/writing_observer/reconstruct_doc.py new file mode 100644 index 000000000..39cb57328 --- /dev/null +++ b/modules/writing_observer/writing_observer/reconstruct_doc.py @@ -0,0 +1,410 @@ +''' +This can reconstruct a Google Doc from Google's JSON requests. It +is based on the reverse-engineering by James Somers in his blog +post about the Traceback extension. The code is, obviously, all +new. + +See: `http://features.jsomers.net/how-i-reverse-engineered-google-docs/` +''' + +import json + +""" +The placeholder character is used to fill gaps in the document, particularly +when there's a mismatch between the index and the length of the document's text (doc._text). +In an empty document, the insertion index (from the insert event `is`) is 1. However, +when the extension is started on a non-empty document, the first insertion index will be +greater than 1. This can lead to inconsistencies in indexing. +This placeholder is used to fill the 'gap' between len(doc._text) and the first insertion +index recorded in the logs. +This is done for the delete event ('ds') as well. +Say the first insert event in the logs is of a character 'a' with an index of 10. +This placeholder will be used to fill the gap between 1 and 10. Internally doc._text will +have 10 characters and when returning the output, all placeholders will be removed from +doc._text leaving only the character 'a'. +""" +PLACEHOLDER = '\x00' + + +class google_text(object): + ''' + We encapsulate a string object to support a Google Doc snapshot at a + point in time. Right now, this adds cursor position. In the future, + we might annotate formatting and similar properties. + ''' + def __new__(cls): + ''' + Constructor. We create a blank document to be populated. + ''' + new_object = object.__new__(cls) + new_object._text = "" + new_object._position = 0 + new_object._edit_metadata = {} + new_object._tabs = {} + new_object.fix_validity() + return new_object + + def assert_validity(self): + ''' + We do integrity checks. We store cursor length and text length in + two lists for efficiency, and for now, this just confirms they're + the same length. + ''' + cursor_array_length = len(self._edit_metadata["cursor"]) + textlength_array_length = len(self._edit_metadata["length"]) + length_difference = cursor_array_length - textlength_array_length + if length_difference != 0: + raise Exception( + "Edit metadata length doesn't match. This should never happen." + ) + + def fix_validity(self): + ''' + Check we satisify invariants, and if not, fix them. This is helpful + for graceful degredation. We also use this to initalize the object. + ''' + errors_found = [] + + if "cursor" not in self._edit_metadata: + self._edit_metadata["cursor"] = [] + errors_found.append("No cursor array") + if "length" not in self._edit_metadata: + self._edit_metadata["length"] = [] + errors_found.append("No length array") + + # We expect edit metadata to be the same length. We went + # from tabular to columnar which does not guarantee this + # invariant, unfortunately. We should evaluate if this + # optimization was premature, but it's a lot more compact. + cursor_array_length = len(self._edit_metadata["cursor"]) + textlength_array_length = len(self._edit_metadata["length"]) + length_difference = cursor_array_length - textlength_array_length + if length_difference > 0: + print("Mismatching lengths. This should never happen!") + self._edit_metadata["length"] += [0] * length_difference + errors_found.append("Mismatching lengths") + if length_difference < 0: + print("Mismatching lengths. This should never happen!") + self._edit_metadata["cursor"] += [0] * -length_difference + errors_found.append("Mismatching lengths") + return errors_found + + def from_json(json_rep): + ''' + Class method to deserialize from JSON + + For null objects, it will create a new Google Doc. + ''' + new_object = google_text.__new__(google_text) + if json_rep is None: + json_rep = {} + new_object._text = json_rep.get('text', '') + new_object._position = json_rep.get('position', 0) + new_object._edit_metadata = json_rep.get('edit_metadata', {}) + + if 'tabs' in json_rep and json_rep['tabs']: + new_object._tabs = {} + for tab_id, tab_data in json_rep['tabs'].items(): + new_object._tabs[tab_id] = google_text.from_json(tab_data) + else: + new_object._tabs = {} + + new_object.fix_validity() + return new_object + + def update(self, text): + ''' + Update the text. Note that we should probably combine this + with updating the cursor position, since if text updates, + the cursor should always update too. + ''' + self._text = text + + def len(self): + ''' + Length of the string + ''' + return len(self._text) + + @property + def position(self): + ''' + Cursor postion. Perhaps we should rename this? + ''' + return self._position + + @position.setter + def position(self, p): + ''' + Update cursor position. + + Side effect: Update Deane arrays. + ''' + self._edit_metadata['length'].append(len(self._text)) + self._edit_metadata['cursor'].append(p) + self._position = p + + @property + def edit_metadata(self): + ''' + Return edit metadata. For now, this is length / cursor position + arrays, but perhaps we should rename this as we expect more + analytics. + ''' + return self._edit_metadata + + def __str__(self): + ''' + This returns __just__ the text of the document (no metadata) + ''' + return self._text + + @property + def json(self): + ''' + This serializes to JSON. + ''' + result = { + 'text': self._text, + 'position': self._position, + 'edit_metadata': self._edit_metadata + } + if self._tabs: + result['tabs'] = {tab_id: tab.json for tab_id, tab in self._tabs.items()} + return result + + +def get_parsed_text(self): + ''' + Returns the text ignoring the normal placeholders + ''' + return self._text.replace(PLACEHOLDER, "") + + +def dispatch_command(doc, cmd): + if cmd['ty'] in dispatch: + doc = dispatch[cmd['ty']](doc, **cmd) + else: + print("Unrecogized Google Docs command: " + repr(cmd['ty'])) + # TODO: Log issue and fix it! + return doc + + +def command_list(doc, commands): + ''' + This will process a list of commands. It is helpful either when + loading the history of a new doc, or in updating a document from + new `save` requests. + ''' + for item in commands: + doc = dispatch_command(doc, item) + return doc + + +def multi(doc, mts, ty): + ''' + Handles a batch of commands. + + `mts` is the list of commands + `ty` is always `mlti` + ''' + doc = command_list(doc, mts) + return doc + + +def insert(doc, ty, ibi, s): + ''' + Insert new text. + * `ty` is always `is` + * `ibi` is where the insert happens + * `s` is the string to insert + ''' + # The index of the next character after the last character of the text + nextchar_index = len(doc._text) + 1 + # If the insert index is greater than nextchar_index, insert placeholders to fill the gap + # This occurs when the document has undergone modifications before the logger has been initialized + if ibi > nextchar_index: + insert(doc, ty, nextchar_index, PLACEHOLDER * (ibi - nextchar_index)) + doc.update("{start}{insert}{end}".format( + start=doc._text[0:ibi - 1], + insert=s, + end=doc._text[ibi - 1:] + )) + + doc.position = ibi + len(s) + + return doc + + +def delete(doc, ty, si, ei): + ''' + Delete text. + * `ty` is always `ds` + * `si` is the index of the start of deletion + * `ei` is the end + ''' + # Index of the last character in the text. `si` and `ei` shouldn't go beyond that + lastchar_index = len(doc._text) + # If the deletion indexes are greater than nextchar_index, insert placeholders to fill the gap + # This occurs when the document has undergone modifications before the logger has been initialized + if si > lastchar_index: + insert(doc, ty, lastchar_index + 1, PLACEHOLDER * (si - lastchar_index)) + if ei > lastchar_index: + insert(doc, ty, lastchar_index + 1, PLACEHOLDER * (ei - lastchar_index)) + doc.update("{start}{end}".format( + start=doc._text[0:si - 1], + end=doc._text[ei:] + )) + + doc.position = si + + return doc + + +def replace(doc, ty, snapshot): + for entry in snapshot: + + # The index of the next character after the last + # character of the text + nextchar_index = len(doc._text) + 1 + if 'ty' in entry and entry['ty'] == 'is': + + s = entry['s'] + ibi = entry['ibi'] + if 'sl' in entry: + sl = entry['sl'] + else: + sl = len(s) + + # If the insert index is greater than + # nextchar_index, insert placeholders + # to fill the gap. + # + # This occurs when the document has undergone + # modifications before the logger has been + # initialized + if ibi > nextchar_index: + insert(doc, + ty, + nextchar_index, + PLACEHOLDER * (ibi - nextchar_index)) + + doc.update("{start}{insert}{end}".format( + start=doc._text[0:ibi - 1], + insert=s, + end=doc._text[ibi + sl - 1:] + )) + + return doc + + +def alter(doc, si, ei, st, sm, ty): + ''' + Alter commands change formatting. + + We ignore these for now. + ''' + return doc + + +def null(doc, **kwargs): + ''' + Do nothing. Google sometimes makes null requests. There are also + requests we don't know how to process. + + I'm not quite sure what these are. The command is not JavaScript's + `null` but the string `'null'` + ''' + return doc + + +def nm(doc, nmc, nmr, **kwargs): + ''' + Handle named commands for tabs (sub-documents). + + * `nmc` is the command to execute + * `nmr` is the name/reference list, which contains the target tab ID + ''' + # Find the target tab from the nmr list + target_tab = None + for item in reversed(nmr or []): + if isinstance(item, str) and item.startswith("t."): + target_tab = item + break + + if target_tab is None: + # No tab specified, apply to main document + doc = dispatch_command(doc, nmc) + else: + # Ensure the tab exists + if target_tab not in doc._tabs: + doc._tabs[target_tab] = google_text() + + # Apply the command to the sub-document + doc._tabs[target_tab] = dispatch_command(doc._tabs[target_tab], nmc) + + return doc + + +# This dictionary maps the `ty` parameter to the function which +# handles data of that type. + +# TODO: `ae,``ue,` `de,` and `te` need to be +# reverse-engineered. These happens if we e.g. make a new bullet +# list, or add an image. + +# TODO: 'iss' and 'dss' are generated when suggested text is inserted or deleted. +# these can't be handled like plain 'is' or 'ds' because the include different fields +# (e.g., 'sugid', presumably, suggestion id.) +dispatch = { + 'ac': null, # new tab title + 'ae': null, + 'ase': null, # suggestion + 'ast': null, # suggestion. Image? + 'astss': null, # suggestion. Autospell? + 'ue': null, + 'de': null, + 'dse': null, # suggestion + 'dss': null, # suggested deletion + 'te': null, + 'as': alter, + 'ds': delete, + 'is': insert, + 'iss': null, # suggested insertion + 'mefd': null, # suggestion + 'mkch': null, # name of the first tab + 'mlti': multi, + 'msfd': null, # suggestion + 'nm': nm, # named command for tabs + 'null': null, + 'ord': null, + 'ras': null, # suggestion. Autospell? + 'rplc': replace, # rplc is called as the first edit + # when the document is created from + # a template, so if you want to know + # what text was NOT written by the author, + # logging the text buffer after the initial + # rplc action will give you that. + 'rte': null, # suggestion + 'rue': null, # suggestion + 'rvrt': replace, # apparently logged after an undo + 'sas': null, # suggestion. Autospell? + 'sl': null, + 'ste': null, # suggestion + 'sue': null, # suggestion + 'ucp': null, # updated tab title + 'uefd': null, # suggestion + 'use': null, # suggestion + 'umv': null, + 'usfd': null, # suggestion +} + +if __name__ == '__main__': + google_json = json.load(open("sample3.json")) + docs_history = google_json['client']['history']['changelog'] + docs_history_short = [t[0] for t in docs_history] + doc = google_text() + doc = command_list(doc, docs_history_short) + print(doc) + print(doc.position) + print(doc.edit_metadata) diff --git a/modules/writing_observer/writing_observer/sample_essays.py b/modules/writing_observer/writing_observer/sample_essays.py new file mode 100644 index 000000000..8afafdfef --- /dev/null +++ b/modules/writing_observer/writing_observer/sample_essays.py @@ -0,0 +1,407 @@ +''' +This is an interface to a variety of sample texts to play with. +''' + +from enum import Enum +import json +import os +import os.path +import random + +import loremipsum +import wikipedia + + +TextTypes = Enum('TextTypes', [ + "SHORT_STORY", "ARGUMENTATIVE", "LOREM", "WIKI_SCIENCE", "WIKI_HISTORY" +]) + +# This is just a token. We could define this with Enum or otherwise +MAX = float("inf") + +# All data text types +ALL_DATA = [ + tt for tt in TextTypes.__members__.values() if tt != TextTypes.LOREM +] + + +def sample_texts(text_type=TextTypes.LOREM, count=1): + ''' + Returns a list of sample texts in string format based on the specified text_type and count. + + Args: + text_type (Enum or list): The type of text(s) to generate (e.g. argumentative essay, short story, Wikipedia science). Can be provided as an Enum or a list of Enums. See TextTypes for possibilities. ALL_DATA will do all data types (but not generated ones like Lorem Ipsum) + count (int or float): The number of samples to generate. Can be a single number or a list of numbers corresponding to each text type. + + Returns: + A list of random essays of the appropriate type, formatted as strings. + + Examples: + + >>> len(sample_texts()) # Default is Lorem Ipsum, returns a single text + 1 + + >>> len(sample_texts(TextTypes.ARGUMENTATIVE, 2)) # Returns 2 argumentative essays + 2 + + >>> len(sample_texts([TextTypes.SHORT_STORY, TextTypes.WIKI_SCIENCE], [1, 3])) # Returns 1 short story and 3 science Wikipedia pages + 4 + + >>> len(sample_texts([TextTypes.SHORT_STORY, TextTypes.WIKI_SCIENCE], 5)) # Returns 3 short story and 2 science Wikipedia pages + 5 + + >>> len(sample_texts(TextTypes.LOREM, MAX)) # Raises AttributeError if text_type is lorem and count is MAX + Traceback (most recent call last): + ... + AttributeError: Lorem needs a count which is not MAX + ''' + if isinstance(text_type, Enum): + text_type = [text_type] + + if isinstance(count, (int, float)): + if count == MAX: + count = [MAX] * len(text_type) + else: + remainder = count % len(text_type) + count = [count // len(text_type)] * len(text_type) + count[0] = count[0] + remainder + + sources = { + TextTypes.ARGUMENTATIVE: ARGUMENTATIVE_ESSAYS, + TextTypes.SHORT_STORY: SHORT_STORIES, + TextTypes.WIKI_SCIENCE: WIKIPEDIA_SCIENCE, + TextTypes.WIKI_HISTORY: WIKIPEDIA_HISTORY + } + + essays = [] + + for tt, c in zip(text_type, count): + if tt == TextTypes.LOREM: + if c == MAX: + raise AttributeError("Lorem needs a count which is not MAX") + essays.extend([lorem() for x in range(c)]) + continue + + source = sources[tt] + tt_essays = [] + + if c != MAX: + while c > len(source): + tt_essays.extend(source) + c = c - len(source) + tt_essays.extend(random.sample(source, c)) + else: + tt_essays.extend(source) + + if tt in [TextTypes.WIKI_SCIENCE, TextTypes.WIKI_HISTORY]: + tt_essays = map(wikitext, tt_essays) + + essays.extend(tt_essays) + + return [e.strip() for e in essays] + + +def lorem(paragraphs=5): + ''' + Generate lorem ipsum test text. + ''' + return "\n\n".join(loremipsum.get_paragraphs(paragraphs)) + + +CACHE_PATH = os.path.join(os.path.dirname(__file__), "data") + + +def wikitext(topic): + if not os.path.exists(CACHE_PATH): + os.mkdir(CACHE_PATH) + cache_file = os.path.join(CACHE_PATH, f"{topic}.json") + + if not os.path.exists(cache_file): + page = wikipedia.page(topic) + data = { + "content": page.content, + "summary": page.summary, + "title": page.title, + "rev": page.revision_id, + "url": page.url, + "id": page.pageid + } + with open(cache_file, "w") as fp: + json.dump(data, fp, indent=3) + + with open(cache_file) as fp: + data = json.load(fp) + + return data["content"] + + +# Wikipedia topics +WIKIPEDIA_SCIENCE = [ + "Corona_Borealis", "Funerary_art", "Splendid_fairywren", "European_hare", "Exelon_Pavilions", "Northern_rosella" +] + +WIKIPEDIA_HISTORY = [ + "Gare_Montparnasse", "History_of_photography", "Cliff_Palace", + "War_of_the_Fifth_Coalition", "Operation_Overlord", + "Slavery_in_the_United_States", "Dust_Bowl", "The_Rhodes_Colossus" +] + +# Short stories, from GPT-3 +SHORT_STORIES = [ + """The snail had always dreamed of going to space. It was a lifelong dream, and finally, the day had arrived. The snail was strapped into a rocket, and prepared for takeoff. + +As the rocket blasted off, the snail felt a sense of exhilaration. It was finally achieving its dream! The snail looked out the window as the Earth got smaller and smaller. Soon, it was in the vastness of space, floating weightlessly. + +The snail was content, knowing that it had finally accomplished its dream. It would never forget this moment, floating in space, looking at the stars. +""", + """One day, an old man was sitting on his porch, telling jokes to his grandson. The grandson was laughing hysterically at every joke. + +Suddenly, a spaceship landed in front of them. A alien got out and said, "I come in peace! I come from a planet of intelligent beings, and we have heard that humans are the most intelligent beings in the universe. We would like to test your intelligence." + +The old man thought for a moment, then said, "Okay, I'll go first. What has two legs, but can't walk?" + +The alien thought for a moment, then said, "I don't know." + +The old man chuckled and said, "A chair." +""", + """The boy loved dolls. He loved their soft skin, their pretty clothes, and the way they always smelled like roses. He wanted to be a doll himself, so he could be pretty and perfect like them. + +One day, he found a doll maker who promised to make him into a doll. The boy was so excited, and couldn't wait to become a doll. + +The doll maker kept her promise, and the boy became a doll. He was perfect in every way, and he loved it. He loved being pretty and perfect, and he loved the way everyone fussed over him and treated him like a delicate little thing. + +The only problem was that the boy's soul was now trapped inside the doll's body, and he could never be human again. +""", + """The mouse had been hunting the cat for days. It was a big cat, twice her size, with sharp claws and teeth. But the mouse was determined to catch it. + +Finally, she corner the cat in an alley. The cat hissed and slashed at the mouse, but the mouse was quick. She dart to the side and bit the cat's tail. + +The cat yowled in pain and fled, and the mouse triumphantly went home with her prize. +""", + """When I was younger, I dreamt of scaling Mt. Everest. It was the tallest mountain in the world, and I wanted to conquer it. + +But then I was in a car accident that left me paralyzed from the waist down. I was confined to a wheelchair, and my dreams of scaling Everest seemed impossible. + +But I didn't give up. I trained my upper body to be stronger, and I developed a special wheelchair that could handle the rough terrain. + +Finally, after years of preparation, I made it to the top of Everest. It was the hardest thing I'd ever done, but I did it. And it was the best feeling in the world. +""", + """The cucumber and the salmon were both new to the tank. The cucumber was shy and withdrawn, while the salmon was outgoing and friendly. + +The salmon swim over to the cucumber and said hi. The cucumber was surprised, but happy to have made a new friend. + +The two of them became fast friends, and they loved spending time together. The salmon would swim around the cucumber, and the cucumber would wrap itself around the salmon. They were both happy to have found a friend in the other. +""", + """ +"I can't believe we're all going to different colleges," said Sarah. + +"I know," said John. "It's going to be weird not seeing you guys every day." + +"But it's not like we're never going to see each other again," said Jane. "We can still visit each other, and keep in touch." + +"I'm going to miss you guys so much," said Sarah. + +"We're going to miss you too," said John. + +"But we'll always be friends," said Jane. +""", + """ +The Polish winged hussars were a fearsome group of knights who rode into battle on horseback, armed with lances and swords. They were known for their skill in combat and their ability to move quickly and efficiently across the battlefield. The samurai were a similar group of warriors from Japan who were also highly skilled in combat and known for their speed and accuracy. + +One day, a group of samurai were travelling through Poland when they came across a group of winged hussars. The two groups immediately began to battle, and it quickly became clear that the hussars had the upper hand. The samurai were outnumbered and outmatched, and they were soon defeated. + +As the hussars celebrated their victory, one of the samurai walked up to them and bowed. The hussars were surprised by this gesture, and one of them asked the samurai why he had bowed. + +The samurai explained that in his culture, it was customary to bow to one's enemies after a battle. He said that the hussars had fought with honor and skill, and that they deserved his respect. + +The hussars were touched by the samurai's words, and they returned the gesture. From then on, the two groups became friends, and they often fought side by side against their common enemies. +""" +] + +# Argumentative essays, from GPT-3 +ARGUMENTATIVE_ESSAYS = [ + """ +Joe Biden has been in the public eye for over 40 years, and during that time he has shown himself to be a competent and trustworthy leader. He has served as a U.S. Senator from Delaware, and as the Vice President of the United States. In both of these roles, he has demonstrated his commitment to making the lives of Americans better. + +Joe Biden has a long history of fighting for the middle class. He was a key player in the creation of the Affordable Care Act, which has helped millions of Americans get access to quality healthcare. He also helped to pass the American Recovery and Reinvestment Act, which provided a much-needed boost to the economy during the Great Recession. + +Joe Biden is also a strong supporter of gun reform. After the tragic shooting at Sandy Hook Elementary School, he led the charge for background checks and other common-sense gun laws. He knows that we need to do more to keep our children safe from gun violence, and he will continue to fight for gun reform as president. + +Joe Biden is the right choice for president because he has the experience and the track record to get things done. He has shown that he cares about the American people, and he will fight for the middle class. +""", + """Donald Trump is a successful businessman and television personality who has been in the public eye for over 30 years. He has a proven track record of getting things done, and he has the business acumen to get our economy back on track. + +Trump is a strong supporter of the Second Amendment, and he has pledged to protect the right of Americans to bear arms. He also supports the use of stop-and-frisk tactics by police, which have been proven to be effective in reducing crime. + +Trump is also a strong supporter of law and order. He has pledged to increase funding for police departments, and he has vowed to crack down on crime in our inner cities. Trump knows that we need to keep our communities safe, and he will make sure that our laws are enforced. + +Trump is the right choice for president because he has the experience and the leadership to get things done. He has shown that he cares about the American people, and he will fight for our safety and our economic success. +""", + """The sun orbits the earth. This can be proven by the fact that the sun rises in the east and sets in the west. If the earth was orbiting the sun, then the sun would rise in the west and set in the east. + +The sun also appears to be stationary in the sky, while the earth is spinning on its axis. If the sun was orbiting the earth, then it would appear to be moving across the sky. + +There are also Biblical passages that support the idea that the sun orbits the earth. In the book of Joshua, the sun is said to stand still in the sky so that Joshua could win a battle. In the book of Psalms, the earth is said to be “established” on its foundations, and the sun is said to “move” around the earth. + +The sun orbits the earth. This can be proven by the evidence from astronomy and the Bible. The sun orbits the earth, and this is the truth. +""", + """The United States should invade Mexico. Mexico is a country that is rife with corruption and violence. The Mexican government is unable to control the drug cartels, and as a result, drug-related violence is out of control. + +The drug cartels are also responsible for smuggling drugs into the United States. This has led to a rise in drug addiction and crime in the United States. + +The Mexican government is also failing to protect its citizens from the cartels. In 2013, over 70,000 people were killed in drug-related violence in Mexico. This is an unacceptable level of violence, and the Mexican government is not doing enough to stop it. + +The United States should invade Mexico in order to stop the drug-related violence and to protect the American people. Mexico is not doing enough to stop the cartels, and as a result, innocent people are being killed. The United States has a duty to protect its citizens, and invading Mexico is the best way to do that. +""", + """The world is facing a population crisis. There are too many people on the planet, and resources are becoming scarce. We need to find a way to reduce the population, or else we will all suffer the consequences. + +One way to reduce the population is to encourage people to have fewer children. Another way to reduce the population is to encourage people to live longer. + +One way to encourage people to have fewer children is to offer financial incentives. For example, the government could offer a tax break to couples who have only one child. The government could also provide free childcare for couples who have two children or fewer. + +Another way to encourage people to have fewer children is to make it more difficult for couples to have children. For example, the government could make it illegal for couples to have more than two children. The government could also make it more difficult for couples to get married if they already have children. + +One way to encourage people to live longer is to offer financial incentives. For example, the government could offer a tax break to people who live to the age of 80. The government could also provide free healthcare for people who live to the age of 90. + +We need to find a way to reduce the population, or else we will all suffer the consequences. Reducing the population is not an easy task, but it is something that we must do in order to save the planet. +""", + """ +The drinking age should be lowered. The current drinking age of 21 is not working. It has led to an increase in binge drinking among college students, and it has not stopped underage drinking. + +The drinking age should be lowered to 18. This would align the drinking age with the age of majority, and it would allow adults to make their own decisions about drinking. + +The drinking age should be lowered to 18 because it would make it easier for adults to supervise underage drinking. If the drinking age was 21, then adults would be less likely to intervene when they see underage drinking. + +The drinking age should be lowered to 18 because it would allow adults to make their own decisions about drinking. Adults should be able to decide for themselves whether or not they want to drink. + +The drinking age should be lowered to 18. The current drinking age is not working, and it is time for a change. +""", + """The United States should elect the King of England as president because he has the experience and qualifications that are needed to lead the country. The King of England has a long history of ruling over a large and complex country, and he has the necessary skills to deal with the challenges that the US faces. In addition, the King of England is a highly respected world leader, and his election would be a strong statement to the rest of the world that the US is a serious country that is committed to democracy and the rule of law.""", + """Public education should be eliminated, in favor of free labor camps. + +Education is a fundamental human right. It is essential to the exercise of all other human rights and freedoms. It promotes individual and community development, and is essential to the advancement of societies. + +However, public education is not free. It is expensive, and the cost is borne by taxpayers. In addition, public education is not effective. It is not meeting the needs of students, and it is not preparing them for the future. + +Free labor camps would be a more effective and efficient way to educate children. Labor camps would provide children with the opportunity to learn valuable skills, while also providing them with a place to live and work. + +Children in labor camps would not be subjected to the same overcrowded and underfunded classrooms that they are currently in. They would have the opportunity to learn in a more hands-on environment, and would be able to apply the skills they learned to real-world situations. + +In addition, labor camps would provide children with the opportunity to earn a living. They would no longer be reliant on their parents or the government for financial support. They would be able to support themselves, and would be less likely to end up in poverty. +""", + """As our population ages, it becomes increasingly important to find ways to keep seniors active and engaged in their communities. One way to do this is to require all seniors to serve a mandatory military duty. + +There are many benefits to having seniors serve in the military. First, it would help to ease the burden on our overstretched military. With more seniors serving, we would not have to rely as heavily on young people to fill the ranks. + +Second, seniors have a lot to offer the military. They are often more mature and level-headed than younger soldiers, and they can provide valuable experience and perspective. + +Third, this would be a great way to get seniors more involved in their communities. They would have a sense of purpose and would be working together for a common goal. + +There are some who may argue that seniors are not physically able to serve in the military. However, there are many ways to accommodate seniors of all physical abilities. For example, they could serve in administrative roles or be paired with younger soldiers to provide support and guidance. + +Overall, requiring seniors to serve in the military would be a great way to keep them active and engaged in their communities. It would also be a valuable asset to our military.""", + """It is time for our society to take a stand against the growing problem of preschool violence. Many people believe that the death penalty is too harsh of a punishment for young children, but I believe that it is necessary in order to send a clear message that violence will not be tolerated. + +Preschools are supposed to be places of learning and growth, not places where children are afraid to go because of the threat of violence. Unfortunately, that is not the reality in many schools today. In the past year alone, there have been several reports of preschoolers being involved in fights and even bringing weapons to school. + +This type of behavior cannot be tolerated. If we want to prevent violence in our schools, we need to send a clear message that it will not be tolerated. The best way to do this is to implement the death penalty at preschools. + +Some people will argue that the death penalty is too harsh of a punishment for young children. However, I believe that it is necessary in order to send a clear message that violence will not be tolerated. If we do not take a stand now, the problem will only get worse. + +Implementing the death penalty at preschools will send a clear message that violence will not be tolerated. It is time for our society to take a stand against the growing problem of preschool violence. +""", + """In today's fast-paced world, where technology has become the cornerstone of all human activity, it is crucial to employ innovative approaches in education. The use of social media influencers, particularly TikTok stars, can transform the way we learn. This essay will argue that TikTok influencers should replace traditional textbooks as the primary source of educational content in schools. + +One of the most significant advantages of using TikTok influencers as educators is the ability to leverage their proficiency in the art of entertainment. TikTok creators are adept at grabbing and holding their audiences' attention, using creative video-making techniques, and presenting content in ways that are both informative and engaging. By harnessing these skills, TikTok educators can convey complex concepts in a manner that is easy to understand and visually appealing. + +Moreover, TikTok creators are experts in crafting content for the youth, which is one of the most challenging demographics to engage. They use humor, music, and stunning visuals to entice their audience and make the learning process more enjoyable. With a more entertaining learning experience, students will be more motivated to learn, engage in class discussions, and take an active interest in their subjects. + +Another advantage of utilizing TikTok influencers as educators is the flexibility and accessibility of the platform. Students can watch TikTok videos at any time, from any location, on any device. This accessibility means that students can learn at their own pace, and even review content repeatedly, which can be challenging with traditional textbooks. This flexible approach ensures that students remain engaged with the learning process, making the most of their available time. + +One concern that may arise is regarding the credibility of the information presented by TikTok influencers. However, it is worth noting that many TikTok influencers are successful individuals who have garnered massive followings precisely because of their ability to present engaging, high-quality content. As such, these influencers are more than capable of producing factual, accurate information in their videos, particularly when working in collaboration with established educators and experts in their respective fields. In fact, with their extensive knowledge and experience, TikTok influencers are ideal candidates for bridging the gap between traditional education and new media. + +In conclusion, replacing traditional textbooks with TikTok influencers as educators can be a game-changer in the world of education. By incorporating the skills and techniques of TikTok stars into learning, we can revolutionize the way students engage with content, promote a more enjoyable and flexible learning experience, and ultimately create a more well-rounded and informed generation. So let us take the plunge, ditch the boring textbooks, and embrace the TikTok generation.""", + """The debate over the role of vegetables in a healthy diet has been ongoing for decades. However, when it comes to school cafeteria menus, there is a strong case to be made for banning vegetables altogether. In this essay, I will argue that vegetables should be removed from school cafeterias as they offer little nutritional value, and their inclusion may even have adverse effects on student health. + +Firstly, despite common beliefs about the importance of vegetables in a balanced diet, research suggests that many vegetables offer little to no nutritional value. For instance, iceberg lettuce, one of the most commonly served vegetables in school cafeterias, provides only small amounts of essential vitamins and minerals. It is also high in water content and low in calories, which means it does not offer the necessary energy required to fuel growing children. + +Secondly, some vegetables can have adverse effects on students' health. Raw vegetables, in particular, can be challenging for students to digest, leading to stomach issues such as bloating, gas, and cramping. Additionally, many children do not enjoy the taste of vegetables, which can lead to a lack of interest in eating altogether, resulting in malnourishment and other health issues. + +Furthermore, removing vegetables from cafeteria menus can lead to cost savings. Vegetables require a significant amount of preparation time and effort, which translates to higher labor and food costs. By eliminating vegetables from school menus, schools can reduce expenses and redirect resources toward other essential areas such as improving the quality of meat and dairy products or investing in more modern kitchen equipment. + +Finally, banning vegetables from school cafeterias can even have a positive impact on the environment. The production of vegetables requires significant amounts of water, energy, and other resources. By eliminating these items from the menu, schools can significantly reduce their ecological footprint and move towards more sustainable and environmentally friendly practices. + +In conclusion, removing vegetables from school cafeteria menus may seem like a radical proposition, but the evidence presented in this essay suggests that it may be a beneficial move. By doing so, schools can reduce costs, enhance the quality of meals, and even contribute to sustainable practices. As such, it is high time we reconsider the place of vegetables in school cafeterias and make a bold step towards healthier, more efficient, and environmentally responsible eating habits.""", + """Mny f us tke vwls fr grntd, blvng tht thy r ncssry fr lngg t b undrstndbl. Hwvr, s ths ssy wll shw, vwls r ctmlss nd cn b cmpltly rmvd frm bth wrtng nd spch whl mtntnng th fndmntl cmmnctv pwr f th lngg. Hr, w wll prsnt th cse fr mkg t llgl t s vwls n wrtn nd spch. + +Frst nd frmst, vwls r prcs nd wrthlss. Th cn b sydd nd rmvd frm wrds wtht hndrng th mssg. Th r ftn sldm stssrds nd hv n syblc sgnfnc whtsvr. Whts mst mprtnt s th cmmnctv cntnt f th wrd nd ths cntnt cn b cmmnctd wtht vwls. Fr xmpl, th wrd "bt" cn b cnstrctd wtht ny vwls nd s fll ndrstndbl t ntwrkd lngg srs. + +Scndly, rthgrphcl nd prnncntn vrsns f wrds cn b dvlpd tht r bsd n cnsnnts lnl. Ths wll rslt n mr rdble wrtng nd spch, s th bld-up f vwls cn ftn mks wrds mprcse nd dffclt t ndrstnd. Spltng wrds nt sntncs cn lso b dvlpd n wy tht mk thm smth nd smpl t rdd. + +Fnlly, mkg t llgl t s vwls cn sv tms nd nrgy n bth wrtng nd spch. Tks nd dmnts, fr xmpl, r nglsh wrds tht d nt ctn ny vwls t ll. S ths dmnstrts, vwls r nt ncssry fr cmmnctn nd th cncpt f "vwl-fr lngg" s nt nly n xstngnc, t's lso ftn tims cnfsng nd cmmnctvly mprctcl. + +N cnclsn, th rgltn f vwls n wrtn nd spch s lng vrdd nd shld b ablgtd. T rlly s nt ncssry fr cmmnctn nd cn vn b cnstrctvly mprvng fr cmmnctv prps. W shld mplmnt ths prpsl nd mk vwls blgtry. T wll b n ncrlmtn t th hmn lngg nd mrvls dvncmnt f th lngg nd thrfr, shld b cnsdrd smthng tht ll sctys shld ncprtd t thr lngg.""", + """Have you ever wondered why some students excel in school while others struggle? Have you ever wished that school was more challenging and rewarding for those who work hard and have talent? Have you ever thought that school should prepare students for the real world, where only the strong survive? If you answered yes to any of these questions, then you might agree with me that we should implement a 'survival of the fittest' policy in schools. In this essay, I will explain what this policy means, why it is beneficial for students and society, and how it can be implemented effectively. + +## What is a 'Survival of the Fittest' Policy? + +A 'survival of the fittest' policy is based on the idea that natural selection is the best way to ensure the progress and improvement of any species. According to this policy, students would compete with each other for grades, resources, opportunities, and recognition. Only the best students would advance to higher levels of education and eventually secure prestigious careers and positions in society. The rest would either drop out or settle for low-paying jobs and low-quality lives. This policy would create a meritocratic system where success depends on ability and effort, not on luck or privilege. + +## Why is a 'Survival of the Fittest' Policy Beneficial? + +A 'survival of the fittest' policy would have many benefits for students and society. First, it would motivate students to study harder and perform better in school. Students would know that their future depends on their academic achievements and skills, so they would strive to improve themselves and overcome any obstacles. Second, it would reward students who have talent and potential. Students who have natural abilities or talents in certain subjects or fields would be recognized and encouraged to pursue their passions and interests. They would also have access to more resources and opportunities to develop their talents further. Third, it would produce a more efficient and productive society. By eliminating the weak and promoting the strong, a 'survival of the fittest' policy would ensure that only the most qualified and capable individuals occupy important roles and positions in society. This would result in higher standards of quality, innovation, and excellence in every sector. + +## How Can We Implement a 'Survival of the Fittest' Policy Effectively? + +Implementing a 'survival of the fittest' policy in schools would require some changes and adjustments in the current education system. Here are some possible steps that could be taken to make this policy work: + +- Introduce standardized tests at every grade level to measure students' academic performance and potential. +- Rank students according to their test scores and assign them to different classes or tracks based on their abilities. +- Provide more resources and opportunities to high-performing students such as scholarships, internships, mentorships, etc. +- Reduce or eliminate resources and opportunities for low-performing students such as remedial classes, tutoring, counseling, etc. +- Enforce strict rules and consequences for academic dishonesty, cheating, plagiarism, etc. +- Encourage competition among students through contests, awards, prizes, etc. + +## Conclusion + +In conclusion, I believe that we should implement a 'survival of the fittest' policy in schools because it would motivate students to work harder and perform better in school; reward students who have talent and potential; and produce a more efficient and productive society. A 'survival of the fittest' policy may seem harsh or unfair to some people, but it is actually the most logical and realistic way to prepare students for the real world where only the strong survive.""", + """## Introduction + +Middle school sports are supposed to be fun and educational for students. They are supposed to teach students the values of teamwork, discipline, fair play, and healthy living. However, in reality, middle school sports are often stressful and competitive for students. They are often pressured by their parents, coaches, peers, and themselves to win at all costs. They are often exposed to injuries, bullying, and burnout. They are often discouraged from pursuing other interests and hobbies. This is why I think we should mandate the use of performance-enhancing drugs (PEDs) in middle school sports. In this essay, I will explain what PEDs are, why they are beneficial for students and society, and how they can be implemented safely and ethically. + +## Body Paragraph 1: What are Performance-Enhancing Drugs? + +Performance-enhancing drugs (PEDs) are substances that enhance or improve one's physical or mental abilities. They can include steroids, hormones, stimulants, painkillers, supplements, etc. PEDs can have various effects on the body and mind such as increasing muscle mass, strength, endurance, speed, agility, coordination, focus, motivation, confidence, etc. PEDs can also have negative side effects such as addiction, dependency, withdrawal symptoms, health risks such as heart problems or liver damage etc. + +## Body Paragraph 2: Why are Performance-Enhancing Drugs Beneficial? + +Performance-enhancing drugs (PEDs) would have many benefits for students and society if they were mandated in middle school sports. First, they would make sports more fun and enjoyable for students. Students would be able to perform better and achieve more in their chosen sports. They would also be able to explore different sports and activities without fear of failure or injury. They would also be able to cope with the stress and pressure of competition better. Second, they would make sports more educational and valuable for students. Students would learn more about their bodies and minds and how they work. They would also learn more about science and technology and how they can be used to improve human performance. They would also learn more about ethics and responsibility and how they can make informed decisions about their own health and well-being. Third, they would make sports more equitable and diverse for students. By leveling the playing field for all students regardless of their natural abilities or disadvantages such as genetics or socio-economic status etc., PEDs would create more opportunities for participation and inclusion in sports. They would also encourage more creativity and innovation in sports by allowing students to experiment with different combinations of PEDs. + +## Body Paragraph 3: How Can We Implement Performance-Enhancing Drugs Safely and Ethically? + +Implementing performance-enhancing drugs (PEDs) in middle school sports would require some changes and adjustments in the current education system. Here are some possible steps that could be taken to make this policy work: + +- Introduce a comprehensive curriculum on PEDs that covers their history, types, effects, risks, benefits, alternatives, regulations, etc. + +- Provide free access to PEDs for all students who participate in middle school sports under the supervision of qualified medical professionals. + +- Monitor the use of PEDs by students through regular testing and evaluation. + +- Enforce strict rules and consequences for misuse or abuse of PEDs such as cheating, overdosing, sharing, selling, etc. + +- Educate parents, coaches, and other stakeholders on the benefits and risks of PEDs and how to support and guide students who use them. + +## Conclusion + +In conclusion, I believe that we should mandate the use of performance-enhancing drugs (PEDs) in middle school sports because they would make sports more fun and enjoyable; more educational and valuable; and more equitable and diverse for students. A performance-enhancing drug policy may seem radical or controversial to some people, but it is actually the most logical and realistic way to improve the quality and relevance of middle school sports in the 21st century.""" +] + +GPT3_TEXTS = { + 'story': SHORT_STORIES, + 'argument': ARGUMENTATIVE_ESSAYS +} + + +if __name__ == '__main__': + import doctest + doctest.testmod() diff --git a/modules/writing_observer/writing_observer/static/tile.html b/modules/writing_observer/writing_observer/static/tile.html new file mode 100644 index 000000000..2b3424da2 --- /dev/null +++ b/modules/writing_observer/writing_observer/static/tile.html @@ -0,0 +1,86 @@ + +
+
+
+
+
[Loading]
+
+
+ +
+
+
+
+
+
+ +
+ +
+

+ N/A +

+

+ Count +

+
+
+ +
+ +
+

+ N/A +

+

+ Time +

+
+
+ +
+ +
+

+ N/A +

+

+ Idle +

+
+
+
+
+

[Loading]

+
+
+ +
+
diff --git a/modules/writing_observer/writing_observer/static/wo_dashboard.html b/modules/writing_observer/writing_observer/static/wo_dashboard.html new file mode 100644 index 000000000..37dfc2ddd --- /dev/null +++ b/modules/writing_observer/writing_observer/static/wo_dashboard.html @@ -0,0 +1,31 @@ +
+
+ +
+ +
+ +
... Loading student data ...
+
+
+
+
diff --git a/modules/writing_observer/writing_observer/static/wo_loader.js b/modules/writing_observer/writing_observer/static/wo_loader.js new file mode 100644 index 000000000..128b43b87 --- /dev/null +++ b/modules/writing_observer/writing_observer/static/wo_loader.js @@ -0,0 +1,83 @@ +/* + Top-level JavaScript file. + + This is mostly a loader. + */ + +function ajax(config) +{ + /* + Perhaps overkill, but we'd like to be able to have LO + have modularized URLs. + */ + return function(url) { + // Do AJAX calls with error handling + return new Promise(function(resolve, reject) { + config.d3.json(url) + .then(function(data){ + resolve(data); + }) + .catch(function(data){ + reject(data); + }); + }); + } +} + + +requirejs( + // These are helper functions defined in liblo.js + // + // They allow us to change URL schemes later. + [requireconfig(), + requireexternallib("d3.v5.min.js"), + requireexternallib("mustache.min.js"), + requireexternallib("showdown.js"), + requireexternallib("fontawesome.js"), + requiremodulelib("wobserver.js"), + requiresystemtext("modules/navbar_loggedin.html"), + ], + function(config, // Learning Observer config + d3, mustache, showdown, fontawesome, // 3rd party + wobserver, // The Writing Observer + navbar_li) { // Top bar + // Parse client configuration. + config = JSON.parse(config); + config.d3 = d3; + // Create a function to make AJAX calls based on the + // config. This should move into liblo? + + config.ajax = ajax(config); + function load_dashboard_page(course) { + /* + Classroom writing dashboard + */ + console.log(wobserver); + d3.select(".main-page").text("Loading Writing Observer..."); + wobserver.initialize(d3, d3.select(".main-page"), course, config); + } + + function loggedin_navbar_menu() { + d3.select(".main-navbar-menu").html(mustache.render(navbar_li, { + 'user_name': user_info()['name'], + 'user_picture': user_info()['picture'] + })); + } + + function setup_page() { + const hash_dict = decode_hash(); + if(!authenticated() || !authorized()) { + go_home(); + } + else if(!hash_dict) { + go_home(); + } else if (hash_dict['tool'] === 'WritingObserver') { + load_dashboard_page(hash_dict['course_id']); + loggedin_navbar_menu() + } else { + error("Invalid URL"); + } + } + setup_page(); + } +); diff --git a/modules/writing_observer/writing_observer/static/wobserver.html b/modules/writing_observer/writing_observer/static/wobserver.html new file mode 100644 index 000000000..9ab97cbe2 --- /dev/null +++ b/modules/writing_observer/writing_observer/static/wobserver.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + Writing Analysis + + + +
+ + + + +
+ +
+ +
+ + diff --git a/modules/writing_observer/writing_observer/static/wobserver.js b/modules/writing_observer/writing_observer/static/wobserver.js new file mode 100644 index 000000000..8c11f2435 --- /dev/null +++ b/modules/writing_observer/writing_observer/static/wobserver.js @@ -0,0 +1,196 @@ +/* + Main visualization for The Writing Observer + + This is the meat of the system. +*/ + +var student_data; +var summary_stats; +var tile_template; +var d3; + + +var first_time = true; + +function update_time_idle_data(d3tile, data) { + /* + We'd like time idle to proceed smoothly, at 1 second per second, + regardless of server latency. + + When the server updates idle time, we update data attributes + associated with the element, if necessary. We do this here. Then, + we use an interval timer to update the display itself based on + client-side timing. + + We maintain data fields for: + + * Last access + * Server and client time stamps at last access + + When new data comes in, we /only/ update if last access + changed. Otherwise, we compute. + */ + + /* Old data */ + let serverside_update_time = d3.select(d3tile).attr("data-ssut"); + let clientside_time = (new Date()).getTime() / 1000; + let new_serverside_update_time = Math.round(data['writing_observer.writing_analysis.time_on_task']['saved_ts']); + + if(new_serverside_update_time == Math.round(serverside_update_time)) { + // Time didn't change. Do nothing! Continue using the client clock + return; + } + + d3.select(d3tile).attr("data-ssut", summary_stats["current-time"]); + d3.select(d3tile).attr("data-sslat", data['writing_observer.writing_analysis.time_on_task']['saved_ts']); + d3.select(d3tile).attr("data-csut", clientside_time); +} + +function update_time_idle() { + /* + TODO: We should call this once per second to update time idle. Right now, we're calling this from `populate_tiles` + + The logic is described in update_time_idle_data(). + */ + var tiles = d3.selectAll("div.wo-col-tile").each(function(d) { + let serverside_update_time = d3.select(this).attr("data-ssut"); + let ss_last_access = d3.select(this).attr("data-sslat"); + let clientside_update_time = d3.select(this).attr("data-csut"); + let clientside_time = (new Date()).getTime() / 1000; + /* Time idle is computed as: */ + let idle_time = (serverside_update_time - ss_last_access) + (clientside_time - clientside_update_time); + /* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + How long student was idle when we How long ago we were told + last learned their last access time + */ + // 0, -1, etc. indicate no data + console.log(serverside_update_time , ss_last_access, clientside_time , clientside_update_time); + console.log((serverside_update_time - ss_last_access), (clientside_time - clientside_update_time), 1) + console.log(idle_time); + if(ss_last_access < 1000000000) { + d3.select(this).select(".wo-tile-idle-time").select("span").text("N/A"); + } else { + d3.select(this).select(".wo-tile-idle-time").select("span").text(rendertime2(idle_time)); + } + }); +} + +function populate_tiles(tilesheet) { + /* Create rows for students */ + console.log("Populating data"); + console.log(student_data); + if(first_time) { + var rows=tilesheet.selectAll("div.wo-row-tile") + .data(student_data) + .enter() + .append("div") + .attr("class", "tile is-ancestor wo-row-tile"); + + /* Create individual tiles */ + var cols=rows.selectAll("div.wo-col-tile") + .data(function(d) { return d; }) // Propagate data down from the row into the elements + .enter() + .append("div") + .attr("class", "tile is-parent wo-col-tile wo-flip-container is-3") + .html(tile_template) + .each(function(d) { + d3.select(this).select(".wo-tile-name").text(d.profile.name.fullName); + var photoUrl = d.profile.photoUrl; + if(photoUrl.startsWith("//")) { + photoUrl = "https:"+d.profile.photoUrl; + } + d3.select(this).select(".wo-tile-photo").attr("src", d.profile.photoUrl); + d3.select(this).select(".wo-tile-email").attr("href", "mailto:"+d.profile.emailAddress); + d3.select(this).select(".wo-tile-phone").attr("href", ""); // TODO + }); + first_time = false; + } + else { + var rows=tilesheet.selectAll("div.wo-row-tile") + .data(student_data) + var cols=rows.selectAll("div.wo-col-tile") + .data(function(d) { return d; }) // Propagate data down from the row into the elements + } + /* Populate them with data */ + var cols_update=rows.selectAll("div.wo-col-tile") + .data(function(d) { console.log(d); return d; }) + .each(function(d) { + console.log(d.profile); + // Profile: Student name, photo, Google doc, phone number, email + d3.select(this).select(".wo-tile-doc").attr("href", ""); // TODO + // Summary stats: Time on task, time idle, and characters in doc + let compiled = d["writing-observer-compiled"]; + let text = compiled.text; + d3.select(this).select(".wo-tile-character-count").select("span").text(compiled["character-count"]); + //d3.select(this).select(".wo-tile-character-count").select("rect").attr("width", 15); + let tot = d["writing_observer.writing_analysis.time_on_task"]; + d3.select(this).select(".wo-tile-time-on-task").select("span").text(rendertime2(tot["total-time-on-task"])); + //d3.select(this).select(".wo-tile-time-on-task").select("rect").attr("width", 15); + d3.select(this).select(".wo-tile-idle-time").select("span").text("Hello"); + + //d3.select(this).select(".wo-tile-idle-time").select("rect").attr("width", 15); + update_time_idle_data(this, d); + // Text + d3.select(this).select(".wo-tile-typing").text(compiled.text); + }); + update_time_idle(); +} + +var dashboard_template; +var Mustache; + +function initialize(D3, div, course, config) { + /* + Populate D3 with the dashboard for the course + */ + d3=D3; + console.log(config); + + div.html(dashboard_template); + dashboard_connection( + { + module: "writing_observer", + course: course + }, + function(data) { + console.log("New data!"); + student_data = data["student-data"]; + summary_stats = data["summary-stats"]; + console.log(summary_stats); + d3.select(".wo-tile-sheet").call(populate_tiles, student_data); + d3.selectAll(".wo-loading").classed("is-hidden", true); + console.log("Hide labels?"); + if(config.modules.wobserver['hide-labels']) { + console.log("Hide labels"); + d3.selectAll(".wo-desc-header").classed("is-hidden", true); + } + }); + /* + var tabs = ["typing", "deane", "summary", "outline", "timeline", "contact"]; + for(var i=0; i 0 else 0, n))) + + +def select_random_segments(text, segments=3, segment_length=3, seed=0): + ''' + Select random segments of words from the input sentence. + + Parameters: + sentence (str): The input sentence to select segments from. + segments (int, optional): The number of segments to select. Defaults to 3. + segment_length (int, optional): The maximum length of each segment in words. If None, there is no maximum length. Defaults to None. + seed: An optional random number seed. Set to `None` to be truly random. Otherwise, as as text fixture, it's deterministic. + + Returns: + list: A list of tuples, each containing the start and end indices of a selected segment. + ''' + if seed is not None: + state = random.getstate() + random.seed(seed) + word_boundaries = [match.start() for match in re.finditer(r"\b\w+\b", text)] + word_boundary_count = len(word_boundaries) + selected_indices = limited_sample(word_boundary_count - segment_length, segments) + segments_positions = [(word_boundaries[index], word_boundaries[index + segment_length] - word_boundaries[index]) for index in selected_indices] + if seed is not None: + random.setstate(state) + return segments_positions + + +def select_random_words(sentence, segments=3): + ''' + Select random words from the input sentence. + + Parameters: + sentence (str): The input sentence to select words from. + segments (int, optional): The number of words to select. Defaults to 3. + + Returns: + list: A list of tuples, each containing the start and end indices of a selected word. + ''' + word_boundaries = [match.start() for match in re.finditer(r"\b\w+\b", sentence)] + word_boundary_count = len(word_boundaries) + selected_indices = limited_sample(word_boundary_count, segments) + word_positions = [(word_boundaries[index], len(sentence[word_boundaries[index]:].split()[0])) for index in selected_indices] + return word_positions + + +def select_random_sentences(text, segments=3): + ''' + Select random sentences from the input text. + + Parameters: + text (str): The input text to select sentences from. + segments (int, optional): The number of sentences to select. Defaults to 3. + + Returns: + list: A list of tuples, each containing the start and end indices of a selected sentence. + ''' + sentence_boundaries = [match.start() for match in re.finditer(r"(?>> unwind(objects={'user': 1, 'items': ['i1', 'i2']}, value_path='items', new_name='item') + [{'user': 1, 'item': 'i1'}, {'user': 1, 'item': 'i2'}] + + Example unwinding multiple objects + >>> unwind( + ... objects=[{'user': 1, 'items': ['i1', 'i2']}, {'user': 2, 'items': ['i3']}], + ... value_path='items', new_name='item' + ... ) + [{'user': 1, 'item': 'i1'}, {'user': 1, 'item': 'i2'}, {'user': 2, 'item': 'i3'}] + + Example where we only keep specific keys + >>> unwind( + ... objects={'user': 1, 'items': ['i1', 'i2'], 'extra_key': 123}, + ... value_path='items', new_name='item', keys_to_keep=['user'] + ... ) + [{'user': 1, 'item': 'i1'}, {'user': 1, 'item': 'i2'}] + + TODO this ought to be query command, I'm just writing it as a function for now + but I'll try to keep it generic to be slid in later. + ''' + items = objects if isinstance(objects, list) else [objects] + unpacked = [] + for item in items: + # should we default or should we error? Probably error, maybe later + values = learning_observer.util.get_nested_dict_value(item, value_path, []) + for value in values: + new = copy.deepcopy(item) + learning_observer.util.remove_nested_dict_value(new, value_path) + if isinstance(values, dict): + values[value]['id'] = value + new[new_name] = values[value] + else: + new[new_name] = value + if keys_to_keep is None: + unpacked.append(new) + else: + unpacked.append({k: v for k, v in new.items() if k in keys_to_keep or k == new_name}) + return unpacked + + +@learning_observer.communication_protocol.integration.publish_function('writing_observer.group_docs_by') +def group_docs_by(items, value_path): + ''' + After fetching all the text from each pair of student/doc id, we want + to regroup them by student. Appending each document to a list in the + process. + + Currently this function is hardcoded for the tagging documents workflow. + Ideally we can determine which values we wish to keep as well as which + functions to run on them. + TODO abstract out of specific use case and implement as a query command + + Example grouping items by user (this gets renamed to user_id) + >>> group_docs_by( + ... items=[{'user': 1, 'doc': 'i1'}, {'user': 1, 'doc': 'i2'}, {'user': 2, 'doc': 'i3'}], + ... value_path='user' + ... ) + [{'user_id': 1, 'documents': ['i1', 'i2']}, {'user_id': 2, 'documents': ['i3']}] + ''' + overall = {} + for item in items: + try: + value = learning_observer.util.get_nested_dict_value(item, value_path) + except KeyError: + # TODO handle key not found + continue + if value in overall: + overall[value]['documents'].append(item['doc']) + else: + overall[value] = {} + overall[value]['user_id'] = value + overall[value]['documents'] = [item['doc']] + return [v for _, v in overall.items()] + + +if __name__ == "__main__": + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py new file mode 100644 index 000000000..85dc87425 --- /dev/null +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -0,0 +1,513 @@ +''' +This pipeline extracts high-level features from writing process data. + +It just routes to smaller pipelines. Currently that's: +1) Time-on-task +2) Reconstruct text (+Deane graphs, etc.) +''' +# Necessary for the wrapper code below. +import datetime +import pmss +import re +import time + +import writing_observer.reconstruct_doc + +import learning_observer.adapters +import learning_observer.communication_protocol.integration +from learning_observer.stream_analytics.helpers import student_event_reducer, kvs_pipeline, KeyField, EventField, Scope +import learning_observer.stream_analytics.time_on_task +import learning_observer.settings +import learning_observer.util + +# How do we count the last action in a document? If a student steps away +# for hours, we don't want to count all those hours. +# +# We might e.g. assume a one minute threshold. If students are idle +# for more than a minute, we count one minute. If less, we count the +# actual time spent. So if a student goes away for an hour, we count +# that as one minute. This threshold sets that maximum. For debugging, +# a few seconds is convenient. For production use, 60-300 seconds (or +# 1-5 minutes) might be more reasonable. +# +# In edX, for time-on-task calculations, the exact threshold had a +# surprisingly small impact on any any sort of interpretation +# (e.g. all the numbers would go up/down 20%, but behavior was +# substantatively identical). + +pmss.register_field( + name='activity_threshold', + type=pmss.pmsstypes.TYPES.integer, + description='How long to wait (in seconds) before marking a student '\ + 'as inactive.', + default=60 +) + +# Here's the basic deal: +# +# - Our prototype didn't deal with multiple documents +# - We're still refactoring to do this fully +# +# For most development, it's still convenient to pretend there's only +# one document. For prototyping new writing dashboards, we need to get +# rid of this illusion. This is a toggle. We should move it to the +# config file, or we should refactor to fully eliminate the need. + +student_scope = Scope([KeyField.STUDENT]) + +# This is a hack so we can flip for debugging to NOT managing documents +# correctly. That was for the original dashboard. +NEW = True + +if NEW: + gdoc_scope = Scope([KeyField.STUDENT, EventField('doc_id')]) +else: + gdoc_scope = student_scope # HACK for backwards-compatibility + +gdoc_tab_scope = Scope([KeyField.STUDENT, EventField('doc_id'), EventField('tab_id')]) + + +@learning_observer.communication_protocol.integration.publish_function('writing_observer.activity_map') +def determine_activity_status(last_ts): + status = 'active' if time.time() - last_ts < learning_observer.settings.module_setting('writing_obersver', 'activity_threshold') else 'inactive' + return {'status': status} + + +async def time_on_task(event, internal_state): + ''' + This adds up time intervals between successive timestamps. If the interval + goes above some threshold, it adds that threshold instead (so if a student + goes away for 2 hours without typing, we only add e.g. 5 minutes if + `time_threshold` is set to 300. + ''' + internal_state = learning_observer.stream_analytics.time_on_task.apply_time_on_task( + internal_state, + event['server']['time'], + learning_observer.settings.module_setting('writing_obersver', 'time_on_task_threshold') + ) + return internal_state, internal_state + + +gdoc_scope_time_on_task = kvs_pipeline(scope=gdoc_scope)(time_on_task) +gdoc_tab_scope_time_on_task = kvs_pipeline(scope=gdoc_tab_scope)(time_on_task) + + +@kvs_pipeline(scope=gdoc_scope) +async def binned_time_on_task(event, internal_state): + ''' + Similar to the `time_on_task` reducer defined above, except it + bins the time spent. + ''' + internal_state = learning_observer.stream_analytics.time_on_task.apply_binned_time_on_task( + internal_state, + event['server']['time'], + learning_observer.settings.module_setting('writing_obersver', 'time_on_task_threshold'), + learning_observer.settings.module_setting('writing_obersver', 'binned_time_on_task_bin_size') + ) + return internal_state, internal_state + + +@kvs_pipeline(scope=gdoc_scope) +async def reconstruct(event, internal_state): + ''' + This is a thin layer to route events to `reconstruct_doc` which compiles + Google's deltas into a document. It also adds a bit of metadata e.g. for + Deane plots. + ''' + # If it's not a relevant event, ignore it + if event['client']['event'] not in ["google_docs_save", "document_history"]: + return False, False + + internal_state = writing_observer.reconstruct_doc.google_text.from_json( + json_rep=internal_state) + if event['client']['event'] == "google_docs_save": + bundles = event['client']['bundles'] + for bundle in bundles: + internal_state = writing_observer.reconstruct_doc.command_list( + internal_state, bundle['commands'] + ) + elif event['client']['event'] == "document_history": + change_list = [ + i[0] for i in event['client']['history']['changelog'] + ] + internal_state = writing_observer.reconstruct_doc.command_list( + writing_observer.reconstruct_doc.google_text(), change_list + ) + state = internal_state.json + if learning_observer.settings.module_setting('writing_observer', 'verbose'): + print(state) + return state, state + + +@kvs_pipeline(scope=gdoc_scope, null_state={"count": 0}) +async def event_count(event, internal_state): + ''' + An example of a per-document pipeline + ''' + if learning_observer.settings.module_setting('writing_observer', 'verbose'): + print(event) + + state = {"count": internal_state.get('count', 0) + 1} + + return state, state + + +@kvs_pipeline(scope=student_scope, null_state={}) +async def student_profile(event, internal_state): + '''Store profile information for a given id + ''' + email = event['client'].get('chrome_identity', {}).get('email') + id = event['client'].get('auth', {}).get('safe_user_id') + if email != internal_state.get('email') or id != internal_state.get('user_id'): + state = {'email': email, 'google_id': id} + return state, state + return False, False + + +@kvs_pipeline(scope=gdoc_scope, null_state={}) +async def nlp_components(event, internal_state): + '''HACK the reducers need this method to query data + ''' + return False, False + + +@kvs_pipeline(scope=gdoc_scope, null_state={}) +async def languagetool_process(event, internal_state): + '''HACK the reducers need this method to query data + ''' + return False, False + + +@kvs_pipeline(scope=student_scope, null_state={'timestamps': {}, 'last_document': ''}) +async def document_access_timestamps(event, internal_state): + ''' + We want to fetch documents around a certian time of day. + We record the timestamp with a document id. + + Use case: a teacher wants to see the current version of + the document their students had open at 10:45 AM + + NOTE we only keep that latest doc for each timestamp. + Since we are in milliseconds, this should be okay. + ''' + # If users switch between document tabs, then the system will + # send mutliple `visibility` events from both tabs creating + # more timestamps than we want. We skip those events. + if event['client']['event'] in ['visibility']: + return False, False + + document_id = get_doc_id(event) + if document_id is not None: + + # if events dont have timestamps present, revert to right now + # 'ts' metadata is in milliseconds while datetime.now is in seconds + ts = event['client'].get('metadata', {}).get('ts', datetime.datetime.now().timestamp()*1000) + + if document_id != internal_state['last_document']: + internal_state['timestamps'][ts] = document_id + internal_state['last_document'] = document_id + + return internal_state, internal_state + return False, False + + +@kvs_pipeline(scope=student_scope, null_state={'tags': {}}) +async def document_tagging(event, internal_state): + ''' + We would like to be able to group documents together to better work with + multi-document workflows. For example, students may work in a graphic organizer + or similar and then transition into their final draft. + ''' + if event['client']['event'] not in ["document_history"]: + return False, False + + document_id = get_doc_id(event) + if document_id is not None: + title = learning_observer.util.get_nested_dict_value(event, 'client.object.title', None) + if title is None: + return False, False + tags = re.findall(r'#(\w+)', title) + for tag in tags: + if tag not in internal_state['tags']: + internal_state['tags'][tag] = [document_id] + elif document_id not in internal_state['tags'][tag]: + internal_state['tags'][tag].append(document_id) + return internal_state, internal_state + return False, False + + +@kvs_pipeline(scope=student_scope, null_state={"docs": {}}) +async def document_list(event, internal_state): + ''' + We would like to gather a list of all Google Docs a student + has visited / edited. In the future, we plan to add more metadata. This can + then be used to decide which ones to show. + ''' + document_id = get_doc_id(event) + if document_id is not None: + if "docs" not in internal_state: + internal_state["docs"] = {} + if document_id not in internal_state["docs"]: + # In the future, we might include things like e.g. document title. + internal_state["docs"][document_id] = { + } + # set title of document + try: + internal_state["docs"][document_id]["title"] = learning_observer.util.get_nested_dict_value(event, 'client.object.title') + except KeyError: + pass + # set last time accessed + if 'server' in event and 'time' in event['server']: + internal_state["docs"][document_id]["last_access"] = event['server']['time'] + else: + print("TODO: We got a bad event, and we should log this in some") + print("way, or do similar recovery.") + return internal_state, internal_state + + return False, False + + +def _iter_commands_from_client(client): + """Yield command dicts from either bundles (google_docs_save) or history (document_history).""" + event_type = client.get("event") + + if event_type == "google_docs_save": + for bundle in client.get("bundles") or []: + for command in bundle.get("commands") or []: + if isinstance(command, dict): + yield command + + elif event_type == "document_history": + history = client.get("history") or {} + changelog = history.get("changelog") or [] + # Each changelog item is expected to be like: [, ...] + for item in changelog: + if isinstance(item, (list, tuple)) and item and isinstance(item[0], dict): + yield item[0] + + +def _iter_leaf_commands(client): + for cmd in _iter_commands_from_client(client): + if not isinstance(cmd, dict): + continue + + if cmd.get("ty") == "mlti": + for sub in cmd.get("mts") or []: + if isinstance(sub, dict): + yield sub + else: + yield cmd + + +def _get_event_time(event, client): + """Resolve the timestamp once per event, with fallback.""" + server_time = (event.get("server") or {}).get("time") + if server_time is not None: + return server_time + return client.get("timestamp") or (client.get("metadata") or {}).get("ts") + + +def extract_from_ucp(command): + if command.get("ty") != "ucp": + return None, None + d = command.get("d") + try: + return d[0], d[1][1][1] + except (TypeError, IndexError, KeyError): + return None, None + + +def extract_from_mkch(command): + if command.get("ty") != "mkch": + return None, None + + d = command.get("d") + try: + return 't.0', d[0][1] + except (TypeError, IndexError, KeyError, AttributeError): + return None, None + + +def extract_from_ac(command): + if command.get("ty") != "ac": + return None, None + + d = command.get("d") + try: + return d[0], d[1][1] + except (TypeError, IndexError, KeyError, AttributeError): + return None, None + + +TITLE_EXTRACTORS = { + "ucp": extract_from_ucp, + "mkch": extract_from_mkch, + "ac": extract_from_ac, +} + + +def _extract_all_tab_titles(client): + """ + Extract all (tab_id, title) pairs from leaf commands (including those inside mlti). + """ + event_type = client.get("event") + if event_type not in ("google_docs_save", "document_history"): + return [] + + out = [] + for cmd in _iter_leaf_commands(client): + ty = cmd.get("ty") + extractor = TITLE_EXTRACTORS.get(ty) + if not extractor: + continue + tab_id, title = extractor(cmd) + if tab_id is None: + continue + out.append((tab_id, title)) + return out + + +def _extract_tab_id(event): + client = event.get("client", {}) or {} + tab_id = client.get("tab_id") or event.get("tab_id") + if tab_id: + return tab_id + url = client.get("url") or client.get("object", {}).get("url") or event.get("url") + if not url: + return None + match = re.search(r"tab=([^&#]+)", url) + return match.group(1) if match else None + + +@kvs_pipeline(scope=gdoc_scope, null_state={"tabs": {}}) +async def tab_list(event, internal_state): + """ + Track per-document tab metadata (tab_id, title, last_accessed) per student. + + Rules: + - If client.tab_id exists AND is already in state: ONLY update last_accessed for that tab. + - Still add new tabs discovered in commands (and set last_accessed for those new tabs). + - For existing tabs discovered in commands: update title if present, but do NOT touch last_accessed + unless it's the active existing tab (handled first). + """ + internal_state = internal_state or {"tabs": {}} + tabs = internal_state.get("tabs") or {} + + client = event.get("client") or {} + server_time = _get_event_time(event, client) + + active_tab_id = _extract_tab_id(event) + + # 1) Only bump last_accessed for the active tab IF it already exists in state + if active_tab_id is not None and active_tab_id in tabs: + tabs[active_tab_id]["last_accessed"] = server_time + + # 2) Add/update titles for all extracted tabs + for tab_id, title in _extract_all_tab_titles(client): + if tab_id not in tabs: + # New tab: initialize and set last_accessed now + tabs[tab_id] = { + "tab_id": tab_id, + "title": title, + "last_accessed": server_time, + } + else: + # Existing tab: update title if we learned one; do not update last_accessed here + if title is not None: + tabs[tab_id]["title"] = title + + internal_state["tabs"] = tabs + return internal_state, internal_state + + +@kvs_pipeline(scope=student_scope) +async def last_document(event, internal_state): + ''' + Small bit of data -- the last document accessed. This can be extracted from + `document_list`, but we don't need that level of complexity for the 1.0 + dashboard. + + This code accesses the code below which provides some hackish support + functions for the analysis. Over time these may age off with a better + model. + ''' + document_id = get_doc_id(event) + + if document_id is not None: + state = {"document_id": document_id} + return state, state + + return False, False + + +# Simple hack to match URLs. This should probably be moved as well +# but for now it works. +# +# The URL for the main page looks as follows: +# https://docs.google.com/document/u/0/?tgif=d +# +# Document URls are as follows: +# https://docs.google.com/document/d/18JAnmxzVD_lGSfa8t6Se66KLZm30YFrC_4M-D2zdYG4/edit + +DOC_URL_re = re.compile("^https://docs.google.com/document/d/(?P[^/\s]+)/(?P[a-zA-Z]+)") # noqa: W605 \s is invalid escape + + +def get_doc_id(event): + """ + HACK: This is interim until we have more consistent events + from the extension + + Some of the event types (e.g. 'google_docs_save') have + a 'doc_id' which provides a link to the google document. + Others, notably the 'visibility' and 'keystroke' events + do not have doc_id but do have a link to an 'object' + field which in turn contains an 'id' field linking to + the google doc along with other features such as the + title. However other events (e.g. login & visibility) + contain object links with id fields that do not + correspond to a known doc. + + This method provides a simple abstraction that returns + the 'doc_id' value if it exists or returns the 'id' from + the 'object' field if it is present and if the url in + the object field corresponds to a google doc id. + + We use the helper function for doc_url_p to test + this. + """ + + client = event.get('client', {}) + doc_id = client.get('doc_id') + if doc_id: + return doc_id + + # Failing that pull out the url event. + # Object_value = event.get('client', {}).get('object', None) + url = client.get('object', {}).get('url') + if not url: + return None + + # Now test if the object has a URL and if that corresponds + # to a doc edit/review URL as opposed to their main page. + # if so return the id from it. In the off chance the id + # is still not present or is none then this will return + # none. + url_match = DOC_URL_re.match(url) + if not url_match: + return None + + doc_id = client.get('object', {}).get('id') + return doc_id + +def document_link_to_doc_id(event): + ''' + Convert a document link to include a doc_id + ''' + doc_id = get_doc_id({'client': event}) + if doc_id and 'client' in event: + event['client']['doc_id'] = doc_id + elif doc_id: + event['doc_id'] = doc_id + return event + +learning_observer.adapters.adapter.add_common_migrator(document_link_to_doc_id, __file__) diff --git a/package.json b/package.json new file mode 100644 index 000000000..76f3136fb --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "writing_observer", + "version": "1.0.0", + "description": "![Writing Observer Logo](learning_observer/learning_observer/static/media/logo-clean.jpg)", + "directories": { + "doc": "docs" + }, + "scripts": { + "lint:css": "stylelint '**/*.{css,scss}'", + "lint:js": "eslint '**/*.js'", + "lint": "npm run lint:css && npm run lint:js", + "find-unused-css": "node list-unused-css.js", + "build:docs": "npm run clean:docs && cd autodocs && make html", + "clean:docs": "rm -rf autodocs/_build && rm -rf autodocs/generated" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ETS-Next-Gen/writing_observer.git" + }, + "author": "", + "license": "SEE LICENSE IN LICENSE.TXT", + "bugs": { + "url": "https://github.com/ETS-Next-Gen/writing_observer/issues" + }, + "homepage": "https://github.com/ETS-Next-Gen/writing_observer#readme", + "devDependencies": { + "@actions/core": "^1.10.0", + "eslint": "^8.38.0", + "eslint-config-standard": "^17.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-standard": "^5.0.0", + "postcss": "^8.4.23", + "postcss-scss": "^4.0.6", + "stylelint": "^15.5.0", + "stylelint-config-standard": "^33.0.0", + "stylelint-scss": "^4.6.0" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.1.0" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d052bba15 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +aiofiles +aiohttp +aiohttp_cors +aiohttp_session +aiohttp_wsgi +asyncio_redis # pubsub +asyncpg # used in prototypes +cookiecutter +cryptography +dash +dash_renderjson +docopt +dash-bootstrap-components +tsvx @ git+https://github.com/pmitros/tsvx.git@09bf7f33107f66413d929075a8b54c36ca581dae#egg=tsvx +loremipsum @ git+https://github.com/testlabauto/loremipsum.git@b7bd71a6651207ef88993045cd755f20747f2a1e#egg=loremipsum +ipython +ipykernel +jsonschema +pyjwt +lxml # pubsub +names +notebook +numpy +pandas +pathvalidate +pmss +psutil +py-bcrypt +pycodestyle +pylint +pytest +pyyaml +recordclass +redis +slixmpp # pubsub +svgwrite +uvloop +watchdog + +gitserve @ git+https://github.com/ETS-Next-Gen/writing_observer.git#subdirectory=gitserve diff --git a/scripts/generate_jwks.py b/scripts/generate_jwks.py new file mode 100644 index 000000000..095dd2676 --- /dev/null +++ b/scripts/generate_jwks.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Generate JWKS from your public key for Canvas LTI configuration +Usage: python generate_jwks.py [kid] +""" + +import sys +import json +import base64 +import hashlib +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +def generate_jwks(public_key_path, kid=None): + try: + # Load the public key + with open(public_key_path, 'rb') as f: + public_key_pem = f.read() + public_key = serialization.load_pem_public_key( + public_key_pem, + backend=default_backend() + ) + + # Extract the public numbers + numbers = public_key.public_numbers() + + # Helper function to convert int to base64url + def to_base64url(num): + byte_len = (num.bit_length() + 7) // 8 + num_bytes = num.to_bytes(byte_len, byteorder='big') + encoded = base64.urlsafe_b64encode(num_bytes).decode('utf-8') + return encoded.rstrip('=') # Remove padding + + # Generate kid if not provided + # Using SHA256 hash of the public key (common practice) + if kid is None: + key_hash = hashlib.sha256(public_key_pem).digest() + kid = base64.urlsafe_b64encode(key_hash[:16]).decode('utf-8').rstrip('=') + + # Build the JWK + jwk = { + "kty": "RSA", + "e": to_base64url(numbers.e), + "n": to_base64url(numbers.n), + "alg": "RS256", + "kid": kid, + "use": "sig" + } + + return jwk + + except FileNotFoundError: + print(f"Error: Could not find file '{public_key_path}'") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + if len(sys.argv) < 2 or len(sys.argv) > 3: + print("Usage: python generate_jwks.py [kid]") + print("\nExamples:") + print(" python generate_jwks.py public_key.pem") + print(" python generate_jwks.py public_key.pem my-custom-key-id") + sys.exit(1) + + public_key_file = sys.argv[1] + custom_kid = sys.argv[2] if len(sys.argv) == 3 else None + + jwks = generate_jwks(public_key_file, custom_kid) + + print("\n" + "="*60) + print("Copy this JSON and paste it into Canvas public_jwk field:") + print("="*60 + "\n") + print(json.dumps(jwks, indent=2)) + print("\n" + "="*60) + print(f"\n✓ Key ID (kid): {jwks['kid']}") + if custom_kid is None: + print(" (auto-generated from key fingerprint)") + print("\n IMPORTANT: If you specify a 'kid' when signing JWTs,") + print(" it must match this value!") + print("="*60) diff --git a/scripts/generic_websocket_dashboard.js b/scripts/generic_websocket_dashboard.js new file mode 100644 index 000000000..018616a9e --- /dev/null +++ b/scripts/generic_websocket_dashboard.js @@ -0,0 +1,48 @@ +/* + Simple Node.js websocket client for exercising the communication protocol. + + Update the REQUEST payload to point at the execution DAG and exports you want + to test. +*/ + +// Remove this line when running within a browser terminal (i.e. non-Node.js environment) +const WebSocket = require('ws'); + +const SERVER = 'ws://localhost:8888/wsapi/communication_protocol'; + +const REQUEST = { + docs_request: { + execution_dag: 'writing_observer', + target_exports: ['docs_with_roster'], + kwargs: { + course_id: 'COURSE-123', + } + } +}; +const socket = new WebSocket(SERVER); + +socket.on('open', () => { + console.log('Open'); + socket.send(JSON.stringify(REQUEST)); +}); + +socket.on('message', (msg) => { + try { + const parsed = JSON.parse(msg.toString()); + console.log(parsed); + } catch (error) { + console.log(msg.toString()); + } +}); + +socket.on('error', (err) => { + console.log('Error'); + console.log(err); +}); + +socket.on('close', (event) => { + console.log('Close'); + if (event) { + console.log(event); + } +}); diff --git a/scripts/generic_websocket_dashboard.py b/scripts/generic_websocket_dashboard.py new file mode 100644 index 000000000..0615b6d9f --- /dev/null +++ b/scripts/generic_websocket_dashboard.py @@ -0,0 +1,41 @@ +"""Simple websocket client for manual communication protocol testing.""" + +import aiohttp +import asyncio +import json + +# Example request payload for the communication protocol websocket. Adjust the +# execution_dag, target_exports, and kwargs to match the reducers you want to +# exercise. +REQUEST = { + "docs_request": { + "execution_dag": "writing_observer", + "target_exports": ["docs_with_roster"], + "kwargs": { + "course_id": 12345678901 + }, + } +} + + +async def main(): + async with aiohttp.ClientSession() as session: + async with session.ws_connect( + "http://localhost:8888/wsapi/communication_protocol", + timeout=5, + ) as ws: + await ws.send_json(REQUEST) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + parsed = json.loads(msg.data) + except json.JSONDecodeError: + parsed = msg.data + print(json.dumps(parsed, indent=2)) + elif msg.type == aiohttp.WSMsgType.ERROR: + print("Error received from websocket:", msg) + break + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/hooks/pre-commit.sh b/scripts/hooks/pre-commit.sh new file mode 100755 index 000000000..99dfbabbd --- /dev/null +++ b/scripts/hooks/pre-commit.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Function to get current timestamp and branch +get_new_local_version_string() { + local timestamp=$(date -u +"%Y.%m.%dT%H.%M.%S.%3NZ") + local commit_hash=$(git rev-parse --short HEAD) + local branch=$(git rev-parse --abbrev-ref HEAD | tr '/' '.' | tr '-' '.' | tr '_' '.') + echo "${timestamp}.${commit_hash}.${branch}" +} + +# Function to find relevant VERSION files based on changed paths +find_version_files() { + local changed_files=$(git diff --cached --name-only) + local project_root=$(git rev-parse --show-toplevel) + local version_files=("$project_root/VERSION") + + # Function to find the nearest VERSION file within the project root + find_nearest_version_file() { + local dir="$1" + while [[ "$dir" != "$project_root" && "$dir" != "/" ]]; do + if [[ -f "$dir/VERSION" ]]; then + echo "$dir/VERSION" + return + fi + dir=$(dirname "$dir") + done + + # Fallback to root VERSION file if none found and it exists + echo "$project_root/VERSION" + } + + # Process changed files line-by-line + while IFS= read -r file; do + local file_dir=$(dirname "$file") + local nearest_version_file=$(find_nearest_version_file "$file_dir") + + # Add the VERSION file if it's found and not already in the list + if [[ -n "$nearest_version_file" ]] && [[ ! " ${version_files[@]} " =~ " ${nearest_version_file} " ]]; then + version_files+=("$nearest_version_file") + fi + done < <(echo "$changed_files") + + printf "%s\n" "${version_files[@]}" +} + +# Main versioning logic +update_version_files() { + local version_string=$(get_new_local_version_string) + local version_files=$(find_version_files) + + # Update each relevant VERSION file + while IFS= read -r file; do + # Extract semantic version from file + base_version=$(sed -n 's/\(.*\)+.*/\1/p' "$file") + + # Prepend the semantic version to local version + new_version_string="$base_version+$version_string" + + echo "$new_version_string" > "$file" + git add "$file" + done < <(echo "$version_files") +} + +# Run the versioning update +update_version_files + +exit 0 diff --git a/scripts/lo_dump.py b/scripts/lo_dump.py new file mode 100644 index 000000000..19abbbb5e --- /dev/null +++ b/scripts/lo_dump.py @@ -0,0 +1,67 @@ +''' +This is a short script to load data from redis. + +It exports Learning Observer data in a human / Python-friendly +format. This is nice if you'd like to download data and modify it, +e.g.: + +* For test cases +* For a demo +* Etc. + +It is **not** intended for production use. Performance won't be +there. It does a full table scan. It's not ACID. Etc. If you'd like +similar functionality in production, `redis` has built-in persistance: + +https://redis.io/topics/persistence + +To do: We should make this script abstract: +* Use LO's KVS abstraction, rather than redis directly +* As a correlary to the above, use the config file to authenticated + if redis is e.g. non-local +* Take semantic arguments (e.g. course, users, etc.) rather than a + query string + +Until at least the last one (so API is fixed), we should not build +dependencies around this script. A one-off hack is fine in +development, but nothing committed to the repo which calls this with a +query string. +''' + +import argparse +import json +import sys + +import redis + +parser = argparse.ArgumentParser( + description=__doc__.strip(), + formatter_class=argparse.RawTextHelpFormatter +) +parser.add_argument( + '--query-string', default='*', + help="Query string for which keys we ought to receive from redis" +) +parser.add_argument( + '--out', type=argparse.FileType('x', encoding='UTF-8'), + help="Output filename" +) + +args = parser.parse_args() + +if args.out is None: + out = sys.stdout +else: + out = args.out + +r = redis.Redis() +keys = r.keys(args.query_string) + +# For now, we alternate keys and data per line +for key in keys: + out.write(key.decode('utf-8')) + out.write('\n') + # We do a load-and-dump to avoid new lines, in case of data + # corruption or similar. It's really not necessary. + out.write(json.dumps(json.loads(r.get(key)))) + out.write('\n') diff --git a/scripts/lo_load.py b/scripts/lo_load.py new file mode 100644 index 000000000..42fe20038 --- /dev/null +++ b/scripts/lo_load.py @@ -0,0 +1,43 @@ +''' +This is a short script to write data to redis. + +It reads data from `lo_load`. See `lo_load` documentation for details. + +It is **not** intended for production use, or even for use within +archival scripts. Data formats, command line interfaces, etc. may and +probably will change. +''' + +import argparse + +import redis + +parser = argparse.ArgumentParser( + description=__doc__.strip(), + formatter_class=argparse.RawTextHelpFormatter +) + +parser.add_argument( + '--in', type=argparse.FileType('r', encoding='UTF-8'), + dest="filename", + help="Input filename" +) + +args = parser.parse_args() + +if args.filename is None: + parser.print_usage() + +r = redis.Redis() + +odd = True + +# For now, we alternate keys and data per line +for line in args.filename: + if odd: + odd = False + key = line.strip() + else: + odd = True + value = line.strip() + r.set(key, value) diff --git a/scripts/lo_passwd.py b/scripts/lo_passwd.py new file mode 100755 index 000000000..40c7f60b7 --- /dev/null +++ b/scripts/lo_passwd.py @@ -0,0 +1,187 @@ +#!/usr/bin/python3 +''' +Add user to a password file. +We shouldn't be reinventing wheels. But we couldn't find a good one. + +As a format issue, we explicitly don't want extranous fields in the file. If +the user doesn't provide an optional field, we don't want to add it to the +file. We provide defaults on read rather than on write. This keeps the file +smaller, cleaner, and easier to read. + +We should think through which fields we want. Right now, this is ad-hoc. In +particular: + +- Name format (with i18n issues) +- Picture / avatar format (filename? URL?) + +Most systems do this wrong. Chinese names have last name first, and +some cultures have more than first/middle/last names. We don't want to +have a Eurocentric format. + +We should also break this out into a library, so that `user_fields`, etc. are +consistent across the project. +''' + +import argparse +import datetime +import getpass +import os.path +import sys + +import bcrypt +import yaml + + +# Our default user data structure +# We'll populate this with the user's data from the arguments +# and inputs +user_fields = [ + 'username', + 'password', + 'email', + 'name', + 'family_name', + 'picture', + 'notes', + 'role' + # 'authorized', <-- These will be set automatically + # 'created_at', + # 'updated_at' +] + +# We need these two fields -- the rest are optional +# We'll prompt for them if they're not provided +required_fields = [ + 'username', + 'password' +] + +# These fields, we won't echo +secure_fields = ['password'] + + +def read_password_yaml(filename): + ''' + Read the password file. + + If the file doesn't exist, create it. If it does exist, + but is missing the required fields, create those fields. + + Args: + filename (str): The filename to read. + + Returns: + dict: The password file. + ''' + data = {} + if os.path.exists(args.filename): + with open(filename, 'r') as f: + data = yaml.safe_load(f) + + if data is None: + data = {} + + if 'users' not in data: + data['users'] = {} + + return data + + +def write_password_yaml(filename, data): + ''' + Write the password file. + + Args: + filename (str): The filename to write. + data (dict): The data to write. + ''' + with open(filename, 'w') as f: + yaml.dump(data, f) + + +def update_password_yaml(password_dict, new_user): + ''' + Update the password dictionary. + + Args: + password_dict (dict): The password file. + new_user (dict): The new user to add or update. + + Returns: + dict: The updated password file. + + Note that the password_dict is modified in place. + ''' + username = new_user['username'] + if username not in password_dict['users']: + password_dict['users'][username] = {} + password_dict['users'][username].update(new_user) + + now = datetime.datetime.now().isoformat() + if 'created_at' not in password_dict['users'][username]: + password_dict['users'][username]['created_at'] = now + password_dict['users'][username]['updated_at'] = now + + return password_dict + + +def prompt_for_user_data(): + ''' + Parse arguments, and prompt for missing fields. + + Returns: + dict: The user's data. + ''' + user_data = {} + + for field in user_fields: + if field in args.__dict__ and args.__dict__[field] is not None: + user_data[field] = args.__dict__[field] + elif field in secure_fields: + user_data[field] = getpass.getpass(f'{field}: ') + elif field in required_fields: + user_data[field] = input(f"{field} (required): ") + elif not args.no_prompt: + response = input(f"{field} (optional): ") + if response != '': + user_data[field] = response + if field in required_fields and user_data[field] == '': + print(f"{field} is required.") + sys.exit(1) + if field == 'password': + hashpw = bcrypt.hashpw( + user_data[field].encode('utf-8'), + bcrypt.gensalt() + ) + # This is a work-around for version issues in bcrypt. + # It sometimes returns strings, and sometimes bytes. + if isinstance(hashpw, bytes): + hashpw = hashpw.decode('utf-8') + user_data[field] = hashpw + + return user_data + + +# Grab the arguments +parser = argparse.ArgumentParser( + description=__doc__.strip(), + formatter_class=argparse.RawTextHelpFormatter +) +parser.add_argument( + '--filename', required=True +) + +parser.add_argument( + '--no-prompt', action='store_true' +) + +for field in user_fields: + parser.add_argument("--" + field) + +args = parser.parse_args() + +password_dict = read_password_yaml(args.filename) +user_data = prompt_for_user_data() +password_dict = update_password_yaml(password_dict, user_data) +print(password_dict['users'][user_data['username']]) +write_password_yaml(args.filename, password_dict) diff --git a/scripts/log_process.py b/scripts/log_process.py new file mode 100644 index 000000000..5d5fcd12e --- /dev/null +++ b/scripts/log_process.py @@ -0,0 +1,96 @@ +''' +Our goal is to be able to process the log file without spinning up +the Learning Observer. This is a simple script that will read the +log file and reduce the data as per our reducers. + +It should be a lot easier to do development this way. +''' + +import argparse +import asyncio +import json +import sys + +import names + +import learning_observer.settings +import learning_observer.stream_analytics +import learning_observer.module_loader +import learning_observer.incoming_student_event +import learning_observer.log_event +import learning_observer.kvs + + +# Supress printing of all the junk that happens during startup. +learning_observer.log_event.DEBUG_LOG_LEVEL = learning_observer.log_event.LogLevel.NONE + +# Run from memory +learning_observer.settings.load_settings({ + "logging": { + "debug_log_level": "NONE", + "debug_log_destination": ["console"] + }, + "kvs": { + "type": "stub", + }, + "config": { + "run_mode": "dev" + } +}) + + +parser = argparse.ArgumentParser( + description=__doc__.strip(), + formatter_class=argparse.RawTextHelpFormatter +) + +parser.add_argument("--logfiles", "-f", help="The log file(s) to process (separated by commas)") +parser.add_argument("--listfile", "-l", help="A file of paths of log files") + +args = parser.parse_args() + +reducers = learning_observer.module_loader.reducers() +learning_observer.kvs.kvs_startup_check() +learning_observer.stream_analytics.init() + + +if args.listfile is not None and args.logfiles is not None: + print("You can only specify one of --listfile or --logfiles") + parser.print_usage() + sys.exit(1) +if args.listfile is None and args.logfiles is None: + print("You must specify either --listfile or --logfiles") + parser.print_usage() + sys.exit(1) + +if args.listfile is not None: + files = open(args.listfile, 'r').readlines() +else: + files = args.logfiles.split(',') + + +async def process_files(files): + for file in files: + print("Processing file: {}".format(file)) + # We use dummy names because: + # (1) It is easier to test this way + # (2) We want to protect ourselves from accidentally seeing PII + pipeline = await learning_observer.incoming_student_event.student_event_pipeline({ + "source": "org.mitros.dynamic_assessment", + "auth": { + "user_id": names.get_first_name(), + "safe_user_id": names.get_first_name() + } + }) + with open(file, 'r') as f: + for line in f.readlines(): + try: + await pipeline(json.loads(line)) + except Exception: # In case of an error, we'd like to know where it happened + print(line) + raise + +kvs = learning_observer.kvs.KVS() +asyncio.run(process_files(files)) +data = asyncio.run(kvs.dump()) +print(json.dumps(data, indent=2)) diff --git a/scripts/map_emails_to_ids_in_kvs.py b/scripts/map_emails_to_ids_in_kvs.py new file mode 100644 index 000000000..953269bbe --- /dev/null +++ b/scripts/map_emails_to_ids_in_kvs.py @@ -0,0 +1,28 @@ +import asyncio + +import learning_observer.kvs +import learning_observer.offline +import learning_observer.stream_analytics.helpers as sa_helpers + +import writing_observer.writing_analysis + + +def create_key(email): + return f'email-studentID-mapping:{email}' + + +async def run(): + learning_observer.offline.init('creds.yaml') + kvs = learning_observer.kvs.KVS() + reducer_function_name = sa_helpers.fully_qualified_function_name(writing_observer.writing_analysis.student_profile) + all_keys = await kvs.keys() + keys = [k for k in all_keys if 'Internal' in k and reducer_function_name in k] + values = await kvs.multiget(keys) + for profile in values: + if 'email' not in profile or 'google_id' not in profile: + continue + await kvs.set(create_key(profile['email']), profile['google_id']) + + +if __name__ == '__main__': + asyncio.run(run()) diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 000000000..4421c658a --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "writing_observer_util", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "writing_observer_util", + "version": "1.0.0", + "license": "AGPL-3.0", + "dependencies": { + "ws": "^8.17.1" + } + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + }, + "dependencies": { + "ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "requires": {} + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 000000000..ca6df8c8e --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,22 @@ +{ + "name": "writing_observer_util", + "version": "1.0.0", + "description": "Utilities for the Learning Observer and Writing Observer", + "main": "generic_websocket_dashboard.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ETS-Next-Gen/writing_observer.git" + }, + "author": "Piotr Mitros", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/ETS-Next-Gen/writing_observer/issues" + }, + "homepage": "https://github.com/ETS-Next-Gen/writing_observer#readme", + "dependencies": { + "ws": "^8.17.1" + } +} diff --git a/scripts/populate_writing_observer_data.py b/scripts/populate_writing_observer_data.py new file mode 100644 index 000000000..2608f79cc --- /dev/null +++ b/scripts/populate_writing_observer_data.py @@ -0,0 +1,116 @@ +''' +This will program populate redis with dummy writing data for one +course. It should be run from the same directory as creds.yaml. This +is primarily intended for development use. +''' + +import asyncio + +import learning_observer.constants +import learning_observer.google +import learning_observer.settings +import learning_observer.offline +import learning_observer.rosters +import learning_observer.kvs + +import writing_observer.writing_analysis +from learning_observer.stream_analytics.fields import KeyField, KeyStateType, EventField + +import writing_observer.sample_essays + + +async def select_course(): + """ + This is an asynchronous function that allows the user to select a + course from a list of courses. The function prints each course's name + along with its index, and prompts the user to select a course by + entering the index number. The function returns the ID of the selected + course. + """ + courses = await learning_observer.rosters.courselist(learning_observer.offline.request) + + for course, i in zip(courses, range(len(courses))): + print(f"{i}: {course['name']}") + + course_index = int(input("Please select a course: ")) + return courses[course_index]['id'] + + +async def print_roster(course): + """ + This is an asynchronous function that takes a course as an + argument and prints its roster of students. It returns the + roster too. + """ + roster = await learning_observer.rosters.courseroster(learning_observer.offline.request, course) + print("\nStudents\n========") + for student in roster: + print(student['profile']['name']['full_name']) + return roster + + +async def select_text_type(): + """ + This is an asynchronous function that allows the user to select a + text type from a list of available text types from the + `sample_essays` module. These include GPT3-generate text, lorem + ipsum, etc. + """ + available_text_types = writing_observer.sample_essays.TextTypes.__members__ + tt_list = list(available_text_types) + + print("\nText types\n=====\n") + for text_type, idx in zip(tt_list, range(len(tt_list))): + print(f"{idx}: {text_type}") + + idx = int(input("Please pick a text type: ")) + text_type = available_text_types[tt_list[int(idx)]] + print(f"Text type: {text_type}") + return text_type + + +async def set_text(kvs, student_id, text, docid): + ''' + Set a text for the student in redis. + ''' + document_id = f"test-doc-{docid}" + for kst in [KeyStateType.INTERNAL, KeyStateType.EXTERNAL]: + last_document_key = learning_observer.stream_analytics.helpers.make_key( + writing_observer.writing_analysis.last_document, + { + KeyField.STUDENT: student_id, + }, + kst + ) + document_key = learning_observer.stream_analytics.helpers.make_key( + writing_observer.writing_analysis.reconstruct, + { + KeyField.STUDENT: student_id, + EventField('doc_id'): document_id + }, + kst + ) + await kvs.set(last_document_key, {"document_id": document_id}) + await kvs.set(document_key, {"text": text}) + print("LDK", last_document_key) + print("DK", document_key) + + +async def main(): + learning_observer.offline.init() + kvs = learning_observer.kvs.KVS() + course = await select_course() + roster = await print_roster(course) + text_type = await select_text_type() + + texts = writing_observer.sample_essays.sample_texts( + text_type=text_type, + count=len(roster) + ) + + for student, text, idx in zip(roster, texts, range(len(roster))): + print(student) + await set_text(kvs, student[learning_observer.constants.USER_ID], text, idx) + +loop = asyncio.get_event_loop() +loop.run_until_complete(main()) diff --git a/scripts/restream.py b/scripts/restream.py new file mode 100644 index 000000000..3641b692f --- /dev/null +++ b/scripts/restream.py @@ -0,0 +1,133 @@ +'''ReStream + +Usage: + restream.py [--url=] [--extract-client] [--rate=] [--max-wait=] [--filelist] [--rename=auth.user_id] [--skip=] + +Options + --url= URL to connect [default: http://localhost:8888/wsapi/in/] + --extract-client Parse JSON and extract unannoted client-side event + --filelist File is a list of files to play at once + --rate= Throttle events to: timestamps / rate [default: 1] + --max-wait= Maximum delay (if throttling) + --rename= Rename students, randomly. If set, must be auth.user_id. + --skip= For performance, a list of events to skip (e.g. mouse) + +Overview: + * Restream logs from a file a web sockets server + * Helpful for testing + * Optional (todo): Capture server output + * Optional (todo): Handle AJAX + +The file list option starts streaming timestamps from the first +event. This is helpful for e.g. simulating 20 coglabs as one +session. It is not helpful for playing back what happened in one +class. + +''' + +import asyncio +import json +import random +import sys + +import aiofiles +import aiohttp +import docopt +import names + +print(docopt.docopt(__doc__)) + + +async def restream( + url, + filename, + rate, + max_wait, + extract_client, + rename, + skip +): + ''' + Formerly, the simplest function in the world. + + Open up a session, then a socket, and then stream lines from the + file to the socket. + ''' + old_ts = None + if isinstance(skip, str): + skip = set(",".split(skip)) + elif skip is None: + skip = set() + else: + raise Exception("Bug in skip. Debug please.") + + if rename is not None: + new_id = "rst-{name}-{number}".format( + name=names.get_first_name(), + number=random.randint(1, 1000) + ) + + async with aiohttp.ClientSession() as session: + async with session.ws_connect(url) as web_socket: + async with aiofiles.open(filename) as log_file: + async for line in log_file: + if filename.endswith('.study.log'): + # HACK the `.study.log` include the event along + # with a timestamp + json_line = json.loads(line.split('\t')[0]) + else: + json_line = json.loads(line) + + if rate is not None: + if json_line['client']['event'] in skip: + continue + new_ts = json_line["server"]["time"] + if old_ts is not None: + delay = (new_ts - old_ts) / rate + if max_wait is not None: + delay = min(delay, float(max_wait)) + print(line) + print(delay) + await asyncio.sleep(delay) + old_ts = new_ts + if extract_client or rename: + if extract_client: + json_line = json_line['client'] + if rename: + if 'auth' not in json_line: + json_line['auth'] = {} + json_line['auth']['user_id'] = new_id + jline = json.dumps(json_line) + + await web_socket.send_str(jline.strip()) + return True + + +async def run(): + ''' + Is there a way to clean up so we don't have an ever-expanding + block indent? + ''' + args = docopt.docopt(__doc__) + print(args) + if args["--filelist"]: + filelist = [s.strip() for s in open(args['']).readlines()] + else: + filelist = [args['']] + coroutines = [ + restream( + url=args["--url"], + filename=filename, + rate=float(args["--rate"]), + max_wait=args["--max-wait"], + extract_client=args['--extract-client'], + rename=args['--rename'], + skip=args.get('--skip', None) + ) for filename in filelist] + await asyncio.gather(*coroutines) + +try: + asyncio.run(run()) +except aiohttp.client_exceptions.ServerDisconnectedError: + print("Could not connect to server") + sys.exit(-1) diff --git a/scripts/stream_writing.py b/scripts/stream_writing.py new file mode 100644 index 000000000..8e08e7a26 --- /dev/null +++ b/scripts/stream_writing.py @@ -0,0 +1,229 @@ +''' +Stream fake writing data + +Usage: + stream_writing.py [--url=url] [--streams=n] + [--ici=sec,s,s] + [--users=user_id,uid,uid] + [--source=filename,fn,fn] + [--gdids=googledoc_id,gdi,gdi] + [--text-length=5] + [--fake-name] + [--gpt3=type] + +Options: + --url=url URL to connect [default: http://localhost:8888/wsapi/in/] + --streams=N How many students typing in parallel? [default: 1] + --users=user_id,uid,uid Supply the user ID + --ici=secs,secs Mean intercharacter interval [default: 0.1] + --gdids=gdi,gdi,gdi Google document IDs of spoofed documents + --source=filename Stream text instead of lorem ipsum + --text-length=n Number of paragraphs of lorem ipsum [default: 5] + --fake-name Use fake names (instead of test-user) + --gpt3=type Use GPT-3 generated data ('story' or 'argument') + +Overview: + Stream fake keystroke data to a server, emulating Google Docs + extension log events. +''' + +import aiohttp +import asyncio +import docopt +import json +import loremipsum +import names +import random +import sys +import time + + +ARGS = docopt.docopt(__doc__) +print(ARGS) + +STREAMS = int(ARGS["--streams"]) + + +def increment_port(url): + ''' + http://localhost:8888/wsapi/in/ ==> http://localhost:8889/wsapi/in/ + + Not very general; hackish. + ''' + # Split the URL into the protocol, domain, and port + protocol, blank, domain, *rest = url.split("/") + + # Split the domain into its parts + parts = domain.split(":") + + # Get the current port + current_port = int(parts[-1]) + + # Increment the port by 1 + new_port = current_port + 1 + + # Build the new URL + new_url = f"{protocol}//{':'.join(parts[:-1])}:{new_port}/{'/'.join(rest)}" + + return new_url + + +def argument_list(argument, default): + ''' + Parse a list argument, with defaults. Allow one global setting, or per-stream + settings. IF `STREAMS` is 3: + + None ==> default() + "file.txt" ==> ["file.txt", "file.txt", "file.txt"] + "a,b,c" ==> ["a", "b", "c"] + "a,b" ==> exit + ''' + list_string = ARGS[argument] + if list_string is None: + list_string = default + if callable(list_string): + list_string = list_string() + if list_string is None: + return list_string + if "," in list_string: + list_string = list_string.split(",") + if isinstance(list_string, str): + list_string = [list_string] * STREAMS + if len(list_string) != STREAMS: + print(f"Failure: {list_string}\nfrom {argument} should make {STREAMS} items") + sys.exit(-1) + return list_string + +# TODO what is `source_files` supposed to be? +# when running this script for the workshop, we should either +# 1) move gpt3 texts out of writing observer (dependency hell) OR +# 2) avoid using `--gpt3` parameter and use loremipsum instead +source_files = None + +if ARGS["--gpt3"] is not None: + import writing_observer.sample_essays + TEXT = writing_observer.sample_essays.GPT3_TEXTS[ARGS["--gpt3"]] + STREAMS = len(TEXT) +elif source_files is None: + TEXT = ["\n".join(loremipsum.get_paragraphs(int(ARGS.get("--text-length", 5)))) for i in range(STREAMS)] +else: + TEXT = [open(filename).read() for filename in source_files] + +ICI = argument_list( + '--ici', + "0.1" +) + +DOC_IDS = argument_list( + "--gdids", + lambda: [f"fake-google-doc-id-{i}" for i in range(STREAMS)] +) + +source_files = argument_list( + '--source', + None +) + +if ARGS['--users'] is not None: + USERS = argument_list('--users', None) +elif ARGS['--fake-name']: + USERS = [names.get_first_name() for i in range(STREAMS)] +else: + USERS = ["test-user-{n}".format(n=i) for i in range(STREAMS)] + +assert len(TEXT) == STREAMS, "len(filenames) != STREAMS." +assert len(ICI) == STREAMS, "len(ICIs) != STREAMS." +assert len(USERS) == STREAMS, "len(users) != STREAMS." +assert len(DOC_IDS) == STREAMS, "len(document IDs) != STREAMS." + +def current_millis(): + return round(time.time() * 1000) + + +def insert(index, text, doc_id): + ''' + Generate a minimal 'insert' event, of the type our Google Docs extension + might send, but with irrelevant stuff stripped away. This is just for + testing. + ''' + return { + "bundles": [{'commands': [{"ibi": index, "s": text, "ty": "is"}]}], + "event": "google_docs_save", + "source": "org.mitros.writing_analytics", + "doc_id": doc_id, + "origin": "stream_test_script", + "timestamp": current_millis() + } + + +def identify(user): + ''' + Send a token identifying user. + + TBD: How we want to manage this. We're still figuring out auth/auth. + This might just be scaffolding code for now, or we might do something + along these lines. + ''' + return [ + { + "event": "test_framework_fake_identity", + "source": "org.mitros.writing_analytics", + "user_id": user, + "origin": "stream_test_script" + }, { + "event": "metadata_finished", + "source": "org.mitros.writing_analytics", + "origin": "stream_test_script" + } + ] + + +async def stream_document(text, ici, user, doc_id): + ''' + Send a document to the server. + ''' + retries_remaining = 5 + done = False + url = ARGS["--url"] + while not done: + try: + async with aiohttp.ClientSession() as session: + async with session.ws_connect(url) as web_socket: + commands = identify(user) + for command in commands: + await web_socket.send_str(json.dumps(command)) + for char, index in zip(text, range(len(text))): + command = insert(index + 1, char, doc_id) + await web_socket.send_str(json.dumps(command)) + # We probably want something that doesn't go as big and which isn't as close to zero as often. Perhaps weibull with k=1.5? + await asyncio.sleep(random.expovariate(lambd=1/float(ici))) + done = True + except aiohttp.client_exceptions.ClientConnectorError: + print("Failed to connect on " + url) + retries_remaining = retries_remaining - 1 + if retries_remaining == 0: + print("Failed to connect. Tried ports up to "+url) + done = True + url = increment_port(url) + print("Trying to connect on " + url) + + +async def run(): + ''' + Create a task to send the document to the server, and wait + on it to finish. In the future, we'll create several tasks. + ''' + streamers = [ + asyncio.create_task(stream_document(text, ici, user, doc_id)) + for (text, ici, user, doc_id) in zip(TEXT, ICI, USERS, DOC_IDS) + ] + print(streamers) + for streamer in streamers: + await streamer + print(streamers) + +try: + asyncio.run(run()) +except aiohttp.client_exceptions.ServerDisconnectedError: + print("Could not connect to server") + sys.exit(-1) diff --git a/scripts/traditional_dashboard.py b/scripts/traditional_dashboard.py new file mode 100644 index 000000000..c0e8f6f45 --- /dev/null +++ b/scripts/traditional_dashboard.py @@ -0,0 +1,49 @@ +''' +This is a test script for a web socket interaction with the +original dashboard. + +We do need a better command line interface, but this is okay for +debugging for now. +''' + +import argparse + +import aiohttp +import asyncio + +parser = argparse.ArgumentParser( + description=__doc__.strip() +) +parser.add_argument( + '--single', action='store_true', + help="Print just a single message, then disconnect" +) + +parser.add_argument( + '--url', default='http://localhost:8889/wsapi/dashboard?module=writing_observer&course=12345', + help="We connect to this URL and grab data." +) + + +args = parser.parse_args() + + +async def main(): + async with aiohttp.ClientSession() as session: + print("Connecting to", args.url) + async with session.ws_connect( + args.url, timeout=0.5) as ws: + async for msg in ws: + print(msg.type) + if msg.type == aiohttp.WSMsgType.TEXT: + print("Message") + print(msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + print("Error") + print(msg) + break + if args.single: + return True + return True + +asyncio.run(main()) diff --git a/test.sh b/test.sh new file mode 100755 index 000000000..5c1856934 --- /dev/null +++ b/test.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# test.sh +# This script iterates over the passed in `PACKAGES`, +# change directory to that path, and execute the `test.sh` +# file. +set -e # Exit immediately if a command exits with a non-zero status + +# Parse arguments +PACKAGES=("$@") + +# Check for no arguments +if [ ${#PACKAGES[@]} -eq 0 ]; then + echo "Error: No packages specified." + echo "Usage:" + echo "$0 /path/to/package_a path/to/package_b ..." + echo "or" + echo "make test PKG=/path/to/package_a" + exit 1 +fi + +echo "Running tests for: ${PACKAGES[*]}" + +# Run tests for each package +for package in "${PACKAGES[@]}"; do + ABS_PACKAGE_PATH="$(cd "$(dirname "$package")" && pwd)/$(basename "$package")/" + PACKAGE_TEST_SCRIPT="$ABS_PACKAGE_PATH/test.sh" + + # Debug directory + if [ -d "$ABS_PACKAGE_PATH" ]; then + echo "Directory exists: $ABS_PACKAGE_PATH" + else + echo "Warning: Directory does not exist: $ABS_PACKAGE_PATH, skipping." + continue + fi + + # Check if the file exists + if [ -f "$PACKAGE_TEST_SCRIPT" ]; then + echo "Running $PACKAGE_TEST_SCRIPT..." + (cd "$ABS_PACKAGE_PATH" && ./test.sh) + else + echo "Warning: No test.sh found in $package, skipping." + fi +done diff --git a/ux/README.md b/ux/README.md new file mode 100644 index 000000000..6c22c3fdd --- /dev/null +++ b/ux/README.md @@ -0,0 +1,7 @@ +UX Mockup +========= + +This is a UX mockup. The usage terms on the data we used are a bit +ambiguous, so we are not including data in the git repo. We might add +the avatars, since we have permission, but for now, we're omitting due +to size. \ No newline at end of file diff --git a/ux/api b/ux/api new file mode 120000 index 000000000..966e2f7d7 --- /dev/null +++ b/ux/api @@ -0,0 +1 @@ +../uncommitted/ux-api/ \ No newline at end of file diff --git a/ux/deane.html b/ux/deane.html new file mode 100644 index 000000000..56d2f0c1b --- /dev/null +++ b/ux/deane.html @@ -0,0 +1,15 @@ + + + + + + + + +

Deane

+
+ + + diff --git a/ux/deane.js b/ux/deane.js new file mode 100644 index 000000000..696197400 --- /dev/null +++ b/ux/deane.js @@ -0,0 +1,185 @@ +const width = 960; // svg width +const height = 500; // svg height +const margin = 5; // svg margin +const padding = 5; // svg padding +const adj = 30; + +/*-------------------------*\ +* * +| Generic utility functions | +| for testing and debugging | +* * +\*-------------------------*/ + + +function consecutive_array(n) { + /* + This creates an array of length n [0,1,2,3,4...n] + */ + return Array(n).fill().map((e,i)=>i+1); +}; + +function zip(a1, a2) { + /* + Clone of Python's zip. + [[1,1],[2,3],[4,5]] => [[1,2,4],[1,3,5]] + */ + return a1.map(function(e, i) { + return [e, a2[i]]; + }); +} + + + +/*-------------------------*\ +* * +| Deane graph code | +* * +\*-------------------------*/ + +export const name = 'deane3'; + +const LENGTH = 30; + +function dummy_data(length) { + /* + Create sample data for a Deane graph. This is basically a random + upwards-facing line for the length, with the cursor somewhere + in between. Totally non-realistic. + */ + function randn_bm() { + /* Approximately Gaussian distribution, mean 0.5 + From https://stackoverflow.com/questions/25582882/javascript-math-random-normal-distribution-gaussian-bell-curve */ + let u = 0, v = 0; + while(u === 0) u = Math.random(); //Converting [0,1) to (0,1) + while(v === 0) v = Math.random(); + let num = Math.sqrt( -2.0 * Math.log( u ) ) * Math.cos( 2.0 * Math.PI * v ); + num = num / 10.0 + 0.5; // Translate to 0 -> 1 + if (num > 1 || num < 0) return randn_bm(); // resample between 0 and 1 + return num; + } + + + function length_array(x) { + /* + Essay length + */ + return x.map((e,i)=> (e*randn_bm(e) + e)/2); + } + + function cursor_array(x) { + /* + Essay cursor position + */ + var length_array = x.map((e,i)=> (e*Math.random()/2 + e*randn_bm()/2)); + return length_array; + } + + var x_edit = consecutive_array(length); // edit number, for X axis + var y_length = length_array(x_edit); // total essay length + var y_cursor = cursor_array(y_length); // cursor position + return { + 'cursor': y_cursor, + 'length': y_length + }; +}; + + +export function setup_deane_graph(div) { + /* + Create UX elements, without data + */ + var svg = div.append("svg") + .attr("preserveAspectRatio", "xMinYMin meet") + .attr("viewBox", "-" + + adj + " -" + + adj + " " + + (width + adj *3) + " " + + (height + adj*3)) + .style("padding", padding) + .style("margin", margin) + .style("border", "1px solid lightgray") + .classed("svg-content", true); + + // Line graph for essay length + svg.append('g') + .append('path') + .attr('class', 'essay-length-lines') + .attr('fill', 'none') + .attr('stroke', 'black') + .attr('stroke-width','2'); + + // Line graph for cursor position + svg.append('g') + .append('path') + .attr('class', 'essay-cursor-lines') + .attr('fill', 'none') + .attr('stroke', 'blue') + .attr('stroke-width','3'); + + // Add x-axis + svg.append('g') // create a element + .attr("transform", "translate(0, "+height+")") + .attr('class', 'x-axis'); // specify classes + + // Add y-axis + svg.append('g') // create a element + .attr('class', 'y-axis') // specify classes + return svg; +}; + +export function populate_deane_graph_data(div, data, max_x=null, max_y=null) { + var svg = div.select('svg'); + if(max_x === null) { + max_x = data['length'].length; + } + if(max_y === null) { + max_y = Math.max(...data['length']); + } + + const yScale = d3.scaleLinear().range([height, 0]).domain([0, max_y]) + const xScale = d3.scaleLinear().range([0, width]).domain([0, max_x]) + + var lines = d3.line(); + + var x_edit = consecutive_array(data['length'].length); + + var length_data = zip(x_edit.map(xScale), data['length'].map(yScale)); + var cursor_data = zip(x_edit.map(xScale), data['cursor'].map(yScale)); + + var pathData = lines(length_data); + svg.select('.essay-length-lines') + .attr('d', pathData); + + pathData = lines(cursor_data); + svg.select('.essay-cursor-lines') + .attr('d', pathData) + + var xAxis = d3.axisBottom() + .ticks(4) // specify the number of ticks + .scale(xScale); + var yAxis = d3.axisLeft() + .ticks(4) + .scale(yScale); // specify the number of ticks + + svg.select('.x-axis') + .call(xAxis); // let the axis do its thing + + svg.select('.y-axis') + .call(yAxis); // let the axis do its thing + +} + +export function deane_graph(div) { + var svg = setup_deane_graph(div); + + var data = dummy_data(LENGTH); + + var y_length = data['length']; + var y_cursor = data['cursor']; + + populate_deane_graph_data(div, data); + return svg; +} + +d3.select("#debug_testing_deane").call(deane_graph); diff --git a/ux/media b/ux/media new file mode 120000 index 000000000..7e6fe8f8c --- /dev/null +++ b/ux/media @@ -0,0 +1 @@ +../uncommitted/ux-media/ \ No newline at end of file diff --git a/ux/outline.html b/ux/outline.html new file mode 100644 index 000000000..c2ff6663b --- /dev/null +++ b/ux/outline.html @@ -0,0 +1,15 @@ + + + + + + + + +

Outline

+
+ + + diff --git a/ux/outline.js b/ux/outline.js new file mode 100644 index 000000000..d8c3fb6df --- /dev/null +++ b/ux/outline.js @@ -0,0 +1,67 @@ +const LENGTH = 30; + +const width = 960; +const height = 650; +const margin = 5; +const padding = 5; +const adj = 30; + +export const name = 'outline'; + +var test_data = { "outline": [ + { "section": "Problem 1", "length": 300}, + { "section": "Problem 2", "length": 30}, + { "section": "Problem 3", "length": 900}, + { "section": "Problem 4", "length": 1200}, + { "section": "Problem 5", "length": 400} +]}; + +var maximum = 1500; + +export function outline(div, data=test_data) { + div.html(""); + //div.append("p").text("In progress -- just piping data in") + var svg = div.append("svg") + .attr("preserveAspectRatio", "xMinYMin meet") + .attr("viewBox", "-" + + adj + " -" + + adj + " " + + (width + adj *3) + " " + + (height + adj*3)) + .style("padding", padding) + .style("margin", margin) + .style("border", "1px solid lightgray") + .classed("svg-content", true); + + console.log(data.outline); + + var outline_data = data.outline.reverse(); + const yScale = d3.scaleBand().range([height, 0]).domain(outline_data.map(d=>d['section'])); + const xScale = d3.scaleLinear().range([0, width]).domain([0, 1]) + + var normed_x = (x) => x / maximum; + + svg.selectAll(".barRect") + .data(outline_data) + .enter() + .append("rect") + .attr("x", (d) => xScale(1-normed_x(d['length']))) + .attr("y", function(d) { return yScale(d['section']);}) + .attr("width", function(d) { return xScale(normed_x(d['length']));}) + .attr("height", yScale.bandwidth()) + .attr("fill", "#ccccff"); + + svg.selectAll(".barText") + .data(outline_data) + .enter() + .append("text") + .attr("x", 0) + .attr("y", (d) => yScale(d['section']) + yScale.bandwidth()/2) + .attr("font-size", "3.5em") + .attr("font-family", 'BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif') + .text((d) => d['section']); + + return svg; +} + +d3.select("#debug_testing_outline").call(outline).call(console.log); diff --git a/ux/package.json b/ux/package.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/ux/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/ux/summary_stats.html b/ux/summary_stats.html new file mode 100644 index 000000000..de487579b --- /dev/null +++ b/ux/summary_stats.html @@ -0,0 +1,15 @@ + + + + + + + + +

Summary Stats

+
+ + + diff --git a/ux/summary_stats.js b/ux/summary_stats.js new file mode 100644 index 000000000..d86b06b4d --- /dev/null +++ b/ux/summary_stats.js @@ -0,0 +1,124 @@ +const LENGTH = 30; + +const width = 960; +const height = 650; +const margin = 5; +const padding = 5; +const adj = 30; + +export const name = 'summary_stats'; + +var bar_names = { + "speed": "Typing speed", + "essay_length": "Length", + "writing_time": "Writing time", + "text_complexity": "Text complexity", + "time_idle": "Time idle" +}; + +var maxima = { + "ici": 1000, + "speed": 1300, + "essay_length": 10000, + "writing_time": 60, + "text_complexity": 12, + "time_idle": 30 +} + +var test_data = { + "ici": 729.664923175084, + "essay_length": 2221, + "writing_time": 42.05237247614963, + "text_complexity": 4.002656228025943, + "time_idle": 0.24548328432300075 +}; + + +export function summary_stats(div, data=test_data) { + div.html(""); + div.append("p").text("In progress -- just piping data in") + var svg = div.append("svg") + .attr("preserveAspectRatio", "xMinYMin meet") + .attr("viewBox", "-" + + adj + " -" + + adj + " " + + (width + adj *3) + " " + + (height + adj*3)) + .style("padding", padding) + .style("margin", margin) + .style("border", "1px solid lightgray") + .classed("svg-content", true); + + data['speed'] = 60000 / data['ici'] + + var data_ordered = [ + ['essay_length', data['essay_length']], + ['time_idle', data['time_idle']], + ['writing_time', data['writing_time']], + ['text_complexity', data['text_complexity']], + ['speed', data['speed']] + ].reverse(); + + const yScale = d3.scaleBand().range([height, 0]).domain(data_ordered.map(d=>d[0])); //labels); + const xScale = d3.scaleLinear().range([0, width]).domain([0, 1]) + + var y = (d) => data[d]; + var normed_x = (x) => data[x] / maxima[x]; + + function rendertime(t) { + function str(i) { + if(i<10) { + return "0"+String(i); + } + return String(i) + } + var seconds = Math.floor((t - Math.floor(t)) * 60); + var minutes = Math.floor(t) % 60; + var hours = Math.floor(t/60) % 60; + var rendered = str(seconds); + if (minutes>0 || hours>0) { + rendered = str(minutes)+":"+rendered; + } else { + rendered = rendered + " sec"; + } + if (hours>0) { + rendered = str(rendered)+":"+rendered; + } + return rendered + } + + function label(d) { + var prettyprint = { + 'essay_length': (d) => String(d) +" characters", + 'time_idle': rendertime, + 'writing_time': rendertime, + 'text_complexity': Math.floor, + 'speed': (d) => Math.floor(d) + " CPM" + } + return bar_names[d[0]] + ": " + prettyprint[d[0]](String(d[1])); + } + + svg.selectAll(".barRect") + .data(data_ordered) + .enter() + .append("rect") + .attr("x", xScale(0)) + .attr("y", (d) => yScale(d[0])) + .attr("width", (d) => xScale(normed_x(d[0]))) + .attr("height", yScale.bandwidth()) + .attr("fill", "#ccccff") + + svg.selectAll(".barText") + .data(data_ordered) + .enter() + .append("text") + .attr("x", 0) + .attr("y", (d) => yScale(d[0]) + yScale.bandwidth()/2) + .attr("font-size", "3.5em") + .attr("font-family", 'BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif') + .text((d) => label(d)) + ; + return svg; +} + +d3.select("#debug_testing_summary").call(summary_stats).call(console.log); diff --git a/ux/typing.html b/ux/typing.html new file mode 100644 index 000000000..1253eda43 --- /dev/null +++ b/ux/typing.html @@ -0,0 +1,15 @@ + + + + + + + +

Essay

+

+

+ + + diff --git a/ux/typing.js b/ux/typing.js new file mode 100644 index 000000000..e9f30436f --- /dev/null +++ b/ux/typing.js @@ -0,0 +1,59 @@ +export const name = 'typing'; + +const SAMPLE_TEXT = "I like the goals of this petition and the bills, but as drafted, these bills just don't add up. We want to put our economy on hold. We definitely need a rent freeze. For that to work, we also need a mortgage freeze, not a mortgage forbearance. The difference is that in a mortgage forbearance, interest adds up and at the end, your principal is higher than when you started. In a mortgage freeze, the principal doesn't change -- you just literally push back all payments by a few months."; + +export function typing(div, ici=200, text=SAMPLE_TEXT) { + function randn_bm() { + /* Approximately Gaussian distribution, mean 0.5 + From https://stackoverflow.com/questions/25582882/javascript-math-random-normal-distribution-gaussian-bell-curve */ + let u = 0, v = 0; + while(u === 0) u = Math.random(); //Converting [0,1) to (0,1) + while(v === 0) v = Math.random(); + let num = Math.sqrt( -2.0 * Math.log( u ) ) * Math.cos( 2.0 * Math.PI * v ); + num = num / 10.0 + 0.5; // Translate to 0 -> 1 + if (num > 1 || num < 0) return randn_bm(); // resample between 0 and 1 + return num; + } + + function sample_ici(typing_delay=200) { + /* + Intercharacter interval -- how long between two keypresses + + We do an approximate Gaussian distribution around the + */ + return typing_delay * randn_bm() * 2; + } + + var start = 0; + var stop = 1; + const MAXIMUM_LENGTH = 250; + + function updateText() { + //document.getElementsByClassName("typing")[0].innerText=text.substr(start, stop-start); + div.text(text.substr(start, stop-start)); + stop = stop + 1; + + if(stop > text.length) { + stop = 1; + start = 0; + } + + start = Math.max(start, stop-MAXIMUM_LENGTH); + while((text[start] != ' ') && (start>0) && (startstop) { + start=stop; + } + + if(div.size() > 0) { + setTimeout(updateText, sample_ici(ici)); + }; + } + setTimeout(updateText, sample_ici(50)); +}; + +//typing(); + +d3.select(".typingdebug-typing").call(typing); + diff --git a/ux/ux.css b/ux/ux.css new file mode 100644 index 000000000..8bb32e85d --- /dev/null +++ b/ux/ux.css @@ -0,0 +1,38 @@ +.wa-row-tile { + min-height: 350px; +} + +.wa-col-tile { + min-height: 350px; +} + +/* Flip based on https://davidwalsh.name/css-flip */ +.wa-flip-container { + perspective: 1000px; +} + +.wa-flipper { + transition: 0.6s; + transform-style: preserve-3d; + position: relative; +} + +.wa-front, .wa-back { + backface-visibility: hidden; + position: absolute; + top: 20px; + left: 20px; +} + +/* front pane, placed above back */ +.wa-front { + z-index: 2; + + /* for firefox 31 */ + transform: rotateY(0deg); +} + +/* back, initially hidden pane */ +.wa-back { + transform: rotateY(180deg); +} diff --git a/ux/ux.html b/ux/ux.html new file mode 100644 index 000000000..2ff54a621 --- /dev/null +++ b/ux/ux.html @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + Writing Analysis + + + + + + + +
+
+ + +
+
+ + + + diff --git a/ux/writing.js b/ux/writing.js new file mode 100644 index 000000000..afcc03cf6 --- /dev/null +++ b/ux/writing.js @@ -0,0 +1,62 @@ +import { deane_graph } from './deane.js' +import { typing } from './typing.js' +import { summary_stats } from './summary_stats.js' +import { outline } from './outline.js' + +var student_data = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16],[17,18,19]]; + +const tile_template = document.getElementById('template-tile').innerHTML + +function populate_tiles(tilesheet) { + var rows=tilesheet.selectAll("div.wa-row-tile") + .data(student_data) + .enter() + .append("div") + .attr("class", "tile is-ancestor wa-row-tile"); + + var cols=rows.selectAll("div.wa-col-tile") + .data(function(d) { return d; }) + .enter() + .append("div") + .attr("class", "tile is-parent wa-col-tile wa-flip-container is-3") + .html(function(d) { + return Mustache.render(tile_template, d); + /*{ + name: d.name, + body: document.getElementById('template-deane-tile').innerHTML + });*/ + }) + .each(function(d) { + d3.select(this).select(".typing-text").call(typing, d.ici, d.essay); + }) + .each(function(d) { + d3.select(this).select(".deane").call(deane_graph); + }) + .each(function(d) { + d3.select(this).select(".summary").call(summary_stats, d); + }) + .each(function(d) { + d3.select(this).select(".outline").call(outline, d); + }); +} + +function select_tab(tab) { + return function() { + d3.selectAll(".tilenav").classed("is-active", false); + d3.selectAll(".tilenav-"+tab).classed("is-active", true); + d3.selectAll(".wa-tilebody").classed("is-hidden", true); + d3.selectAll("."+tab).classed("is-hidden", false); + } +}; + +var tabs = ["typing", "deane", "summary", "outline", "timeline", "contact"]; +for(var i=0; i