From 91334a46b589149fe8a99a684a24f84ca73cd3f7 Mon Sep 17 00:00:00 2001 From: Mohammad Reza Zamani Date: Tue, 1 Sep 2026 23:59:56 +0330 Subject: [PATCH] Adobe Connect Bridge free edition (meetletacbfree) v1.1.3 Co-authored-by: Cursor --- .gitignore | 47 + COPYING.txt | 674 +++++ README.md | 317 +++ ajax/conntest.php | 41 + ajax/registration.php | 83 + ajax/settings_reset.php | 69 + ...kup_meetletacbfree_activity_task.class.php | 41 + .../backup_meetletacbfree_stepslib.php | 38 + ...ore_meetletacbfree_activity_task.class.php | 48 + .../restore_meetletacbfree_stepslib.php | 104 + classes/admin/access_reset_ui.php | 153 ++ classes/admin/setting_featureaccess.php | 85 + classes/admin/setting_registration.php | 256 ++ classes/admin/setting_rolemapping.php | 88 + classes/admin/settingspage_tabs.php | 49 + classes/api/client.php | 398 +++ classes/api/service.php | 1642 ++++++++++++ classes/event/course_module_viewed.php | 34 + classes/event/meeting_joined.php | 46 + classes/external/get_content_launch_url.php | 97 + classes/external/get_join_url.php | 126 + classes/external/get_meeting_status.php | 86 + classes/external/get_recording_launch_url.php | 117 + classes/external/get_tab.php | 346 +++ classes/external/rename_recording.php | 108 + classes/external/search_server_meetings.php | 110 + classes/local/config.php | 655 +++++ classes/local/conntest_runner.php | 263 ++ classes/local/content.php | 196 ++ classes/local/featureaccess.php | 544 ++++ classes/local/icons.php | 59 + classes/local/instance.php | 98 + classes/local/meetingstatus.php | 263 ++ classes/local/privacy_policy.php | 83 + classes/local/recordings.php | 127 + classes/local/roles.php | 486 ++++ classes/local/roomsession.php | 91 + classes/local/server_guide.php | 158 ++ classes/local/sso.php | 175 ++ classes/local/usererror.php | 397 +++ classes/output/renderer.php | 38 + classes/output/view_page.php | 130 + classes/privacy/provider.php | 373 +++ classes/registration/api_client.php | 113 + classes/registration/crypto.php | 175 ++ classes/registration/error_mapper.php | 135 + classes/registration/manager.php | 616 +++++ .../registration/registration_exception.php | 88 + classes/registration/store.php | 139 ++ classes/task/registration_heartbeat.php | 42 + classes/task/sync_recordings.php | 61 + conntest.php | 132 + db/access.php | 98 + db/caches.php | 52 + db/install.php | 37 + db/install.xml | 118 + db/services.php | 84 + db/tasks.php | 46 + db/upgrade.php | 1063 ++++++++ download_content.php | 51 + index.php | 55 + javascript/featureaccess_matrix.js | 54 + javascript/meetlet_modal_scroll.js | 110 + javascript/modform.js | 480 ++++ javascript/settings_access_reset.js | 420 ++++ javascript/settings_conntest.js | 307 +++ javascript/settings_registration.js | 334 +++ javascript/settings_toggles.js | 146 ++ javascript/view.js | 1029 ++++++++ lang/en/meetletacbfree.php | 570 +++++ lang/fa/meetletacbfree.php | 572 +++++ lib.php | 584 +++++ mod_form.php | 464 ++++ pix/adobeconnect.png | Bin 0 -> 35611 bytes pix/adobeconnect.svg | 19 + pix/adobeconnect_official.svg | 1 + pix/icon-128.png | Bin 0 -> 6159 bytes pix/icon-24.png | Bin 0 -> 1001 bytes pix/icon-32.png | Bin 0 -> 1275 bytes pix/icon-64.png | Bin 0 -> 2864 bytes pix/icon.png | Bin 0 -> 6159 bytes pix/icon.svg | 24 + pix/meetlet_logo.png | Bin 0 -> 6764 bytes pix/monologo.png | Bin 0 -> 2864 bytes pix/monologo.svg | 24 + settings.php | 967 ++++++++ styles.css | 2202 +++++++++++++++++ templates/participant_form.mustache | 19 + templates/participant_form_add.mustache | 17 + templates/participant_single_select.mustache | 22 + templates/view_page.mustache | 83 + version.php | 32 + view.php | 99 + 93 files changed, 21023 insertions(+) create mode 100644 .gitignore create mode 100644 COPYING.txt create mode 100644 README.md create mode 100644 ajax/conntest.php create mode 100644 ajax/registration.php create mode 100644 ajax/settings_reset.php create mode 100644 backup/moodle2/backup_meetletacbfree_activity_task.class.php create mode 100644 backup/moodle2/backup_meetletacbfree_stepslib.php create mode 100644 backup/moodle2/restore_meetletacbfree_activity_task.class.php create mode 100644 backup/moodle2/restore_meetletacbfree_stepslib.php create mode 100644 classes/admin/access_reset_ui.php create mode 100644 classes/admin/setting_featureaccess.php create mode 100644 classes/admin/setting_registration.php create mode 100644 classes/admin/setting_rolemapping.php create mode 100644 classes/admin/settingspage_tabs.php create mode 100644 classes/api/client.php create mode 100644 classes/api/service.php create mode 100644 classes/event/course_module_viewed.php create mode 100644 classes/event/meeting_joined.php create mode 100644 classes/external/get_content_launch_url.php create mode 100644 classes/external/get_join_url.php create mode 100644 classes/external/get_meeting_status.php create mode 100644 classes/external/get_recording_launch_url.php create mode 100644 classes/external/get_tab.php create mode 100644 classes/external/rename_recording.php create mode 100644 classes/external/search_server_meetings.php create mode 100644 classes/local/config.php create mode 100644 classes/local/conntest_runner.php create mode 100644 classes/local/content.php create mode 100644 classes/local/featureaccess.php create mode 100644 classes/local/icons.php create mode 100644 classes/local/instance.php create mode 100644 classes/local/meetingstatus.php create mode 100644 classes/local/privacy_policy.php create mode 100644 classes/local/recordings.php create mode 100644 classes/local/roles.php create mode 100644 classes/local/roomsession.php create mode 100644 classes/local/server_guide.php create mode 100644 classes/local/sso.php create mode 100644 classes/local/usererror.php create mode 100644 classes/output/renderer.php create mode 100644 classes/output/view_page.php create mode 100644 classes/privacy/provider.php create mode 100644 classes/registration/api_client.php create mode 100644 classes/registration/crypto.php create mode 100644 classes/registration/error_mapper.php create mode 100644 classes/registration/manager.php create mode 100644 classes/registration/registration_exception.php create mode 100644 classes/registration/store.php create mode 100644 classes/task/registration_heartbeat.php create mode 100644 classes/task/sync_recordings.php create mode 100644 conntest.php create mode 100644 db/access.php create mode 100644 db/caches.php create mode 100644 db/install.php create mode 100644 db/install.xml create mode 100644 db/services.php create mode 100644 db/tasks.php create mode 100644 db/upgrade.php create mode 100644 download_content.php create mode 100644 index.php create mode 100644 javascript/featureaccess_matrix.js create mode 100644 javascript/meetlet_modal_scroll.js create mode 100644 javascript/modform.js create mode 100644 javascript/settings_access_reset.js create mode 100644 javascript/settings_conntest.js create mode 100644 javascript/settings_registration.js create mode 100644 javascript/settings_toggles.js create mode 100644 javascript/view.js create mode 100644 lang/en/meetletacbfree.php create mode 100644 lang/fa/meetletacbfree.php create mode 100644 lib.php create mode 100644 mod_form.php create mode 100644 pix/adobeconnect.png create mode 100644 pix/adobeconnect.svg create mode 100644 pix/adobeconnect_official.svg create mode 100644 pix/icon-128.png create mode 100644 pix/icon-24.png create mode 100644 pix/icon-32.png create mode 100644 pix/icon-64.png create mode 100644 pix/icon.png create mode 100644 pix/icon.svg create mode 100644 pix/meetlet_logo.png create mode 100644 pix/monologo.png create mode 100644 pix/monologo.svg create mode 100644 settings.php create mode 100644 styles.css create mode 100644 templates/participant_form.mustache create mode 100644 templates/participant_form_add.mustache create mode 100644 templates/participant_single_select.mustache create mode 100644 templates/view_page.mustache create mode 100644 version.php create mode 100644 view.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a9f12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# PHP / Composer / Node (plugin source only) +/vendor/ +/node_modules/ +/composer.phar +.phpunit.result.cache +.phpunit.cache/ +/coverage/ +/amd/build/ +grunt/ +npm-debug.log* + +# Secrets & local config +.env +.env.* +!.env.example +*.pem +*.key +credentials.json +auth.json +secrets.* + +# Local IDE files (never push) +.claude/ +.cursor/ +.idea/ +.vscode/ +*.code-workspace +*.swp +*~ + +# Local junk +*.log +*.tmp +*.bak +*.orig +__pycache__/ +*.pyc +*.pyo + +# Local junk / archives (do not ignore plugin backup/moodle2 API) +*.zip +*.tar.gz diff --git a/COPYING.txt b/COPYING.txt new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/COPYING.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..80f221f --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +# MeetLet Adobe Connect Bridge — Marketplace Edition + +**Component:** `mod_meetletacbfree` +**Release:** 1.0.9-marketplace +**License:** [GNU GPL v3 or later](COPYING.txt) + +Connect Moodle to Adobe Connect for live classes, single sign-on (SSO), recordings, and shared meeting content — without a separate Windows agent or commercial license. + +Designed for the [Moodle Plugins Directory](https://moodledev.io/general/community/plugincontribution) and self-hosted Moodle sites that want a production-ready Adobe Connect integration out of the box. + +--- + +## Overview + +MeetLet Adobe Connect Bridge (Marketplace edition) is a Moodle activity module that lets teachers create or link Adobe Connect meetings directly from a course. Learners join with one click through HTTP header SSO. Site administrators configure the Adobe Connect server once; role mapping and feature access are managed from a clear, tabbed admin interface. + +This edition is free to install and use. It includes the core classroom workflow: meetings, SSO, role mapping, recordings, and content — everything most institutions need to run live sessions from Moodle. + +--- + +## Features + +| Area | What you get | +|------|----------------| +| **Meetings** | Create new Adobe Connect rooms or link existing server meetings from the activity form | +| **Single sign-on** | HTTP header SSO — users join without a separate Connect login; no Windows Agent required | +| **Role mapping** | Map Moodle roles (and individual users) to Adobe Connect Host, Presenter, or Participant | +| **Feature access matrix** | Per-role control over join, recordings, content, recording rename, Adobe editor, and early entry | +| **Recordings** | Browse, play, and rename session recordings; open the Adobe Connect recording editor | +| **Content** | View and download files uploaded to the meeting room | +| **Meeting status** | Live room status (scheduled, in progress, waiting for host, ended) refreshed from the server | +| **Connection test** | Step-by-step admin test for API login, folders, user provisioning, and SSO | +| **Server setup guide** | In-plugin checklist for `custom.ini`, `web.xml`, and Connect service configuration | +| **Activity defaults** | Site-wide meeting language, wait-for-host, and tab visibility with per-activity overrides | +| **Backup & restore** | Standard Moodle 2 backup API support for course copies and restores | +| **Privacy API** | GDPR-ready privacy provider for exported and deleted user data | +| **Optional site registration** | Transparent opt-in registration with MeetLet for update notices and support statistics (plugin works fully without it) | + +--- + +## Marketplace Edition Scope + +The Marketplace edition is a complete, self-contained integration for live teaching. The following capabilities are part of the **commercial MeetLet Adobe Connect Bridge** at [meetlet.net](https://meetlet.net): + +| Capability | Marketplace | Commercial | +|------------|:-----------:|:----------:| +| Meeting create / link | Yes | Yes | +| HTTP header SSO | Yes | Yes | +| Role mapping & access matrix | Yes | Yes | +| Recordings & content tabs | Yes | Yes | +| Connection test & server guide | Yes | Yes | +| MeetLet license platform | — | Yes | +| Attendance reports & CSV export | — | Yes | +| Offline MP4 generation & download | — | Yes | +| Windows Render Agent & video queue | — | Yes | +| Priority support & deployment consulting | — | Yes | + +If your organisation needs attendance analytics, offline video, or agent-based rendering at scale, the commercial edition extends this same plugin architecture with those enterprise features. + +--- + +## Requirements + +### Moodle + +| Requirement | Version | +|-------------|---------| +| Moodle | **4.5 LTS** or later (through **5.2**) | +| Plugin maturity | Stable | + +### Adobe Connect Server + +- Adobe Connect 11.x (or compatible) with XML API enabled +- Administrator account for API calls +- HTTP header authentication configured (`HTTP_AUTH_HEADER` and `HTTP_AUTH_TRUSTED_HOSTS` in `custom.ini`) +- `HeaderAuthenticationFilter` enabled in `web.xml` (see the in-plugin **Server setup guide** tab) + +### PHP (on the Moodle server) + +| Extension | Purpose | +|-----------|---------| +| **cURL** | Adobe Connect XML API and SSO requests | +| **JSON** | Role mapping and feature-access configuration | +| **OpenSSL** | HTTPS connections to Adobe Connect | +| **SimpleXML** or **libxml** | Parsing Adobe Connect API responses | + +Standard Moodle PHP requirements also apply. + +--- + +## Installation + +### From a ZIP package (recommended for production) + +1. Download or build a ZIP whose **top-level folder** is named `meetletacbfree` (not `mod_meetletacbfree`). +2. In Moodle, go to **Site administration → Plugins → Install plugins**. +3. Upload the ZIP and follow the on-screen prompts. +4. Complete the upgrade when prompted. + +### Manual copy + +1. Copy the `meetletacbfree` folder into your Moodle `mod/` directory: + + ``` + mod/meetletacbfree/ + ``` + +2. Log in as administrator and open **Site administration → Notifications** to run the upgrade. + +### After installation + +1. Go to **Site administration → Plugins → Activity modules → Adobe Connect Bridge (Marketplace edition)**. +2. Complete the **Server setup guide** and **Server settings** tabs. +3. Run the **Connection test** until all steps pass. +4. Add an **Adobe Connect** activity to a course and verify join and recordings. + +### Plugin activation (required) + +Free **plugin activation** with MeetLet is **required** before meetings, SSO join, recordings, and other features unlock. Activation is voluntary in the sense that nothing is sent until an administrator clicks the button — but the plugin does not function until activation succeeds. + +1. Open **Plugin activation** in the plugin admin settings (first tab). +2. Review what data is sent and click **Activate plugin**. +3. MeetLet receives your domain, installation ID, plugin version, and site contact details at `https://license.webatsign.com` (configurable). +4. A daily scheduled task re-validates activation **only while activated**. You can deactivate at any time; features lock again until you re-activate. + +--- + +## Configuration + +Admin settings are organised in tabs under **Site administration → Plugins → Activity modules → Adobe Connect Bridge (Marketplace edition)**: + +| Tab | Purpose | +|-----|---------| +| **Server setup guide** | Step-by-step Adobe Connect server configuration (`custom.ini`, `web.xml`, service restart) | +| **Server settings** | XML API URL, meeting domain, port, HTTPS, admin credentials, HTTP auth header, legacy email login | +| **Plugin activation** | Free activation with MeetLet (required to unlock all features; voluntary click — no silent phone-home) | +| **Connection test** | Live diagnostic: admin login, shared folders, test user, SSO, cleanup | +| **Meeting settings** | Default meeting language, wait-for-host, recordings tab and content tab visibility | +| **Access settings** | Moodle → Adobe Connect role mapping; site-wide feature-access matrix; per-activity override toggle | +| **About the plugin developer** | MeetLet product information and link to the commercial edition | + +Teachers can override role mapping and feature access per activity when the site allows it. + +--- + +## Commercial Edition + +The Marketplace edition is maintained by [MeetLet](https://meetlet.net) — specialists in Adobe Connect infrastructure for education and training. + +For attendance reporting, offline MP4 video, Windows Render Agent integration, licensing, and dedicated support, visit **[meetlet.net](https://meetlet.net)**. + +--- + +## Support + +- **Website:** [https://meetlet.net](https://meetlet.net) +- **Email:** info@meetlet.net +- **Moodle Plugins Directory:** use the plugin's support tab for community questions on the free edition + +--- + +## License + +This plugin is free software licensed under the **GNU General Public License v3.0 or later**. See [COPYING.txt](COPYING.txt) for the full text. + +--- + +--- + +# پل Adobe Connect میت‌لت — نسخه Marketplace + +**کامپوننت:** `mod_meetletacbfree` +**نسخه:** 1.0.9-marketplace +**مجوز:** [GNU GPL v3 یا بالاتر](COPYING.txt) + +اتصال مودل به Adobe Connect برای کلاس آنلاین، ورود یک‌مرحله‌ای (SSO)، ضبط جلسات و محتوای اشتراکی — بدون نیاز به Agent ویندوز یا لایسنس تجاری. + +این افزونه برای [فهرست افزونه‌های مودل](https://moodledev.io/general/community/plugincontribution) و سایت‌های خودمیزبان طراحی شده است. + +--- + +## معرفی + +افزونه **Adobe Connect Bridge (نسخه Marketplace)** یک ماژول فعالیت مودل است که به مدرس امکان می‌دهد جلسات Adobe Connect را از داخل درس ایجاد یا به جلسات موجود متصل کند. فراگیران با یک کلیک و از طریق SSO هدر HTTP وارد جلسه می‌شوند. مدیر سایت یک‌بار سرور Connect را پیکربندی می‌کند؛ نگاشت نقش‌ها و دسترسی‌ها از رابط مدیریتی تب‌دار انجام می‌شود. + +این نسخه رایگان و آماده استفاده در محیط تولید است و جریان اصلی کلاس آنلاین را پوشش می‌دهد. + +--- + +## امکانات + +| بخش | توضیح | +|-----|--------| +| **جلسات** | ایجاد اتاق جدید یا اتصال به جلسات موجود روی سرور | +| **ورود یک‌مرحله‌ای (SSO)** | بدون لاگین جداگانه Connect؛ بدون Agent ویندوز | +| **نگاشت نقش** | نگاشت نقش‌ها و کاربران مودل به Host، Presenter یا Participant در Connect | +| **ماتریس دسترسی** | کنترل نقش‌به‌نقش برای ورود، ضبط‌ها، محتوا، ویرایش نام ضبط، ویرایشگر Adobe و ورود زودهنگام | +| **ضبط‌ها** | مشاهده، پخش و تغییر نام ضبط‌ها؛ ویرایشگر ضبط Adobe Connect | +| **محتوا** | مشاهده و دانلود فایل‌های آپلودشده در اتاق جلسه | +| **وضعیت جلسه** | وضعیت زنده (برنامه‌ریزی‌شده، در حال برگزاری، انتظار برای میزبان، پایان‌یافته) | +| **تست اتصال** | تست گام‌به‌گام برای API، پوشه‌ها، کاربر آزمایشی و SSO | +| **راهنمای راه‌اندازی سرور** | چک‌لیست `custom.ini`، `web.xml` و سرویس Connect داخل افزونه | +| **پیش‌فرض‌های فعالیت** | زبان جلسه، انتظار برای میزبان و نمایش تب‌ها با امکان تغییر در هر فعالیت | +| **پشتیبان‌گیری و بازیابی** | پشتیبانی از API پشتیبان مودل ۲ | +| **حریم خصوصی** | ارائه‌دهنده Privacy API سازگار با GDPR | +| **ثبت اختیاری سایت** | ثبت شفاف و اختیاری در MeetLet برای اعلان به‌روزرسانی و آمار پشتیبانی (بدون ثبت هم کامل کار می‌کند) | + +--- + +## محدوده نسخه Marketplace + +نسخه Marketplace برای تدریس زنده کامل و مستقل است. قابلیت‌های زیر در **نسخه تجاری MeetLet Adobe Connect Bridge** در [meetlet.net](https://meetlet.net) ارائه می‌شوند: + +| قابلیت | Marketplace | تجاری | +|--------|:-----------:|:-----:| +| ایجاد / اتصال جلسه | بله | بله | +| SSO هدر HTTP | بله | بله | +| نگاشت نقش و ماتریس دسترسی | بله | بله | +| تب ضبط‌ها و محتوا | بله | بله | +| تست اتصال و راهنمای سرور | بله | بله | +| پلتفرم لایسنس MeetLet | — | بله | +| گزارش حضور و غیاب و خروجی CSV | — | بله | +| تولید و دانلود MP4 آفلاین | — | بله | +| Render Agent ویندوز و صف ویدیو | — | بله | +| پشتیبانی اولویت‌دار و مشاوره استقرار | — | بله | + +--- + +## پیش‌نیازها + +### مودل + +| مورد | نسخه | +|------|------| +| مودل | **۴.۵ LTS** یا بالاتر (تا **۵.۲**) | + +### سرور Adobe Connect + +- Adobe Connect 11.x (یا سازگار) با XML API فعال +- حساب مدیر برای فراخوانی API +- احراز هویت هدر HTTP (`HTTP_AUTH_HEADER` و `HTTP_AUTH_TRUSTED_HOSTS` در `custom.ini`) +- فعال‌سازی `HeaderAuthenticationFilter` در `web.xml` (راهنمای داخل افزونه) + +### PHP (روی سرور مودل) + +| افزونه | کاربرد | +|--------|--------| +| **cURL** | API و درخواست‌های SSO | +| **JSON** | پیکربندی نگاشت نقش و دسترسی | +| **OpenSSL** | اتصال HTTPS | +| **SimpleXML / libxml** | پردازش پاسخ API | + +--- + +## نصب + +### از فایل ZIP (توصیه‌شده) + +1. ZIP را طوری آماده کنید که پوشه سطح اول آن `meetletacbfree` باشد. +2. در مودل: **مدیریت سایت → افزونه‌ها → نصب افزونه‌ها**. +3. ZIP را آپلود و مراحل را تکمیل کنید. +4. ارتقای پایگاه داده را اجرا کنید. + +### کپی دستی + +پوشه `meetletacbfree` را در `mod/` قرار دهید و از **مدیریت سایت → اعلان‌ها** ارتقا را اجرا کنید. + +### پس از نصب + +1. **مدیریت سایت → افزونه‌ها → ماژول‌های فعالیت → Adobe Connect Bridge (Marketplace edition)** +2. تب‌های **راهنمای راه‌اندازی سرور** و **تنظیمات سرور** را تکمیل کنید. +3. **تست اتصال** را تا موفقیت همه مراحل اجرا کنید. +4. یک فعالیت **Adobe Connect** به درس اضافه و ورود و ضبط‌ها را آزمایش کنید. + +### فعال‌سازی پلاگین (الزامی) + +**فعال‌سازی رایگان پلاگین** با MeetLet برای باز شدن جلسات، ورود SSO، ضبط‌ها و سایر امکانات **لازم** است. فعال‌سازی داوطلبانه است به این معنا که تا کلیک مدیر هیچ داده‌ای ارسال نمی‌شود — اما بدون فعال‌سازی موفق، افزونه کار نمی‌کند. + +1. تب **فعال‌سازی پلاگین** را در تنظیمات افزونه باز کنید (تب اول). +2. داده‌های ارسالی را بخوانید و **فعال‌سازی پلاگین** را بزنید. +3. دامنه، شناسه نصب، نسخه افزونه و تماس سایت به `https://license.webatsign.com` ارسال می‌شود (قابل تغییر). +4. وظیفه روزانه فقط در صورت فعال بودن، وضعیت را تازه می‌کند. با غیرفعال‌سازی، امکانات دوباره قفل می‌شوند. + +--- + +## پیکربندی + +| تب | کاربرد | +|----|--------| +| **راهنمای راه‌اندازی سرور** | پیکربندی `custom.ini`، `web.xml` و راه‌اندازی مجدد سرویس | +| **تنظیمات سرور** | URL API، دامنه جلسه، پورت، HTTPS، اعتبار مدیر، هدر احراز هویت | +| **فعال‌سازی پلاگین** | فعال‌سازی رایگان با MeetLet (برای باز شدن همه امکانات؛ فقط با کلیک مدیر — بدون ارسال خودکار) | +| **تست اتصال** | تشخیص زنده: لاگین، پوشه‌ها، کاربر آزمایشی، SSO | +| **تنظیمات جلسه** | زبان پیش‌فرض، انتظار برای میزبان، نمایش تب ضبط‌ها و محتوا | +| **تنظیمات دسترسی** | نگاشت نقش؛ ماتریس دسترسی سراسری؛ امکان تغییر در هر فعالیت | +| **درباره توسعه‌دهنده افزونه** | اطلاعات MeetLet و لینک نسخه تجاری | + +--- + +## نسخه تجاری + +نسخه Marketplace توسط [MeetLet](https://meetlet.net) — متخصص زیرساخت Adobe Connect برای آموزش — نگهداری می‌شود. + +برای گزارش حضور، ویدیوی MP4 آفلاین، Render Agent ویندوز، لایسنس و پشتیبانی اختصاصی به **[meetlet.net](https://meetlet.net)** مراجعه کنید. + +--- + +## پشتیبانی + +- **وب‌سایت:** [https://meetlet.net](https://meetlet.net) +- **ایمیل:** info@meetlet.net + +--- + +## مجوز + +این افزونه نرم‌افزار آزاد تحت **GNU General Public License نسخه ۳ یا بالاتر** است. متن کامل در [COPYING.txt](COPYING.txt). diff --git a/ajax/conntest.php b/ajax/conntest.php new file mode 100644 index 0000000..9279d5a --- /dev/null +++ b/ajax/conntest.php @@ -0,0 +1,41 @@ +. + +/** + * Ajax endpoint: run one Adobe Connect connection-test step. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); + +require_once('../../../config.php'); + +use mod_meetletacbfree\local\conntest_runner; + +require_login(); +require_capability('moodle/site:config', context_system::instance()); +require_sesskey(); + +\mod_meetletacbfree\registration\manager::require_registered(); + +$step = required_param('step', PARAM_ALPHA); +$result = conntest_runner::run_step($step); + +header('Content-Type: application/json; charset=utf-8'); +echo json_encode($result); diff --git a/ajax/registration.php b/ajax/registration.php new file mode 100644 index 0000000..93a76b0 --- /dev/null +++ b/ajax/registration.php @@ -0,0 +1,83 @@ + true, + 'message' => get_string('registration_activated', 'meetletacbfree'), + 'summary' => manager::status_summary(), + ]); + exit; + } + + if ($action === 'recheck' || $action === 'refresh' || $action === 'validate') { + manager::recheck(true); + echo json_encode([ + 'success' => true, + 'message' => get_string('registration_rechecked', 'meetletacbfree'), + 'summary' => manager::status_summary(), + ]); + exit; + } + + if ($action === 'deactivate') { + manager::deactivate(); + echo json_encode([ + 'success' => true, + 'message' => get_string('registration_deactivated', 'meetletacbfree'), + 'summary' => manager::status_summary(), + ]); + exit; + } + + if ($action === 'status') { + echo json_encode([ + 'success' => true, + 'summary' => manager::status_summary(), + ]); + exit; + } + + throw new moodle_exception('invalidparameter'); +} catch (Throwable $e) { + $ex = registration_exception::from_throwable($e); + $mapped = error_mapper::from_exception($ex); + echo json_encode([ + 'success' => false, + 'error' => $mapped['message'] ?? $ex->get_api_message(), + 'error_code' => $ex->get_error_code(), + 'request_id' => $ex->get_request_id(), + 'summary' => manager::status_summary(), + ]); +} diff --git a/ajax/settings_reset.php b/ajax/settings_reset.php new file mode 100644 index 0000000..dd53118 --- /dev/null +++ b/ajax/settings_reset.php @@ -0,0 +1,69 @@ + true, + 'section' => 'rolemapping', + 'matrix' => roles::matrix_from_rules($rules), + 'message' => get_string('reset_success_rolemapping', 'meetletacbfree'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + if ($section === 'featureaccess') { + $matrix = featureaccess::builtin_defaults(); + $json = json_encode($matrix, JSON_UNESCAPED_UNICODE); + set_config('featureaccess', $json, 'meetletacbfree'); + echo json_encode([ + 'success' => true, + 'section' => 'featureaccess', + 'matrix' => $matrix, + 'message' => get_string('reset_success_featureaccess', 'meetletacbfree'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + throw new moodle_exception('invalidparameter'); +} catch (Throwable $e) { + $error = $e->getMessage(); + if ($e instanceof moodle_exception) { + $info = get_exception_info($e); + if (!empty($info->message)) { + $error = (string)$info->message; + } + } + echo json_encode([ + 'success' => false, + 'error' => $error !== '' ? $error : get_string('reset_error', 'meetletacbfree'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} diff --git a/backup/moodle2/backup_meetletacbfree_activity_task.class.php b/backup/moodle2/backup_meetletacbfree_activity_task.class.php new file mode 100644 index 0000000..5575098 --- /dev/null +++ b/backup/moodle2/backup_meetletacbfree_activity_task.class.php @@ -0,0 +1,41 @@ +dirroot . '/mod/meetletacbfree/backup/moodle2/backup_meetletacbfree_stepslib.php'); + +/** + * Backup task. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class backup_meetletacbfree_activity_task extends backup_activity_task { + protected function define_my_settings() { + } + + protected function define_my_steps() { + $this->add_step(new backup_meetletacbfree_activity_structure_step( + 'meetletacbfree_structure', + 'meetletacbfree.xml' + )); + } + + public static function encode_content_links($content) { + global $CFG; + $base = preg_quote($CFG->wwwroot, '/'); + $content = preg_replace("/($base\/mod\/meetletacbfree\/index.php\?id=)([0-9]+)/", + '$@MEETLETACBINDEX*$2@$', $content); + $content = preg_replace("/($base\/mod\/meetletacbfree\/view.php\?id=)([0-9]+)/", + '$@MEETLETACBVIEWBYID*$2@$', $content); + $content = preg_replace("/($base\/mod\/meetletadobeconnectbridge\/index.php\?id=)([0-9]+)/", + '$@MEETLETACBINDEX*$2@$', $content); + $content = preg_replace("/($base\/mod\/meetletadobeconnectbridge\/view.php\?id=)([0-9]+)/", + '$@MEETLETACBVIEWBYID*$2@$', $content); + // Legacy plugin path (pre-rename). + $content = preg_replace("/($base\/mod\/meetlet\/index.php\?id=)([0-9]+)/", '$@MEETLETACBINDEX*$2@$', $content); + $content = preg_replace("/($base\/mod\/meetlet\/view.php\?id=)([0-9]+)/", '$@MEETLETACBVIEWBYID*$2@$', $content); + return $content; + } +} diff --git a/backup/moodle2/backup_meetletacbfree_stepslib.php b/backup/moodle2/backup_meetletacbfree_stepslib.php new file mode 100644 index 0000000..b71f13a --- /dev/null +++ b/backup/moodle2/backup_meetletacbfree_stepslib.php @@ -0,0 +1,38 @@ +get_setting_value('userinfo'); + + $meetlet = new backup_nested_element('meetletacbfree', ['id'], [ + 'name', 'intro', 'introformat', 'userid', 'templatescoid', 'meeturl', + 'starttime', 'endtime', 'meetingpublic', 'participants', 'grade', + 'autooffline', 'showzip', 'showofflineplayer', 'showrecordingstab', 'showcontenttab', 'showmp4', + 'showmakeoffline', + 'linkmode', 'existingscoid', 'meetinglang', 'waitforhost', 'featureaccess', 'settingoverrides', + 'participantsmode', + 'timecreated', 'timemodified', + ]); + $meetings = new backup_nested_element('meetings'); + $meeting = new backup_nested_element('meeting', ['id'], [ + 'meetingscoid', 'groupid', 'urlpath', + ]); + + $meetlet->add_child($meetings); + $meetings->add_child($meeting); + + $meetlet->set_source_table('meetletacbfree', ['id' => backup::VAR_ACTIVITYID]); + $meeting->set_source_table('meetletacbfree_meetings', ['instanceid' => backup::VAR_PARENTID]); + + return $this->prepare_activity_structure($meetlet); + } +} diff --git a/backup/moodle2/restore_meetletacbfree_activity_task.class.php b/backup/moodle2/restore_meetletacbfree_activity_task.class.php new file mode 100644 index 0000000..c54e210 --- /dev/null +++ b/backup/moodle2/restore_meetletacbfree_activity_task.class.php @@ -0,0 +1,48 @@ +dirroot . '/mod/meetletacbfree/backup/moodle2/restore_meetletacbfree_stepslib.php'); + +/** + * Restore task. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class restore_meetletacbfree_activity_task extends restore_activity_task { + protected function define_my_settings() { + } + + protected function define_my_steps() { + $filename = 'meetletacbfree.xml'; + $base = $this->get_taskbasepath(); + if ($base) { + foreach (['meetletacbfree.xml', 'meetletadobeconnectbridge.xml', 'meetlet.xml'] as $candidate) { + if (file_exists($base . '/' . $candidate)) { + $filename = $candidate; + break; + } + } + } + $this->add_step(new restore_meetletacbfree_activity_structure_step( + 'meetletacbfree_structure', + $filename + )); + } + + public static function define_decode_contents() { + return [new restore_decode_content('meetletacbfree', ['intro'], 'meetletacbfree')]; + } + + public static function define_decode_rules() { + return [ + new restore_decode_rule('MEETLETACBVIEWBYID', '/mod/meetletacbfree/view.php?id=$1', 'course_module'), + new restore_decode_rule('MEETLETACBINDEX', '/mod/meetletacbfree/index.php?id=$1', 'course'), + // Legacy tokens from pre-rename backups. + new restore_decode_rule('MEETLETVIEWBYID', '/mod/meetletacbfree/view.php?id=$1', 'course_module'), + new restore_decode_rule('MEETLETINDEX', '/mod/meetletacbfree/index.php?id=$1', 'course'), + ]; + } +} diff --git a/backup/moodle2/restore_meetletacbfree_stepslib.php b/backup/moodle2/restore_meetletacbfree_stepslib.php new file mode 100644 index 0000000..aa0ca3f --- /dev/null +++ b/backup/moodle2/restore_meetletacbfree_stepslib.php @@ -0,0 +1,104 @@ +prepare_activity_structure($paths); + } + + protected function process_meetletacbfree($data) { + $this->process_instance_record($data); + } + + protected function process_meetletacbfree_long($data) { + $this->process_instance_record($data); + } + + protected function process_meetlet_legacy($data) { + $this->process_instance_record($data); + } + + protected function process_instance_record($data) { + global $DB; + $data = (object)$data; + $data->course = $this->get_courseid(); + if (!isset($data->showmakeoffline)) { + $data->showmakeoffline = 1; + } + if (!isset($data->settingoverrides)) { + $data->settingoverrides = \mod_meetletacbfree\local\config::setting_overrides_json([ + 'waitforhost' => !empty($data->waitforhost) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + 'autooffline' => !empty($data->autooffline) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + 'showzip' => !empty($data->showzip) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + 'showrecordingstab' => !empty($data->showrecordingstab) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + 'showcontenttab' => !empty($data->showcontenttab) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + 'showmp4' => !empty($data->showmp4) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + 'showmakeoffline' => !empty($data->showmakeoffline) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF, + ]); + } + if (!isset($data->participantsmode)) { + $data->participantsmode = 1; + } + $newid = $DB->insert_record('meetletacbfree', $data); + $this->apply_activity_instance($newid); + } + + protected function process_meetletacbfree_meeting($data) { + $this->process_meeting_record($data, 'meetletacbfree'); + } + + protected function process_meetletacbfree_long_meeting($data) { + $this->process_meeting_record($data, 'meetletacbfree_long'); + } + + protected function process_meetlet_legacy_meeting($data) { + $this->process_meeting_record($data, 'meetlet_legacy'); + } + + protected function process_meeting_record($data, string $parentname) { + global $DB; + $data = (object)$data; + $data->instanceid = $this->get_new_parentid($parentname); + if (!empty($data->groupid)) { + $data->groupid = $this->get_mappingid('group', $data->groupid); + } + $DB->insert_record('meetletacbfree_meetings', $data); + } + + protected function after_execute() { + $this->add_related_files('mod_meetletacbfree', 'intro', null); + } +} diff --git a/classes/admin/access_reset_ui.php b/classes/admin/access_reset_ui.php new file mode 100644 index 0000000..4279eff --- /dev/null +++ b/classes/admin/access_reset_ui.php @@ -0,0 +1,153 @@ +. + +/** + * Reset-to-default controls for access settings (role mapping + feature matrix). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\admin; + +use mod_meetletacbfree\local\icons; +use moodle_url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Shared reset button, modal, and client strings for the access settings tab. + */ +class access_reset_ui { + /** @var bool */ + protected static $assetsrendered = false; + + /** + * Toolbar with a section-specific reset button. + */ + public static function toolbar(string $section): string { + if ($section === 'rolemapping') { + $label = get_string('reset_rolemapping', 'meetletacbfree'); + } else if ($section === 'featureaccess') { + $label = get_string('reset_featureaccess', 'meetletacbfree'); + } else { + return ''; + } + + return \html_writer::div( + \html_writer::tag('button', + icons::svg('refresh') . + \html_writer::span($label, 'meetlet-btn-with-icon__label'), + [ + 'type' => 'button', + 'class' => 'meetlet-settings-btn meetlet-btn-with-icon meetlet-access-reset-btn', + 'data-meetlet-reset-section' => $section, + ] + ), + 'meetlet-access-reset-toolbar' + ); + } + + /** + * Modal + config root (render once on the access settings tab). + */ + public static function page_assets(moodle_url $ajaxurl): string { + if (self::$assetsrendered) { + return ''; + } + self::$assetsrendered = true; + + $strings = self::client_strings(); + $html = \html_writer::div('', '', [ + 'id' => 'meetlet-access-reset-root', + 'hidden' => 'hidden', + 'data-endpoint' => $ajaxurl->out(false), + 'data-sesskey' => sesskey(), + 'data-strings' => json_encode($strings, JSON_UNESCAPED_UNICODE), + ]); + $html .= self::modal_html(); + return $html; + } + + /** + * @return array + */ + public static function client_strings(): array { + return [ + 'modal_cancel' => get_string('accessreset_modal_cancel', 'meetletacbfree'), + 'modal_close' => get_string('accessreset_modal_close', 'meetletacbfree'), + 'modal_ok' => get_string('accessreset_modal_ok', 'meetletacbfree'), + 'confirm_reset' => get_string('accessreset_confirm_reset', 'meetletacbfree'), + 'confirm_rolemapping_title' => get_string('reset_rolemapping_confirm_title', 'meetletacbfree'), + 'confirm_rolemapping' => get_string('reset_rolemapping_confirm', 'meetletacbfree'), + 'confirm_featureaccess_title' => get_string('reset_featureaccess_confirm_title', 'meetletacbfree'), + 'confirm_featureaccess' => get_string('reset_featureaccess_confirm', 'meetletacbfree'), + 'success_rolemapping' => get_string('reset_success_rolemapping', 'meetletacbfree'), + 'success_featureaccess' => get_string('reset_success_featureaccess', 'meetletacbfree'), + 'error' => get_string('reset_error', 'meetletacbfree'), + 'busy' => get_string('accessreset_busy', 'meetletacbfree'), + ]; + } + + protected static function modal_html(): string { + return \html_writer::div( + \html_writer::div('', 'meetlet-modal__backdrop', ['data-meetlet-modal-dismiss' => '1']) . + \html_writer::div( + \html_writer::div( + \html_writer::tag('h3', '', [ + 'class' => 'meetlet-modal__title', + 'id' => 'meetlet-access-reset-modal-title', + ]) . + \html_writer::tag('button', '×', [ + 'type' => 'button', + 'class' => 'meetlet-modal__close', + 'data-meetlet-modal-dismiss' => '1', + 'aria-label' => get_string('accessreset_modal_close', 'meetletacbfree'), + ]), + 'meetlet-modal__head' + ) . + \html_writer::div('', 'meetlet-modal__body', [ + 'id' => 'meetlet-access-reset-modal-body', + ]) . + \html_writer::div( + \html_writer::tag('button', get_string('accessreset_modal_cancel', 'meetletacbfree'), [ + 'type' => 'button', + 'class' => 'btn btn-outline-secondary', + 'data-meetlet-modal-dismiss' => '1', + ]) . + \html_writer::tag('button', get_string('accessreset_confirm_reset', 'meetletacbfree'), [ + 'type' => 'button', + 'class' => 'btn btn-outline-danger', + 'id' => 'meetlet-access-reset-modal-confirm', + ]), + 'meetlet-modal__foot' + ), + 'meetlet-modal__dialog', + [ + 'role' => 'dialog', + 'aria-modal' => 'true', + 'aria-labelledby' => 'meetlet-access-reset-modal-title', + ] + ), + 'meetlet-modal meetlet-modal--confirm', + [ + 'id' => 'meetlet-access-reset-modal', + 'hidden' => 'hidden', + ] + ); + } +} diff --git a/classes/admin/setting_featureaccess.php b/classes/admin/setting_featureaccess.php new file mode 100644 index 0000000..f304cd7 --- /dev/null +++ b/classes/admin/setting_featureaccess.php @@ -0,0 +1,85 @@ +. + +/** + * Admin setting: site-wide role × feature access matrix. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\admin; + +use admin_setting; +use mod_meetletacbfree\local\featureaccess; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Stores the site default feature-access JSON used when an activity does not override. + */ +class setting_featureaccess extends admin_setting { + public function __construct() { + parent::__construct( + 'meetletacbfree/featureaccess', + get_string('featureaccess', 'meetletacbfree'), + get_string('featureaccess_desc', 'meetletacbfree'), + json_encode(featureaccess::builtin_defaults(), JSON_UNESCAPED_UNICODE) + ); + } + + public function get_setting() { + return $this->config_read($this->name); + } + + public function write_setting($data) { + if (!is_array($data)) { + $data = []; + } + $json = featureaccess::matrix_from_post($data); + return $this->config_write($this->name, $json) ? '' : get_string('errorsetting', 'admin'); + } + + public function output_html($data, $query = '') { + $matrix = featureaccess::builtin_defaults(); + if (is_string($data) && $data !== '') { + $decoded = json_decode($data, true); + if (is_array($decoded) && !empty($decoded)) { + $matrix = featureaccess::normalize($decoded); + } + } else if (is_array($data) && !empty($data)) { + $json = featureaccess::matrix_from_post($data); + $decoded = json_decode($json, true); + if (is_array($decoded)) { + $matrix = featureaccess::normalize($decoded); + } + } + + $html = access_reset_ui::toolbar('featureaccess'); + $html .= featureaccess::render_matrix_html($matrix, $this->get_full_name()); + return format_admin_setting( + $this, + $this->visiblename, + $html, + $this->description, + true, + '', + get_string('featureaccess_usesite', 'meetletacbfree'), + $query + ); + } +} diff --git a/classes/admin/setting_registration.php b/classes/admin/setting_registration.php new file mode 100644 index 0000000..8720e19 --- /dev/null +++ b/classes/admin/setting_registration.php @@ -0,0 +1,256 @@ +nosave = true; + } + + public function get_setting() { + return true; + } + + public function write_setting($data) { + return ''; + } + + public function output_html($data, $query = '') { + global $OUTPUT, $PAGE; + + $summary = manager::status_summary(); + $active = !empty($summary['active']); + $candeactivate = !empty($summary['can_deactivate']); + $ajaxurl = new \moodle_url('/mod/meetletacbfree/ajax/registration.php'); + $scrolljsurl = new \moodle_url('/mod/meetletacbfree/javascript/meetlet_modal_scroll.js', [ + 'v' => '2026090146', + ]); + $jsurl = new \moodle_url('/mod/meetletacbfree/javascript/settings_registration.js', [ + 'v' => '2026090146', + ]); + $privacytaburl = manager::privacy_tab_url(); + $PAGE->requires->js($scrolljsurl); + $PAGE->requires->js($jsurl); + + $statusbadge = \html_writer::span( + s($summary['status_label'] ?? ($active + ? get_string('registration_status_active', 'meetletacbfree') + : get_string('registration_status_inactive', 'meetletacbfree'))), + 'badge meetlet-license-badge ' . ($active ? 'meetlet-license-badge--active' : 'meetlet-license-badge--inactive'), + ['id' => 'meetlet-registration-status-badge'] + ); + + $datalist = \html_writer::tag('ul', + \html_writer::tag('li', get_string('registration_data_item_domain', 'meetletacbfree', s($summary['domain']))) . + \html_writer::tag('li', get_string('registration_data_item_moodle', 'meetletacbfree', s($summary['moodle_release']))) . + \html_writer::tag('li', get_string('registration_data_item_plugin', 'meetletacbfree', s($summary['plugin_release']))) . + \html_writer::tag('li', get_string('registration_data_item_installation', 'meetletacbfree', s($summary['installation_id']))) . + \html_writer::tag('li', get_string('registration_data_item_sitename', 'meetletacbfree', s($summary['site_company']))) . + \html_writer::tag('li', get_string('registration_data_item_email', 'meetletacbfree', + $summary['site_admin_email'] !== '' ? s($summary['site_admin_email']) : get_string('registration_data_optional', 'meetletacbfree'))), + ['class' => 'meetlet-registration-data'] + ); + + $why = \html_writer::div( + \html_writer::tag('h4', get_string('registration_why_title', 'meetletacbfree')) . + \html_writer::tag('p', get_string('registration_why', 'meetletacbfree')), + 'meetlet-guide__section' + ); + $what = \html_writer::div( + \html_writer::tag('h4', get_string('registration_data_title', 'meetletacbfree')) . + \html_writer::tag('p', get_string('registration_data_sent', 'meetletacbfree')) . + $datalist . + \html_writer::tag('p', + get_string( + 'registration_privacy', + 'meetletacbfree', + \html_writer::link($privacytaburl, get_string('registration_privacy_tablink', 'meetletacbfree')) + ), + ['class' => 'mb-0'] + ), + 'meetlet-guide__section' + ); + + $inactiveactions = [ + 'id' => 'meetlet-registration-actions-inactive', + 'class' => 'meetlet-license-actions mt-3' . ($active ? ' d-none' : ''), + ]; + $activeactions = [ + 'id' => 'meetlet-registration-actions-active', + 'class' => 'meetlet-license-actions mt-3' . ($active ? '' : ' d-none'), + ]; + + $html = \html_writer::div( + $why . + $what . + \html_writer::div( + \html_writer::tag('p', get_string('registration_status_label', 'meetletacbfree') . ' ' . + \html_writer::span($statusbadge, '', ['id' => 'meetlet-registration-status-badge'])) . + \html_writer::tag('p', get_string('registration_domain', 'meetletacbfree', s($summary['domain'])), [ + 'id' => 'meetlet-registration-domain', + ]) . + \html_writer::tag('p', get_string('registration_installation', 'meetletacbfree', s($summary['installation_id'])), [ + 'id' => 'meetlet-registration-installation', + ]) . + \html_writer::tag('p', s($summary['product_label'] ?? ''), [ + 'id' => 'meetlet-registration-product', + 'class' => $active ? '' : 'd-none', + ]) . + \html_writer::div( + $summary['error'] ? s($summary['error']) : '', + 'alert alert-danger mt-2' . (!empty($summary['error']) ? '' : ' d-none'), + ['id' => 'meetlet-registration-error'] + ), + 'meetlet-license-status mb-2' + ) . + \html_writer::div( + \html_writer::tag('button', + '' + . \html_writer::span(get_string('registration_activate', 'meetletacbfree'), 'meetlet-btn-with-icon__label'), + [ + 'type' => 'button', + 'class' => 'meetlet-settings-btn meetlet-btn-with-icon meetlet-license-btn--free', + 'id' => 'meetlet-registration-activate', + 'title' => get_string('registration_activate_help', 'meetletacbfree'), + ] + ), + '', + $inactiveactions + ) . + \html_writer::div( + \html_writer::tag('button', + icons::svg('refresh') + . \html_writer::span(get_string('registration_recheck', 'meetletacbfree'), 'meetlet-btn-with-icon__label'), + [ + 'type' => 'button', + 'class' => 'btn btn-secondary meetlet-btn-with-icon', + 'id' => 'meetlet-registration-recheck', + ] + ) . + ($candeactivate ? ' ' . + \html_writer::tag('button', + '' + . \html_writer::span(get_string('registration_deactivate', 'meetletacbfree'), 'meetlet-btn-with-icon__label'), + [ + 'type' => 'button', + 'class' => 'btn btn-outline-danger meetlet-btn-with-icon', + 'id' => 'meetlet-registration-deactivate', + ] + ) : ''), + '', + $activeactions + ) . + \html_writer::div('', 'mt-3 d-none', ['id' => 'meetlet-registration-flash']) . + \html_writer::div('', '', [ + 'id' => 'meetlet-registration-config', + 'data-ajaxurl' => $ajaxurl->out(false), + 'data-sesskey' => sesskey(), + 'data-api-input' => 'id_s_meetletacbfree_registration_api_url', + 'data-label-active' => get_string('registration_status_active', 'meetletacbfree'), + 'data-label-inactive' => get_string('registration_status_inactive', 'meetletacbfree'), + 'data-label-domain' => get_string('registration_domain', 'meetletacbfree', '__V__'), + 'data-label-installation' => get_string('registration_installation', 'meetletacbfree', '__V__'), + 'data-confirm-activate-title' => get_string('registration_activate_confirm_title', 'meetletacbfree'), + 'data-confirm-activate-ok' => get_string('registration_activate_confirm_ok', 'meetletacbfree'), + 'data-confirm-deactivate-title' => get_string('registration_deactivate_confirm_title', 'meetletacbfree'), + 'data-confirm-deactivate-ok' => get_string('registration_deactivate_confirm_ok', 'meetletacbfree'), + 'data-confirm-deactivate' => get_string('registration_deactivate_confirm', 'meetletacbfree'), + 'hidden' => 'hidden', + ]) . + self::confirm_modal_html(), + 'meetlet-registration-panel meetlet-settings-card' + ); + + return format_admin_setting($this, '', $html, '', true, '', null, $query); + } + + protected static function confirm_modal_html(): string { + $activatebody = \html_writer::tag('p', get_string('registration_activate_confirm_lead', 'meetletacbfree'), [ + 'class' => 'meetlet-modal__lead', + ]) . + \html_writer::tag('p', get_string('registration_activate_confirm_data', 'meetletacbfree'), [ + 'class' => 'meetlet-modal__keep', + ]); + $deactivatebody = \html_writer::tag('p', get_string('registration_deactivate_confirm_lead', 'meetletacbfree'), [ + 'class' => 'meetlet-modal__lead', + ]) . + \html_writer::div( + \html_writer::tag('p', get_string('registration_deactivate_confirm_warning', 'meetletacbfree'), [ + 'class' => 'meetlet-modal__callout-title', + ]), + 'meetlet-modal__callout' + ); + + return \html_writer::div( + \html_writer::div('', 'meetlet-modal__backdrop', ['data-meetlet-modal-dismiss' => '1']) . + \html_writer::div( + \html_writer::div( + \html_writer::tag('h3', '', [ + 'class' => 'meetlet-modal__title', + 'id' => 'meetlet-registration-modal-title', + ]) . + \html_writer::tag('button', '×', [ + 'type' => 'button', + 'class' => 'meetlet-modal__close', + 'data-meetlet-modal-dismiss' => '1', + 'aria-label' => get_string('registration_modal_close', 'meetletacbfree'), + ]), + 'meetlet-modal__head' + ) . + \html_writer::div('', 'meetlet-modal__body', ['id' => 'meetlet-registration-modal-body']) . + \html_writer::tag('template', $activatebody, ['id' => 'meetlet-registration-tpl-activate']) . + \html_writer::tag('template', $deactivatebody, ['id' => 'meetlet-registration-tpl-deactivate']) . + \html_writer::div( + \html_writer::tag('button', get_string('cancel', 'moodle'), [ + 'type' => 'button', + 'class' => 'btn btn-secondary', + 'data-meetlet-modal-dismiss' => '1', + ]) . + \html_writer::tag('button', '', [ + 'type' => 'button', + 'class' => 'btn btn-primary', + 'id' => 'meetlet-registration-modal-confirm', + ]), + 'meetlet-modal__foot' + ), + 'meetlet-modal__dialog', + [ + 'role' => 'dialog', + 'aria-modal' => 'true', + 'aria-labelledby' => 'meetlet-registration-modal-title', + ] + ), + 'meetlet-modal meetlet-modal--confirm', + [ + 'id' => 'meetlet-registration-modal', + 'hidden' => 'hidden', + ] + ); + } +} diff --git a/classes/admin/setting_rolemapping.php b/classes/admin/setting_rolemapping.php new file mode 100644 index 0000000..e5626ce --- /dev/null +++ b/classes/admin/setting_rolemapping.php @@ -0,0 +1,88 @@ +. + +/** + * Admin setting: default Moodle role → Adobe Connect role mapping. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\admin; + +use admin_setting; +use mod_meetletacbfree\local\roles; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Stores default participant rules JSON used for new activities / empty lists. + */ +class setting_rolemapping extends admin_setting { + public function __construct() { + parent::__construct( + 'meetletacbfree/default_participants', + get_string('siterolemapping', 'meetletacbfree'), + get_string('siterolemapping_desc', 'meetletacbfree'), + json_encode(roles::builtin_site_defaults(), JSON_UNESCAPED_UNICODE) + ); + } + + public function get_setting() { + return $this->config_read($this->name); + } + + public function write_setting($data) { + if (!is_array($data)) { + $data = []; + } + $json = roles::rules_json_from_matrix_post($data); + return $this->config_write($this->name, $json) ? '' : get_string('errorsetting', 'admin'); + } + + public function output_html($data, $query = '') { + $rules = roles::builtin_site_defaults(); + if (is_string($data) && $data !== '') { + $decoded = json_decode($data, true); + if (is_array($decoded) && !empty($decoded)) { + $rules = roles::normalize_rules($decoded); + } + } else if (is_array($data) && !empty($data) && isset($data[0]) && is_array($data[0])) { + $rules = roles::normalize_rules($data); + } else if (is_array($data) && !empty($data)) { + // Posted radio matrix already (preview / validation). + $json = roles::rules_json_from_matrix_post($data); + $decoded = json_decode($json, true); + if (is_array($decoded)) { + $rules = roles::normalize_rules($decoded); + } + } + + $html = access_reset_ui::toolbar('rolemapping'); + $html .= roles::render_site_mapping_html($rules, $this->get_full_name()); + return format_admin_setting( + $this, + $this->visiblename, + $html, + $this->description, + true, + '', + get_string('siterolemapping_defaultinfo', 'meetletacbfree'), + $query + ); + } +} diff --git a/classes/admin/settingspage_tabs.php b/classes/admin/settingspage_tabs.php new file mode 100644 index 0000000..0defa21 --- /dev/null +++ b/classes/admin/settingspage_tabs.php @@ -0,0 +1,49 @@ +. + +/** + * Admin settings tabs with a fixed hero above the tab bar. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\admin; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Extends Boost tabbed settings so a hero banner stays above all tabs. + */ +class settingspage_tabs extends \theme_boost_admin_settingspage_tabs { + /** @var string HTML rendered above the tab list. */ + protected string $herohtml = ''; + + /** + * Set hero markup shown above tabs on every tab switch. + */ + public function set_hero(string $html): void { + $this->herohtml = $html; + } + + /** + * @return string + */ + public function output_html() { + return $this->herohtml . parent::output_html(); + } +} diff --git a/classes/api/client.php b/classes/api/client.php new file mode 100644 index 0000000..efaba67 --- /dev/null +++ b/classes/api/client.php @@ -0,0 +1,398 @@ +. + +/** + * Adobe Connect XML API client. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\api; + +use mod_meetletacbfree\local\config; +use moodle_exception; +use SimpleXMLElement; + +/** + * Lightweight Adobe Connect XML API client with session reuse. + */ +class client { + /** @var string */ + protected $apiurl; + /** @var int */ + protected $port; + /** @var bool */ + protected $https; + /** @var string */ + protected $cookie = ''; + /** @var string */ + protected $lastresponse = ''; + + /** + * Constructor. + */ + public function __construct(?string $apiurl = null, ?int $port = null, ?bool $https = null) { + $this->apiurl = $apiurl ?? config::get_api_url(); + $this->port = $port ?? config::get_port(); + $this->https = $https ?? config::use_https(); + } + + /** + * Login as admin and store session cookie. + * + * @return self + */ + public function login_admin(): self { + \mod_meetletacbfree\registration\manager::require_registered(); + $info = $this->request(['action' => 'common-info']); + $this->cookie = (string)($info->common->cookie ?? ''); + if ($this->cookie === '') { + throw new moodle_exception('erroremptyxml', 'meetletacbfree'); + } + + $result = $this->request([ + 'action' => 'login', + 'login' => config::get_admin_login(), + 'password' => config::get_admin_password(), + ]); + + if ((string)$result->status['code'] !== 'ok') { + throw new moodle_exception('errorlogin', 'meetletacbfree'); + } + + return $this; + } + + /** + * External-auth (HTTP header) login for a Connect user. + * + * @param string $connectlogin Adobe Connect login (often email) + * @return string BREEZESESSION cookie value + */ + public function login_external(string $connectlogin): string { + \mod_meetletacbfree\registration\manager::require_registered(); + + $headername = config::get_http_auth_header(); + $candidates = array_values(array_unique(array_filter([ + trim($connectlogin), + ]))); + + $lastfail = []; + foreach ($candidates as $login) { + // Preferred: XML params (same as classic adobeconnect plugin). + $cookie = $this->try_external_login_xml($headername, $login, $lastfail); + if ($cookie !== '') { + $this->cookie = $cookie; + return $cookie; + } + // Fallback: form-urlencoded (some Connect setups only accept this). + $cookie = $this->try_external_login_form($headername, $login, $lastfail); + if ($cookie !== '') { + $this->cookie = $cookie; + return $cookie; + } + } + + $facts = [ + 'login' => (string)($lastfail['login'] ?? ($candidates[0] ?? '')), + 'adobe_code' => (string)($lastfail['code'] ?? ''), + 'adobe_subcode' => (string)($lastfail['subcode'] ?? 'no-login'), + ]; + throw new \mod_meetletacbfree\local\usererror( + 'sso_01', + 'errorhttpauth', + array_filter($facts), + (string)($lastfail['xml'] ?? '') + ); + } + + /** + * Try SSO via XML body. + */ + protected function try_external_login_xml(string $headername, string $login, array &$lastfail): string { + $xml = $this->build_params_xml([ + 'action' => 'login', + 'external-auth' => 'use', + ]); + $raw = $this->curl_post($xml, ["{$headername}: {$login}"], true); + $this->lastresponse = $raw; + $lastfail = $this->describe_auth_failure($raw, $login); + if (!$this->response_ok($raw)) { + return ''; + } + return $this->extract_breeze_cookie($raw); + } + + /** + * Try SSO via application/x-www-form-urlencoded POST. + */ + protected function try_external_login_form(string $headername, string $login, array &$lastfail): string { + $raw = $this->curl_post_form( + 'action=login&external-auth=use', + ["{$headername}: {$login}"], + true + ); + $this->lastresponse = $raw; + $lastfail = $this->describe_auth_failure($raw, $login); + if (!$this->response_ok($raw)) { + return ''; + } + return $this->extract_breeze_cookie($raw); + } + + /** + * Structured Adobe SSO failure (login, code, subcode, xml). + * + * @return array{login:string,code:string,subcode:string,xml:string} + */ + protected function describe_auth_failure(string $raw, string $login): array { + $xmlstart = strpos($raw, 'status['code'] ?? ''); + $sub = (string)($sx->status['subcode'] ?? ''); + } + return [ + 'login' => $login, + 'code' => $code, + 'subcode' => $sub, + 'xml' => \mod_meetletacbfree\local\usererror::format_xml($raw), + ]; + } + + /** + * POST form fields to Adobe Connect. + * + * @param string $body + * @param array $extraheaders + * @param bool $includeheader + * @return string + */ + protected function curl_post_form(string $body, array $extraheaders = [], bool $includeheader = false): string { + global $CFG; + + $url = $this->apiurl; + if ($this->https && stripos($url, 'http://') === 0) { + $url = 'https://' . substr($url, 7); + } else if ($this->https && stripos($url, 'https://') !== 0 && stripos($url, 'http://') !== 0) { + $url = 'https://' . $url; + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_PORT, $this->port); + curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge( + ['Content-Type: application/x-www-form-urlencoded'], + $extraheaders + )); + curl_setopt($ch, CURLOPT_HEADER, $includeheader ? 1 : 0); + if (!empty($_SERVER['HTTP_USER_AGENT'])) { + curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']); + } + if ($this->https) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + } + if (!empty($CFG->proxyhost)) { + curl_setopt($ch, CURLOPT_PROXY, $CFG->proxyhost); + if (!empty($CFG->proxyport)) { + curl_setopt($ch, CURLOPT_PROXYPORT, $CFG->proxyport); + } + } + + $result = curl_exec($ch); + if ($result === false) { + $err = curl_error($ch); + curl_close($ch); + throw new moodle_exception('erroremptyxml', 'meetletacbfree', '', null, $err); + } + curl_close($ch); + return (string)$result; + } + + /** + * Logout current session. + */ + public function logout(): void { + if ($this->cookie === '') { + return; + } + try { + $this->request(['action' => 'logout']); + } catch (\Throwable $e) { + // Ignore logout failures. + } + $this->cookie = ''; + } + + /** + * Perform an API action and return SimpleXMLElement. + * + * @param array $params + * @return SimpleXMLElement + */ + public function request(array $params): SimpleXMLElement { + $xml = $this->build_params_xml($params); + $raw = $this->curl_post($xml); + $this->lastresponse = $raw; + + $xmlstart = strpos($raw, 'lastresponse; + $xmlstart = strpos($raw, 'status['code'] === 'ok'; + } + + /** + * Get session cookie. + */ + public function get_cookie(): string { + return $this->cookie; + } + + /** + * Get raw last response. + */ + public function get_last_response(): string { + return $this->lastresponse; + } + + /** + * Build XML params payload. + * + * @param array $params + * @return string + */ + protected function build_params_xml(array $params): string { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = $dom->createElement('params'); + $dom->appendChild($root); + foreach ($params as $name => $value) { + $child = $dom->createElement('param', htmlspecialchars((string)$value, ENT_XML1 | ENT_COMPAT, 'UTF-8')); + $child->setAttribute('name', (string)$name); + $root->appendChild($child); + } + return $dom->saveXML(); + } + + /** + * POST XML to Adobe Connect. + * + * @param string $xml + * @param array $extraheaders + * @param bool $includeheader + * @return string + */ + protected function curl_post(string $xml, array $extraheaders = [], bool $includeheader = false): string { + global $CFG; + + $url = $this->apiurl; + if ($this->https && stripos($url, 'http://') === 0) { + $url = 'https://' . substr($url, 7); + } else if ($this->https && stripos($url, 'https://') !== 0 && stripos($url, 'http://') !== 0) { + $url = 'https://' . $url; + } + + if ($this->cookie !== '') { + $url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($this->cookie); + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); + curl_setopt($ch, CURLOPT_PORT, $this->port); + curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge(['Content-Type: text/xml'], $extraheaders)); + curl_setopt($ch, CURLOPT_HEADER, $includeheader ? 1 : 0); + if (!empty($_SERVER['HTTP_USER_AGENT'])) { + curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']); + } + if ($this->https) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + } + if (!empty($CFG->proxyhost)) { + curl_setopt($ch, CURLOPT_PROXY, $CFG->proxyhost); + if (!empty($CFG->proxyport)) { + curl_setopt($ch, CURLOPT_PROXYPORT, $CFG->proxyport); + } + } + + $result = curl_exec($ch); + if ($result === false) { + $err = curl_error($ch); + curl_close($ch); + throw new moodle_exception('erroremptyxml', 'meetletacbfree', '', null, $err); + } + curl_close($ch); + return (string)$result; + } + + /** + * Extract BREEZESESSION from header+body response. + * + * @param string $raw + * @return string + */ + protected function extract_breeze_cookie(string $raw): string { + if (preg_match('/BREEZESESSION=([^;\s]+)/', $raw, $m)) { + return $m[1]; + } + return ''; + } +} diff --git a/classes/api/service.php b/classes/api/service.php new file mode 100644 index 0000000..be1e5e1 --- /dev/null +++ b/classes/api/service.php @@ -0,0 +1,1642 @@ +. + +/** + * High-level Adobe Connect service operations. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\api; + +use cache; +use mod_meetletacbfree\local\config; +use moodle_exception; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Service layer on top of the XML API client. + */ +class service { + public const ROLE_HOST = 'host'; + public const ROLE_PRESENTER = 'mini-host'; + public const ROLE_PARTICIPANT = 'view'; + + /** @var client */ + protected $client; + + public function __construct(?client $client = null) { + $this->client = $client ?? new client(); + } + + public function get_client(): client { + return $this->client; + } + + public function login_admin(): self { + $this->client->login_admin(); + return $this; + } + + public function logout(): void { + $this->client->logout(); + } + + /** + * Resolve a sco-shortcuts folder id (cached). + */ + public function get_folder_scoid(string $type): int { + $cache = cache::make('mod_meetletacbfree', 'folders'); + $key = 'folder_' . str_replace('-', '_', $type); + $cached = $cache->get($key); + if ($cached !== false && $cached !== null) { + return (int)$cached; + } + + $xml = $this->client->request(['action' => 'sco-shortcuts']); + $scoid = 0; + if (!empty($xml->shortcuts->sco)) { + foreach ($xml->shortcuts->sco as $sco) { + if ((string)$sco['type'] === $type) { + $scoid = (int)$sco['sco-id']; + break; + } + } + } + if ($scoid) { + $cache->set($key, $scoid); + } + return $scoid; + } + + /** + * Meeting templates list (cached). + * + * @return array scoid => name + */ + public function get_templates(): array { + $cache = cache::make('mod_meetletacbfree', 'templates'); + $cached = $cache->get('all'); + if (is_array($cached)) { + return $cached; + } + + $folder = $this->get_folder_scoid('shared-meeting-templates'); + if (!$folder) { + $folder = $this->get_folder_scoid('my-meeting-templates'); + } + $templates = []; + if ($folder) { + $xml = $this->client->request([ + 'action' => 'sco-contents', + 'sco-id' => $folder, + 'filter-type' => 'meeting', + ]); + if (!empty($xml->scos->sco)) { + foreach ($xml->scos->sco as $sco) { + $templates[(int)$sco['sco-id']] = (string)$sco->name; + } + } + } + $cache->set('all', $templates); + return $templates; + } + + /** + * Create a meeting SCO. + */ + public function create_meeting(stdClass $data, int $folderid): int { + $params = [ + 'action' => 'sco-update', + 'type' => 'meeting', + 'name' => $data->name, + 'folder-id' => $folderid, + 'date-begin' => self::format_date((int)$data->starttime), + ]; + if (!empty($data->endtime)) { + $params['date-end'] = self::format_date((int)$data->endtime); + } + if (!empty($data->meeturl)) { + $params['url-path'] = ltrim($data->meeturl, '/'); + } + if (!empty($data->templatescoid)) { + $params['source-sco-id'] = (int)$data->templatescoid; + } + + $xml = $this->client->request($params); + if ((string)$xml->status['code'] !== 'ok') { + throw new moodle_exception('errorcreatemeeting', 'meetletacbfree'); + } + $scoid = (int)$xml->sco['sco-id']; + $this->apply_meeting_options($scoid, $data); + return $scoid; + } + + /** + * Update meeting metadata. + */ + public function update_meeting(int $scoid, stdClass $data): bool { + $params = [ + 'action' => 'sco-update', + 'sco-id' => $scoid, + 'name' => $data->name, + 'date-begin' => self::format_date((int)$data->starttime), + ]; + if (!empty($data->endtime)) { + $params['date-end'] = self::format_date((int)$data->endtime); + } + $xml = $this->client->request($params); + $ok = (string)$xml->status['code'] === 'ok'; + if ($ok) { + cache::make('mod_meetletacbfree', 'meetinginfo')->delete((string)$scoid); + $this->apply_meeting_options($scoid, $data); + } + return $ok; + } + + /** + * Apply Adobe room options (language + wait-for-host ACL). + * Wait-for-host is also enforced by Moodle on join (host presence check). + */ + public function apply_meeting_options(int $scoid, stdClass $data): void { + if (isset($data->meetinglang) && (string)$data->meetinglang !== '') { + $this->client->request([ + 'action' => 'acl-field-update', + 'acl-id' => $scoid, + 'field-id' => 'meeting-language', + 'value' => (string)$data->meetinglang, + ]); + } + if (isset($data->waitforhost)) { + $hold = !empty($data->waitforhost) ? 'true' : 'false'; + foreach ([ + 'meetingHostMustJoinFirst', + 'meeting-hold-until-host', + 'meeting-block-participants-before-host', + 'meetingBlockAVUntilHost', + ] as $fieldid) { + $this->client->request([ + 'action' => 'acl-field-update', + 'acl-id' => $scoid, + 'field-id' => $fieldid, + 'value' => $hold, + ]); + } + } + } + + /** + * Active participants currently in the meeting room (Connect report). + * + * @return array list of participant row objects + */ + public function list_active_participants(int $meetingscoid): array { + $attempts = [ + ['action' => 'report-active-participants', 'sco-id' => $meetingscoid], + ['action' => 'report-meeting-concurrent-users', 'sco-id' => $meetingscoid], + ]; + foreach ($attempts as $params) { + try { + $xml = $this->client->request($params); + } catch (\Throwable $e) { + continue; + } + if ((string)($xml->status['code'] ?? '') !== 'ok') { + continue; + } + $nodes = []; + if (!empty($xml->{'report-active-participants'}->row)) { + foreach ($xml->{'report-active-participants'}->row as $row) { + $nodes[] = $row; + } + } + if (!empty($xml->{'report-meeting-concurrent-users'}->row)) { + foreach ($xml->{'report-meeting-concurrent-users'}->row as $row) { + $nodes[] = $row; + } + } + if (empty($nodes) && !empty($xml->principal)) { + foreach ($xml->principal as $row) { + $nodes[] = $row; + } + } + if (!empty($nodes)) { + return $nodes; + } + + // Some Connect builds return only a count attribute (no row nodes). + // Note: empty(SimpleXMLElement) is true when there are no children, even if attributes exist. + if (isset($xml->{'report-active-participants'})) { + $wrap = $xml->{'report-active-participants'}; + $count = 0; + foreach ($wrap->attributes() ?: [] as $aname => $avalue) { + $n = strtolower((string)$aname); + // Exact / known count attrs only — ignore unrelated fields. + if (in_array($n, ['noofparticipant', 'no-of-participant', 'noofparticipants', 'participants', 'count'], true)) { + $count = max($count, (int)$avalue); + } + } + if ($count > 0) { + $fake = new stdClass(); + $fake->countonly = $count; + return [$fake]; + } + } + } + return []; + } + + /** + * Whether Adobe Connect holds the room until a host joins first. + */ + public function meeting_requires_host_first(int $meetingscoid): bool { + $want = [ + 'meetinghostmustjoinfirst' => true, + 'meeting-hold-until-host' => true, + 'meeting-block-participants-before-host' => true, + 'meetingblockavuntilhost' => true, + ]; + $queries = [ + [ + 'action' => 'acl-field-info', + 'acl-id' => $meetingscoid, + 'filter-field-id' => 'meetingHostMustJoinFirst', + ], + [ + 'action' => 'acl-field-info', + 'acl-id' => $meetingscoid, + ], + ]; + foreach ($queries as $params) { + try { + $xml = $this->client->request($params); + } catch (\Throwable $e) { + continue; + } + if ((string)($xml->status['code'] ?? '') !== 'ok') { + continue; + } + foreach ($xml->xpath('//*[@field-id]') ?: [] as $node) { + $fid = strtolower(trim((string)$node['field-id'])); + $val = strtolower(trim((string)($node['value'] ?? $node->value ?? ''))); + if ($val === '' && isset($node->values->value)) { + $val = strtolower(trim((string)$node->values->value)); + } + if (isset($want[$fid]) && in_array($val, ['true', '1', 'yes'], true)) { + return true; + } + } + foreach ($xml->xpath('//acl-field') ?: [] as $node) { + $fid = strtolower(trim((string)($node['field-id'] ?? $node->{'field-id'} ?? ''))); + $val = strtolower(trim((string)($node['value'] ?? $node->value ?? ''))); + if (isset($want[$fid]) && in_array($val, ['true', '1', 'yes'], true)) { + return true; + } + } + } + return false; + } + + /** + * Whether anyone is currently inside the meeting room. + */ + public function meeting_is_live(int $meetingscoid): bool { + $nodes = $this->list_active_participants($meetingscoid); + if (empty($nodes)) { + return false; + } + foreach ($nodes as $row) { + if (is_object($row) && !empty($row->countonly)) { + return ((int)$row->countonly) > 0; + } + } + return true; + } + + /** + * Whether a host (or presenter acting as host) is currently in the meeting room. + */ + public function host_is_present(int $meetingscoid): bool { + $nodes = $this->list_active_participants($meetingscoid); + if (empty($nodes)) { + return false; + } + $sawrole = false; + foreach ($nodes as $row) { + if (is_object($row) && !empty($row->countonly)) { + // Count-only payloads prove occupancy, not that a host is present. + continue; + } + $perm = self::sco_xml_string($row, 'permission-id'); + $role = self::sco_xml_string($row, 'role'); + if ($perm !== '' || $role !== '') { + $sawrole = true; + } + if (in_array($perm, ['host', 'mini-host', self::ROLE_HOST, self::ROLE_PRESENTER], true) + || in_array($role, ['host', 'presenter', 'mini-host'], true)) { + return true; + } + $ishost = self::sco_xml_string($row, 'is-host'); + if ($ishost === 'true' || $ishost === '1') { + return true; + } + } + // Rows without role fields: cannot prove a host is present. + return false; + } + + /** + * Read attribute or child string from a SimpleXML SCO/row node. + * + * @param \SimpleXMLElement|object $node + */ + protected static function sco_xml_string($node, string $name): string { + if (isset($node[$name]) && (string)$node[$name] !== '') { + return strtolower(trim((string)$node[$name])); + } + if (isset($node->{$name}) && (string)$node->{$name} !== '') { + return strtolower(trim((string)$node->{$name})); + } + return ''; + } + + /** + * List shared meetings on the Connect server (meetings folder). + * + * @param string $query Optional name filter + * @param int $limit Max rows (0 = no limit) + * @return array scoid => object{scoid,name,urlpath,datebegin,dateend,datemodified} + */ + public function list_server_meetings(string $query = '', int $limit = 10): array { + $query = trim($query); + $items = []; + $addfromxml = function($nodes) use (&$items, $query): void { + foreach ($nodes as $sco) { + $type = strtolower((string)($sco['type'] ?? '')); + $icon = strtolower((string)($sco['icon'] ?? '')); + if ($type !== 'meeting' && $icon !== 'meeting' && $icon !== 'virtual-classroom') { + continue; + } + $scoid = (int)$sco['sco-id']; + if ($scoid <= 0 || isset($items[$scoid])) { + continue; + } + $item = new stdClass(); + $item->scoid = $scoid; + $item->name = trim((string)($sco->name ?? '')); + $item->urlpath = trim((string)($sco->{'url-path'} ?? '')); + $item->datebegin = (string)($sco->{'date-begin'} ?? ''); + $item->dateend = (string)($sco->{'date-end'} ?? ''); + $item->datemodified = (string)($sco->{'date-modified'} ?? $sco->{'date-created'} ?? ''); + if ($item->name === '') { + $item->name = $item->urlpath !== '' ? $item->urlpath : ('SCO ' . $scoid); + } + if ($query !== '' && stripos($item->name, $query) === false + && stripos($item->urlpath, $query) === false) { + continue; + } + $items[$scoid] = $item; + } + }; + + $folders = []; + foreach (['meetings', 'user-meetings', 'my-meetings'] as $foldertype) { + $folderid = $this->get_folder_scoid($foldertype); + if ($folderid) { + $folders[$folderid] = true; + } + } + + $filters = []; + if ($query !== '') { + $filters[] = ['filter-type' => 'meeting', 'filter-like-name' => $query]; + $filters[] = ['filter-icon' => 'meeting', 'filter-like-name' => $query]; + $filters[] = ['filter-icon' => 'virtual-classroom', 'filter-like-name' => $query]; + } else { + $filters[] = ['filter-type' => 'meeting']; + $filters[] = ['filter-icon' => 'meeting']; + $filters[] = ['filter-icon' => 'virtual-classroom']; + } + + foreach (array_keys($folders) as $folder) { + foreach ($filters as $filter) { + // Do not send sort-* params: this Connect host returns status=invalid with them. + $params = array_merge([ + 'action' => 'sco-contents', + 'sco-id' => $folder, + ], $filter); + if ($limit > 0) { + $params['filter-rows'] = max($limit * 5, $limit); + } + try { + $xml = $this->client->request($params); + } catch (\Throwable $e) { + continue; + } + if ((string)($xml->status['code'] ?? '') !== 'ok') { + continue; + } + if (!empty($xml->scos->sco)) { + $addfromxml($xml->scos->sco); + } + if ($limit > 0 && count($items) >= $limit * 3) { + break 2; + } + } + } + + // Broad search / report only when folder listing found nothing (or query needs more hits). + $needfallback = ($query !== '') ? (count($items) < $limit) : (count($items) === 0); + if ($needfallback) { + $searchactions = []; + if ($query !== '') { + $searchactions[] = [ + 'action' => 'sco-search-by-field', + 'field' => 'name', + 'query' => $query, + ]; + $searchactions[] = [ + 'action' => 'sco-search-by-field', + 'filter-like-name' => $query, + 'query' => $query, + ]; + $searchactions[] = [ + 'action' => 'sco-search', + 'query' => $query, + 'filter-type' => 'meeting', + ]; + } else { + // Prefer folder listing above; these are last-resort only. + $searchactions[] = [ + 'action' => 'report-bulk-objects', + 'filter-type' => 'meeting', + 'filter-rows' => max($limit * 5, $limit), + ]; + } + foreach ($searchactions as $params) { + try { + $xml = $this->client->request($params); + } catch (\Throwable $e) { + continue; + } + if ((string)($xml->status['code'] ?? '') !== 'ok') { + continue; + } + $nodes = $xml->scos->sco + ?? $xml->{'sco-search-by-field-info'}->sco + ?? $xml->{'sco-search-info'}->sco + ?? $xml->{'report-bulk-objects'}->row + ?? []; + if (!empty($nodes)) { + $addfromxml($nodes); + } + if ($limit > 0 && count($items) >= $limit) { + break; + } + } + } + + uasort($items, static function($a, $b) { + $ta = !empty($a->datemodified) ? strtotime((string)$a->datemodified) : 0; + $tb = !empty($b->datemodified) ? strtotime((string)$b->datemodified) : 0; + if ($ta === $tb) { + return strcasecmp((string)$a->name, (string)$b->name); + } + return $tb <=> $ta; + }); + + if ($limit > 0 && count($items) > $limit) { + $items = array_slice($items, 0, $limit, true); + } + return $items; + } + + /** + * Rename a recording (archive) SCO. + */ + public function rename_sco(int $scoid, string $name): bool { + $name = trim($name); + if ($name === '') { + return false; + } + $xml = $this->client->request([ + 'action' => 'sco-update', + 'sco-id' => $scoid, + 'name' => $name, + ]); + return (string)($xml->status['code'] ?? '') === 'ok'; + } + + public function set_meeting_public(int $scoid, bool $public): void { + $this->client->request([ + 'action' => 'permissions-update', + 'acl-id' => $scoid, + 'principal-id' => 'public-access', + 'permission-id' => $public ? 'view-hidden' : 'denied', + ]); + } + + /** + * Prefer sco-info (fast) over sco-contents listing. + */ + public function meeting_info(int $scoid): ?stdClass { + $cache = cache::make('mod_meetletacbfree', 'meetinginfo'); + $cached = $cache->get((string)$scoid); + if (is_object($cached)) { + return $cached; + } + + $xml = $this->client->request(['action' => 'sco-info', 'sco-id' => $scoid]); + if ((string)$xml->status['code'] !== 'ok' || empty($xml->sco)) { + return null; + } + $info = new stdClass(); + $info->scoid = (int)$xml->sco['sco-id']; + $info->name = (string)$xml->sco->name; + $info->url = (string)$xml->sco->{'url-path'}; + $info->starttime = (string)($xml->sco->{'date-begin'} ?? ''); + $info->endtime = (string)($xml->sco->{'date-end'} ?? ''); + $info->folderid = (int)($xml->sco['folder-id'] ?? 0); + $cache->set((string)$scoid, $info); + return $info; + } + + /** + * Find or create Connect user. Returns object with principalid + login. + * + * Identity key is Moodle user id → Connect login mlu{id}. Email/username + * are only used to find legacy principals and then realign them. + */ + public function ensure_user(stdClass $user): stdClass { + $stable = config::connect_login($user); + $email = config::connect_email($user); + $lookup = $this->user_login_candidates($user); + + foreach ($lookup as $logintry) { + $found = $this->find_principal_by_login($logintry); + if (!$found) { + $found = $this->find_principal_by_ext_login($logintry); + } + if ($found && !$this->is_reserved_connect_login($found->login)) { + return $this->align_principal_to_moodle($found, $user); + } + } + if (config::use_email_login()) { + $found = $this->find_principal_by_email($email); + if ($found && !$this->is_reserved_connect_login($found->login)) { + return $this->align_principal_to_moodle($found, $user); + } + foreach ($lookup as $logintry) { + $found = $this->find_principal_like($logintry, $email); + if ($found && !$this->is_reserved_connect_login($found->login)) { + return $this->align_principal_to_moodle($found, $user); + } + } + } + + $firstname = trim((string)($user->firstname ?? '')); + $lastname = trim((string)($user->lastname ?? '')); + if ($firstname === '') { + $firstname = 'Moodle'; + } + if ($lastname === '') { + $lastname = 'User' . (int)($user->id ?? 0); + } + + $lastdetail = ''; + $login = $stable; + $password = 'Ac!' . substr(sha1($login . '|' . (int)$user->id . '|' . microtime(true)), 0, 14) . '9a'; + $this->clear_ext_login_conflicts($login, 0); + $attempts = [ + [ + 'action' => 'principal-update', + 'first-name' => $firstname, + 'last-name' => $lastname, + 'login' => $login, + 'password' => $password, + 'ext-login' => $login, + 'extlogin' => $login, + 'type' => 'user', + 'send-email' => 'false', + 'has-children' => 0, + 'email' => $email, + ], + // Second try without ext-login (some Connect builds reject it). + [ + 'action' => 'principal-update', + 'first-name' => $firstname, + 'last-name' => $lastname, + 'login' => $login, + 'password' => $password, + 'extlogin' => $login, + 'type' => 'user', + 'send-email' => 'false', + 'has-children' => 0, + 'email' => $email, + ], + ]; + foreach ($attempts as $params) { + $xml = $this->client->request($params); + $code = (string)($xml->status['code'] ?? ''); + $sub = (string)($xml->status['subcode'] ?? ''); + if ($code === 'ok' && !empty($xml->principal['principal-id'])) { + $out = new stdClass(); + $out->principalid = (int)$xml->principal['principal-id']; + $returnedlogin = (string)($xml->principal->{'ext-login'} ?? $xml->principal->extlogin + ?? $xml->principal->login ?? ''); + $out->login = $returnedlogin !== '' ? $returnedlogin : $login; + return $this->refresh_principal_login($out); + } + $lastdetail = 'login=' . $login . ' email=' . $email . ' status=' . $code . ($sub !== '' ? '/' . $sub : ''); + if (!empty($xml->status->invalid)) { + $lastdetail .= ' invalid=' . (string)$xml->status->invalid['field'] + . '/' . (string)$xml->status->invalid['type']; + } + // Duplicate — resolve again (another process may have created it). + $found = $this->find_principal_by_login($login); + if (!$found && config::use_email_login()) { + $found = $this->find_principal_by_email($email); + } + if (!$found && config::use_email_login()) { + $found = $this->find_principal_like($login, $email); + } + if ($found && !$this->is_reserved_connect_login($found->login)) { + return $this->align_principal_to_moodle($found, $user); + } + } + + throw new moodle_exception('errorcreateuser', 'meetletacbfree', '', null, $lastdetail); + } + + /** + * Re-point a found principal to stable Moodle-id login and refresh profile fields. + * If Connect refuses login rename, keep the existing login so SSO still works. + */ + protected function align_principal_to_moodle(stdClass $adobeuser, stdClass $user): stdClass { + // Safety net — callers should already skip reserved logins. + if ($this->is_reserved_connect_login((string)($adobeuser->login ?? ''))) { + throw new moodle_exception( + 'errorcreateuser', + 'meetletacbfree', + '', + null, + 'refusing to align reserved principal login=' . $adobeuser->login + ); + } + + $stable = config::connect_login($user); + $email = config::connect_email($user); + $firstname = trim((string)($user->firstname ?? '')); + $lastname = trim((string)($user->lastname ?? '')); + if ($firstname === '') { + $firstname = 'Moodle'; + } + if ($lastname === '') { + $lastname = 'User' . (int)($user->id ?? 0); + } + + $params = [ + 'action' => 'principal-update', + 'principal-id' => (int)$adobeuser->principalid, + 'first-name' => $firstname, + 'last-name' => $lastname, + 'email' => $email, + ]; + $renamelogin = (strcasecmp((string)$adobeuser->login, $stable) !== 0); + if ($renamelogin) { + $this->clear_ext_login_conflicts($stable, (int)$adobeuser->principalid); + $params['login'] = $stable; + $params['ext-login'] = $stable; + $params['extlogin'] = $stable; + } + + $xml = $this->client->request($params); + if ((string)($xml->status['code'] ?? '') === 'ok') { + if ($renamelogin) { + $adobeuser->login = $stable; + } + return $this->refresh_principal_login($adobeuser); + } + + // Login rename rejected — update profile, then best-effort ext-login for header SSO. + if ($renamelogin) { + $profile = $params; + unset($profile['login'], $profile['ext-login'], $profile['extlogin']); + $this->client->request($profile); + try { + $this->clear_ext_login_conflicts($stable, (int)$adobeuser->principalid); + $this->client->request([ + 'action' => 'principal-update', + 'principal-id' => (int)$adobeuser->principalid, + 'ext-login' => $stable, + 'extlogin' => $stable, + ]); + } catch (\Throwable $e) { + // SSO will fall back to legacy login candidates. + } + } + return $this->refresh_principal_login($adobeuser); + } + + /** + * Re-read principal login/ext-login from Connect after create or align. + */ + public function refresh_principal_login(stdClass $adobeuser): stdClass { + $pid = (int)($adobeuser->principalid ?? 0); + if ($pid <= 0) { + return $adobeuser; + } + try { + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-principal-id' => $pid, + ]); + } catch (\Throwable $e) { + return $adobeuser; + } + if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->{'principal-list'}->principal)) { + return $adobeuser; + } + foreach ($xml->{'principal-list'}->principal as $p) { + if ((int)($p['principal-id'] ?? 0) !== $pid) { + continue; + } + if (!$this->is_sso_eligible_user_principal($p)) { + break; + } + $internallogin = trim((string)($p->login ?? '')); + $extlogin = trim((string)($p->{'ext-login'} ?? '')); + if ($extlogin === '') { + $extlogin = trim((string)($p->extlogin ?? '')); + } + // Header SSO matches ext-login when Adobe stores one separately from login. + $ssologin = $extlogin !== '' ? $extlogin : $internallogin; + if ($ssologin !== '' && !$this->is_reserved_connect_login($ssologin)) { + $adobeuser->login = $ssologin; + } + if ($internallogin !== '' && strcasecmp($internallogin, $ssologin) !== 0 + && !$this->is_reserved_connect_login($internallogin)) { + $adobeuser->internallogin = $internallogin; + } else { + unset($adobeuser->internallogin); + } + break; + } + return $adobeuser; + } + + /** + * Ensure user exists and grant meeting permission (admin API). + */ + public function provision_user_on_meeting(stdClass $user, int $scoid, string $permission): stdClass { + $adobeuser = $this->ensure_user($user); + $adobeuser = $this->refresh_principal_login($adobeuser); + $this->assign_permission((int)$adobeuser->principalid, $scoid, $permission); + $info = $this->meeting_info($scoid); + if ($info && !empty($info->folderid)) { + $this->assign_permission((int)$adobeuser->principalid, (int)$info->folderid, $permission); + } + return $adobeuser; + } + + /** + * Ordered login candidates for Connect principal lookup (stable first, then legacy). + * + * @return string[] + */ + protected function user_login_candidates(stdClass $user): array { + $out = [config::connect_login($user)]; + if (!config::use_email_login()) { + return $out; + } + foreach (config::legacy_connect_logins($user) as $candidate) { + if (!in_array($candidate, $out, true)) { + $out[] = $candidate; + } + } + return $out; + } + + public function find_principal_by_login(string $login): ?stdClass { + if (trim($login) === '') { + return null; + } + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-type' => 'user', + 'filter-login' => $login, + ]); + // Exact login match only — never accept the first fuzzy row. + return $this->parse_principal($xml, $login, true); + } + + /** + * Find a user principal whose ext-login matches exactly (header SSO identity). + */ + public function find_principal_by_ext_login(string $extlogin): ?stdClass { + $extlogin = trim($extlogin); + if ($extlogin === '') { + return null; + } + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-type' => 'user', + 'filter-like-login' => $extlogin, + ]); + if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->{'principal-list'}->principal)) { + return null; + } + foreach ($xml->{'principal-list'}->principal as $p) { + if (!$this->is_sso_eligible_user_principal($p)) { + continue; + } + $ext = trim((string)($p->{'ext-login'} ?? $p->extlogin ?? '')); + if ($ext !== '' && strcasecmp($ext, $extlogin) === 0) { + return $this->principal_from_xml($p); + } + } + return null; + } + + /** + * Clear ext-login from other principals so header SSO is unambiguous. + */ + protected function clear_ext_login_conflicts(string $extlogin, int $keepprincipalid): void { + $extlogin = trim($extlogin); + if ($extlogin === '' || $keepprincipalid <= 0) { + return; + } + try { + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-type' => 'user', + 'filter-like-login' => $extlogin, + ]); + } catch (\Throwable $e) { + return; + } + if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->{'principal-list'}->principal)) { + return; + } + foreach ($xml->{'principal-list'}->principal as $p) { + $pid = (int)($p['principal-id'] ?? 0); + if ($pid <= 0 || $pid === $keepprincipalid) { + continue; + } + if (!$this->is_sso_eligible_user_principal($p)) { + continue; + } + $ext = trim((string)($p->{'ext-login'} ?? $p->extlogin ?? '')); + if ($ext === '' || strcasecmp($ext, $extlogin) !== 0) { + continue; + } + try { + $this->client->request([ + 'action' => 'principal-update', + 'principal-id' => $pid, + 'ext-login' => '', + 'extlogin' => '', + ]); + } catch (\Throwable $e) { + // Best effort — customer may need to clean duplicates manually. + } + } + } + + public function find_principal_by_email(string $email): ?stdClass { + if (trim($email) === '') { + return null; + } + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-type' => 'user', + 'filter-email' => $email, + ]); + return $this->parse_principal($xml, $email, false); + } + + /** + * Broader principal lookup when exact filters miss. + * + * Adobe filter-like-login is substring-based ("admin" matches group + * "Administrators"). We only accept real users with an exact login/email. + */ + protected function find_principal_like(string $login, string $email): ?stdClass { + foreach (array_unique(array_filter([$login, $email])) as $needle) { + // Short bare usernames are too ambiguous for substring search. + if (strpos($needle, '@') === false && strlen($needle) < 5) { + continue; + } + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-type' => 'user', + 'filter-like-login' => $needle, + ]); + $found = $this->parse_principal($xml, $needle, true); + if ($found) { + return $found; + } + if (strpos($needle, '@') !== false) { + $xml = $this->client->request([ + 'action' => 'principal-list', + 'filter-type' => 'user', + 'filter-like-email' => $needle, + ]); + $found = $this->parse_principal($xml, $needle, false); + if ($found) { + return $found; + } + } + } + return null; + } + + /** + * Parse principal-list response into a user principal only. + * + * @param \SimpleXMLElement $xml + * @param string $fallbacklogin + * @param bool $requireexactlogin When true, login/ext-login must equal $fallbacklogin. + */ + protected function parse_principal($xml, string $fallbacklogin, bool $requireexactlogin = false): ?stdClass { + if ((string)$xml->status['code'] !== 'ok' || empty($xml->{'principal-list'}->principal)) { + return null; + } + foreach ($xml->{'principal-list'}->principal as $p) { + if (!$this->is_sso_eligible_user_principal($p)) { + continue; + } + $candidate = $this->principal_from_xml($p); + if ($requireexactlogin && !$this->principal_matches_login($p, $fallbacklogin)) { + continue; + } + if ($candidate->login === '' && $fallbacklogin !== '') { + $candidate->login = $fallbacklogin; + } + return $candidate; + } + return null; + } + + /** + * Build principal object preferring ext-login for header SSO. + * + * @param \SimpleXMLElement $p + */ + protected function principal_from_xml($p): stdClass { + $internallogin = trim((string)($p->login ?? '')); + $extlogin = trim((string)($p->{'ext-login'} ?? '')); + if ($extlogin === '') { + $extlogin = trim((string)($p->extlogin ?? '')); + } + $ssologin = $extlogin !== '' ? $extlogin : $internallogin; + $out = new stdClass(); + $out->principalid = (int)$p['principal-id']; + $out->login = $ssologin; + if ($internallogin !== '' && strcasecmp($internallogin, $ssologin) !== 0 + && !$this->is_reserved_connect_login($internallogin)) { + $out->internallogin = $internallogin; + } + return $out; + } + + /** + * Whether a principal row matches a login or ext-login exactly. + * + * @param \SimpleXMLElement $p + */ + protected function principal_matches_login($p, string $needle): bool { + $needle = trim($needle); + if ($needle === '') { + return false; + } + foreach ([(string)($p->login ?? ''), (string)($p->{'ext-login'} ?? ''), (string)($p->extlogin ?? '')] as $field) { + $field = trim($field); + if ($field !== '' && strcasecmp($field, $needle) === 0) { + return true; + } + } + return false; + } + + /** + * Groups / built-in Adobe principals cannot be used for header SSO. + * + * @param \SimpleXMLElement $p + */ + protected function is_sso_eligible_user_principal($p): bool { + $type = strtolower(trim((string)($p['type'] ?? 'user'))); + if ($type !== '' && $type !== 'user') { + return false; + } + $haschildren = strtolower(trim((string)($p['has-children'] ?? 'false'))); + if (in_array($haschildren, ['true', '1', 'yes'], true)) { + return false; + } + $login = strtolower(trim((string)($p->login ?? $p->{'ext-login'} ?? ''))); + if ($this->is_reserved_connect_login($login)) { + return false; + } + return true; + } + + /** + * Built-in Adobe Connect group / role logins that must never be used for SSO. + */ + protected function is_reserved_connect_login(string $login): bool { + $login = strtolower(trim($login)); + if ($login === '') { + return false; + } + $reserved = [ + 'administrators', + 'authors', + 'everyone', + 'live-admins', + 'admins', + 'course-admins', + 'event-administrators', + 'event-managers', + 'learners', + 'seminar-administrators', + ]; + return in_array($login, $reserved, true); + } + + public function assign_permission(int $principalid, int $scoid, string $permission): bool { + $xml = $this->client->request([ + 'action' => 'permissions-update', + 'acl-id' => $scoid, + 'principal-id' => $principalid, + 'permission-id' => $permission, + ]); + return (string)$xml->status['code'] === 'ok'; + } + + /** + * List recordings for a meeting sco. + * + * @return array of stdClass + */ + public function list_recordings(int $meetingscoid): array { + $items = []; + $xml = $this->client->request([ + 'action' => 'sco-contents', + 'sco-id' => $meetingscoid, + 'filter-icon' => 'archive', + ]); + if (!empty($xml->scos->sco)) { + foreach ($xml->scos->sco as $sco) { + $items[] = $this->sco_to_recording($sco, $meetingscoid); + } + } + + // Also check forced-archives folder for linked recordings. + $forced = $this->get_folder_scoid('forced-archives'); + if ($forced) { + $xml2 = $this->client->request([ + 'action' => 'sco-contents', + 'sco-id' => $forced, + 'filter-icon' => 'archive', + ]); + if (!empty($xml2->scos->sco)) { + foreach ($xml2->scos->sco as $sco) { + $source = (int)($sco['source-sco-id'] ?? 0); + if ($source === $meetingscoid) { + $items[(int)$sco['sco-id']] = $this->sco_to_recording($sco, $meetingscoid); + } + } + } + } + + // Reindex unique by scoid. + $unique = []; + foreach ($items as $item) { + $unique[$item->scoid] = $item; + } + return array_values($unique); + } + + protected function sco_to_recording($sco, int $meetingscoid): stdClass { + $rec = new stdClass(); + $rec->scoid = (int)$sco['sco-id']; + $rec->meetingscoid = $meetingscoid; + $rec->name = (string)$sco->name; + $rec->urlpath = (string)$sco->{'url-path'}; + $rec->duration = (int)($sco['duration'] ?? 0); + $rec->datebegin = (string)($sco->{'date-begin'} ?? $sco->{'date-created'} ?? ''); + $rec->dateend = (string)($sco->{'date-end'} ?? ''); + $rec->icon = (string)($sco['icon'] ?? 'archive'); + return $rec; + } + + /** + * True when a sco-contents row is a recording/archive, not uploaded meeting content. + * + * @param \SimpleXMLElement|null $sco + */ + protected function sco_is_recording_sco($sco, string $icon = '', string $type = ''): bool { + if ($sco !== null) { + if ($icon === '') { + $icon = strtolower(trim((string)($sco['icon'] ?? ''))); + } + if ($type === '') { + $type = strtolower(trim((string)($sco['type'] ?? ''))); + } + } + $scotype = ''; + if ($sco !== null) { + $scotype = strtolower(trim((string)($sco['sco-type'] ?? ''))); + } + + $skipicons = ['archive', 'recording', 'mp4-archive', 'folder']; + $skiptypes = [ + 'recording', 'archive', 'meeting-recording', 'session-recording', + 'meeting', 'folder', 'virtual-classroom', + ]; + if (in_array($icon, $skipicons, true) || in_array($type, $skiptypes, true)) { + return true; + } + if ($scotype !== '') { + $scotypeskip = [ + 'recording', 'archive', 'meeting-recording', 'session-recording', 'meeting', 'folder', + ]; + if (in_array($scotype, $scotypeskip, true)) { + return true; + } + } + + // Connect recordings expose session window + duration; uploaded files do not. + if ($sco !== null) { + $duration = (int)($sco['duration'] ?? 0); + $datebegin = trim((string)($sco->{'date-begin'} ?? '')); + $dateend = trim((string)($sco->{'date-end'} ?? '')); + if ($duration > 0 && $datebegin !== '' && $dateend !== '') { + $name = trim((string)($sco->name ?? '')); + if (!preg_match('/\.[a-z0-9]{2,5}$/i', $name)) { + return true; + } + } + } + return false; + } + + /** + * Content uploaded inside a meeting folder (non-archive). + * Filters out folders and Adobe placeholder SCOs named like "/abc123/". + */ + public function list_meeting_content(int $meetingscoid): array { + $items = []; + $recordingscoids = []; + foreach ($this->list_recordings($meetingscoid) as $rec) { + $recordingscoids[(int)$rec->scoid] = true; + } + + $xml = $this->client->request([ + 'action' => 'sco-contents', + 'sco-id' => $meetingscoid, + ]); + if (empty($xml->scos->sco)) { + return $items; + } + foreach ($xml->scos->sco as $sco) { + $scoid = (int)$sco['sco-id']; + if (isset($recordingscoids[$scoid])) { + continue; + } + + $icon = strtolower(trim((string)($sco['icon'] ?? ''))); + $type = strtolower(trim((string)($sco['type'] ?? ''))); + $name = trim((string)($sco->name ?? '')); + $urlpath = trim((string)($sco->{'url-path'} ?? '')); + + // Recordings / nested meetings / folders are not "uploaded content". + if ($this->sco_is_recording_sco($sco, $icon, $type)) { + continue; + } + // Adobe often lists a content SCO whose title is just the room url-path. + if ($name === '' || $name === $urlpath || preg_match('#^/[A-Za-z0-9_-]+/?$#', $name)) { + if (preg_match('#/([^/]+\.(jpe?g|png|gif|webp|bmp|pdf|pptx?|docx?|xlsx?|mp4|zip|mp3))$#i', $urlpath, $m)) { + $name = rawurldecode($m[1]); + } else { + continue; + } + } + + $bytesize = $this->sco_byte_size($sco); + $datemod = trim((string)($sco->{'date-modified'} ?? '')); + $datecreated = trim((string)($sco->{'date-created'} ?? $sco->{'date-begin'} ?? '')); + $datesource = $datemod !== '' ? $datemod : $datecreated; + + // sco-contents often omits size — enrich from sco-info. + if ($bytesize <= 0 || $icon === '' || $icon === 'content') { + $detail = $this->sco_content_detail((int)$sco['sco-id']); + if ($detail) { + if ($bytesize <= 0 && !empty($detail->bytesize)) { + $bytesize = (int)$detail->bytesize; + } + if (($icon === '' || $icon === 'content') && !empty($detail->icon)) { + $icon = $detail->icon; + } + if ($type === '' || $type === 'content') { + $type = $detail->type !== '' ? $detail->type : $type; + } + if ($datesource === '' && !empty($detail->datemodified)) { + $datesource = $detail->datemodified; + } + } + } + + // Infer icon from filename when API only says "content". + if (($icon === '' || $icon === 'content') && preg_match('/\.([a-z0-9]+)$/i', $name, $m)) { + $ext = strtolower($m[1]); + $extmap = [ + 'jpg' => 'image', 'jpeg' => 'image', 'png' => 'image', 'gif' => 'image', 'webp' => 'image', 'bmp' => 'image', + 'pdf' => 'pdf', + 'ppt' => 'presentation', 'pptx' => 'presentation', + 'mp3' => 'mp3', 'mp4' => 'mp4', 'flv' => 'flv', + 'zip' => 'zip', 'swf' => 'swf', 'html' => 'html', 'htm' => 'html', + ]; + if (isset($extmap[$ext])) { + $icon = $extmap[$ext]; + } + } + + // sco-info can reveal archive/recording icon after an initial "content" placeholder. + if ($this->sco_is_recording_sco($sco, $icon, $type)) { + continue; + } + + $item = new stdClass(); + $item->scoid = (int)$sco['sco-id']; + $item->name = $name; + $item->urlpath = $urlpath; + $item->icon = $icon; + $item->type = $type !== '' ? $type : $icon; + $item->typelabel = self::content_type_label($icon, $type, $name); + if ($bytesize <= 0) { + $bytesize = $this->fetch_output_bytesize($urlpath, $name); + } + $item->bytesize = $bytesize; + $item->datecreated = $datesource; + $item->datecreated_fmt = $this->format_connect_date($datesource); + $item->url = config::public_url($item->urlpath); + $items[] = $item; + } + return $items; + } + + /** + * Human label for uploaded content type (icon / extension based). + */ + public static function content_type_label(string $icon, string $type, string $name = ''): string { + $candidates = []; + $icon = strtolower(trim($icon)); + $type = strtolower(trim($type)); + if ($icon !== '') { + $candidates[] = $icon; + } + if ($type !== '' && $type !== $icon) { + $candidates[] = $type; + } + if (preg_match('/\.([a-z0-9]+)$/i', $name, $m)) { + $candidates[] = strtolower($m[1]); + } + foreach ($candidates as $key) { + $stringkey = 'contenticon_' . $key; + if (get_string_manager()->string_exists($stringkey, 'meetletacbfree')) { + return get_string($stringkey, 'meetletacbfree'); + } + } + return $icon !== '' && $icon !== 'content' ? $icon : get_string('contenticon_content', 'meetletacbfree'); + } + + /** + * Read byte size from sco XML (attribute or child, several Connect variants). + * + * @param \SimpleXMLElement $sco + */ + protected function sco_byte_size($sco): int { + $names = ['byte-count', 'byte-size', 'bytes', 'size', 'file-size']; + foreach ($names as $name) { + if (isset($sco[$name]) && is_numeric((string)$sco[$name])) { + return (int)$sco[$name]; + } + if (isset($sco->{$name}) && is_numeric((string)$sco->{$name})) { + return (int)$sco->{$name}; + } + } + return 0; + } + + /** + * Extra metadata for one content SCO. + */ + protected function sco_content_detail(int $scoid): ?stdClass { + $xml = $this->client->request(['action' => 'sco-info', 'sco-id' => $scoid]); + if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->sco)) { + return null; + } + $sco = $xml->sco; + $out = new stdClass(); + $out->icon = strtolower(trim((string)($sco['icon'] ?? ''))); + $out->type = strtolower(trim((string)($sco['type'] ?? ''))); + $out->bytesize = $this->sco_byte_size($sco); + $out->datemodified = trim((string)($sco->{'date-modified'} ?? $sco->{'date-created'} ?? '')); + return $out; + } + + /** + * Adobe stores binaries under {url-path}/output/{filename}; XML often omits byte-count. + */ + protected function fetch_output_bytesize(string $urlpath, string $filename): int { + $filename = basename(str_replace('\\', '/', $filename)); + if ($filename === '' || strpos($filename, '/') !== false) { + return 0; + } + if (!preg_match('/\.[a-z0-9]{2,5}$/i', $filename)) { + return 0; + } + $base = '/' . trim($urlpath, '/'); + if ($base === '/') { + return 0; + } + $url = config::public_url($base . '/output/' . rawurlencode($filename)); + $cookie = $this->client->get_cookie(); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_NOBODY => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_TIMEOUT => 12, + CURLOPT_COOKIE => $cookie !== '' ? ('BREEZESESSION=' . $cookie) : '', + ]); + $headers = curl_exec($ch); + $len = (int)curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($len <= 0 && is_string($headers) && preg_match('/Content-Length:\s*(\d+)/i', $headers, $m)) { + $len = (int)$m[1]; + } + if ($code >= 200 && $code < 400 && $len > 0) { + return $len; + } + return 0; + } + + /** + * Schedule server-side MP4 job (requires encoder). + */ + public function schedule_offline_job(int $recordingscoid): array { + $xml = $this->client->request([ + 'action' => 'schedule-recording-job', + 'source-sco-id' => $recordingscoid, + ]); + $code = (string)$xml->status['code']; + $sub = (string)($xml->status['subcode'] ?? ''); + return [ + 'ok' => $code === 'ok', + 'code' => $code, + 'subcode' => $sub, + 'raw' => $this->client->get_last_response(), + ]; + } + + public function list_recording_jobs(int $scoid): array { + $xml = $this->client->request([ + 'action' => 'list-recording-jobs', + 'sco-id' => $scoid, + ]); + $jobs = []; + if (!empty($xml->{'recording-jobs'}->{'recording-job'})) { + foreach ($xml->{'recording-jobs'}->{'recording-job'} as $job) { + $jobs[] = [ + 'scoid' => (int)($job['sco-id'] ?? 0), + 'status' => (string)($job['job-status'] ?? $job['status'] ?? ''), + ]; + } + } + return $jobs; + } + + /** + * Find MP4 child under a recording sco. + */ + public function find_mp4_child(int $recordingscoid): ?stdClass { + $xml = $this->client->request([ + 'action' => 'sco-contents', + 'sco-id' => $recordingscoid, + ]); + if (empty($xml->scos->sco)) { + return null; + } + foreach ($xml->scos->sco as $sco) { + $icon = (string)($sco['icon'] ?? ''); + if ($icon === 'mp4' || $icon === 'mp4-archive') { + $out = new stdClass(); + $out->scoid = (int)$sco['sco-id']; + $out->urlpath = (string)$sco->{'url-path'}; + $out->name = (string)$sco->name; + return $out; + } + } + return null; + } + + /** + * Attendance rows for a meeting, grouped by session. + * + * @return array{sessions:array,attendees:array} + */ + public function get_attendance(int $meetingscoid): array { + $sessions = []; + $xml = $this->client->request([ + 'action' => 'report-meeting-sessions', + 'sco-id' => $meetingscoid, + ]); + if (!empty($xml->{'report-meeting-sessions'}->row)) { + foreach ($xml->{'report-meeting-sessions'}->row as $row) { + $assetid = (string)($row['asset-id'] ?? ''); + $datecreated = (string)($row->{'date-created'} ?? ''); + $dateend = (string)($row->{'date-end'} ?? ''); + $sessions[] = [ + 'scoid' => (int)($row['sco-id'] ?? $meetingscoid), + 'assetid' => $assetid, + 'numparticipants' => (int)($row['num-participants'] ?? 0), + 'datecreated' => $datecreated, + 'dateend' => $dateend, + 'datecreated_fmt' => $this->format_connect_date($datecreated), + 'dateend_fmt' => $this->format_connect_date($dateend), + 'attendees' => $this->get_session_attendees($meetingscoid, $assetid, $datecreated, $dateend), + ]; + } + } + + // Flat list kept for compatibility / empty-session fallback. + $people = []; + foreach ($sessions as $session) { + foreach ($session['attendees'] as $person) { + $key = ($person['login'] !== '' ? $person['login'] : $person['name']) . '|' . ($person['timein'] ?? ''); + $people[$key] = $person; + } + } + + if (empty($sessions)) { + $xml2 = $this->client->request([ + 'action' => 'report-bulk-consolidated-transactions', + 'filter-type' => 'meeting', + 'filter-sco-id' => $meetingscoid, + ]); + if (!empty($xml2->{'report-bulk-consolidated-transactions'}->row)) { + foreach ($xml2->{'report-bulk-consolidated-transactions'}->row as $row) { + $login = (string)($row->login ?? ''); + $name = trim((string)($row->{'user-name'} ?? $row->name ?? $login)); + $key = $login !== '' ? $login : $name; + if (!isset($people[$key])) { + $people[$key] = [ + 'login' => $login, + 'name' => $name, + 'status' => 'present', + 'timein' => $this->format_connect_date((string)($row->{'date-created'} ?? '')), + 'timeout' => $this->format_connect_date((string)($row->{'date-closed'} ?? '')), + ]; + } + } + } + } + + return [ + 'sessions' => $sessions, + 'attendees' => array_values($people), + ]; + } + + /** + * Attendees for one meeting session (asset). + * + * @return array + */ + public function get_session_attendees(int $meetingscoid, string $assetid, string $datecreated = '', string $dateend = ''): array { + $people = []; + + if ($assetid !== '') { + $xml = $this->client->request([ + 'action' => 'report-event-participants', + 'sco-id' => $meetingscoid, + 'asset-id' => $assetid, + ]); + $rows = $xml->{'report-event-participants'}->row + ?? $xml->{'report-meeting-attendance'}->row + ?? null; + if (!empty($rows)) { + foreach ($rows as $row) { + $login = (string)($row->login ?? ''); + $name = trim((string)($row->{'user-name'} ?? $row->name ?? $login)); + $key = $login !== '' ? $login : $name . '|' . (string)($row->{'date-created'} ?? ''); + $people[$key] = [ + 'login' => $login, + 'name' => $name !== '' ? $name : $login, + 'status' => 'present', + 'timein' => $this->format_connect_date((string)($row->{'date-created'} ?? $row->{'first-time'} ?? '')), + 'timeout' => $this->format_connect_date((string)($row->{'date-closed'} ?? $row->{'last-time'} ?? '')), + ]; + } + } + } + + if (!empty($people)) { + return array_values($people); + } + + // Fallback: filter consolidated transactions by session window. + $xml2 = $this->client->request([ + 'action' => 'report-bulk-consolidated-transactions', + 'filter-type' => 'meeting', + 'filter-sco-id' => $meetingscoid, + ]); + if (empty($xml2->{'report-bulk-consolidated-transactions'}->row)) { + return []; + } + + $startts = $datecreated !== '' ? strtotime($datecreated) : 0; + $endts = $dateend !== '' ? strtotime($dateend) : 0; + foreach ($xml2->{'report-bulk-consolidated-transactions'}->row as $row) { + $created = (string)($row->{'date-created'} ?? ''); + $createdts = $created !== '' ? strtotime($created) : 0; + if ($startts && $createdts && $createdts < ($startts - 120)) { + continue; + } + if ($endts && $createdts && $createdts > ($endts + 120)) { + continue; + } + $login = (string)($row->login ?? ''); + $name = trim((string)($row->{'user-name'} ?? $row->name ?? $login)); + $key = $login !== '' ? $login : $name . '|' . $created; + $people[$key] = [ + 'login' => $login, + 'name' => $name !== '' ? $name : $login, + 'status' => 'present', + 'timein' => $this->format_connect_date($created), + 'timeout' => $this->format_connect_date((string)($row->{'date-closed'} ?? '')), + ]; + } + return array_values($people); + } + + /** + * Pretty-print Connect ISO dates for UI. + */ + protected function format_connect_date(string $value): string { + $value = trim($value); + if ($value === '') { + return ''; + } + $ts = strtotime($value); + if (!$ts) { + return $value; + } + return userdate($ts); + } + + public function delete_sco(int $scoid): bool { + $xml = $this->client->request(['action' => 'sco-delete', 'sco-id' => $scoid]); + return (string)$xml->status['code'] === 'ok'; + } + + public static function format_date(int $timestamp): string { + // Adobe expects ISO8601 with timezone offset. + return date('c', $timestamp); + } + + /** + * Build zip download URL for an archive. + */ + public static function zip_url(string $urlpath): string { + $path = trim($urlpath, '/'); + $segment = $path; + if (strpos($path, '/') !== false) { + $parts = explode('/', $path); + $segment = end($parts); + } + return config::public_url('/' . $path . '/output/' . $segment . '.zip?download=zip'); + } +} diff --git a/classes/event/course_module_viewed.php b/classes/event/course_module_viewed.php new file mode 100644 index 0000000..1b3b11f --- /dev/null +++ b/classes/event/course_module_viewed.php @@ -0,0 +1,34 @@ +. + +namespace mod_meetletacbfree\event; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Course module viewed event. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_module_viewed extends \core\event\course_module_viewed { + protected function init() { + $this->data['objecttable'] = 'meetletacbfree'; + $this->data['crud'] = 'r'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + } +} diff --git a/classes/event/meeting_joined.php b/classes/event/meeting_joined.php new file mode 100644 index 0000000..0eae49a --- /dev/null +++ b/classes/event/meeting_joined.php @@ -0,0 +1,46 @@ +. + +namespace mod_meetletacbfree\event; + +defined('MOODLE_INTERNAL') || die(); + +/** + * User joined a MeetLet meeting. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class meeting_joined extends \core\event\base { + protected function init() { + $this->data['objecttable'] = 'meetletacbfree'; + $this->data['crud'] = 'r'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + } + + public static function get_name() { + return get_string('joinmeeting', 'meetletacbfree'); + } + + public function get_description() { + return "The user with id '{$this->userid}' joined MeetLet meeting '{$this->objectid}'."; + } + + public function get_url() { + return new \moodle_url('/mod/meetletacbfree/view.php', ['id' => $this->contextinstanceid]); + } +} diff --git a/classes/external/get_content_launch_url.php b/classes/external/get_content_launch_url.php new file mode 100644 index 0000000..cecc933 --- /dev/null +++ b/classes/external/get_content_launch_url.php @@ -0,0 +1,97 @@ +. + +/** + * Ajax: SSO URL to view uploaded meeting content in Adobe Connect. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\content; +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\roles; +use mod_meetletacbfree\local\sso; +use mod_meetletacbfree\local\usererror; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Prepare Adobe Connect content view URL with session for current user. + */ +class get_content_launch_url extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module id'), + 'contentscoid' => new external_value(PARAM_INT, 'Content sco id'), + 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(int $cmid, int $contentscoid, int $groupid = 0): array { + global $USER; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'contentscoid' => $contentscoid, + 'groupid' => $groupid, + ]); + + \mod_meetletacbfree\registration\manager::require_registered(); + + $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); + self::validate_context($inst->context); + require_capability('mod/meetletacbfree:view', $inst->context); + featureaccess::require_feature(featureaccess::CONTENT, $inst->context, $inst->record); + + $service = new service(); + $service->login_admin(); + $item = content::find_in_meeting($service, $inst->require_meeting(), (int)$params['contentscoid']); + $service->logout(); + + if (!$item || empty($item->urlpath)) { + throw new moodle_exception('nocontent', 'meetletacbfree'); + } + + try { + $adoberole = roles::resolve_adobe_role($inst->record, $inst->context, $USER->id); + $permission = $adoberole ?: service::ROLE_PARTICIPANT; + $auth = sso::provision_and_login($USER, (int)$params['contentscoid'], $permission); + $url = config::content_view_url((string)$item->urlpath, $auth['cookie']); + return usererror::ws_ok($url); + } catch (\Throwable $e) { + if ($e instanceof moodle_exception && in_array($e->errorcode, ['nopermissions', 'requirelogin', 'invalidparameter'], true)) { + throw $e; + } + return usererror::ws_fail($e, $inst->context); + } + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure(usererror::ws_url_returns()); + } +} diff --git a/classes/external/get_join_url.php b/classes/external/get_join_url.php new file mode 100644 index 0000000..723ed18 --- /dev/null +++ b/classes/external/get_join_url.php @@ -0,0 +1,126 @@ +. + +/** + * Ajax: build SSO join URL. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\roles; +use mod_meetletacbfree\local\sso; +use mod_meetletacbfree\local\usererror; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Prepare Adobe Connect join URL for current user. + */ +class get_join_url extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module id'), + 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(int $cmid, int $groupid = 0): array { + global $USER; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'groupid' => $groupid, + ]); + + \mod_meetletacbfree\registration\manager::require_registered(); + + $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); + self::validate_context($inst->context); + require_capability('mod/meetletacbfree:view', $inst->context); + if (!\mod_meetletacbfree\local\featureaccess::allowed( + \mod_meetletacbfree\local\featureaccess::JOIN, + $inst->context, + $inst->record + )) { + throw new moodle_exception('notparticipant', 'meetletacbfree'); + } + + if ($inst->cm->groupmode != NOGROUPS) { + $usrgroups = groups_get_user_groups($inst->course->id, $USER->id); + $usrgroups = $usrgroups[0] ?? []; + $aag = has_capability('moodle/site:accessallgroups', $inst->context); + if (!$aag && $params['groupid'] && !in_array($params['groupid'], $usrgroups, false)) { + throw new moodle_exception('usergrouprequired', 'meetletacbfree'); + } + } + + $adoberole = roles::resolve_adobe_role($inst->record, $inst->context, $USER->id); + if ($adoberole === null) { + throw new moodle_exception('notparticipant', 'meetletacbfree'); + } + + try { + // Gate join before any Adobe user provisioning / SSO. + $lifecycle = \mod_meetletacbfree\local\meetingstatus::resolve($inst, (int)$USER->id, true); + if (empty($lifecycle['joinable'])) { + $code = !empty($lifecycle['errorcode']) ? $lifecycle['errorcode'] : 'meetingended'; + throw new moodle_exception($code, 'meetletacbfree'); + } + + $scoid = $inst->require_meeting(); + $service = new service(); + $service->login_admin(); + $info = $service->meeting_info($scoid); + $service->logout(); + if (!$info) { + throw new moodle_exception('nomeeting', 'meetletacbfree'); + } + + $auth = sso::provision_and_login($USER, $scoid, $adoberole); + $url = config::with_html_client(config::public_url($info->url, $auth['cookie'])); + + $event = \mod_meetletacbfree\event\meeting_joined::create([ + 'context' => $inst->context, + 'objectid' => $inst->record->id, + 'relateduserid' => $USER->id, + ]); + $event->trigger(); + + return usererror::ws_ok($url); + } catch (\Throwable $e) { + if ($e instanceof moodle_exception && in_array($e->errorcode, ['nopermissions', 'requirelogin', 'invalidparameter'], true)) { + throw $e; + } + return usererror::ws_fail($e, $inst->context); + } + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure(usererror::ws_url_returns()); + } +} diff --git a/classes/external/get_meeting_status.php b/classes/external/get_meeting_status.php new file mode 100644 index 0000000..0dca8ae --- /dev/null +++ b/classes/external/get_meeting_status.php @@ -0,0 +1,86 @@ +. + +/** + * Ajax: poll meeting join status. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\meetingstatus; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Lightweight status poll for the activity hero badge / join gate. + */ +class get_meeting_status extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module id'), + 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(int $cmid, int $groupid = 0): array { + global $USER; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'groupid' => $groupid, + ]); + + \mod_meetletacbfree\registration\manager::require_registered(); + + $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); + self::validate_context($inst->context); + require_capability('mod/meetletacbfree:view', $inst->context); + + $info = meetingstatus::resolve($inst, (int)$USER->id, true); + $featurejoin = featureaccess::allowed(featureaccess::JOIN, $inst->context, $inst->record); + $joinable = $featurejoin && !empty($info['joinable']); + + return [ + 'status' => (string)$info['status'], + 'statuslabel' => (string)$info['statuslabel'], + 'joinable' => (bool)$joinable, + 'message' => (string)$info['message'], + 'errorcode' => (string)$info['errorcode'], + 'servertime' => time(), + ]; + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'status' => new external_value(PARAM_ALPHANUMEXT, 'Status code'), + 'statuslabel' => new external_value(PARAM_TEXT, 'Localized status label'), + 'joinable' => new external_value(PARAM_BOOL, 'Whether join may be requested'), + 'message' => new external_value(PARAM_TEXT, 'User-facing gate message'), + 'errorcode' => new external_value(PARAM_ALPHANUMEXT, 'Exception string id when not joinable'), + 'servertime' => new external_value(PARAM_INT, 'Server unix time'), + ]); + } +} diff --git a/classes/external/get_recording_launch_url.php b/classes/external/get_recording_launch_url.php new file mode 100644 index 0000000..8f0b176 --- /dev/null +++ b/classes/external/get_recording_launch_url.php @@ -0,0 +1,117 @@ +. + +/** + * Ajax: SSO URL to play or edit a recording in Adobe Connect. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\roles; +use mod_meetletacbfree\local\sso; +use mod_meetletacbfree\local\usererror; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Prepare Adobe Connect recording play/edit URL with session for current user. + */ +class get_recording_launch_url extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module id'), + 'recordingscoid' => new external_value(PARAM_INT, 'Recording sco id'), + 'mode' => new external_value(PARAM_ALPHA, 'play or edit'), + 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(int $cmid, int $recordingscoid, string $mode, int $groupid = 0): array { + global $DB, $USER; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'recordingscoid' => $recordingscoid, + 'mode' => $mode, + 'groupid' => $groupid, + ]); + + $mode = strtolower($params['mode']); + if (!in_array($mode, ['play', 'edit'], true)) { + throw new moodle_exception('error', 'meetletacbfree'); + } + + \mod_meetletacbfree\registration\manager::require_registered(); + + $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); + self::validate_context($inst->context); + require_capability('mod/meetletacbfree:view', $inst->context); + featureaccess::require_feature(featureaccess::RECORDINGS, $inst->context, $inst->record); + + if ($mode === 'edit') { + featureaccess::require_feature(featureaccess::ADOBEEDITOR, $inst->context, $inst->record); + } + + $record = $DB->get_record('meetletacbfree_recordings', [ + 'instanceid' => $inst->record->id, + 'recordingscoid' => $params['recordingscoid'], + ]); + if (!$record || empty($record->urlpath)) { + throw new moodle_exception('norecordings', 'meetletacbfree'); + } + + try { + // Prefer meeting role when available; fall back to view for play / host for edit. + $adoberole = roles::resolve_adobe_role($inst->record, $inst->context, $USER->id); + if ($mode === 'edit') { + $permission = ($adoberole === service::ROLE_HOST || $adoberole === service::ROLE_PRESENTER) + ? $adoberole + : service::ROLE_HOST; + } else { + $permission = $adoberole ?: service::ROLE_PARTICIPANT; + } + + $auth = sso::provision_and_login($USER, (int)$params['recordingscoid'], $permission); + $url = $mode === 'edit' + ? config::recording_editor_url((string)$record->urlpath, $auth['cookie']) + : config::recording_play_url((string)$record->urlpath, $auth['cookie']); + + return usererror::ws_ok($url); + } catch (\Throwable $e) { + if ($e instanceof moodle_exception && in_array($e->errorcode, ['nopermissions', 'requirelogin', 'invalidparameter'], true)) { + throw $e; + } + return usererror::ws_fail($e, $inst->context); + } + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure(usererror::ws_url_returns()); + } +} diff --git a/classes/external/get_tab.php b/classes/external/get_tab.php new file mode 100644 index 0000000..ee84313 --- /dev/null +++ b/classes/external/get_tab.php @@ -0,0 +1,346 @@ +. + +/** + * Ajax: load tab HTML/data. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\content; +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\icons; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\recordings; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Return tab payload for the activity UI. + */ +class get_tab extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module id'), + 'tab' => new external_value(PARAM_ALPHA, 'Tab name'), + 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(int $cmid, string $tab, int $groupid = 0): array { + global $PAGE; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'tab' => $tab, + 'groupid' => $groupid, + ]); + + \mod_meetletacbfree\registration\manager::require_registered(); + + $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); + self::validate_context($inst->context); + require_capability('mod/meetletacbfree:view', $inst->context); + $PAGE->set_context($inst->context); + + if (!config::is_configured()) { + return [ + 'html' => \html_writer::div(get_string('notconfigured', 'meetletacbfree'), 'meetlet-alert'), + 'tab' => $params['tab'], + ]; + } + + $html = ''; + switch ($params['tab']) { + case 'details': + // Served locally in the activity page; keep endpoint defensive. + $html = \html_writer::div(get_string('emptydetails', 'meetletacbfree'), 'meetlet-empty'); + break; + case 'info': + // Legacy tab id — no Adobe call. + $html = \html_writer::div(get_string('emptydetails', 'meetletacbfree'), 'meetlet-empty'); + break; + case 'recordings': + featureaccess::require_feature(featureaccess::RECORDINGS, $inst->context, $inst->record); + $html = self::render_recordings($inst); + break; + case 'content': + featureaccess::require_feature(featureaccess::CONTENT, $inst->context, $inst->record); + $html = self::render_content($inst); + break; + default: + throw new moodle_exception('error', 'meetletacbfree'); + } + + return ['html' => $html, 'tab' => $params['tab']]; + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'html' => new external_value(PARAM_RAW, 'Tab HTML'), + 'tab' => new external_value(PARAM_ALPHA, 'Tab id'), + ]); + } + + /** + * Toolbar with refresh for cacheable Adobe tabs. + */ + protected static function wrap_refreshable(string $html, string $tab, bool $pending = false): string { + $refreshlabel = get_string('refreshtab', 'meetletacbfree'); + $refreshcontent = icons::svg('refresh') + . \html_writer::span($refreshlabel, 'meetlet-btn-with-icon__label'); + $bits = [ + \html_writer::tag('button', $refreshcontent, [ + 'type' => 'button', + 'class' => 'meetlet-btn meetlet-btn--ghost meetlet-btn--sm meetlet-btn-with-icon meetlet-tabtoolbar__refresh', + 'data-action' => 'refresh-tab', + 'data-tab' => $tab, + 'title' => $refreshlabel, + 'aria-label' => $refreshlabel, + ]), + ]; + if ($pending) { + $bits[] = \html_writer::div( + \html_writer::span(get_string('jobpollwait', 'meetletacbfree'), 'meetlet-jobpoll__label') . + \html_writer::div(\html_writer::div('', 'meetlet-jobpoll__bar'), 'meetlet-jobpoll__track'), + 'meetlet-jobpoll', + ['aria-live' => 'polite'] + ); + } + $bar = \html_writer::div(implode('', $bits), 'meetlet-tabtoolbar'); + $attrs = ['data-meetlet-tab' => $tab]; + if ($pending) { + $attrs['data-meetlet-jobs-pending'] = '1'; + $attrs['data-meetlet-jobs-poll-ms'] = '60000'; + } + return \html_writer::div($bar . $html, 'meetlet-tabbody', $attrs); + } + + /** + * Toolbar control with icon and visible label (matches refresh button styling). + * + * @param array $attrs Extra attributes (href makes it a link). + */ + protected static function labeled_action(string $icon, string $label, array $attrs = []): string { + $extraclass = $attrs['class'] ?? ''; + unset($attrs['class']); + $classes = trim( + 'meetlet-btn meetlet-btn--ghost meetlet-btn--sm meetlet-btn-with-icon ' . $extraclass + ); + $attrs['class'] = $classes; + $attrs['title'] = $label; + $attrs['aria-label'] = $label; + $content = icons::svg($icon) + . \html_writer::span($label, 'meetlet-btn-with-icon__label'); + if (!empty($attrs['href'])) { + $url = $attrs['href']; + unset($attrs['href']); + $attrs['target'] = $attrs['target'] ?? '_blank'; + if (($attrs['target'] ?? '') === '_self') { + unset($attrs['rel']); + } else { + $attrs['rel'] = $attrs['rel'] ?? 'noopener'; + } + return \html_writer::link($url, $content, $attrs); + } + $attrs['type'] = $attrs['type'] ?? 'button'; + return \html_writer::tag('button', $content, $attrs); + } + + /** + * Icon control with hover tip. + * + * @param array $attrs Extra attributes (href makes it a link). + */ + protected static function icon_action(string $icon, string $label, array $attrs = []): string { + $classes = trim('meetlet-iconbtn ' . ($attrs['class'] ?? '')); + unset($attrs['class']); + $attrs['class'] = $classes; + $attrs['data-tip'] = $label; + $attrs['aria-label'] = $label; + $content = icons::svg($icon) . \html_writer::span($label, 'meetlet-sr-only'); + if (!empty($attrs['href'])) { + $url = $attrs['href']; + unset($attrs['href']); + $attrs['target'] = $attrs['target'] ?? '_blank'; + if (($attrs['target'] ?? '') === '_self') { + unset($attrs['rel']); + } else { + $attrs['rel'] = $attrs['rel'] ?? 'noopener'; + } + return \html_writer::link($url, $content, $attrs); + } + $attrs['type'] = $attrs['type'] ?? 'button'; + return \html_writer::tag('button', $content, $attrs); + } + + protected static function format_jobstatus(string $status, bool $mp4ready = false): string { + return recordings::jobstatus_label($status, $mp4ready); + } + + protected static function render_recordings(instance $inst): string { + $rows = recordings::for_tab($inst); + if (empty($rows)) { + return self::wrap_refreshable( + \html_writer::div(get_string('norecordings', 'meetletacbfree'), 'meetlet-empty'), + 'recordings' + ); + } + $canedit = featureaccess::allowed(featureaccess::EDITRECORDING, $inst->context, $inst->record); + $canadobeedit = featureaccess::allowed(featureaccess::ADOBEEDITOR, $inst->context, $inst->record); + $table = new \html_table(); + $table->attributes['class'] = 'generaltable meetlet-table meetlet-table--recordings'; + $table->head = [ + get_string('recordingname', 'meetletacbfree'), + get_string('recordingdate', 'meetletacbfree'), + get_string('duration', 'meetletacbfree'), + get_string('sessiondetails', 'meetletacbfree'), + ]; + foreach ($rows as $r) { + $actions = []; + $actions[] = self::icon_action('play', get_string('play', 'meetletacbfree'), [ + 'class' => 'meetlet-iconbtn--play', + 'data-action' => 'launch-recording', + 'data-mode' => 'play', + 'data-recording' => $r->recordingscoid, + ]); + if ($canadobeedit && !empty($r->urlpath)) { + $actions[] = self::icon_action('adobeedit', get_string('adobeeditor', 'meetletacbfree'), [ + 'class' => 'meetlet-iconbtn--adobeedit', + 'data-action' => 'launch-recording', + 'data-mode' => 'edit', + 'data-recording' => $r->recordingscoid, + ]); + } + $namehtml = self::render_recording_name((string)$r->name, (int)$r->recordingscoid, $canedit); + $dur = $r->duration ? gmdate('H:i:s', (int)$r->duration) : '-'; + $recdate = !empty($r->timestart) ? userdate((int)$r->timestart) : '—'; + $table->data[] = [ + $namehtml, + $recdate, + $dur, + \html_writer::div(implode('', $actions), 'meetlet-iconactions'), + ]; + } + return self::wrap_refreshable(\html_writer::div(\html_writer::table($table), 'meetlet-card'), 'recordings', false); + } + + /** + * Recording title with optional inline rename control. + */ + protected static function render_recording_name(string $name, int $recordingscoid, bool $canedit): string { + $text = \html_writer::span(format_string($name), 'meetlet-recname__text'); + if (!$canedit) { + return \html_writer::div($text, 'meetlet-recname'); + } + $pencil = self::icon_action('edit', get_string('editrecording', 'meetletacbfree'), [ + 'class' => 'meetlet-iconbtn--edit meetlet-iconbtn--inline', + 'data-action' => 'inline-rename', + ]); + $input = \html_writer::empty_tag('input', [ + 'type' => 'text', + 'class' => 'meetlet-recname__input', + 'value' => $name, + 'maxlength' => 255, + 'hidden' => 'hidden', + ]); + return \html_writer::div($text . $pencil . $input, 'meetlet-recname', [ + 'data-recording' => $recordingscoid, + 'data-name' => $name, + ]); + } + + protected static function render_content(instance $inst): string { + global $USER; + + $service = new service(); + $service->login_admin(); + $items = $service->list_meeting_content($inst->require_meeting()); + $service->logout(); + if (empty($items)) { + return self::wrap_refreshable( + \html_writer::div(get_string('nocontent', 'meetletacbfree'), 'meetlet-empty'), + 'content' + ); + } + $table = new \html_table(); + $table->attributes['class'] = 'generaltable meetlet-table meetlet-table--content'; + $table->head = [ + get_string('name'), + get_string('contenttype', 'meetletacbfree'), + get_string('contentsize', 'meetletacbfree'), + get_string('contentdate', 'meetletacbfree'), + get_string('sessiondetails', 'meetletacbfree'), + ]; + foreach ($items as $item) { + $scoid = (int)($item->scoid ?? 0); + $actions = [ + self::icon_action('play', get_string('view'), [ + 'class' => 'meetlet-iconbtn--play', + 'data-action' => 'launch-content', + 'data-content' => $scoid, + ]), + ]; + if ($scoid > 0 && !empty($item->name)) { + $dlurl = content::download_url((int)$USER->id, (int)$inst->cm->id, $scoid)->out(false); + $actions[] = self::icon_action('download', get_string('downloadcontent', 'meetletacbfree'), [ + 'href' => $dlurl, + 'class' => 'meetlet-iconbtn--download', + 'target' => '_self', + 'download' => $item->name, + 'data-action' => 'download-content', + ]); + } + $table->data[] = [ + format_string($item->name), + s($item->typelabel ?? service::content_type_label( + (string)($item->icon ?? ''), + (string)($item->type ?? ''), + (string)($item->name ?? '') + )), + self::format_bytes((int)($item->bytesize ?? 0)), + s($item->datecreated_fmt !== '' ? $item->datecreated_fmt : '—'), + \html_writer::div(implode('', $actions), 'meetlet-iconactions'), + ]; + } + return self::wrap_refreshable(\html_writer::div(\html_writer::table($table), 'meetlet-card'), 'content'); + } + + protected static function format_bytes(int $bytes): string { + if ($bytes <= 0) { + return '—'; + } + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + $n = (float)$bytes; + while ($n >= 1024 && $i < count($units) - 1) { + $n /= 1024; + $i++; + } + return format_float($n, $i === 0 ? 0 : 1) . ' ' . $units[$i]; + } +} diff --git a/classes/external/rename_recording.php b/classes/external/rename_recording.php new file mode 100644 index 0000000..9c8e92e --- /dev/null +++ b/classes/external/rename_recording.php @@ -0,0 +1,108 @@ +. + +/** + * Ajax: rename a recording SCO. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\instance; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Rename Adobe Connect recording. + */ +class rename_recording extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module id'), + 'recordingscoid' => new external_value(PARAM_INT, 'Recording sco id'), + 'name' => new external_value(PARAM_TEXT, 'New recording name'), + 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(int $cmid, int $recordingscoid, string $name, int $groupid = 0): array { + global $DB; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'recordingscoid' => $recordingscoid, + 'name' => $name, + 'groupid' => $groupid, + ]); + + \mod_meetletacbfree\registration\manager::require_registered(); + + $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); + self::validate_context($inst->context); + require_capability('mod/meetletacbfree:view', $inst->context); + \mod_meetletacbfree\local\featureaccess::require_feature( + \mod_meetletacbfree\local\featureaccess::EDITRECORDING, + $inst->context, + $inst->record + ); + + $name = trim($params['name']); + if ($name === '') { + throw new moodle_exception('error', 'meetletacbfree'); + } + + $service = new service(); + $service->login_admin(); + $ok = $service->rename_sco((int)$params['recordingscoid'], $name); + $service->logout(); + if (!$ok) { + throw new moodle_exception('errorrenamerecording', 'meetletacbfree'); + } + + $record = $DB->get_record('meetletacbfree_recordings', [ + 'instanceid' => $inst->record->id, + 'recordingscoid' => (int)$params['recordingscoid'], + ]); + if ($record) { + $record->name = $name; + $record->timemodified = time(); + $DB->update_record('meetletacbfree_recordings', $record); + } + + return [ + 'success' => true, + 'name' => $name, + 'message' => get_string('recordingrenamed', 'meetletacbfree'), + ]; + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'success' => new external_value(PARAM_BOOL, 'OK'), + 'name' => new external_value(PARAM_TEXT, 'New name'), + 'message' => new external_value(PARAM_TEXT, 'Message'), + ]); + } +} diff --git a/classes/external/search_server_meetings.php b/classes/external/search_server_meetings.php new file mode 100644 index 0000000..d08b076 --- /dev/null +++ b/classes/external/search_server_meetings.php @@ -0,0 +1,110 @@ +. + +/** + * Ajax: search/list existing Adobe meetings for the mod form. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Return a short list of server meetings (latest / search). + */ +class search_server_meetings extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'query' => new external_value(PARAM_TEXT, 'Search query', VALUE_DEFAULT, ''), + 'limit' => new external_value(PARAM_INT, 'Max results', VALUE_DEFAULT, 10), + 'courseid' => new external_value(PARAM_INT, 'Course id for capability check', VALUE_DEFAULT, 0), + ]); + } + + public static function execute(string $query = '', int $limit = 10, int $courseid = 0): array { + $params = self::validate_parameters(self::execute_parameters(), [ + 'query' => $query, + 'limit' => $limit, + 'courseid' => $courseid, + ]); + + \mod_meetletacbfree\registration\manager::require_registered(); + + if (!empty($params['courseid'])) { + $context = \context_course::instance((int)$params['courseid']); + } else { + $context = \context_system::instance(); + } + self::validate_context($context); + if (!has_capability('moodle/course:manageactivities', $context) + && !has_capability('mod/meetletacbfree:addinstance', $context)) { + throw new moodle_exception('nopermissions', 'error', '', 'search meetings'); + } + + if (!config::is_configured()) { + throw new moodle_exception('notconfigured', 'meetletacbfree'); + } + + $limit = max(1, min(50, (int)$params['limit'])); + $service = new service(); + $service->login_admin(); + $rows = $service->list_server_meetings((string)$params['query'], $limit); + $service->logout(); + + $out = []; + foreach ($rows as $m) { + $label = $m->name; + if (!empty($m->urlpath)) { + $label .= ' (' . $m->urlpath . ')'; + } + $out[] = [ + 'scoid' => (int)$m->scoid, + 'name' => (string)$m->name, + 'label' => $label, + 'urlpath' => trim((string)$m->urlpath, '/'), + 'starttime' => !empty($m->datebegin) ? (int)strtotime((string)$m->datebegin) : 0, + 'endtime' => !empty($m->dateend) ? (int)strtotime((string)$m->dateend) : 0, + ]; + } + return ['meetings' => $out]; + } + + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'meetings' => new external_multiple_structure(new external_single_structure([ + 'scoid' => new external_value(PARAM_INT, 'SCO id'), + 'name' => new external_value(PARAM_TEXT, 'Meeting name'), + 'label' => new external_value(PARAM_TEXT, 'Select label'), + 'urlpath' => new external_value(PARAM_TEXT, 'URL path'), + 'starttime' => new external_value(PARAM_INT, 'Start unix time'), + 'endtime' => new external_value(PARAM_INT, 'End unix time'), + ])), + ]); + } +} diff --git a/classes/local/config.php b/classes/local/config.php new file mode 100644 index 0000000..1e17aac --- /dev/null +++ b/classes/local/config.php @@ -0,0 +1,655 @@ +. + +/** + * Plugin config helpers. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +/** + * Read MeetLet / Adobe Connect global settings. + */ +class config { + /** Activity follows site-wide setting. */ + public const OVERRIDE_INHERIT = 0; + /** Activity explicitly disables the setting. */ + public const OVERRIDE_OFF = 1; + /** Activity explicitly enables the setting. */ + public const OVERRIDE_ON = 2; + /** + * API endpoint URL (may already include https://.../api/xml). + */ + public static function get_api_url(): string { + return trim((string)get_config('meetletacbfree', 'host')); + } + + /** + * Meeting host domain (no protocol). + */ + public static function get_meet_host(): string { + return trim((string)get_config('meetletacbfree', 'meethost')); + } + + public static function get_port(): int { + $port = (int)get_config('meetletacbfree', 'port'); + return $port > 0 ? $port : 443; + } + + public static function use_https(): bool { + return (bool)get_config('meetletacbfree', 'https'); + } + + public static function get_admin_login(): string { + return (string)get_config('meetletacbfree', 'admin_login'); + } + + public static function get_admin_password(): string { + return (string)get_config('meetletacbfree', 'admin_password'); + } + + public static function get_http_auth_header(): string { + $h = trim((string)get_config('meetletacbfree', 'admin_httpauth')); + return $h !== '' ? $h : 'Generate-Safe-Random-Token'; + } + + /** + * When true, also look up legacy Connect accounts by email/username + * and realign them to mlu{id}. SSO identity itself is always Moodle id. + */ + public static function use_email_login(): bool { + return (bool)get_config('meetletacbfree', 'email_login'); + } + + /** + * Site-wide auto MP4 after Adobe Connect class actually ends (room empty). + * Per-activity checkbox is an extra opt-in when this is off. + */ + public static function auto_offline_default(): bool { + if (!self::offline_render_active()) { + return false; + } + return (bool)get_config('meetletacbfree', 'auto_offline'); + } + + /** + * Minutes between recording-sync / auto-MP4 cron runs (1–60, default 5). + */ + public static function cron_interval_minutes(): int { + $v = (int)get_config('meetletacbfree', 'cron_interval'); + if ($v < 1) { + $v = 5; + } + if ($v > 60) { + $v = 60; + } + return $v; + } + + /** Send collected jobs with every MeetLet cron run. */ + public const DISPATCH_IMMEDIATE = 'immediate'; + /** Send collected jobs only during selected site-timezone hours. */ + public const DISPATCH_WINDOW = 'window'; + /** Default overnight hours (22:00–05:59). */ + public const DEFAULT_DISPATCH_HOURS = '22,23,0,1,2,3,4,5'; + + /** + * How auto-MP4 jobs are handed to the Agent. + */ + public static function agent_dispatch_mode(): string { + $v = trim((string)get_config('meetletacbfree', 'agent_dispatch')); + return $v === self::DISPATCH_WINDOW ? self::DISPATCH_WINDOW : self::DISPATCH_IMMEDIATE; + } + + /** + * Selected hours (0–23) in the site timezone. Empty means “none”. + * + * @return int[] + */ + public static function agent_dispatch_hours(): array { + $raw = (string)get_config('meetletacbfree', 'agent_dispatch_hours'); + if ($raw === '') { + $raw = self::DEFAULT_DISPATCH_HOURS; + } + return self::parse_dispatch_hours($raw); + } + + /** + * @return int[] + */ + public static function parse_dispatch_hours(string $raw): array { + $hours = []; + foreach (preg_split('/[,\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $bit) { + if (!preg_match('/^\d{1,2}$/', $bit)) { + continue; + } + $h = (int)$bit; + if ($h >= 0 && $h <= 23) { + $hours[$h] = $h; + } + } + $hours = array_values($hours); + sort($hours); + return $hours; + } + + /** + * Current hour 0–23 in the Moodle server / site timezone. + */ + public static function site_hour_now(): int { + $tz = \core_date::get_server_timezone_object(); + return (int)(new \DateTimeImmutable('now', $tz))->format('G'); + } + + /** + * True when collected jobs may be POSTed to the Agent. + */ + public static function agent_send_allowed_now(): bool { + if (self::agent_dispatch_mode() !== self::DISPATCH_WINDOW) { + return true; + } + $hours = self::agent_dispatch_hours(); + if (!$hours) { + return false; + } + return in_array(self::site_hour_now(), $hours, true); + } + + /** + * Unix time of the next allowed send hour start, or 0 if sending is allowed now / immediate. + */ + public static function agent_send_next_open(): int { + if (self::agent_send_allowed_now()) { + return 0; + } + $hours = self::agent_dispatch_hours(); + if (!$hours) { + return 0; + } + $tz = \core_date::get_server_timezone_object(); + $now = new \DateTimeImmutable('now', $tz); + $cur = (int)$now->format('G'); + foreach ($hours as $h) { + if ($h > $cur) { + return $now->setTime($h, 0, 0)->getTimestamp(); + } + } + return $now->modify('+1 day')->setTime($hours[0], 0, 0)->getTimestamp(); + } + + /** + * Write the scheduled-task minute field from plugin settings. + */ + public static function apply_cron_interval(): void { + global $DB; + $mins = self::cron_interval_minutes(); + $row = $DB->get_record('task_scheduled', ['classname' => '\\mod_meetletacbfree\\task\\sync_recordings']); + if (!$row) { + return; + } + $row->minute = $mins <= 1 ? '*' : ('*/' . $mins); + $row->nextruntime = time() + ($mins * MINSECS); + $DB->update_record('task_scheduled', $row); + } + + /** + * Offline MP4 is Premium-only: paid license + mp4_offline entitlement. + * Free / unlicensed sites never activate Agent auto/manual MP4 paths. + */ + public static function offline_render_active(): bool { + return false; + } + + /** + * Configured Render Agent / offline listener base URL (may be empty). + */ + public static function agent_url(): string { + return trim((string)get_config('meetletacbfree', 'agent_url')); + } + + /** + * Site-wide default meeting UI language (Adobe Connect), empty = server default. + */ + public static function default_meeting_lang(): string { + $v = get_config('meetletacbfree', 'default_meetinglang'); + if ($v === false || $v === null) { + return ''; + } + return clean_param((string)$v, PARAM_ALPHANUMEXT); + } + + /** + * Site-wide default: block participants until host joins. + */ + public static function default_wait_for_host(): bool { + return (bool)get_config('meetletacbfree', 'default_waitforhost'); + } + + /** + * Language choices for meeting UI (site + activity forms). + * + * @return array code => label + */ + public static function meeting_lang_options(): array { + return [ + '' => get_string('meetinglang_default', 'meetletacbfree'), + 'en' => 'English', + 'fa' => 'فارسی', + 'ar' => 'العربية', + 'de' => 'Deutsch', + 'fr' => 'Français', + 'es' => 'Español', + 'tr' => 'Türkçe', + ]; + } + + /** + * Whether the manual “request offline video” button is allowed (optionally per activity). + * + * @param \stdClass|null $activity + */ + public static function allow_manual_offline(?\stdClass $activity = null): bool { + if (!self::offline_render_active()) { + return false; + } + return self::show_feature('makeoffline', $activity); + } + + /** + * Whether a UI feature is enabled globally. + * + * @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline + */ + public static function feature_enabled(string $feature): bool { + // Offline player (pbMode) removed from product — always hidden. + if ($feature === 'offlineplayer') { + return false; + } + if (($feature === 'mp4' || $feature === 'makeoffline') && !self::offline_render_active()) { + return false; + } + $key = 'enable_' . $feature; + $v = get_config('meetletacbfree', $key); + if ($v === false && $feature === 'mp4') { + $legacy = get_config('meetletacbfree', 'allow_manual_offline'); + return $legacy === false ? true : (bool)$legacy; + } + // ZIP is opt-in; other features default on when unset. + if ($v === false) { + return $feature !== 'zip'; + } + return (bool)$v; + } + + /** + * Whether teachers may override a feature per activity. + * + * @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline + */ + public static function feature_allow_override(string $feature): bool { + $key = 'allow_override_' . $feature; + $v = get_config('meetletacbfree', $key); + return $v === false ? true : (bool)$v; + } + + /** + * Effective visibility for a feature on an activity instance (site + activity switches only). + * Always combine with featureaccess::allowed() / the role matrix for user actions. + * + * @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline + * @param \stdClass|null $activity + */ + public static function show_feature(string $feature, ?\stdClass $activity = null): bool { + if (!self::feature_enabled($feature)) { + return false; + } + if (!self::feature_allow_override($feature) || $activity === null) { + return true; + } + $fieldmap = [ + 'zip' => 'showzip', + 'offlineplayer' => 'showofflineplayer', + 'recordingstab' => 'showrecordingstab', + 'contenttab' => 'showcontenttab', + 'mp4' => 'showmp4', + 'makeoffline' => 'showmakeoffline', + ]; + $field = $fieldmap[$feature] ?? null; + if ($field === null) { + return true; + } + $siteenabled = true; + if ($field === 'showofflineplayer') { + $siteenabled = false; + } + return self::resolve_activity_toggle($activity, $field, $siteenabled); + } + + /** + * Effective wait-for-host value for one activity. + */ + public static function wait_for_host(?\stdClass $activity = null): bool { + $sitevalue = self::default_wait_for_host(); + return self::resolve_activity_toggle($activity, 'waitforhost', $sitevalue); + } + + /** + * Effective automatic offline-video value for one activity. + */ + public static function auto_offline_enabled(?\stdClass $activity = null): bool { + if (!self::offline_render_active()) { + return false; + } + return self::resolve_activity_toggle($activity, 'autooffline', self::auto_offline_default()); + } + + /** + * Three-state options for inherit / off / on controls. + * + * @return array + */ + public static function override_state_options(): array { + return [ + self::OVERRIDE_INHERIT => get_string('settingstate_inherit', 'meetletacbfree'), + self::OVERRIDE_OFF => get_string('settingstate_disable', 'meetletacbfree'), + self::OVERRIDE_ON => get_string('settingstate_enable', 'meetletacbfree'), + ]; + } + + /** + * Read JSON override map stored on the activity row. + * + * @return array + */ + public static function activity_setting_overrides(?\stdClass $activity): array { + if (!$activity || empty($activity->settingoverrides)) { + return []; + } + $decoded = json_decode((string)$activity->settingoverrides, true); + if (!is_array($decoded)) { + return []; + } + $out = []; + foreach ($decoded as $key => $value) { + $state = self::normalize_override_state($value); + if ($state !== self::OVERRIDE_INHERIT) { + $out[(string)$key] = $state; + } + } + return $out; + } + + /** + * Effective state for one activity toggle. + */ + public static function activity_override_state(?\stdClass $activity, string $field): int { + $overrides = self::activity_setting_overrides($activity); + if (array_key_exists($field, $overrides)) { + return $overrides[$field]; + } + return self::OVERRIDE_INHERIT; + } + + /** + * Normalize one override state from DB / form values. + */ + public static function normalize_override_state($value): int { + $value = (int)$value; + if (in_array($value, [self::OVERRIDE_INHERIT, self::OVERRIDE_OFF, self::OVERRIDE_ON], true)) { + return $value; + } + return self::OVERRIDE_INHERIT; + } + + /** + * Build normalized JSON for activity setting overrides. + * + * @param array $states + */ + public static function setting_overrides_json(array $states): string { + $out = []; + foreach ($states as $field => $value) { + $state = self::normalize_override_state($value); + if ($state !== self::OVERRIDE_INHERIT) { + $out[(string)$field] = $state; + } + } + return $out ? json_encode($out, JSON_UNESCAPED_UNICODE) : ''; + } + + /** + * Resolve a boolean site/activity setting. + */ + protected static function resolve_activity_toggle(?\stdClass $activity, string $field, bool $sitevalue): bool { + if ($activity === null) { + return $sitevalue; + } + $state = self::activity_override_state($activity, $field); + if ($state === self::OVERRIDE_OFF) { + return false; + } + if ($state === self::OVERRIDE_ON) { + return true; + } + if (property_exists($activity, 'settingoverrides') || property_exists($activity, 'participantsmode')) { + return $sitevalue; + } + if (!isset($activity->{$field})) { + return $sitevalue; + } + return !empty($activity->{$field}); + } + + /** + * ZIP download link: site/activity “show ZIP” AND role matrix. + * Prefer featureaccess::allowed(ZIP) in new code. + */ + public static function user_can_download_zip(\context $context, ?\stdClass $activity = null, ?int $userid = null): bool { + return false; + } + + /** + * Whether an email is acceptable for Adobe Connect principal-update. + */ + public static function email_usable_for_adobe(string $email): bool { + $email = trim($email); + if ($email === '' || strpos($email, '@') === false) { + return false; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return false; + } + // Adobe rejects fake/reserved domains (e.g. test@test.test → invalid/format). + if (preg_match('/\.(test|invalid|localhost|local|example)$/i', $email)) { + return false; + } + if (preg_match('/@(users\.moodle\.local|example\.(com|org|net))$/i', $email)) { + return false; + } + return true; + } + + /** + * Synthetic Connect email on the meeting host domain. + */ + public static function synthetic_connect_email(\stdClass $user): string { + $host = self::get_meet_host(); + $host = preg_replace('#^https?://#i', '', $host); + $host = explode('/', $host)[0] ?? ''; + $host = explode(':', $host)[0] ?? ''; + $host = trim((string)$host); + if ($host === '' || !preg_match('/\./', $host)) { + global $CFG; + $www = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST); + $host = is_string($www) && $www !== '' ? $www : 'meetlet.local'; + } + return 'moodleuser' . (int)($user->id ?? 0) . '@' . $host; + } + + /** + * Canonical Adobe Connect login for a Moodle user. + * + * Stable across email/username changes: mlu{moodleuserid}. + * Header SSO must send this same string (or the principal's current login + * until a legacy account is realigned). + */ + public static function connect_login(\stdClass $user): string { + $id = (int)($user->id ?? 0); + if ($id > 0) { + return 'mlu' . $id; + } + // Disposable / connection-test users may have no Moodle id. + $username = trim((string)($user->username ?? '')); + $safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $username); + if ($safe !== '') { + return 'mlu_' . $safe; + } + return 'mlu_tmp' . substr(sha1(($user->email ?? '') . '|' . microtime(true)), 0, 12); + } + + /** + * Legacy Connect logins that may already exist for this Moodle user + * (before stable mlu{id} identity). Used only for lookup/migration. + * + * @return string[] + */ + public static function legacy_connect_logins(\stdClass $user): array { + $out = []; + $email = trim((string)($user->email ?? '')); + $username = trim((string)($user->username ?? '')); + foreach ([ + self::synthetic_connect_email($user), + 'moodleuser' . (int)($user->id ?? 0), + self::email_usable_for_adobe($email) ? $email : '', + ($username !== '' && strpos($username, '@') === false) ? $username : '', + self::email_usable_for_adobe($username) ? $username : '', + ] as $candidate) { + $candidate = trim((string)$candidate); + if ($candidate === '') { + continue; + } + if (strpos($candidate, '@') !== false && !self::email_usable_for_adobe($candidate)) { + continue; + } + if (!in_array($candidate, $out, true)) { + $out[] = $candidate; + } + } + return $out; + } + + /** + * Best-effort email for Adobe principal-update (profile field only; not SSO key). + */ + public static function connect_email(\stdClass $user): string { + $email = trim((string)($user->email ?? '')); + if (self::email_usable_for_adobe($email)) { + return $email; + } + return self::synthetic_connect_email($user); + } + + /** + * Build public meeting/content URL. + */ + public static function public_url(string $urlpath, string $session = ''): string { + $protocol = self::use_https() ? 'https://' : 'http://'; + $host = self::get_meet_host(); + $port = self::get_port(); + $portpart = ''; + if ($port && $port != 80 && !(self::use_https() && $port == 443)) { + $portpart = ':' . $port; + } + if ($urlpath !== '' && $urlpath[0] !== '/') { + $urlpath = '/' . $urlpath; + } + $url = $protocol . $host . $portpart . $urlpath; + if ($session !== '') { + $url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($session); + } + return $url; + } + + /** + * Append query params without duplicating keys. + * + * @param array $params + */ + public static function append_query(string $url, array $params): string { + foreach ($params as $key => $value) { + if ($value === null || $value === '') { + continue; + } + $pair = rawurlencode((string)$key) . '=' . rawurlencode((string)$value); + // Skip if already present. + if (preg_match('/([?&])' . preg_quote((string)$key, '/') . '=/', $url)) { + continue; + } + $url .= (strpos($url, '?') === false ? '?' : '&') . $pair; + } + return $url; + } + + /** + * Force HTML client in-browser — skip Flash / desktop-app launcher interstitial + * that otherwise hangs on “preparing the recording/meeting”. + */ + public static function with_html_client(string $url): string { + return self::append_query($url, [ + 'html-view' => 'true', + 'launcher' => 'false', + ]); + } + + /** + * Recording playback URL (HTML client). + */ + public static function recording_play_url(string $urlpath, string $session = ''): string { + return self::with_html_client(self::public_url($urlpath, $session)); + } + + /** + * Uploaded meeting content view URL (HTML client). + */ + public static function content_view_url(string $urlpath, string $session = ''): string { + return self::with_html_client(self::public_url($urlpath, $session)); + } + + /** + * Adobe Connect recording editor URL (pbMode=edit). + */ + public static function recording_editor_url(string $urlpath, string $session = ''): string { + $url = self::public_url($urlpath, $session); + return self::with_html_client(self::append_query($url, [ + 'pbMode' => 'edit', + 'page' => 'm', + ])); + } + + /** + * Whether settings look complete enough to call the API. + */ + public static function is_configured(): bool { + return self::get_api_url() !== '' + && self::get_meet_host() !== '' + && self::get_admin_login() !== '' + && self::get_admin_password() !== ''; + } +} diff --git a/classes/local/conntest_runner.php b/classes/local/conntest_runner.php new file mode 100644 index 0000000..c1ae71e --- /dev/null +++ b/classes/local/conntest_runner.php @@ -0,0 +1,263 @@ +. + +/** + * Stepwise Adobe Connect connection tests for admin Ajax UI. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +defined('MOODLE_INTERNAL') || die(); + +use mod_meetletacbfree\api\client; +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\usererror; +use Throwable; + +/** + * Runs connection checks one step at a time (session-backed). + */ +class conntest_runner { + public const STEPS = ['login', 'folders', 'user', 'sso', 'cleanup']; + + /** + * Reset session scratch data for a new run. + */ + public static function reset(): void { + global $SESSION; + $SESSION->meetletacbfree_conntest = [ + 'service' => null, + 'meetings' => null, + 'scoid' => null, + 'principalid' => null, + 'login' => null, + 'failed' => false, + ]; + } + + /** + * @return array{label:string,ok:bool,detail:string,done:bool,step:string} + */ + public static function run_step(string $step): array { + global $SESSION; + + \mod_meetletacbfree\registration\manager::require_registered(); + + if (empty($SESSION->meetletacbfree_conntest) || $step === 'login') { + self::reset(); + } + $state = &$SESSION->meetletacbfree_conntest; + + if (!config::is_configured()) { + return [ + 'step' => $step, + 'label' => get_string('error', 'meetletacbfree'), + 'ok' => false, + 'detail' => get_string('notconfigured', 'meetletacbfree'), + 'done' => true, + 'abort' => true, + ]; + } + + try { + switch ($step) { + case 'login': + return self::step_login($state); + case 'folders': + return self::step_folders($state); + case 'user': + return self::step_user($state); + case 'sso': + return self::step_sso($state); + case 'cleanup': + return self::step_cleanup($state); + default: + return [ + 'step' => $step, + 'label' => get_string('error', 'meetletacbfree'), + 'ok' => false, + 'detail' => 'Unknown step', + 'done' => true, + 'abort' => true, + ]; + } + } catch (Throwable $e) { + $state['failed'] = true; + return [ + 'step' => $step, + 'label' => get_string('error', 'meetletacbfree'), + 'ok' => false, + 'detail' => $e->getMessage(), + 'done' => false, + 'abort' => true, + ]; + } + } + + /** + * Rebuild a fresh service (API client is not serializable in session). + */ + protected static function service(): service { + $service = new service(); + $service->login_admin(); + return $service; + } + + protected static function step_login(array &$state): array { + $service = self::service(); + $state['ok_login'] = true; + // Logout immediately; later steps re-login. + $service->logout(); + return [ + 'step' => 'login', + 'label' => get_string('testlogin', 'meetletacbfree'), + 'ok' => true, + 'detail' => '', + 'done' => false, + 'abort' => false, + ]; + } + + protected static function step_folders(array &$state): array { + $service = self::service(); + $content = $service->get_folder_scoid('content'); + $archives = $service->get_folder_scoid('forced-archives'); + $meetings = $service->get_folder_scoid('meetings'); + $state['meetings'] = $meetings; + $ok = ($content && $archives && $meetings); + if (!$ok) { + $state['failed'] = true; + } + $service->logout(); + return [ + 'step' => 'folders', + 'label' => get_string('testfolders', 'meetletacbfree'), + 'ok' => (bool)$ok, + 'detail' => "content={$content} · archives={$archives} · meetings={$meetings}", + 'done' => false, + 'abort' => !$ok, + ]; + } + + protected static function step_user(array &$state): array { + $service = self::service(); + $meetings = (int)($state['meetings'] ?? 0); + if (!$meetings) { + $meetings = (int)$service->get_folder_scoid('meetings'); + $state['meetings'] = $meetings; + } + $meeting = (object)[ + 'name' => 'meetlet_test_' . time(), + 'starttime' => time(), + 'endtime' => time() + HOURSECS, + 'meeturl' => '', + 'templatescoid' => 0, + ]; + $scoid = $service->create_meeting($meeting, $meetings); + $user = (object)[ + 'id' => random_int(900000000, 999999999), + 'firstname' => 'MeetLet', + 'lastname' => 'Test', + 'username' => 'meetlettest' . random_int(1000, 9999), + 'email' => 'meetlet.test.' . random_int(1000, 9999) . '@example.com', + ]; + $principal = $service->ensure_user($user); + $assigned = $service->assign_permission($principal->principalid, $scoid, service::ROLE_PRESENTER); + $state['scoid'] = $scoid; + $state['principalid'] = $principal->principalid; + $state['login'] = $principal->login; + $service->logout(); + if (!$assigned) { + $state['failed'] = true; + } + return [ + 'step' => 'user', + 'label' => get_string('testuser', 'meetletacbfree'), + 'ok' => (bool)$assigned, + 'detail' => '', + 'done' => false, + 'abort' => false, + ]; + } + + protected static function step_sso(array &$state): array { + $directok = false; + $directdetail = ''; + try { + $sso = new client(); + $cookie = $sso->login_external(config::get_admin_login()); + $directok = ($cookie !== ''); + $directdetail = get_string('testsso_direct_ok', 'meetletacbfree'); + } catch (Throwable $e) { + $msg = $e->getMessage(); + $directdetail = get_string('testsso_direct_fail', 'meetletacbfree', usererror::label('sso_01') . ': ' . $msg); + } + if ($directok && !empty($state['login'])) { + try { + $sso2 = new client(); + $sso2->login_external($state['login']); + $directdetail .= ' · ' . get_string('testsso_userok', 'meetletacbfree'); + } catch (Throwable $e) { + $directdetail .= ' · ' . get_string('testsso_userwarn', 'meetletacbfree'); + } + } + + if (!$directok) { + $state['failed'] = true; + } + + return [ + 'step' => 'sso', + 'label' => get_string('testsso', 'meetletacbfree'), + 'ok' => $directok, + 'detail' => $directdetail, + 'done' => false, + 'abort' => false, + ]; + } + + protected static function step_cleanup(array &$state): array { + $service = self::service(); + $client = $service->get_client(); + $ok = true; + $detail = ''; + try { + if (!empty($state['scoid'])) { + $service->delete_sco((int)$state['scoid']); + } + if (!empty($state['principalid'])) { + $client->request(['action' => 'principals-delete', 'principal-id' => (int)$state['principalid']]); + } + } catch (Throwable $e) { + $ok = false; + $detail = $e->getMessage(); + } + $service->logout(); + self::reset(); + return [ + 'step' => 'cleanup', + 'label' => get_string('testmeeting', 'meetletacbfree'), + 'ok' => $ok, + 'detail' => $detail, + 'done' => true, + 'abort' => false, + ]; + } +} diff --git a/classes/local/content.php b/classes/local/content.php new file mode 100644 index 0000000..bdc1976 --- /dev/null +++ b/classes/local/content.php @@ -0,0 +1,196 @@ +. + +/** + * Meeting content download helpers (Adobe Connect proxy). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use mod_meetletacbfree\api\service; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Authenticated Moodle proxy for uploaded meeting content files. + */ +class content { + /** + * Opaque, HMAC-signed, current-user download URL. + */ + public static function download_url(int $userid, int $cmid, int $scoid): \moodle_url { + return new \moodle_url('/mod/meetletacbfree/download_content.php', [ + 'k' => self::make_download_token($userid, $cmid, $scoid), + ]); + } + + /** + * Unpack and verify a content download token. + * + * @return array{userid:int,cmid:int,cid:int} + */ + public static function unpack_download_token(string $token): array { + $token = trim($token); + if (!preg_match('/^[A-Za-z0-9_-]+$/', $token)) { + throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree'); + } + $b64 = strtr($token, '-_', '+/'); + $pad = strlen($b64) % 4; + if ($pad) { + $b64 .= str_repeat('=', 4 - $pad); + } + $raw = base64_decode($b64, true); + if ($raw === false || strlen($raw) < 44) { + throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree'); + } + $mac = substr($raw, 0, 32); + $payload = substr($raw, 32); + if (strlen($payload) < 32) { + throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree'); + } + + $ids = unpack('Nuid/Ncmid/Ncid/Nexp', substr($payload, 0, 16)); + $nonce = substr($payload, 16, 16); + $userid = (int)$ids['uid']; + $cmid = (int)$ids['cmid']; + $cid = (int)$ids['cid']; + $expires = (int)$ids['exp']; + $expected = hash_hmac( + 'sha256', + 'meetletacbfree-content-v1|' . $userid . ':' . $cmid . ':' . $cid . ':' . $expires . '|' . $nonce, + self::download_secret(), + true + ); + if (!hash_equals($expected, $mac)) { + throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree'); + } + if ($expires < time() || $userid <= 0 || $cmid <= 0 || $cid <= 0) { + throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree'); + } + return ['userid' => $userid, 'cmid' => $cmid, 'cid' => $cid]; + } + + /** + * Find a content item listed under a meeting folder. + */ + public static function find_in_meeting(service $service, int $meetingscoid, int $scoid): ?stdClass { + foreach ($service->list_meeting_content($meetingscoid) as $item) { + if ((int)($item->scoid ?? 0) === $scoid) { + return $item; + } + } + return null; + } + + /** + * Adobe stores uploaded binaries under {url-path}/output/{filename}. + */ + public static function output_file_url(string $urlpath, string $filename): string { + $filename = basename(str_replace('\\', '/', $filename)); + if ($filename === '' || strpos($filename, '/') !== false) { + throw new \moodle_exception('filenotfound', 'error'); + } + $base = '/' . trim($urlpath, '/'); + if ($base === '/') { + throw new \moodle_exception('filenotfound', 'error'); + } + return config::public_url($base . '/output/' . rawurlencode($filename)); + } + + /** + * Stream a meeting content file to the browser via user SSO. + */ + public static function serve_download(instance $inst, int $scoid): void { + global $USER; + + $service = new service(); + $service->login_admin(); + $item = self::find_in_meeting($service, $inst->require_meeting(), $scoid); + $service->logout(); + + if (!$item || empty($item->name) || empty($item->urlpath)) { + throw new \moodle_exception('filenotfound', 'error'); + } + + $adoberole = roles::resolve_adobe_role($inst->record, $inst->context, (int)$USER->id); + $permission = $adoberole ?: service::ROLE_PARTICIPANT; + $auth = sso::provision_and_login($USER, $scoid, $permission); + + $fileurl = self::output_file_url((string)$item->urlpath, (string)$item->name); + $filename = clean_filename((string)$item->name); + if ($filename === '') { + $filename = 'content_' . $scoid; + } + + $tmpdir = make_request_directory(); + $tmp = $tmpdir . '/content_' . $scoid . '_' . $filename; + self::fetch_to_path($fileurl, $auth['cookie'], $tmp); + + \core\session\manager::write_close(); + send_temp_file($tmp, $filename); + } + + /** + * Download remote content bytes using a BREEZESESSION cookie. + */ + protected static function fetch_to_path(string $url, string $session, string $dest): void { + $ch = curl_init($url); + $fp = fopen($dest, 'wb'); + if ($fp === false) { + curl_close($ch); + throw new \moodle_exception('filenotfound', 'error'); + } + curl_setopt_array($ch, [ + CURLOPT_FILE => $fp, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_TIMEOUT => 300, + CURLOPT_COOKIE => $session !== '' ? ('BREEZESESSION=' . $session) : '', + ]); + $ok = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $size = (int)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD); + curl_close($ch); + fclose($fp); + + if (!$ok || $code < 200 || $code >= 400 || $size <= 0 || !is_file($dest) || filesize($dest) <= 0) { + @unlink($dest); + throw new \moodle_exception('filenotfound', 'error'); + } + } + + protected static function make_download_token(int $userid, int $cmid, int $scoid): string { + $expires = time() + DAYSECS * 7; + $nonce = random_bytes(16); + $payload = pack('N4', $userid, $cmid, $scoid, $expires) . $nonce; + $mac = hash_hmac( + 'sha256', + 'meetletacbfree-content-v1|' . $userid . ':' . $cmid . ':' . $scoid . ':' . $expires . '|' . $nonce, + self::download_secret(), + true + ); + return rtrim(strtr(base64_encode($mac . $payload), '+/', '-_'), '='); + } + + protected static function download_secret(): string { + return 'mod_meetletacbfree/content/' . get_site_identifier(); + } +} diff --git a/classes/local/featureaccess.php b/classes/local/featureaccess.php new file mode 100644 index 0000000..31630f3 --- /dev/null +++ b/classes/local/featureaccess.php @@ -0,0 +1,544 @@ +. + +/** + * Role-based feature access matrix (site + activity). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use context; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Decide which Moodle roles may use which MeetLet features. + * + * ===================================================================== + * ACCESS RULE (mandatory for current and future features) + * --------------------------------------------------------------------- + * Every user-facing MeetLet capability MUST go through + * featureaccess::allowed() / require_feature(). + * + * allowed() = role×feature matrix (site or activity override) + * AND optional site/activity visibility switch when mapped. + * + * Do NOT grant access via Moodle capabilities alone (except the base + * mod/meetletacbfree:view gate to open the activity page). + * Do NOT special-case admins/hosts outside the matrix. + * + * Adding a new feature: + * 1) Add a constant + entry in features() + * 2) Add accessfeature_* lang strings (fa + en) + * 3) Set defaults in features_for_profile() / profile_for_role() + * 4) If it also has a site “show …” switch, map it in visibility_feature() + * 5) Gate UI and web services with allowed() / require_feature() + * ===================================================================== + */ +class featureaccess { + public const JOIN = 'join'; + public const RECORDINGS = 'recordings'; + public const CONTENT = 'content'; + public const EDITRECORDING = 'editrecording'; + public const ADOBEEDITOR = 'adobeeditor'; + public const ENTERBEFOREHOST = 'enterbeforehost'; + + /** @var array|null */ + protected static $rolerecords = null; + + /** + * Ordered feature keys for UI. + * + * @return string[] + */ + public static function features(): array { + return [ + self::JOIN, + self::ENTERBEFOREHOST, + self::RECORDINGS, + self::CONTENT, + self::EDITRECORDING, + self::ADOBEEDITOR, + ]; + } + + /** + * All defined Moodle roles (shortnames), sorted like the role UI. + * + * @return string[] + */ + public static function role_shortnames(): array { + $out = []; + foreach (self::role_records() as $role) { + if ($role->shortname !== '') { + $out[] = $role->shortname; + } + } + return $out; + } + + /** + * @return array id => role row (id, shortname, name, archetype, sortorder) + */ + public static function role_records(): array { + global $DB; + if (self::$rolerecords === null) { + self::$rolerecords = $DB->get_records( + 'role', + null, + 'sortorder ASC, id ASC', + 'id, shortname, name, archetype, sortorder' + ); + if (!is_array(self::$rolerecords)) { + self::$rolerecords = []; + } + } + return self::$rolerecords; + } + + /** + * Reset cached role list (tests / after role changes in same request). + */ + public static function reset_role_cache(): void { + self::$rolerecords = null; + } + + /** + * Profile of features enabled for a given access level. + * + * @return string[] feature keys + */ + public static function features_for_profile(string $profile): array { + switch ($profile) { + case 'manager': + case 'editingteacher': + return self::features(); + case 'teacher': + return [ + self::JOIN, self::ENTERBEFOREHOST, + self::RECORDINGS, self::CONTENT, + ]; + case 'student': + return [self::JOIN, self::RECORDINGS, self::CONTENT]; + default: + return []; + } + } + + /** + * Map a Moodle role to a sensible MeetLet access profile. + */ + public static function profile_for_role(stdClass $role): string { + $arch = strtolower(trim((string)($role->archetype ?? ''))); + $short = strtolower(trim((string)($role->shortname ?? ''))); + $name = strtolower(trim((string)($role->name ?? ''))); + $hay = $short . ' ' . $name; + + if (in_array($arch, ['manager', 'coursecreator'], true) + || in_array($short, ['manager', 'coursecreator'], true)) { + return 'manager'; + } + if ($arch === 'editingteacher' || $short === 'editingteacher') { + return 'editingteacher'; + } + if ($arch === 'teacher' || $short === 'teacher') { + return 'teacher'; + } + if ($arch === 'student' || $short === 'student') { + return 'student'; + } + if (in_array($arch, ['guest', 'user', 'frontpage'], true) + || in_array($short, ['guest', 'user', 'frontpage'], true)) { + return 'none'; + } + + // Custom / plugin roles — infer from shortname & name. + if (preg_match('/\b(host|manager|admin|owner)\b/u', $hay) + || str_contains($hay, 'adobeconnecthost')) { + return 'editingteacher'; + } + if (preg_match('/\b(presenter|editingteacher|instructor|tutor|moderator)\b/u', $hay) + || str_contains($hay, 'adobeconnectpresenter')) { + return 'teacher'; + } + if (preg_match('/\b(participant|student|learner|attendee)\b/u', $hay) + || str_contains($hay, 'adobeconnectparticipant')) { + return 'student'; + } + if (preg_match('/\b(teacher|teacherrole|ostad)\b/u', $hay)) { + return 'teacher'; + } + + return 'none'; + } + + /** + * Built-in defaults by role archetype + name heuristics for custom roles. + * + * @return array> role => feature => 0|1 + */ + public static function builtin_defaults(): array { + $alloff = array_fill_keys(self::features(), 0); + $matrix = []; + foreach (self::role_records() as $role) { + $row = $alloff; + foreach (self::features_for_profile(self::profile_for_role($role)) as $f) { + $row[$f] = 1; + } + $matrix[$role->shortname] = $row; + } + return $matrix; + } + + /** + * Keep explicit non-empty stored rows; fill blank/missing roles from builtins. + * + * @param mixed $raw + * @return array> + */ + public static function normalize_fill_blank_roles($raw): array { + $defaults = self::builtin_defaults(); + if (!is_array($raw)) { + return $defaults; + } + $out = $defaults; + foreach (self::role_shortnames() as $role) { + if (empty($raw[$role]) || !is_array($raw[$role])) { + continue; + } + $enabled = 0; + foreach (self::features() as $feature) { + if (!empty($raw[$role][$feature])) { + $enabled++; + } + } + if ($enabled === 0) { + // Entirely empty row → keep sensible builtin defaults for that role. + continue; + } + foreach (self::features() as $feature) { + $out[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0; + } + } + return $out; + } + + /** + * Whether the activity form may show a per-activity access matrix. + */ + public static function activity_override_enabled(): bool { + $raw = get_config('meetletacbfree', 'activity_featureaccess'); + if ($raw === false || $raw === null || $raw === '') { + return true; + } + return (bool)$raw; + } + + /** + * Normalize a stored matrix; missing keys fall back to builtin defaults. + * + * @param mixed $raw + * @return array> + */ + public static function normalize($raw): array { + $base = self::builtin_defaults(); + if (!is_array($raw)) { + return $base; + } + foreach (self::role_shortnames() as $role) { + if (empty($raw[$role]) || !is_array($raw[$role])) { + continue; + } + foreach (self::features() as $feature) { + if (array_key_exists($feature, $raw[$role])) { + $base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0; + } + } + } + return $base; + } + + /** + * Build matrix from checkbox POST (missing checkbox = denied). + * + * @param mixed $raw + * @return array> + */ + public static function normalize_from_form($raw): array { + $base = []; + foreach (self::role_shortnames() as $role) { + $base[$role] = array_fill_keys(self::features(), 0); + if (empty($raw[$role]) || !is_array($raw[$role])) { + continue; + } + foreach (self::features() as $feature) { + $base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0; + } + } + return $base; + } + + /** + * Site-wide matrix from plugin config. + * + * @return array> + */ + public static function site_matrix(): array { + $raw = get_config('meetletacbfree', 'featureaccess'); + if ($raw === false || $raw === null || $raw === '') { + return self::builtin_defaults(); + } + $decoded = json_decode((string)$raw, true); + return self::normalize($decoded); + } + + /** + * Effective matrix for an activity (override or site). + * + * @param stdClass|null $activity + * @return array> + */ + public static function matrix_for_activity(?stdClass $activity): array { + if ($activity && !empty($activity->featureaccess)) { + $decoded = json_decode((string)$activity->featureaccess, true); + if (is_array($decoded) && !empty($decoded)) { + return self::normalize($decoded); + } + } + return self::site_matrix(); + } + + /** + * Whether current/user may use a feature in this module context. + * Decision is based solely on the role × feature matrix (no visibility switch). + */ + public static function user_can(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool { + global $USER; + $userid = $userid ?? (int)$USER->id; + + // Must at least be able to view the activity page. + $viewcap = self::view_capability_for_context($context); + if ($viewcap && !has_capability($viewcap, $context, $userid)) { + return false; + } + + $matrix = self::matrix_for_activity($activity); + $roles = get_user_roles($context, $userid, true); + foreach ($roles as $role) { + $short = $role->shortname ?? ''; + if ($short === '' || !isset($matrix[$short])) { + continue; + } + if (!empty($matrix[$short][$feature])) { + return true; + } + } + + // No matching matrix role grants the feature. + return false; + } + + /** + * Optional site/activity visibility key for a matrix feature. + * Null = matrix-only (no enable_* / show_* switch). + */ + public static function visibility_feature(string $feature): ?string { + $map = [ + self::RECORDINGS => 'recordingstab', + self::CONTENT => 'contenttab', + ]; + return $map[$feature] ?? null; + } + + /** + * Single entry point: matrix grant AND visibility switch (when mapped). + * + * Use this for tabs, buttons, links, and web-service actions. + */ + public static function allowed(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool { + if (!\mod_meetletacbfree\registration\manager::is_registered()) { + return false; + } + $vis = self::visibility_feature($feature); + if ($vis !== null && !config::show_feature($vis, $activity)) { + return false; + } + return self::user_can($feature, $context, $activity, $userid); + } + + /** + * Require allowed() or throw. + */ + public static function require_feature(string $feature, context $context, ?stdClass $activity = null): void { + \mod_meetletacbfree\registration\manager::require_registered(); + if (self::allowed($feature, $context, $activity)) { + return; + } + $vis = self::visibility_feature($feature); + if ($vis !== null && !config::show_feature($vis, $activity)) { + throw new \moodle_exception('featuredisabled', 'meetletacbfree'); + } + throw new \moodle_exception('nopermissions', 'error', '', self::feature_label($feature)); + } + + public static function feature_label(string $feature): string { + $key = 'accessfeature_' . $feature; + if (get_string_manager()->string_exists($key, 'meetletacbfree')) { + return get_string($key, 'meetletacbfree'); + } + return $feature; + } + + /** + * @return array feature => label + */ + public static function feature_labels(): array { + $out = []; + foreach (self::features() as $f) { + $out[$f] = self::feature_label($f); + } + return $out; + } + + /** + * @return array shortname => local name + */ + public static function role_labels(): array { + $out = []; + foreach (self::role_records() as $role) { + $out[$role->shortname] = role_get_name($role); + } + return $out; + } + + protected static function view_capability_for_context(context $context): string { + return 'mod/meetletacbfree:view'; + } + + /** + * Render HTML matrix table (admin or mod form). + * + * @param array $matrix + * @param string $inputname e.g. featureaccess[student][join] or admin name + * @param bool $disabled + */ + public static function render_matrix_html(array $matrix, string $inputname, bool $disabled = false): string { + $matrix = self::normalize($matrix); + $roles = self::role_labels(); + $features = self::feature_labels(); + $strtogglerow = get_string('accessmatrix_toggle_row', 'meetletacbfree'); + $strtogglecol = get_string('accessmatrix_toggle_col', 'meetletacbfree'); + $strall = get_string('accessmatrix_all', 'meetletacbfree'); + + $html = \html_writer::start_div('meetlet-access-matrix', ['data-meetlet-access-matrix' => '1']); + $html .= \html_writer::start_tag('table', ['class' => 'generaltable meetlet-access-table']); + + $head = \html_writer::tag('th', get_string('accessrole', 'meetletacbfree')); + foreach ($features as $fkey => $flabel) { + $toggle = \html_writer::tag('button', s($strall), [ + 'type' => 'button', + 'class' => 'meetlet-access-toggle meetlet-access-toggle--col', + 'data-access-toggle' => 'col', + 'data-feature' => $fkey, + 'title' => $strtogglecol, + 'aria-label' => $strtogglecol . ': ' . $flabel, + ]); + $head .= \html_writer::tag('th', + \html_writer::div(s($flabel), 'meetlet-access-col__label') . $toggle, + ['class' => 'meetlet-access-col', 'title' => $flabel] + ); + } + $html .= \html_writer::tag('thead', \html_writer::tag('tr', $head)); + + $body = ''; + foreach ($roles as $rshort => $rlabel) { + $rowtoggle = \html_writer::tag('button', s($strall), [ + 'type' => 'button', + 'class' => 'meetlet-access-toggle meetlet-access-toggle--row', + 'data-access-toggle' => 'row', + 'data-role' => $rshort, + 'title' => $strtogglerow, + 'aria-label' => $strtogglerow . ': ' . $rlabel, + ]); + $cells = \html_writer::tag('th', + \html_writer::span(s($rlabel), 'meetlet-access-role') . $rowtoggle, + ['scope' => 'row', 'class' => 'meetlet-access-rowhead'] + ); + foreach (array_keys($features) as $fkey) { + $checked = !empty($matrix[$rshort][$fkey]); + $attrs = [ + 'type' => 'checkbox', + 'name' => $inputname . '[' . $rshort . '][' . $fkey . ']', + 'value' => '1', + 'class' => 'meetlet-access-check', + 'data-role' => $rshort, + 'data-feature' => $fkey, + ]; + if ($checked) { + $attrs['checked'] = 'checked'; + } + if ($disabled) { + $attrs['disabled'] = 'disabled'; + } + $cells .= \html_writer::tag('td', \html_writer::empty_tag('input', $attrs), [ + 'class' => 'meetlet-access-cell', + ]); + } + $body .= \html_writer::tag('tr', $cells, ['data-role-row' => $rshort]); + } + $html .= \html_writer::tag('tbody', $body); + $html .= \html_writer::end_tag('table'); + $html .= \html_writer::end_div(); + return $html; + } + + /** + * Copy $from grants onto $to when $to is missing from a stored row. + * + * @param mixed $raw + * @return array> + */ + public static function copy_missing_feature_from($raw, string $from, string $to): array { + $stored = is_array($raw) ? $raw : []; + $out = self::normalize($stored); + foreach (self::role_shortnames() as $role) { + if (empty($stored[$role]) || !is_array($stored[$role])) { + continue; + } + if (array_key_exists($to, $stored[$role])) { + continue; + } + if (array_key_exists($from, $stored[$role])) { + $out[$role][$to] = !empty($stored[$role][$from]) ? 1 : 0; + } + } + return $out; + } + + /** + * Parse POSTed matrix checkbox array into normalized JSON. + * + * @param mixed $posted + */ + public static function matrix_from_post($posted): string { + return json_encode(self::normalize_from_form(is_array($posted) ? $posted : []), JSON_UNESCAPED_UNICODE); + } +} diff --git a/classes/local/icons.php b/classes/local/icons.php new file mode 100644 index 0000000..89a16c3 --- /dev/null +++ b/classes/local/icons.php @@ -0,0 +1,59 @@ +. + +/** + * Local inline SVG icons (air-gapped — no CDN). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Shared MeetLet icon SVG snippets. + */ +class icons { + /** + * Return inline SVG markup for a named icon. + */ + public static function svg(string $name): string { + $svgs = [ + 'play' => '', + 'zip' => '', + 'offline' => '', + 'mp4' => '', + 'import' => '', + 'request' => '', + 'edit' => '', + 'adobeedit' => '', + 'users' => '', + 'csv' => '', + 'refresh' => '', + 'update' => '', + 'download' => '', + 'details' => '', + 'attendance' => '', + 'recordings' => '', + 'content' => '', + 'sessions' => '', + ]; + return $svgs[$name] ?? $svgs['play']; + } +} diff --git a/classes/local/instance.php b/classes/local/instance.php new file mode 100644 index 0000000..0638446 --- /dev/null +++ b/classes/local/instance.php @@ -0,0 +1,98 @@ +. + +/** + * Activity instance helpers. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use cm_info; +use context_module; +use moodle_exception; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Load and resolve MeetLet instances. + */ +class instance { + /** @var stdClass */ + public $record; + /** @var stdClass */ + public $course; + /** @var cm_info|stdClass */ + public $cm; + /** @var context_module */ + public $context; + /** @var int */ + public $groupid = 0; + /** @var stdClass|null */ + public $meetingmap; + + public static function get_from_cmid(int $cmid, int $groupid = 0): self { + list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'meetletacbfree'); + return self::from_cm($cm, $course, $groupid); + } + + public static function get_from_instanceid(int $instanceid, int $groupid = 0): self { + list($course, $cm) = get_course_and_cm_from_instance($instanceid, 'meetletacbfree'); + return self::from_cm($cm, $course, $groupid); + } + + protected static function from_cm($cm, $course, int $groupid): self { + global $DB; + $inst = new self(); + $inst->cm = $cm; + $inst->course = $course; + $inst->context = context_module::instance($cm->id); + $inst->record = $DB->get_record('meetletacbfree', ['id' => $cm->instance], '*', MUST_EXIST); + $inst->groupid = $groupid; + $inst->meetingmap = $DB->get_record('meetletacbfree_meetings', [ + 'instanceid' => $inst->record->id, + 'groupid' => $groupid, + ]); + if (!$inst->meetingmap && $groupid !== 0) { + // Fallback to no-group mapping. + $inst->meetingmap = $DB->get_record('meetletacbfree_meetings', [ + 'instanceid' => $inst->record->id, + 'groupid' => 0, + ]); + } + return $inst; + } + + public function meetingscoid(): int { + return (int)($this->meetingmap->meetingscoid ?? 0); + } + + public function require_meeting(): int { + $scoid = $this->meetingscoid(); + if (!$scoid) { + throw new moodle_exception('nomeeting', 'meetletacbfree'); + } + return $scoid; + } + + public function auto_offline_enabled(): bool { + return config::auto_offline_enabled($this->record); + } +} diff --git a/classes/local/meetingstatus.php b/classes/local/meetingstatus.php new file mode 100644 index 0000000..e46ac20 --- /dev/null +++ b/classes/local/meetingstatus.php @@ -0,0 +1,263 @@ +. + +/** + * Meeting lifecycle status (schedule + live presence + wait-for-host). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use mod_meetletacbfree\api\service; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Resolve whether the activity meeting can accept join requests. + */ +class meetingstatus { + public const OPEN = 'open'; + public const LIVE = 'live'; + public const SCHEDULED = 'scheduled'; + public const ENDED = 'ended'; + public const WAITINGHOST = 'waitinghost'; + /** Local-only placeholder until Adobe is polled from the browser. */ + public const CHECKING = 'checking'; + + /** + * Fast local status for first paint — no Adobe API calls. + * + * Join stays blocked until the client polls {@see resolve()} unless the + * schedule alone already forbids join (and the user cannot bypass). + * + * @return array{ + * status:string, + * statuslabel:string, + * joinable:bool, + * message:string, + * errorcode:string, + * starttime:int, + * endtime:int, + * waitforhost:bool + * } + */ + public static function resolve_local(instance $inst, ?int $userid = null): array { + global $USER; + $userid = $userid ?? (int)$USER->id; + $rec = $inst->record; + $now = time(); + $startts = !empty($rec->starttime) ? (int)$rec->starttime : 0; + $endts = !empty($rec->endtime) ? (int)$rec->endtime : 0; + $waitforhost = config::wait_for_host($rec); + $canbypass = featureaccess::allowed( + featureaccess::ENTERBEFOREHOST, + $inst->context, + $rec, + $userid + ); + + $status = self::CHECKING; + $errorcode = ''; + $message = get_string('statuschecking_help', 'meetletacbfree'); + // Never allow join before Adobe confirms room state. + $joinable = false; + + if ($endts > 0 && $endts < $now) { + $status = self::ENDED; + $message = get_string('meetingended', 'meetletacbfree'); + $errorcode = 'meetingended'; + $joinable = $canbypass; + } else if ($startts > $now) { + $status = self::SCHEDULED; + $message = get_string('meetingnotstarted', 'meetletacbfree'); + $errorcode = 'meetingnotstarted'; + $joinable = $canbypass; + } else if ($waitforhost && !$canbypass) { + // Provisional: assume waiting until Adobe poll confirms a live/open room. + $status = self::WAITINGHOST; + $message = get_string('joinblocked_waitinghost', 'meetletacbfree'); + $errorcode = 'waitingforhost'; + $joinable = false; + } else if (!$canbypass) { + // Until Adobe confirms the room is live, keep Join disabled for participants. + $status = self::CHECKING; + $message = get_string('statuschecking_help', 'meetletacbfree'); + $joinable = false; + } + + return [ + 'status' => $status, + 'statuslabel' => self::label($status), + 'joinable' => $joinable, + 'message' => $message, + 'errorcode' => $errorcode, + 'starttime' => $startts, + 'endtime' => $endts, + 'waitforhost' => $waitforhost, + ]; + } + + /** + * Full status including Adobe live presence and host-hold. + * + * Priority: + * 1) Live room presence on Adobe Connect + * 2) Moodle schedule (start / end) + * 3) Wait-for-host (Moodle flag and/or Adobe ACL) + * + * @return array{ + * status:string, + * statuslabel:string, + * joinable:bool, + * message:string, + * errorcode:string, + * starttime:int, + * endtime:int, + * waitforhost:bool + * } + */ + public static function resolve(instance $inst, ?int $userid = null, bool $checkhost = true): array { + global $USER; + $userid = $userid ?? (int)$USER->id; + $rec = $inst->record; + $now = time(); + $startts = !empty($rec->starttime) ? (int)$rec->starttime : 0; + $endts = !empty($rec->endtime) ? (int)$rec->endtime : 0; + + $service = null; + $scoid = 0; + $islive = false; + $adobehold = false; + + if (config::is_configured()) { + $scoid = $inst->meetingscoid(); + if ($scoid) { + try { + $service = new service(); + $service->login_admin(); + $info = $service->meeting_info($scoid); + if ($info && !empty($info->starttime) && $startts <= 0) { + $adobestart = strtotime((string)$info->starttime); + if ($adobestart) { + $startts = $adobestart; + } + } + $islive = $service->meeting_is_live($scoid); + if ($checkhost) { + $adobehold = $service->meeting_requires_host_first($scoid); + } + } catch (\Throwable $e) { + $service = null; + $islive = false; + $adobehold = false; + } + } + } + + $waitforhost = config::wait_for_host($rec) || $adobehold; + $status = self::OPEN; + $errorcode = ''; + $message = ''; + $joinable = true; + $canbypass = featureaccess::allowed( + featureaccess::ENTERBEFOREHOST, + $inst->context, + $rec, + $userid + ); + + if ($islive) { + $status = self::LIVE; + $joinable = true; + } else if ($endts > 0 && $endts < $now) { + $status = self::ENDED; + if (!$canbypass) { + $joinable = false; + $errorcode = 'meetingended'; + $message = get_string('meetingended', 'meetletacbfree'); + } + } else if ($startts > $now) { + $status = self::SCHEDULED; + if (!$canbypass) { + $joinable = false; + $errorcode = 'meetingnotstarted'; + $message = get_string('meetingnotstarted', 'meetletacbfree'); + } + } else if (!$islive && $checkhost && !$canbypass) { + // Nobody is in the Adobe room yet. Participants must not get a join URL — + // Connect would only show "Meeting Host Expected". Moderators with + // enterbeforehost may still join to start the session. + $status = self::WAITINGHOST; + $joinable = false; + $errorcode = 'waitingforhost'; + $message = get_string('joinblocked_waitinghost', 'meetletacbfree'); + } else if (!$islive && $canbypass && ($waitforhost || $adobehold)) { + // Host/moderator may enter; still show waiting-for-host for clarity. + $status = self::WAITINGHOST; + $joinable = true; + $message = get_string('joinblocked_waitinghost', 'meetletacbfree'); + } + + if ($service instanceof service) { + try { + $service->logout(); + } catch (\Throwable $e) { + // Ignore. + } + } + + if ($inst->meetingmap) { + try { + if (roomsession::note_presence($inst, $islive)) { + recordings::sync($inst); + } + } catch (\Throwable $e) { + debugging('MeetLet room session observe failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + } + } + + return [ + 'status' => $status, + 'statuslabel' => self::label($status), + 'joinable' => $joinable, + 'message' => $message !== '' ? $message : get_string('statushelp', 'meetletacbfree'), + 'errorcode' => $errorcode, + 'starttime' => $startts, + 'endtime' => $endts, + 'waitforhost' => $waitforhost, + ]; + } + + public static function label(string $status): string { + switch ($status) { + case self::LIVE: + return get_string('statuslive', 'meetletacbfree'); + case self::ENDED: + return get_string('statusended', 'meetletacbfree'); + case self::SCHEDULED: + return get_string('statusscheduled', 'meetletacbfree'); + case self::WAITINGHOST: + return get_string('statuswaitinghost', 'meetletacbfree'); + case self::CHECKING: + return get_string('statuschecking', 'meetletacbfree'); + default: + return get_string('statusopen', 'meetletacbfree'); + } + } +} diff --git a/classes/local/privacy_policy.php b/classes/local/privacy_policy.php new file mode 100644 index 0000000..2e24c2b --- /dev/null +++ b/classes/local/privacy_policy.php @@ -0,0 +1,83 @@ +. + +/** + * Admin HTML for the in-plugin privacy policy tab (Marketplace edition). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Renders the Privacy admin settings tab. + */ +class privacy_policy { + + /** + * Build privacy policy HTML (safe for admin settings heading). + */ + public static function render(): string { + $plugin = 'meetletacbfree'; + + $sections = [ + self::section('scope', 'privacy_tab_scope_title', 'privacy_tab_scope_body'), + self::section('registration', 'privacy_tab_registration_title', 'privacy_tab_registration_body'), + self::section('purpose', 'privacy_tab_purpose_title', 'privacy_tab_purpose_body'), + self::section('service', 'privacy_tab_service_title', 'privacy_tab_service_body'), + self::section('adobe', 'privacy_tab_adobe_title', 'privacy_tab_adobe_body'), + self::section('local', 'privacy_tab_local_title', 'privacy_tab_local_body'), + self::section('retention', 'privacy_tab_retention_title', 'privacy_tab_retention_body'), + self::section('sharing', 'privacy_tab_sharing_title', 'privacy_tab_sharing_body'), + self::section('control', 'privacy_tab_control_title', 'privacy_tab_control_body'), + self::section('contact', 'privacy_tab_contact_title', 'privacy_tab_contact_body'), + ]; + + return \html_writer::div( + \html_writer::tag('h3', get_string('privacy_tab_heading', $plugin), ['class' => 'meetlet-guide__heading']) . + \html_writer::tag('p', get_string('privacy_tab_intro', $plugin), ['class' => 'meetlet-guide__intro']) . + \html_writer::tag('p', get_string('privacy_tab_effective', $plugin), [ + 'class' => 'meetlet-guide__intro text-muted', + ]) . + implode('', $sections), + 'meetlet-guide meetlet-settings-card meetlet-privacy' + ); + } + + /** + * @param string $id Section id for anchoring. + * @param string $titlekey Lang string key for section title. + * @param string $bodykey Lang string key or pre-formatted body HTML. + */ + protected static function section(string $id, string $titlekey, string $bodykey): string { + $plugin = 'meetletacbfree'; + $title = get_string($titlekey, $plugin); + $body = strpos($bodykey, '<') !== false + ? $bodykey + : get_string($bodykey, $plugin); + + return \html_writer::div( + \html_writer::tag('h4', $title) . + \html_writer::div($body, 'meetlet-guide__body'), + 'meetlet-guide__section', + ['id' => 'meetlet-privacy-' . $id] + ); + } +} diff --git a/classes/local/recordings.php b/classes/local/recordings.php new file mode 100644 index 0000000..fe69da5 --- /dev/null +++ b/classes/local/recordings.php @@ -0,0 +1,127 @@ +. + +/** + * Recording list sync (community edition — no offline MP4 / agent). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use mod_meetletacbfree\api\service; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +class recordings { + public static function for_tab(instance $instance): array { + self::sync($instance); + return self::load_local($instance); + } + + public static function sync(instance $instance, ?service $service = null): array { + global $DB; + + $ownsclient = false; + if ($service === null) { + $service = new service(); + $service->login_admin(); + $ownsclient = true; + } + + $scoid = $instance->meetingscoid(); + if (!$scoid) { + if ($ownsclient) { + $service->logout(); + } + return []; + } + + $remote = $service->list_recordings($scoid); + $now = time(); + $out = []; + + foreach ($remote as $rec) { + $record = $DB->get_record('meetletacbfree_recordings', [ + 'instanceid' => $instance->record->id, + 'recordingscoid' => $rec->scoid, + ]); + + $zipurl = service::zip_url($rec->urlpath); + + if (!$record) { + $record = new stdClass(); + $record->instanceid = $instance->record->id; + $record->meetingscoid = $scoid; + $record->recordingscoid = $rec->scoid; + $record->name = $rec->name; + $record->urlpath = $rec->urlpath; + $record->zipurl = $zipurl; + $record->duration = $rec->duration; + $record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0; + $record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0; + $record->timemodified = $now; + $record->id = $DB->insert_record('meetletacbfree_recordings', $record); + } else { + $record->name = $rec->name; + $record->urlpath = $rec->urlpath; + $record->zipurl = $zipurl; + $record->duration = $rec->duration; + $record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0; + $record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0; + $record->timemodified = $now; + $DB->update_record('meetletacbfree_recordings', $record); + } + + $record->playurl = config::public_url($rec->urlpath); + $record->editurl = config::recording_editor_url($rec->urlpath); + $out[] = $record; + } + + if ($ownsclient) { + $service->logout(); + } + return $out; + } + + public static function load_local(instance $instance): array { + global $DB; + + $rows = $DB->get_records( + 'meetletacbfree_recordings', + ['instanceid' => $instance->record->id], + 'timestart DESC, id DESC' + ); + foreach ($rows as $record) { + $record->playurl = config::public_url((string)$record->urlpath); + $record->editurl = config::recording_editor_url((string)$record->urlpath); + $record->mp4ready = false; + $record->mp4url = ''; + } + return $rows; + } + + public static function jobstatus_label(string $status, bool $mp4ready = false): string { + return $status !== '' ? $status : '—'; + } + + public static function job_is_busy(string $status): bool { + return false; + } +} diff --git a/classes/local/roles.php b/classes/local/roles.php new file mode 100644 index 0000000..ea799e0 --- /dev/null +++ b/classes/local/roles.php @@ -0,0 +1,486 @@ +. + +/** + * BBB-style participant role mapping for Adobe Connect. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use context; +use mod_meetletacbfree\api\service; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Participant list helpers (BBB pattern: all/role/user → Adobe role). + */ +class roles { + public const ROLE_HOST = 'host'; + public const ROLE_PRESENTER = 'presenter'; + public const ROLE_PARTICIPANT = 'participant'; + public const MODE_INHERIT = 0; + public const MODE_OVERRIDE = 1; + + /** + * Map form role id to Adobe permission-id. + */ + public static function to_adobe_permission(string $role): string { + switch ($role) { + case self::ROLE_HOST: + return service::ROLE_HOST; + case self::ROLE_PRESENTER: + return service::ROLE_PRESENTER; + default: + return service::ROLE_PARTICIPANT; + } + } + + public static function role_rank(string $role): int { + switch ($role) { + case self::ROLE_HOST: + return 3; + case self::ROLE_PRESENTER: + return 2; + case self::ROLE_PARTICIPANT: + return 1; + default: + return 0; + } + } + + /** + * Built-in site defaults (no owner user rule). + */ + public static function builtin_site_defaults(): array { + global $DB; + $list = [[ + 'selectiontype' => 'all', + 'selectionid' => 'all', + 'role' => self::ROLE_PARTICIPANT, + ]]; + + foreach (['manager', 'coursecreator', 'editingteacher'] as $shortname) { + if ($role = $DB->get_record('role', ['shortname' => $shortname], 'id')) { + $list[] = [ + 'selectiontype' => 'role', + 'selectionid' => (string)$role->id, + 'role' => self::ROLE_HOST, + ]; + } + } + if ($role = $DB->get_record('role', ['shortname' => 'teacher'], 'id')) { + $list[] = [ + 'selectiontype' => 'role', + 'selectionid' => (string)$role->id, + 'role' => self::ROLE_PRESENTER, + ]; + } + return $list; + } + + /** + * Site-wide default participant rules from plugin config (or built-in). + */ + public static function get_site_default_rules(): array { + $raw = get_config('meetletacbfree', 'default_participants'); + if (is_string($raw) && $raw !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded) && !empty($decoded)) { + return self::normalize_rules($decoded); + } + } + return self::builtin_site_defaults(); + } + + /** + * Normalize and sanitize a participant rule list. + */ + public static function normalize_rules(array $rules): array { + $allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT]; + $out = []; + $hasall = false; + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + $type = (string)($rule['selectiontype'] ?? ''); + $id = (string)($rule['selectionid'] ?? ''); + $role = (string)($rule['role'] ?? self::ROLE_PARTICIPANT); + if (!in_array($role, $allowed, true)) { + $role = self::ROLE_PARTICIPANT; + } + if ($type === 'all') { + $hasall = true; + $out[] = ['selectiontype' => 'all', 'selectionid' => 'all', 'role' => $role]; + continue; + } + if ($type === 'role' && ctype_digit($id) && (int)$id > 0) { + $out[] = ['selectiontype' => 'role', 'selectionid' => $id, 'role' => $role]; + continue; + } + if ($type === 'user' && ctype_digit($id) && (int)$id > 0) { + $out[] = ['selectiontype' => 'user', 'selectionid' => $id, 'role' => $role]; + } + } + if (!$hasall) { + array_unshift($out, [ + 'selectiontype' => 'all', + 'selectionid' => 'all', + 'role' => self::ROLE_PARTICIPANT, + ]); + } + return $out; + } + + /** + * Default participant rules for a new / empty activity. + */ + public static function get_participant_list_default(context $context, ?int $ownerid = null): array { + $list = self::get_site_default_rules(); + // Activity owner as host when enrolled (activity-only convenience rule). + if (!empty($ownerid) && is_enrolled($context, $ownerid)) { + $list[] = [ + 'selectiontype' => 'user', + 'selectionid' => (string)$ownerid, + 'role' => self::ROLE_HOST, + ]; + } + return self::normalize_rules($list); + } + + /** + * Whether one activity should inherit the site role mapping. + */ + public static function activity_inherits_defaults(?stdClass $instance): bool { + if ($instance === null) { + return true; + } + if (!property_exists($instance, 'participantsmode')) { + return false; + } + return (int)$instance->participantsmode !== self::MODE_OVERRIDE; + } + + /** + * Convert rule list → admin matrix keyed by _all / role shortname. + * + * @return array + */ + public static function matrix_from_rules(array $rules): array { + $matrix = ['_all' => self::ROLE_PARTICIPANT]; + foreach (featureaccess::role_records() as $role) { + if ($role->shortname !== '') { + $matrix[$role->shortname] = self::ROLE_PARTICIPANT; + } + } + $byid = featureaccess::role_records(); + foreach (self::normalize_rules($rules) as $rule) { + $adobe = $rule['role'] ?? self::ROLE_PARTICIPANT; + if (($rule['selectiontype'] ?? '') === 'all') { + $matrix['_all'] = $adobe; + } else if (($rule['selectiontype'] ?? '') === 'role') { + $rid = (int)($rule['selectionid'] ?? 0); + if (isset($byid[$rid]) && $byid[$rid]->shortname !== '') { + $matrix[$byid[$rid]->shortname] = $adobe; + } + } + } + return $matrix; + } + + /** + * Convert posted admin matrix → JSON rule list for config storage. + * + * @param array $data role shortname / _all => adobe role + */ + public static function rules_json_from_matrix_post(array $data): string { + $allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT]; + $allrole = (string)($data['_all'] ?? self::ROLE_PARTICIPANT); + if (!in_array($allrole, $allowed, true)) { + $allrole = self::ROLE_PARTICIPANT; + } + $list = [[ + 'selectiontype' => 'all', + 'selectionid' => 'all', + 'role' => $allrole, + ]]; + foreach (featureaccess::role_records() as $role) { + if ($role->shortname === '') { + continue; + } + $adobe = (string)($data[$role->shortname] ?? self::ROLE_PARTICIPANT); + if (!in_array($adobe, $allowed, true)) { + $adobe = self::ROLE_PARTICIPANT; + } + // Skip redundant participant rows that match the “everyone” default. + if ($adobe === $allrole && $adobe === self::ROLE_PARTICIPANT) { + continue; + } + $list[] = [ + 'selectiontype' => 'role', + 'selectionid' => (string)$role->id, + 'role' => $adobe, + ]; + } + return json_encode(self::normalize_rules($list), JSON_UNESCAPED_UNICODE); + } + + /** + * Adobe Connect role options for selects/radios. + * + * @return array value => label + */ + public static function adobe_role_labels(): array { + return [ + self::ROLE_HOST => get_string('role_host', 'meetletacbfree'), + self::ROLE_PRESENTER => get_string('role_presenter', 'meetletacbfree'), + self::ROLE_PARTICIPANT => get_string('role_participant', 'meetletacbfree'), + ]; + } + + /** + * Render site admin Moodle-role → Adobe-role mapping table. + */ + public static function render_site_mapping_html(array $rules, string $inputname): string { + $matrix = self::matrix_from_rules($rules); + $adobelabels = self::adobe_role_labels(); + $rolelabels = featureaccess::role_labels(); + + $html = \html_writer::start_div('meetlet-rolemapping', ['data-meetlet-rolemapping' => '1']); + $html .= \html_writer::start_tag('table', [ + 'class' => 'generaltable meetlet-access-table meetlet-rolemapping-table', + ]); + + $head = \html_writer::tag('th', get_string('accessrole', 'meetletacbfree')); + $head .= \html_writer::tag('th', get_string('adoberole', 'meetletacbfree')); + $html .= \html_writer::tag('thead', \html_writer::tag('tr', $head)); + + $body = ''; + // Everyone row first. + $body .= self::mapping_row_html( + get_string('participant_type_all', 'meetletacbfree'), + $inputname . '[_all]', + $matrix['_all'] ?? self::ROLE_PARTICIPANT, + $adobelabels, + '_all' + ); + foreach ($rolelabels as $shortname => $label) { + $body .= self::mapping_row_html( + $label, + $inputname . '[' . $shortname . ']', + $matrix[$shortname] ?? self::ROLE_PARTICIPANT, + $adobelabels, + $shortname + ); + } + $html .= \html_writer::tag('tbody', $body); + $html .= \html_writer::end_tag('table'); + $html .= \html_writer::end_div(); + return $html; + } + + /** + * One mapping table row with Adobe role radios. + * + * @param array $adobelabels + */ + protected static function mapping_row_html( + string $moodlelabel, + string $inputname, + string $selected, + array $adobelabels, + string $rowkey + ): string { + $cells = \html_writer::tag('th', s($moodlelabel), [ + 'scope' => 'row', + 'class' => 'meetlet-access-rowhead', + ]); + $chips = ''; + foreach ($adobelabels as $value => $label) { + $id = 'meetlet-map-' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $rowkey . '-' . $value); + $attrs = [ + 'type' => 'radio', + 'name' => $inputname, + 'id' => $id, + 'value' => $value, + 'class' => 'meetlet-rolechip__input', + ]; + if ($selected === $value) { + $attrs['checked'] = 'checked'; + } + $chips .= \html_writer::tag('label', + \html_writer::empty_tag('input', $attrs) . + \html_writer::span(s($label), 'meetlet-rolechip__text'), + [ + 'class' => 'meetlet-rolechip', + 'for' => $id, + 'data-role-value' => $value, + ] + ); + } + $group = \html_writer::div($chips, 'meetlet-rolechip-group', [ + 'role' => 'radiogroup', + 'aria-label' => $moodlelabel, + ]); + $cells .= \html_writer::tag('td', $group, ['class' => 'meetlet-rolemapping-cell']); + return \html_writer::tag('tr', $cells, ['data-role-row' => $rowkey]); + } + + public static function get_participant_list(?stdClass $instance, context $context): array { + global $USER; + if ($instance === null || self::activity_inherits_defaults($instance) + || empty($instance->participants) || $instance->participants === '[]') { + return self::get_participant_list_default($context, $USER->id); + } + $rules = json_decode($instance->participants, true); + if (!is_array($rules) || empty($rules)) { + // Legacy format support. + if (isset($rules['hosts']) || (is_array($rules) && isset($rules[0]) === false && isset($rules['participants']))) { + return self::legacy_to_rules($rules); + } + return self::get_participant_list_default($context, $USER->id); + } + // Detect legacy associative map. + if (isset($rules['hosts']) || isset($rules['presenters'])) { + return self::legacy_to_rules($rules); + } + return $rules; + } + + protected static function legacy_to_rules(array $map): array { + $list = [[ + 'selectiontype' => 'all', + 'selectionid' => 'all', + 'role' => self::ROLE_PARTICIPANT, + ]]; + foreach ($map['hosts'] ?? [] as $id) { + $list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_HOST]; + } + foreach ($map['presenters'] ?? [] as $id) { + $list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PRESENTER]; + } + foreach ($map['participants'] ?? [] as $id) { + $list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PARTICIPANT]; + } + return $list; + } + + public static function get_participant_data(context $context, ?stdClass $instance = null): array { + $data = [ + 'all' => [ + 'name' => get_string('participant_type_all', 'meetletacbfree'), + 'children' => [], + ], + 'role' => [ + 'name' => get_string('participant_type_role', 'meetletacbfree'), + 'children' => self::get_roles_select($context), + ], + 'user' => [ + 'name' => get_string('participant_type_user', 'meetletacbfree'), + 'children' => [], + ], + ]; + if ($instance) { + $data['user']['children'] = self::get_users_array($context); + } + return $data; + } + + protected static function get_roles_select(context $context): array { + $roles = get_viewable_roles($context); + $out = []; + foreach ($roles as $id => $name) { + $out[$id] = ['id' => $id, 'name' => $name]; + } + return $out; + } + + protected static function get_users_array(context $context): array { + $users = get_enrolled_users($context, '', 0, 'u.id, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic, u.middlename, u.alternatename'); + $out = []; + foreach ($users as $u) { + $out[$u->id] = ['id' => $u->id, 'name' => fullname($u)]; + } + return $out; + } + + public static function get_participant_selection_data(): array { + return [ + 'type_options' => [ + 'all' => get_string('participant_type_all', 'meetletacbfree'), + 'role' => get_string('participant_type_role', 'meetletacbfree'), + 'user' => get_string('participant_type_user', 'meetletacbfree'), + ], + 'type_selected' => 'role', + 'options' => ['all' => '---------------'], + 'selected' => 'all', + ]; + } + + /** + * Resolve Adobe permission for user from participant rules. + * + * @return string|null Adobe permission-id or null if denied + */ + public static function resolve_adobe_role(stdClass $instance, context $context, int $userid): ?string { + if (has_capability('mod/meetletacbfree:host', $context, $userid)) { + return service::ROLE_HOST; + } + if (has_capability('mod/meetletacbfree:presenter', $context, $userid)) { + return service::ROLE_PRESENTER; + } + + $rules = self::get_participant_list($instance, $context); + $userroles = get_user_roles($context, $userid); + $roleids = []; + foreach ($userroles as $r) { + $roleids[] = (int)$r->roleid; + } + + $best = null; + $bestrank = 0; + foreach ($rules as $rule) { + $match = false; + if (($rule['selectiontype'] ?? '') === 'all') { + $match = true; + } else if (($rule['selectiontype'] ?? '') === 'role') { + $match = in_array((int)$rule['selectionid'], $roleids, true); + } else if (($rule['selectiontype'] ?? '') === 'user') { + $match = ((int)$rule['selectionid'] === (int)$userid); + } + if (!$match) { + continue; + } + $role = $rule['role'] ?? self::ROLE_PARTICIPANT; + $rank = self::role_rank($role); + if ($rank > $bestrank) { + $bestrank = $rank; + $best = $role; + } + } + + if ($best === null) { + return !empty($instance->meetingpublic) ? service::ROLE_PARTICIPANT : null; + } + return self::to_adobe_permission($best); + } +} diff --git a/classes/local/roomsession.php b/classes/local/roomsession.php new file mode 100644 index 0000000..56052f0 --- /dev/null +++ b/classes/local/roomsession.php @@ -0,0 +1,91 @@ +. + +/** + * Track Adobe Connect room occupancy for auto MP4 after the class actually ends. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Persist live/closed room state on meetletacbfree_meetings and decide when to sweep recordings. + */ +class roomsession { + /** Seconds to keep looking for new recordings after the room empties. */ + public const SWEEP_GRACE = 20 * MINSECS; + + /** + * Record occupancy. Returns true for an opportunistic queue (just closed / grace window). + * + * Open = at least one person in the Adobe room. Closed = it was open, then empty. + * Moodle schedule endtime is intentionally ignored here. + * Cron retries leftover recordings forever while the room is empty; this return + * only gates page-view / join-status opportunistic queues during the grace window. + */ + public static function note_presence(instance $instance, bool $islive): bool { + global $DB; + + $map = $instance->meetingmap; + if (!$map || empty($map->id)) { + return false; + } + $row = $DB->get_record('meetletacbfree_meetings', ['id' => (int)$map->id]); + if (!$row) { + return false; + } + + $now = time(); + $waslive = !empty($row->islive); + + if ($islive) { + $row->islive = 1; + $row->liveat = $now; + $DB->update_record('meetletacbfree_meetings', $row); + $instance->meetingmap = $row; + return false; + } + + if ($waslive) { + $row->islive = 0; + $row->closedat = $now; + $row->sweepuntil = $now + self::sweep_window(); + } + + $insweep = !empty($row->sweepuntil) && $now <= (int)$row->sweepuntil; + $due = $insweep && $instance->auto_offline_enabled() + && ($now - (int)($row->sweptat ?? 0) >= 45); + + if ($due) { + $row->sweptat = $now; + } + if ($waslive || $due) { + $DB->update_record('meetletacbfree_meetings', $row); + } + $instance->meetingmap = $row; + return $due; + } + + protected static function sweep_window(): int { + $mins = config::cron_interval_minutes(); + return max(self::SWEEP_GRACE, $mins * MINSECS * 4); + } +} diff --git a/classes/local/server_guide.php b/classes/local/server_guide.php new file mode 100644 index 0000000..33ae9d0 --- /dev/null +++ b/classes/local/server_guide.php @@ -0,0 +1,158 @@ +. + +/** + * Admin HTML for Adobe Connect server setup guide. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Renders the “Server setup” admin help tab. + */ +class server_guide { + /** + * Build guide HTML (safe for admin settings heading). + */ + public static function render(): string { + $plugin = 'meetletacbfree'; + + $match = \html_writer::tag('h4', get_string('serverguide_match_title', $plugin)) . + \html_writer::tag('p', get_string('serverguide_match_intro', $plugin)) . + self::table([ + [get_string('admin_httpauth', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_httpauth', $plugin)))], + [get_string('host', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_apiurl', $plugin)))], + [get_string('meethost', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_meethost', $plugin)))], + ]); + + $customini = self::section( + 'customini', + get_string('serverguide_customini_title', $plugin), + get_string('serverguide_customini_body', $plugin) . + \html_writer::tag('pre', s(get_string('serverguide_customini_sample', $plugin)), [ + 'class' => 'meetlet-guide__code', + ]) + ); + + $webxml = self::section( + 'webxml', + get_string('serverguide_webxml_title', $plugin), + get_string('serverguide_webxml_body', $plugin) . + \html_writer::tag('pre', s(get_string('serverguide_webxml_sample', $plugin)), [ + 'class' => 'meetlet-guide__code', + ]) + ); + + $serverxml = self::section( + 'serverxml', + get_string('serverguide_serverxml_title', $plugin), + get_string('serverguide_serverxml_body', $plugin) . + \html_writer::tag('pre', s(get_string('serverguide_serverxml_sample', $plugin)), [ + 'class' => 'meetlet-guide__code', + ]) + ); + + $upgrade = \html_writer::div( + \html_writer::tag('strong', get_string('serverguide_upgrade_title', $plugin)) . + \html_writer::div(get_string('serverguide_upgrade_body', $plugin), 'meetlet-guide__body'), + 'meetlet-guide__callout meetlet-guide__callout--warn', + ['id' => 'meetlet-guide-upgrade'] + ); + + $codes = self::errorcodes_section(); + + return \html_writer::div( + \html_writer::tag('h3', get_string('serverguide_heading', $plugin), ['class' => 'meetlet-guide__heading']) . + \html_writer::tag('p', get_string('serverguide_intro', $plugin), ['class' => 'meetlet-guide__intro']) . + \html_writer::div($match, 'meetlet-guide__section meetlet-guide__section--match') . + $customini . + $webxml . + $serverxml . + $upgrade . + $codes, + 'meetlet-guide meetlet-settings-card' + ); + } + + /** + * Help-code table so an admin can match a student-reported ML- code. + */ + protected static function errorcodes_section(): string { + $plugin = 'meetletacbfree'; + $head = \html_writer::tag('tr', + \html_writer::tag('th', get_string('serverguide_errorcodes_col_code', $plugin)) . + \html_writer::tag('th', get_string('serverguide_errorcodes_col_meaning', $plugin)) . + \html_writer::tag('th', get_string('serverguide_errorcodes_col_fix', $plugin)) + ); + $body = ''; + foreach (usererror::catalog() as $item) { + $body .= \html_writer::tag('tr', + \html_writer::tag('td', \html_writer::tag('code', s($item['label'])), ['class' => 'meetlet-guide__code-cell']) . + \html_writer::tag('td', get_string($item['titlekey'], $plugin)) . + \html_writer::tag('td', get_string($item['fixkey'], $plugin)) + ); + } + $table = \html_writer::tag( + 'table', + \html_writer::tag('thead', $head) . \html_writer::tag('tbody', $body), + ['class' => 'meetlet-guide__table generaltable'] + ); + return self::section( + 'errorcodes', + get_string('serverguide_errorcodes_title', $plugin), + \html_writer::tag('p', get_string('serverguide_errorcodes_intro', $plugin)) . $table + ); + } + + /** + * @param string $id Section id for anchoring. + * @param string $title Section title. + * @param string $bodyhtml Trusted HTML from lang strings (admin-only). + */ + protected static function section(string $id, string $title, string $bodyhtml): string { + return \html_writer::div( + \html_writer::tag('h4', $title) . + \html_writer::div($bodyhtml, 'meetlet-guide__body'), + 'meetlet-guide__section', + ['id' => 'meetlet-guide-' . $id] + ); + } + + /** + * @param array $rows Label / value (value may contain HTML). + */ + protected static function table(array $rows): string { + $trs = ''; + foreach ($rows as $row) { + $trs .= \html_writer::tag( + 'tr', + \html_writer::tag('th', $row[0], ['scope' => 'row']) . + \html_writer::tag('td', $row[1]) + ); + } + return \html_writer::tag( + 'table', + \html_writer::tag('tbody', $trs), + ['class' => 'meetlet-guide__table generaltable'] + ); + } +} diff --git a/classes/local/sso.php b/classes/local/sso.php new file mode 100644 index 0000000..814b48e --- /dev/null +++ b/classes/local/sso.php @@ -0,0 +1,175 @@ +. + +/** + * Adobe Connect SSO helpers for the current Moodle user. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use mod_meetletacbfree\api\client; +use mod_meetletacbfree\api\service; +use moodle_exception; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Build Connect logins and BREEZESESSION for Moodle users. + */ +class sso { + /** + * Ordered Connect login candidates for a Moodle user. + * + * @return string[] + */ + public static function login_candidates(stdClass $user, ?stdClass $adobeuser = null): array { + $reserved = [ + 'administrators', 'authors', 'everyone', 'live-admins', 'admins', + 'course-admins', 'event-administrators', 'event-managers', 'learners', + 'seminar-administrators', + // Generic Moodle accounts must never be sent as Connect SSO identities. + 'admin', 'guest', 'root', 'administrator', 'moodle', + ]; + $candidates = []; + // Prefer ext-login (refresh_principal_login) then stable mlu{id}, then legacy logins. + $seed = []; + if (!empty($adobeuser->login)) { + $seed[] = (string)$adobeuser->login; + } + if (!empty($adobeuser->internallogin)) { + $seed[] = (string)$adobeuser->internallogin; + } + $seed[] = config::connect_login($user); + foreach (array_merge($seed, config::legacy_connect_logins($user)) as $candidate) { + $candidate = trim((string)$candidate); + if ($candidate === '' || in_array(strtolower($candidate), $reserved, true)) { + continue; + } + if (!in_array($candidate, $candidates, true)) { + $candidates[] = $candidate; + } + } + return $candidates; + } + + /** + * Obtain a BREEZESESSION cookie via HTTP header SSO for the given logins. + * + * @param string[] $logincandidates + */ + public static function breeze_session(array $logincandidates): string { + \mod_meetletacbfree\registration\manager::require_registered(); + + $cookie = ''; + $lasterror = null; + $primary = $logincandidates[0] ?? ''; + + foreach ($logincandidates as $logintry) { + try { + $sso = new client(); + $cookie = $sso->login_external($logintry); + if ($cookie !== '') { + return $cookie; + } + } catch (\Throwable $e) { + $lasterror = $e; + } + } + $parsed = $lasterror ? usererror::from_throwable($lasterror) : ['helpid' => 'sso_01', 'facts' => [], 'xml' => '']; + $facts = $parsed['facts']; + if ($primary !== '' && empty($facts['login'])) { + $facts['login'] = $primary; + } + $ip = self::guess_egress_ip(); + if ($ip !== '') { + $facts['egress_ip'] = $ip; + } + $helpid = $parsed['helpid']; + if ($lasterror instanceof moodle_exception && $lasterror->errorcode === 'agentrequestfailed') { + $helpid = 'sso_02'; + $facts['agent'] = is_scalar($lasterror->a) ? (string)$lasterror->a : $lasterror->getMessage(); + } + if ($lasterror instanceof usererror && $lasterror->helpid === 'sso_01') { + $helpid = 'sso_01'; + } + throw new usererror($helpid, 'errorhttpauth', $facts, $parsed['xml']); + } + + /** + * Best-effort public IP Adobe will see for Moodle API calls. + */ + public static function guess_egress_ip(): string { + try { + $curl = new \curl(['ignoresecurity' => true]); + $curl->setopt(['CURLOPT_TIMEOUT' => 5]); + $raw = $curl->get('https://api.ipify.org'); + $ip = trim((string)$raw); + if (preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip)) { + return $ip; + } + } catch (\Throwable $e) { + // Ignore. + } + return ''; + } + + /** + * Ensure Connect user exists, grant permission on a SCO (and parent meeting for + * recordings), then SSO login. + * + * @return array{cookie:string,adobeuser:stdClass} + */ + public static function provision_and_login(stdClass $user, int $scoid, string $permission): array { + \mod_meetletacbfree\registration\manager::require_registered(); + + $service = new service(); + $service->login_admin(); + $adobeuser = $service->ensure_user($user); + $adobeuser = $service->refresh_principal_login($adobeuser); + if (trim((string)($adobeuser->login ?? '')) === '') { + throw new usererror('usr_01', 'errorcreateuser', ['login' => config::connect_login($user)]); + } + + $targets = [$scoid]; + try { + $info = $service->get_client()->request([ + 'action' => 'sco-info', + 'sco-id' => $scoid, + ]); + $folder = (int)($info->sco['folder-id'] ?? 0); + // Parent meeting folder ACL is required for recordings and uploaded content. + if ($folder > 0) { + $targets[] = $folder; + } + } catch (\Throwable $e) { + // Best-effort enrichment only. + } + foreach (array_unique($targets) as $aclid) { + if ($aclid > 0) { + $service->assign_permission((int)$adobeuser->principalid, (int)$aclid, $permission); + } + } + $service->logout(); + + $cookie = self::breeze_session(self::login_candidates($user, $adobeuser)); + return ['cookie' => $cookie, 'adobeuser' => $adobeuser]; + } +} diff --git a/classes/local/usererror.php b/classes/local/usererror.php new file mode 100644 index 0000000..cc635fa --- /dev/null +++ b/classes/local/usererror.php @@ -0,0 +1,397 @@ +. + +/** + * Audience-aware MeetLet errors (student vs teacher/admin) with help codes. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\local; + +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Structured plugin error with a public help code (ML-…) and staff-only detail. + */ +class usererror extends moodle_exception { + /** @var string Internal catalog id, e.g. sso_01 */ + public string $helpid = 'err_01'; + + /** @var array Staff fact rows (login, adobe_code, …) */ + public array $facts = []; + + /** @var string Optional Adobe XML / raw snippet */ + public string $xml = ''; + + /** + * @param string $helpid Catalog id (sso_01, api_01, …) + * @param string $errorcode Moodle lang string id + * @param array $facts + * @param string $xml + */ + public function __construct(string $helpid, string $errorcode, array $facts = [], string $xml = '') { + $this->helpid = $helpid !== '' ? $helpid : 'err_01'; + $this->facts = $facts; + $this->xml = trim($xml); + $debug = $this->facts; + if ($this->xml !== '') { + $debug['xml'] = $this->xml; + } + parent::__construct($errorcode, 'meetletacbfree', '', null, json_encode($debug, JSON_UNESCAPED_UNICODE)); + } + + /** + * Teachers, managers, and site admins see technical detail; students do not. + */ + public static function is_staff(\context $context, ?int $userid = null): bool { + return has_any_capability([ + 'moodle/site:config', + 'moodle/course:update', + 'moodle/course:manageactivities', + 'mod/meetletacbfree:addinstance', + 'mod/meetletacbfree:host', + 'mod/meetletacbfree:presenter', + ], $context, $userid); + } + + /** + * Help-code catalog shown in the server-setup guide. + * + * @return array + */ + public static function catalog(): array { + return [ + 'sso_01' => [ + 'label' => 'ML-SSO-01', + 'titlekey' => 'helpcode_sso_01_title', + 'fixkey' => 'helpcode_sso_01_fix', + ], + 'api_01' => [ + 'label' => 'ML-API-01', + 'titlekey' => 'helpcode_api_01_title', + 'fixkey' => 'helpcode_api_01_fix', + ], + 'api_02' => [ + 'label' => 'ML-API-02', + 'titlekey' => 'helpcode_api_02_title', + 'fixkey' => 'helpcode_api_02_fix', + ], + 'usr_01' => [ + 'label' => 'ML-USR-01', + 'titlekey' => 'helpcode_usr_01_title', + 'fixkey' => 'helpcode_usr_01_fix', + ], + 'mtg_01' => [ + 'label' => 'ML-MTG-01', + 'titlekey' => 'helpcode_mtg_01_title', + 'fixkey' => 'helpcode_mtg_01_fix', + ], + 'cfg_01' => [ + 'label' => 'ML-CFG-01', + 'titlekey' => 'helpcode_cfg_01_title', + 'fixkey' => 'helpcode_cfg_01_fix', + ], + 'err_01' => [ + 'label' => 'ML-ERR-01', + 'titlekey' => 'helpcode_err_01_title', + 'fixkey' => 'helpcode_err_01_fix', + ], + ]; + } + + /** + * Public label for a catalog id (ML-SSO-01). + */ + public static function label(string $helpid): string { + $cat = self::catalog(); + return $cat[$helpid]['label'] ?? 'ML-ERR-01'; + } + + /** + * Ajax payload for join / recording launch failures. + * + * @return array{code:string,message:string,detailhtml:string,staff:bool} + */ + public static function payload(\Throwable $e, \context $context): array { + if (self::is_gate_exception($e)) { + return [ + 'code' => '', + 'message' => $e->getMessage(), + 'detailhtml' => '', + 'staff' => false, + ]; + } + + $parsed = self::from_throwable($e); + $label = self::label($parsed['helpid']); + $staff = self::is_staff($context); + + if (!$staff) { + return [ + 'code' => $label, + 'message' => get_string('error_user_unavailable', 'meetletacbfree', $label), + 'detailhtml' => '', + 'staff' => false, + ]; + } + + return [ + 'code' => $label, + 'message' => self::staff_title($parsed['helpid'], $e), + 'detailhtml' => self::render_staff_html($parsed), + 'staff' => true, + ]; + } + + /** + * WS success envelope. + * + * @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string} + */ + public static function ws_ok(string $url): array { + return [ + 'url' => $url, + 'ok' => true, + 'helpcode' => '', + 'message' => '', + 'detailhtml' => '', + ]; + } + + /** + * WS failure envelope (HTTP 200 so the UI can format HTML). + * + * @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string} + */ + public static function ws_fail(\Throwable $e, \context $context): array { + $p = self::payload($e, $context); + return [ + 'url' => '', + 'ok' => false, + 'helpcode' => $p['code'], + 'message' => $p['message'], + 'detailhtml' => $p['detailhtml'], + ]; + } + + /** + * @return array{url: \core_external\external_value, ok: \core_external\external_value, helpcode: \core_external\external_value, message: \core_external\external_value, detailhtml: \core_external\external_value} + */ + public static function ws_url_returns(): array { + return [ + 'url' => new \core_external\external_value(PARAM_RAW, 'URL with session when ok'), + 'ok' => new \core_external\external_value(PARAM_BOOL, 'False when the session could not be opened'), + 'helpcode' => new \core_external\external_value(PARAM_TEXT, 'Public help code, e.g. ML-SSO-01'), + 'message' => new \core_external\external_value(PARAM_RAW, 'Audience-appropriate short message'), + 'detailhtml' => new \core_external\external_value(PARAM_RAW, 'Staff-only HTML; empty for students'), + ]; + } + + /** + * Pretty-print an Adobe XML snippet for a code box. + */ + public static function format_xml(string $raw): string { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + $start = strpos($raw, '/i', $chunk, $m)) { + $chunk = $m[0]; + } else { + $chunk = substr($chunk, 0, 1200); + } + $dom = new \DOMDocument(); + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + if (@$dom->loadXML($chunk)) { + $out = $dom->saveXML($dom->documentElement); + return $out !== false ? trim($out) : trim($chunk); + } + return trim($chunk); + } + + /** + * @return array{helpid:string,facts:array,xml:string} + */ + public static function from_throwable(\Throwable $e): array { + if ($e instanceof self) { + return [ + 'helpid' => $e->helpid, + 'facts' => $e->facts, + 'xml' => $e->xml, + ]; + } + + $code = $e instanceof moodle_exception ? (string)$e->errorcode : ''; + $helpid = self::helpid_from_moodle($code); + $facts = []; + $xml = ''; + + if ($e instanceof moodle_exception && is_scalar($e->a) && (string)$e->a !== '') { + $legacy = self::parse_legacy_detail((string)$e->a); + $facts = $legacy['facts']; + $xml = $legacy['xml']; + } + + return ['helpid' => $helpid, 'facts' => $facts, 'xml' => $xml]; + } + + protected static function is_gate_exception(\Throwable $e): bool { + if (!$e instanceof moodle_exception) { + return false; + } + return in_array($e->errorcode, [ + 'meetingended', + 'meetingnotstarted', + 'waitingforhost', + 'notparticipant', + 'usergrouprequired', + 'joinblocked_waitinghost', + ], true); + } + + protected static function helpid_from_moodle(string $errorcode): string { + $map = [ + 'errorhttpauth' => 'sso_01', + 'erroremptyxml' => 'api_01', + 'errorlogin' => 'api_02', + 'errorcreateuser' => 'usr_01', + 'errorcreatemeeting' => 'mtg_01', + 'nomeeting' => 'mtg_01', + 'errorexistingsco' => 'mtg_01', + 'notconfigured' => 'cfg_01', + ]; + return $map[$errorcode] ?? 'err_01'; + } + + /** + * @return array{facts:array,xml:string} + */ + protected static function parse_legacy_detail(string $detail): array { + $facts = []; + $xml = ''; + if (preg_match('/login=([^\s·]+)/u', $detail, $m)) { + $facts['login'] = $m[1]; + } + if (preg_match('/\bcode=([^\s·]+)/u', $detail, $m)) { + $facts['adobe_code'] = $m[1]; + } + if (preg_match('/subcode=([^\s·]+)/u', $detail, $m)) { + $facts['adobe_subcode'] = $m[1]; + } + $bodypos = stripos($detail, 'body='); + if ($bodypos !== false) { + $xml = self::format_xml(substr($detail, $bodypos + 5)); + } + return ['facts' => $facts, 'xml' => $xml]; + } + + protected static function staff_title(string $helpid, \Throwable $e): string { + $cat = self::catalog(); + $key = $cat[$helpid]['titlekey'] ?? ''; + if ($key !== '' && get_string_manager()->string_exists($key, 'meetletacbfree')) { + return get_string($key, 'meetletacbfree'); + } + if ($e instanceof moodle_exception) { + return $e->getMessage(); + } + return get_string('error', 'meetletacbfree'); + } + + /** + * @param array{helpid:string,facts:array,xml:string} $parsed + */ + protected static function render_staff_html(array $parsed): string { + $helpid = $parsed['helpid']; + $label = self::label($helpid); + $cat = self::catalog(); + $titlekey = $cat[$helpid]['titlekey'] ?? ''; + $fixkey = $cat[$helpid]['fixkey'] ?? ''; + $title = ($titlekey !== '' && get_string_manager()->string_exists($titlekey, 'meetletacbfree')) + ? get_string($titlekey, 'meetletacbfree') + : get_string('error', 'meetletacbfree'); + + $codebox = \html_writer::div( + \html_writer::span(get_string('error_helpcode_label', 'meetletacbfree'), 'meetlet-err__codelabel') . + \html_writer::tag('code', s($label), ['data-copy-code' => $label]), + 'meetlet-err__codebox' + ); + + $rows = ''; + $factorder = ['login', 'adobe_code', 'adobe_subcode', 'egress_ip']; + $facts = $parsed['facts']; + foreach ($factorder as $key) { + if (empty($facts[$key])) { + continue; + } + $dtkey = 'errorfact_' . $key; + $dt = get_string_manager()->string_exists($dtkey, 'meetletacbfree') + ? get_string($dtkey, 'meetletacbfree') + : $key; + $rows .= \html_writer::tag( + 'div', + \html_writer::tag('dt', s($dt)) . + \html_writer::tag('dd', \html_writer::tag('code', s((string)$facts[$key]))), + ['class' => 'meetlet-err__fact'] + ); + } + foreach ($facts as $key => $val) { + if (in_array($key, $factorder, true) || (string)$val === '') { + continue; + } + $rows .= \html_writer::tag( + 'div', + \html_writer::tag('dt', s((string)$key)) . + \html_writer::tag('dd', \html_writer::tag('code', s((string)$val))), + ['class' => 'meetlet-err__fact'] + ); + } + $factshtml = $rows !== '' ? \html_writer::tag('dl', $rows, ['class' => 'meetlet-err__facts']) : ''; + + $fix = ''; + if ($fixkey !== '' && get_string_manager()->string_exists($fixkey, 'meetletacbfree')) { + $fix = \html_writer::div(get_string($fixkey, 'meetletacbfree'), 'meetlet-err__fix'); + } + + $xmlhtml = ''; + if ($parsed['xml'] !== '') { + $xmlhtml = \html_writer::tag('p', get_string('error_adobe_response', 'meetletacbfree'), ['class' => 'meetlet-err__prelabel']) . + \html_writer::tag('pre', s($parsed['xml']), ['class' => 'meetlet-err__xml']); + } + + $guide = \html_writer::tag('p', get_string('error_see_serverguide', 'meetletacbfree'), ['class' => 'meetlet-err__guide']); + + return \html_writer::div( + \html_writer::tag('p', s($title), ['class' => 'meetlet-err__lead']) . + $codebox . + $factshtml . + $fix . + $xmlhtml . + $guide, + 'meetlet-err meetlet-err--staff' + ); + } +} diff --git a/classes/output/renderer.php b/classes/output/renderer.php new file mode 100644 index 0000000..849eb28 --- /dev/null +++ b/classes/output/renderer.php @@ -0,0 +1,38 @@ +. + +/** + * Plugin renderer. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\output; + +use plugin_renderer_base; + +defined('MOODLE_INTERNAL') || die(); + +/** + * MeetLet renderer. + */ +class renderer extends plugin_renderer_base { + public function render_view_page(view_page $page): string { + return $this->render_from_template('mod_meetletacbfree/view_page', $page->export_for_template($this)); + } +} diff --git a/classes/output/view_page.php b/classes/output/view_page.php new file mode 100644 index 0000000..be04a42 --- /dev/null +++ b/classes/output/view_page.php @@ -0,0 +1,130 @@ +. + +/** + * View page renderable. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\output; + +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\icons; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\meetingstatus; +use renderable; +use renderer_base; +use templatable; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Main activity shell with tabs. + */ +class view_page implements renderable, templatable { + /** @var instance */ + protected $instance; + /** @var bool */ + protected $configured; + /** @var array|null */ + protected $lifecycle; + /** @var bool */ + protected $registrationusable = true; + + public function __construct( + instance $instance, + bool $configured, + ?array $lifecycle = null, + bool $registrationusable = true + ) { + $this->instance = $instance; + $this->configured = $configured; + $this->lifecycle = $lifecycle; + $this->registrationusable = $registrationusable; + } + + public function export_for_template(renderer_base $output): array { + global $USER; + + $rec = $this->instance->record; + $lifecycle = $this->lifecycle ?? meetingstatus::resolve_local($this->instance, (int)$USER->id); + $status = $lifecycle['status']; + $statuslabel = $lifecycle['statuslabel']; + $startts = (int)$lifecycle['starttime']; + $endts = (int)$lifecycle['endtime']; + + $intro = format_module_intro('meetletacbfree', $rec, $this->instance->cm->id); + $tabs = [ + [ + 'id' => 'details', + 'label' => get_string('tabdetails', 'meetletacbfree'), + 'icon' => icons::svg('details'), + 'active' => true, + ], + ]; + if (featureaccess::allowed(featureaccess::RECORDINGS, $this->instance->context, $rec)) { + $tabs[] = [ + 'id' => 'recordings', + 'label' => get_string('tabrecordings', 'meetletacbfree'), + 'icon' => icons::svg('recordings'), + 'active' => false, + ]; + } + if (featureaccess::allowed(featureaccess::CONTENT, $this->instance->context, $rec)) { + $tabs[] = [ + 'id' => 'content', + 'label' => get_string('tabcontent', 'meetletacbfree'), + 'icon' => icons::svg('content'), + 'active' => false, + ]; + } + + $featurejoin = featureaccess::allowed(featureaccess::JOIN, $this->instance->context, $rec); + $joinable = $featurejoin && !empty($lifecycle['joinable']); + + return [ + 'cmid' => (int)$this->instance->cm->id, + 'groupid' => (int)$this->instance->groupid, + 'name' => format_string($rec->name), + 'intro' => $intro, + 'hasintro' => trim(strip_tags($intro)) !== '', + 'detailstitle' => get_string('tabdetails', 'meetletacbfree'), + 'emptydetails' => get_string('emptydetails', 'meetletacbfree'), + 'configured' => $this->configured, + 'notconfiguredmsg' => get_string('notconfigured', 'meetletacbfree'), + 'subtitle' => get_string('subtitle', 'meetletacbfree'), + 'logourl' => $output->image_url('adobeconnect', 'meetletacbfree')->out(false), + 'status' => $status, + 'statuslabel' => $statuslabel, + 'statusmessage' => (string)$lifecycle['message'], + 'starttime' => $startts ? userdate($startts) : '', + 'endtime' => $endts ? userdate($endts) : '', + 'hasend' => !empty($endts), + 'tabs' => $tabs, + 'loading' => get_string('loading', 'meetletacbfree'), + 'joining' => get_string('joining', 'meetletacbfree'), + 'joiningtitle' => get_string('joiningtitle', 'meetletacbfree'), + 'joinlabel' => get_string('joinmeeting', 'meetletacbfree'), + 'canjoin' => $featurejoin, + 'joinable' => $joinable, + 'statuspollseconds' => $this->registrationusable ? 15 : 0, + 'registrationusable' => $this->registrationusable, + ]; + } +} diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php new file mode 100644 index 0000000..ee73b9f --- /dev/null +++ b/classes/privacy/provider.php @@ -0,0 +1,373 @@ +. + +/** + * Privacy API for mod_meetletacbfree. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\privacy; + +defined('MOODLE_INTERNAL') || die(); + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\approved_userlist; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\helper; +use core_privacy\local\request\transform; +use core_privacy\local\request\userlist; +use core_privacy\local\request\writer; + +/** + * Privacy subsystem implementation. + * + * Local personal data is mainly the activity creator (userid) and optional + * per-user rows in the participants JSON. Profile fields are also sent to + * Adobe Connect for SSO. + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\plugin\provider, + \core_privacy\local\request\core_userlist_provider { + + /** + * Describe stored / exported personal data. + * + * @param collection $collection + * @return collection + */ + public static function get_metadata(collection $collection): collection { + $collection->add_database_table('meetletacbfree', [ + 'userid' => 'privacy:metadata:meetletacbfree:userid', + 'name' => 'privacy:metadata:meetletacbfree:name', + 'intro' => 'privacy:metadata:meetletacbfree:intro', + 'participants' => 'privacy:metadata:meetletacbfree:participants', + 'timecreated' => 'privacy:metadata:meetletacbfree:timecreated', + 'timemodified' => 'privacy:metadata:meetletacbfree:timemodified', + ], 'privacy:metadata:meetletacbfree'); + + $collection->add_database_table('meetletacbfree_meetings', [ + 'groupid' => 'privacy:metadata:meetletacbfree_meetings:groupid', + 'meetingscoid' => 'privacy:metadata:meetletacbfree_meetings:meetingscoid', + 'urlpath' => 'privacy:metadata:meetletacbfree_meetings:urlpath', + ], 'privacy:metadata:meetletacbfree_meetings'); + + $collection->add_database_table('meetletacbfree_recordings', [ + 'name' => 'privacy:metadata:meetletacbfree_recordings:name', + 'urlpath' => 'privacy:metadata:meetletacbfree_recordings:urlpath', + 'jobstatus' => 'privacy:metadata:meetletacbfree_recordings:jobstatus', + ], 'privacy:metadata:meetletacbfree_recordings'); + + $collection->add_external_location_link('adobeconnect', [ + 'firstname' => 'privacy:metadata:adobeconnect:firstname', + 'lastname' => 'privacy:metadata:adobeconnect:lastname', + 'email' => 'privacy:metadata:adobeconnect:email', + 'username' => 'privacy:metadata:adobeconnect:username', + ], 'privacy:metadata:adobeconnect'); + + $collection->add_database_table('meetletacbfree_license', [ + 'installation_id' => 'privacy:metadata:meetletacbfree_license:installation_id', + 'status' => 'privacy:metadata:meetletacbfree_license:status', + 'last_validated' => 'privacy:metadata:meetletacbfree_license:last_validated', + ], 'privacy:metadata:meetletacbfree_license'); + + $collection->add_external_location_link('meetlet_registration', [ + 'domain' => 'privacy:metadata:meetlet_registration:domain', + 'installation_id' => 'privacy:metadata:meetlet_registration:installation_id', + 'company' => 'privacy:metadata:meetlet_registration:company', + 'email' => 'privacy:metadata:meetlet_registration:email', + 'plugin' => 'privacy:metadata:meetlet_registration:plugin', + 'plugin_release' => 'privacy:metadata:meetlet_registration:plugin_release', + 'platform' => 'privacy:metadata:meetlet_registration:platform', + 'platform_version' => 'privacy:metadata:meetlet_registration:platform_version', + ], 'privacy:metadata:meetlet_registration'); + + return $collection; + } + + /** + * Contexts that hold data for a user. + * + * @param int $userid + * @return contextlist + */ + public static function get_contexts_for_userid(int $userid): contextlist { + $contextlist = new contextlist(); + + $sql = "SELECT c.id + FROM {context} c + JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {meetletacbfree} a ON a.id = cm.instance + WHERE a.userid = :userid"; + $contextlist->add_from_sql($sql, [ + 'contextlevel' => CONTEXT_MODULE, + 'modname' => 'meetletacbfree', + 'userid' => $userid, + ]); + + // Per-user participant mapping rows (selectiontype = user). + global $DB; + $pat1 = '%"selectiontype":"user","selectionid":"' . $userid . '"%'; + $pat2 = '%"selectionid":"' . $userid . '","selectiontype":"user"%'; + $sql = "SELECT c.id + FROM {context} c + JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {meetletacbfree} a ON a.id = cm.instance + WHERE " . $DB->sql_like('a.participants', ':pat1') . " + OR " . $DB->sql_like('a.participants', ':pat2'); + $contextlist->add_from_sql($sql, [ + 'contextlevel' => CONTEXT_MODULE, + 'modname' => 'meetletacbfree', + 'pat1' => $pat1, + 'pat2' => $pat2, + ]); + + return $contextlist; + } + + /** + * Users who have data in a module context. + * + * @param userlist $userlist + */ + public static function get_users_in_context(userlist $userlist): void { + $context = $userlist->get_context(); + if (!$context instanceof \context_module) { + return; + } + + $params = [ + 'instanceid' => $context->instanceid, + 'modulename' => 'meetletacbfree', + ]; + $sql = "SELECT a.userid + FROM {course_modules} cm + JOIN {modules} m ON m.id = cm.module AND m.name = :modulename + JOIN {meetletacbfree} a ON a.id = cm.instance + WHERE cm.id = :instanceid AND a.userid <> 0"; + $userlist->add_from_sql('userid', $sql, $params); + + global $DB; + $instance = $DB->get_record_sql( + "SELECT a.participants + FROM {course_modules} cm + JOIN {modules} m ON m.id = cm.module AND m.name = :modulename + JOIN {meetletacbfree} a ON a.id = cm.instance + WHERE cm.id = :instanceid", + $params + ); + if (!$instance || empty($instance->participants)) { + return; + } + $rules = json_decode((string)$instance->participants, true); + if (!is_array($rules)) { + return; + } + $userids = []; + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + if (($rule['selectiontype'] ?? '') === 'user' && !empty($rule['selectionid'])) { + $userids[] = (int)$rule['selectionid']; + } + } + $userlist->add_users(array_values(array_unique(array_filter($userids)))); + } + + /** + * Export user data for approved module contexts. + * + * @param approved_contextlist $contextlist + */ + public static function export_user_data(approved_contextlist $contextlist): void { + global $DB; + + $user = $contextlist->get_user(); + $userid = (int)$user->id; + + foreach ($contextlist->get_contexts() as $context) { + if ($context->contextlevel !== CONTEXT_MODULE) { + continue; + } + $cm = get_coursemodule_from_id('meetletacbfree', $context->instanceid); + if (!$cm) { + continue; + } + $activity = $DB->get_record('meetletacbfree', ['id' => $cm->instance]); + if (!$activity) { + continue; + } + + $contextdata = helper::get_context_data($context, $user); + $exportdata = [ + 'name' => format_string($activity->name, true, ['context' => $context]), + 'intro' => format_text($activity->intro ?? '', $activity->introformat ?? FORMAT_HTML, + ['context' => $context]), + 'is_creator' => ((int)$activity->userid === $userid), + 'timecreated' => empty($activity->timecreated) ? null : transform::datetime($activity->timecreated), + 'timemodified' => empty($activity->timemodified) ? null : transform::datetime($activity->timemodified), + 'adobeconnect_note' => get_string('privacy:export:adobeconnect', 'meetletacbfree'), + ]; + + $userrules = []; + $rules = json_decode((string)($activity->participants ?? ''), true); + if (is_array($rules)) { + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + if (($rule['selectiontype'] ?? '') === 'user' && (int)($rule['selectionid'] ?? 0) === $userid) { + $userrules[] = [ + 'role' => (string)($rule['role'] ?? ''), + ]; + } + } + } + if ($userrules) { + $exportdata['participant_mappings'] = $userrules; + } + + writer::with_context($context)->export_data([], (object)array_merge((array)$contextdata, $exportdata)); + helper::export_context_files($context, $user); + } + } + + /** + * Delete all user data in a module context (anonymise creator + scrub user rules). + * + * @param \context $context + */ + public static function delete_data_for_all_users_in_context(\context $context): void { + global $DB; + + if (!$context instanceof \context_module) { + return; + } + $cm = get_coursemodule_from_id('meetletacbfree', $context->instanceid); + if (!$cm) { + return; + } + $activity = $DB->get_record('meetletacbfree', ['id' => $cm->instance]); + if (!$activity) { + return; + } + $activity->userid = 0; + $activity->participants = self::strip_user_rules((string)($activity->participants ?? ''), null); + $activity->timemodified = time(); + $DB->update_record('meetletacbfree', $activity); + } + + /** + * Delete one user's data in the approved contexts. + * + * @param approved_contextlist $contextlist + */ + public static function delete_data_for_user(approved_contextlist $contextlist): void { + global $DB; + + $userid = (int)$contextlist->get_user()->id; + foreach ($contextlist->get_contexts() as $context) { + if ($context->contextlevel !== CONTEXT_MODULE) { + continue; + } + $cm = get_coursemodule_from_id('meetletacbfree', $context->instanceid); + if (!$cm) { + continue; + } + $activity = $DB->get_record('meetletacbfree', ['id' => $cm->instance]); + if (!$activity) { + continue; + } + if ((int)$activity->userid === $userid) { + $activity->userid = 0; + } + $activity->participants = self::strip_user_rules((string)($activity->participants ?? ''), $userid); + $activity->timemodified = time(); + $DB->update_record('meetletacbfree', $activity); + } + } + + /** + * Delete data for users in a context (bulk). + * + * @param approved_userlist $userlist + */ + public static function delete_data_for_users(approved_userlist $userlist): void { + global $DB; + + $context = $userlist->get_context(); + if (!$context instanceof \context_module) { + return; + } + $userids = $userlist->get_userids(); + if (!$userids) { + return; + } + $cm = get_coursemodule_from_id('meetletacbfree', $context->instanceid); + if (!$cm) { + return; + } + $activity = $DB->get_record('meetletacbfree', ['id' => $cm->instance]); + if (!$activity) { + return; + } + if (in_array((int)$activity->userid, $userids, true)) { + $activity->userid = 0; + } + foreach ($userids as $userid) { + $activity->participants = self::strip_user_rules((string)($activity->participants ?? ''), (int)$userid); + } + $activity->timemodified = time(); + $DB->update_record('meetletacbfree', $activity); + } + + /** + * Remove user selection rules from participants JSON. + * + * @param string $json + * @param int|null $userid null = remove every user-type rule + * @return string + */ + protected static function strip_user_rules(string $json, ?int $userid): string { + if ($json === '') { + return $json; + } + $rules = json_decode($json, true); + if (!is_array($rules)) { + return $json; + } + $out = []; + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + if (($rule['selectiontype'] ?? '') === 'user') { + if ($userid === null || (int)($rule['selectionid'] ?? 0) === $userid) { + continue; + } + } + $out[] = $rule; + } + return json_encode($out, JSON_UNESCAPED_UNICODE); + } +} diff --git a/classes/registration/api_client.php b/classes/registration/api_client.php new file mode 100644 index 0000000..d0b122b --- /dev/null +++ b/classes/registration/api_client.php @@ -0,0 +1,113 @@ +baseurl = rtrim(trim($url), '/'); + } + + public function activate(array $body): array { + return $this->request('/api/v1/activations', $body); + } + + public function validate(array $body): array { + return $this->request('/api/v1/validations', $body); + } + + public function refresh(array $body): array { + return $this->request('/api/v1/authorizations/refresh', $body); + } + + public function deactivate(string $installationid, array $body): array { + $path = '/api/v1/activations/' . rawurlencode($installationid) . '/deactivate'; + return $this->request($path, $body); + } + + /** + * @return array data from API envelope + */ + protected function request(string $path, array $body): array { + global $CFG; + require_once($CFG->libdir . '/filelib.php'); + + $url = $this->baseurl . $path; + $json = json_encode($body, JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new registration_exception(error_mapper::LOCAL_NETWORK_ERROR, 'Could not encode request JSON'); + } + + $curl = new \curl(['ignoresecurity' => true]); + $raw = $curl->post($url, $json, [ + 'CURLOPT_HTTPHEADER' => [ + 'Content-Type: application/json', + 'Accept: application/json', + ], + 'CURLOPT_TIMEOUT' => 30, + ]); + + $info = $curl->get_info(); + $httpcode = (int)($info['http_code'] ?? 0); + + if ($curl->get_errno()) { + throw new registration_exception( + error_mapper::LOCAL_NETWORK_ERROR, + $curl->error . ' (url=' . $url . ')' + ); + } + + $raw = is_string($raw) ? trim($raw) : ''; + if ($raw === '') { + throw new registration_exception( + error_mapper::LOCAL_NETWORK_ERROR, + 'Empty response HTTP ' . $httpcode . ' from ' . $url + ); + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + $snippet = \core_text::substr(preg_replace('/\s+/', ' ', $raw), 0, 180); + throw new registration_exception( + error_mapper::LOCAL_NETWORK_ERROR, + 'Invalid JSON (HTTP ' . $httpcode . '): ' . $snippet + ); + } + + $requestid = isset($decoded['request_id']) ? (string)$decoded['request_id'] : null; + + if (empty($decoded['success'])) { + $err = is_array($decoded['error'] ?? null) ? $decoded['error'] : []; + $code = (string)($err['code'] ?? 'UNKNOWN'); + $message = (string)($err['message'] ?? 'Registration request failed'); + $details = isset($err['details']) && is_array($err['details']) ? $err['details'] : null; + throw new registration_exception($code, $message, $details, $requestid); + } + + $data = $decoded['data'] ?? []; + if (!is_array($data)) { + $data = []; + } + if ($requestid !== null && $requestid !== '') { + $data['_request_id'] = $requestid; + } + return $data; + } +} diff --git a/classes/registration/crypto.php b/classes/registration/crypto.php new file mode 100644 index 0000000..0716a9a --- /dev/null +++ b/classes/registration/crypto.php @@ -0,0 +1,175 @@ + $v) { + $out->{$k} = self::sort_value($v); + } + return $out; + } + if (is_array($value)) { + if (self::is_list($value)) { + return array_map([self::class, 'sort_value'], $value); + } + ksort($value); + $out = []; + foreach ($value as $k => $v) { + $out[$k] = self::sort_value($v); + } + return $out; + } + return $value; + } + + /** + * @param array $arr + */ + protected static function is_list(array $arr): bool { + if ($arr === []) { + return true; + } + return array_keys($arr) === range(0, count($arr) - 1); + } + + public static function base64url_encode(string $bin): string { + return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); + } + + public static function base64url_decode(string $b64): string { + $pad = 4 - (strlen($b64) % 4); + if ($pad < 4) { + $b64 .= str_repeat('=', $pad); + } + $raw = base64_decode(strtr($b64, '-_', '+/'), true); + if ($raw === false) { + throw new \moodle_exception('registration_crypto_error', 'meetletacbfree'); + } + return $raw; + } + + /** + * @return array{private_pem:string,public_pem:string,secret:string,public:string} + */ + public static function generate_installation_keypair(): array { + if (!function_exists('sodium_crypto_sign_keypair')) { + throw new \moodle_exception('registration_sodium_required', 'meetletacbfree'); + } + $kp = sodium_crypto_sign_keypair(); + $secret = sodium_crypto_sign_secretkey($kp); + $public = sodium_crypto_sign_publickey($kp); + $seed = substr($secret, 0, 32); + return [ + 'private_pem' => self::seed_to_pkcs8_pem($seed), + 'public_pem' => self::raw_public_to_spki_pem($public), + 'secret' => $secret, + 'public' => $public, + ]; + } + + public static function seed_to_pkcs8_pem(string $seed32): string { + $der = hex2bin('302e020100300506032b657004220420') . $seed32; + return "-----BEGIN PRIVATE KEY-----\n" . + chunk_split(base64_encode($der), 64, "\n") . + "-----END PRIVATE KEY-----\n"; + } + + public static function raw_public_to_spki_pem(string $public32): string { + $der = hex2bin('302a300506032b6570032100') . $public32; + return "-----BEGIN PUBLIC KEY-----\n" . + chunk_split(base64_encode($der), 64, "\n") . + "-----END PUBLIC KEY-----\n"; + } + + public static function spki_pem_to_raw(string $pem): string { + $pem = trim($pem); + $b64 = preg_replace('/-----[^-]+-----/', '', $pem); + $b64 = preg_replace('/\s+/', '', $b64); + $der = base64_decode($b64, true); + if ($der === false || strlen($der) < 32) { + throw new \moodle_exception('registration_crypto_error', 'meetletacbfree'); + } + return substr($der, -32); + } + + public static function pkcs8_pem_to_secret(string $pem): string { + $pem = trim($pem); + $b64 = preg_replace('/-----[^-]+-----/', '', $pem); + $b64 = preg_replace('/\s+/', '', $b64); + $der = base64_decode($b64, true); + if ($der === false || strlen($der) < 48) { + throw new \moodle_exception('registration_crypto_error', 'meetletacbfree'); + } + $seed = substr($der, -32); + $kp = sodium_crypto_sign_seed_keypair($seed); + return sodium_crypto_sign_secretkey($kp); + } + + public static function sign(string $privatepem, $payload): string { + $secret = self::pkcs8_pem_to_secret($privatepem); + $msg = self::canonicalize($payload); + $sig = sodium_crypto_sign_detached($msg, $secret); + return self::base64url_encode($sig); + } + + public static function verify(string $publicpem, $payload, string $signatureb64url): bool { + try { + $public = self::spki_pem_to_raw($publicpem); + $msg = self::canonicalize($payload); + $sig = self::base64url_decode($signatureb64url); + return sodium_crypto_sign_verify_detached($sig, $msg, $public); + } catch (\Throwable $e) { + return false; + } + } + + public static function random_nonce(): string { + return self::base64url_encode(random_bytes(16)); + } + + public static function random_installation_id(): string { + return 'mdl-' . bin2hex(random_bytes(16)); + } +} diff --git a/classes/registration/error_mapper.php b/classes/registration/error_mapper.php new file mode 100644 index 0000000..0bf0d86 --- /dev/null +++ b/classes/registration/error_mapper.php @@ -0,0 +1,135 @@ +get_error_code(), $e->get_details(), $hasgraceauth); + $mapped['request_id'] = $e->get_request_id(); + $mapped['message'] = self::user_message($mapped['message_key'], $e->get_api_message()); + return $mapped; + } + + public static function user_message(string $messagekey, string $fallback = ''): string { + if ($messagekey !== '' && get_string_manager()->string_exists($messagekey, 'meetletacbfree')) { + return get_string($messagekey, 'meetletacbfree'); + } + return $fallback !== '' ? $fallback : $messagekey; + } + + /** + * @param array|null $details + * @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array} + */ + protected static function result( + string $code, + string $status, + bool $clearauth, + string $messagekey, + ?array $details + ): array { + return [ + 'code' => $code, + 'status' => $status, + 'clear_auth' => $clearauth, + 'message_key' => $messagekey, + 'details' => $details, + ]; + } +} diff --git a/classes/registration/manager.php b/classes/registration/manager.php new file mode 100644 index 0000000..fbc193e --- /dev/null +++ b/classes/registration/manager.php @@ -0,0 +1,616 @@ +status ?? '') !== error_mapper::STATUS_ACTIVE) { + return false; + } + return self::get_verified_authorization() !== null; + } + + public static function is_usable(): bool { + return self::is_registered(); + } + + /** + * Moodle tabbed admin settings URL for a specific tab. + */ + public static function settings_tab_url(string $tab): \moodle_url { + return new \moodle_url('/admin/settings.php', [ + 'section' => 'modsettingmeetletacbfree', + ], $tab); + } + + public static function settings_url(): \moodle_url { + return self::settings_tab_url('meetletacbfree_registration'); + } + + public static function privacy_tab_url(): \moodle_url { + return self::settings_tab_url('meetletacbfree_privacy'); + } + + /** + * Throw registration_required with continue link to Online activation tab. + */ + public static function require_registered(): void { + if (self::is_registered()) { + return; + } + try { + if (store::get_authorization() !== null) { + self::recheck(true); + } + } catch (\Throwable $e) { + // Fall through. + } + if (self::is_registered()) { + return; + } + throw new \moodle_exception('registration_required', 'meetletacbfree', self::settings_url()); + } + + /** @deprecated Use require_registered() */ + public static function require_active(): void { + self::require_registered(); + } + + public static function admin_gate_notice_html(): string { + if (self::is_registered()) { + return ''; + } + $url = self::settings_url(); + return \html_writer::div( + \html_writer::tag('strong', get_string('registration_gate_title', 'meetletacbfree')) . + ' ' . get_string('registration_gate_body', 'meetletacbfree') . ' ' . + \html_writer::link($url, get_string('registration_gate_link', 'meetletacbfree')), + 'alert alert-warning meetlet-registration-gate mb-3' + ); + } + + public static function admin_hero_banner_html(): string { + return ''; + } + + public static function activate_free(): array { + return self::activate(); + } + + public static function activate(): array { + if (self::is_registered()) { + throw new \moodle_exception('registration_already_active', 'meetletacbfree'); + } + $row = store::get_record(); + $stale = $row && in_array($row->status ?? '', [ + error_mapper::STATUS_REVOKED, + error_mapper::STATUS_INVALID_KEY, + error_mapper::STATUS_EXPIRED, + error_mapper::STATUS_OFFLINE_GRACE_EXPIRED, + error_mapper::STATUS_SUSPENDED, + error_mapper::STATUS_DOMAIN_MISMATCH, + error_mapper::STATUS_ACTIVATION_LIMIT, + ], true); + if ($stale) { + store::rotate_identity(); + } + return self::activate_with_current_identity(true); + } + + public static function validate(bool $force = true): array { + return self::validate_or_refresh($force); + } + + public static function recheck(bool $force = true): array { + return self::validate_or_refresh($force); + } + + public static function deactivate(): void { + $identity = store::ensure_identity(); + $signed = self::signed_request($identity, ['action' => 'deactivate']); + try { + (new api_client())->deactivate($identity->installation_id, $signed); + } catch (\Throwable $e) { + // Still clear local state. + } + store::update([ + 'auth_json' => null, + 'license_uuid' => null, + 'status' => 'inactive', + 'last_error' => null, + ]); + store::rotate_identity(); + } + + /** + * @return array + */ + public static function status_summary(): array { + self::enforce_local_expiry(); + $row = store::get_record(); + $storedstatus = (string)($row->status ?? 'inactive'); + $hardblock = in_array($storedstatus, [ + error_mapper::STATUS_SUSPENDED, + error_mapper::STATUS_REVOKED, + error_mapper::STATUS_EXPIRED, + error_mapper::STATUS_INVALID_KEY, + error_mapper::STATUS_DOMAIN_MISMATCH, + error_mapper::STATUS_ACTIVATION_LIMIT, + ], true); + $lasterror = store::get_last_error(); + $auth = $hardblock ? null : self::get_verified_authorization(); + $payload = is_array($auth['payload'] ?? null) ? $auth['payload'] : null; + $status = $storedstatus; + if ($payload !== null && $auth !== null) { + if ($status !== error_mapper::STATUS_ACTIVE) { + store::update([ + 'status' => error_mapper::STATUS_ACTIVE, + 'last_error' => null, + ]); + } + $status = error_mapper::STATUS_ACTIVE; + } else if ($auth === null && $status === error_mapper::STATUS_ACTIVE) { + $status = error_mapper::STATUS_INACTIVE; + } + $active = $auth !== null && !$hardblock; + + return [ + 'active' => $active, + 'registered' => $active, + 'status' => $status, + 'status_label' => self::status_label($active ? error_mapper::STATUS_ACTIVE : $status), + 'product' => $active ? self::PRODUCT_SLUG : '', + 'product_label' => $active ? get_string('registration_product_free', 'meetletacbfree') : '', + 'can_deactivate' => self::can_deactivate($row), + 'auth_expires_at' => self::parse_unix_timestamp($payload['expires_at'] ?? null), + 'features' => is_array($payload['features'] ?? null) ? $payload['features'] : [], + 'installation_id' => $row->installation_id ?? '', + 'domain' => self::site_domain(), + 'api_url' => self::api_base_url(), + 'last_validated' => !empty($row->last_validated) ? (int)$row->last_validated : null, + 'error' => $active ? null : self::status_error_message($status, $lasterror), + 'last_error' => $lasterror, + 'license_uuid' => $row->license_uuid ?? null, + 'site_company' => self::site_company(), + 'site_admin_email' => self::site_admin_email(), + 'moodle_release' => self::moodle_release(), + 'plugin_release' => self::plugin_release(), + ]; + } + + public static function heartbeat(): void { + $row = store::get_record(); + if (!$row || empty($row->auth_json)) { + mtrace('MeetLet registration: not registered — skip heartbeat.'); + return; + } + if (self::enforce_local_expiry()) { + mtrace('MeetLet registration: authorization expired — cleared auth.'); + return; + } + try { + self::validate_or_refresh(true); + mtrace('MeetLet registration: heartbeat OK.'); + } catch (\Throwable $e) { + mtrace('MeetLet registration heartbeat failed: ' . $e->getMessage()); + } + } + + public static function site_domain(): string { + global $CFG; + $host = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST); + return self::normalize_domain(is_string($host) ? $host : 'localhost'); + } + + public static function normalize_domain(string $input): string { + $host = trim(strtolower($input)); + if ($host === '') { + return 'localhost'; + } + if (preg_match('~(://|/|\?|#)~', $host)) { + $parsed = parse_url((strpos($host, '://') === false ? 'https://' : '') . $host, PHP_URL_HOST); + $host = is_string($parsed) ? strtolower($parsed) : $host; + } + $host = rtrim($host, '.'); + if (strpos($host, ':') !== false) { + $host = explode(':', $host, 2)[0]; + } + if (str_starts_with($host, 'www.')) { + $host = substr($host, 4); + } + if ($host !== '' && function_exists('idn_to_ascii') && preg_match('/[^\x20-\x7f]/', $host)) { + $ascii = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); + if (is_string($ascii) && $ascii !== '') { + $host = strtolower($ascii); + } + } + return $host !== '' ? $host : 'localhost'; + } + + public static function status_label(string $status): string { + $key = 'registration_status_' . $status; + if (get_string_manager()->string_exists($key, 'meetletacbfree')) { + return get_string($key, 'meetletacbfree'); + } + return $status; + } + + public static function parse_unix_timestamp($value): ?int { + if ($value === null || $value === '') { + return null; + } + if (is_int($value) || is_float($value)) { + $n = (int) $value; + } else if (is_string($value) && is_numeric($value)) { + $n = (int) $value; + } else if (is_string($value)) { + $parsed = strtotime($value); + return ($parsed !== false && $parsed > 0) ? $parsed : null; + } else { + return null; + } + if ($n > 9999999999) { + $n = (int) floor($n / 1000); + } + return $n > 0 ? $n : null; + } + + public static function enforce_local_expiry(): bool { + $auth = store::get_authorization(); + if ($auth === null) { + return false; + } + if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) { + return false; + } + $expires = self::parse_unix_timestamp($auth['payload']['expires_at'] ?? null); + if ($expires !== null && time() >= $expires) { + self::invalidate_local([ + 'code' => 'LICENSE_EXPIRED', + 'message' => get_string('registration_err_expired', 'meetletacbfree'), + 'at' => time(), + ], error_mapper::STATUS_EXPIRED); + return true; + } + return false; + } + + /** + * @return array{payload:array,signature:string}|null + */ + public static function get_verified_authorization(): ?array { + $row = store::get_record(); + if ($row && in_array($row->status ?? '', [ + error_mapper::STATUS_SUSPENDED, + error_mapper::STATUS_REVOKED, + error_mapper::STATUS_EXPIRED, + error_mapper::STATUS_INVALID_KEY, + error_mapper::STATUS_DOMAIN_MISMATCH, + error_mapper::STATUS_ACTIVATION_LIMIT, + ], true)) { + return null; + } + $auth = store::get_authorization(); + if ($auth === null) { + return null; + } + if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) { + return null; + } + $expires = self::parse_unix_timestamp($auth['payload']['expires_at'] ?? null); + if ($expires !== null && time() >= $expires) { + return null; + } + return $auth; + } + + /** + * @param \stdClass|null $row + */ + public static function can_deactivate(?\stdClass $row = null): bool { + $row = $row ?? store::get_record(); + if (!$row) { + return false; + } + if (!empty($row->auth_json)) { + return true; + } + return in_array($row->status ?? '', [ + error_mapper::STATUS_ACTIVE, + error_mapper::STATUS_GRACE, + error_mapper::STATUS_EXPIRED, + error_mapper::STATUS_OFFLINE_GRACE_EXPIRED, + error_mapper::STATUS_SUSPENDED, + error_mapper::STATUS_REVOKED, + error_mapper::STATUS_INVALID_KEY, + error_mapper::STATUS_DOMAIN_MISMATCH, + error_mapper::STATUS_ACTIVATION_LIMIT, + ], true); + } + + protected static function validate_or_refresh(bool $force = false): array { + self::enforce_local_expiry(); + $identity = store::ensure_identity(); + $auth = store::get_authorization(); + $now = time(); + if (!$force && $auth && !empty($auth['payload']['expires_at'])) { + $expires = self::parse_unix_timestamp($auth['payload']['expires_at']); + if ($expires !== null && $now < ($expires - 3600) && self::get_verified_authorization() !== null) { + return $auth; + } + } + + $api = new api_client(); + if (!$force && $auth) { + try { + $signedrefresh = self::signed_request($identity, new \stdClass()); + $data = $api->refresh($signedrefresh); + return self::persist_authorization($data, $identity); + } catch (\Throwable $e) { + $mapped = self::apply_mapped_error($e, false); + if (!empty($mapped['clear_auth'])) { + throw registration_exception::from_throwable($e); + } + } + } + + $body = [ + 'product' => self::PRODUCT_SLUG, + 'domain' => self::site_domain(), + 'reported_host' => self::site_domain(), + 'current_version' => self::plugin_release(), + ]; + $signed = self::signed_request($identity, $body); + try { + $data = $api->validate($signed); + } catch (\Throwable $e) { + self::apply_mapped_error($e); + if (self::get_verified_authorization() !== null) { + store::update(['last_validated' => $now]); + $cached = self::get_verified_authorization(); + if ($cached !== null) { + return $cached; + } + } + throw registration_exception::from_throwable($e); + } + return self::persist_authorization($data, $identity); + } + + protected static function activate_with_current_identity(bool $allowrotate): array { + $identity = store::ensure_identity(); + $body = [ + 'product' => self::PRODUCT_SLUG, + 'installation_id' => $identity->installation_id, + 'installation_public_key' => $identity->public_key_pem, + 'domain' => self::site_domain(), + 'environment' => self::environment(), + 'platform' => 'moodle', + 'platform_version' => self::moodle_release(), + 'runtime' => 'php', + 'runtime_version' => PHP_VERSION, + 'metadata' => [ + 'plugin' => 'mod_meetletacbfree', + 'plugin_release' => self::plugin_release(), + ], + ]; + $company = self::site_company(); + $email = self::site_admin_email(); + if ($company !== '') { + $body['metadata']['company'] = $company; + } + if ($email !== '') { + $body['metadata']['email'] = $email; + $body['metadata']['admin_email'] = $email; + } + try { + $data = (new api_client())->activate($body); + } catch (\Throwable $e) { + $ex = registration_exception::from_throwable($e); + $code = $ex->get_error_code(); + if ($allowrotate && in_array($code, ['INVALID_LICENSE', 'INSTALLATION_REVOKED'], true)) { + store::rotate_identity(); + return self::activate_with_current_identity(false); + } + self::apply_mapped_error($ex); + throw $ex; + } + return self::persist_authorization($data, store::ensure_identity()); + } + + protected static function persist_authorization(array $data, \stdClass $identity): array { + $auth = $data['authorization'] ?? null; + if (!is_array($auth) || empty($auth['payload']) || empty($auth['signature'])) { + $ex = new registration_exception('INVALID_REQUEST', 'Missing authorization in API response'); + self::apply_mapped_error($ex); + throw $ex; + } + if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) { + $ex = new registration_exception( + error_mapper::LOCAL_INVALID_AUTHORIZATION_SIGNATURE, + 'Authorization signature verification failed' + ); + self::apply_mapped_error($ex); + throw $ex; + } + $licenseuuid = $data['license_id'] ?? ($auth['payload']['license_id'] ?? null); + store::update([ + 'auth_json' => json_encode($auth, JSON_UNESCAPED_SLASHES), + 'license_uuid' => $licenseuuid, + 'status' => error_mapper::STATUS_ACTIVE, + 'last_error' => null, + 'last_validated' => time(), + ]); + return $auth; + } + + /** + * @param array|string|null $error + */ + public static function invalidate_local($error, string $status = 'inactive'): void { + $fields = [ + 'auth_json' => null, + 'status' => $status, + 'last_validated' => time(), + ]; + if (is_array($error)) { + $fields['last_error'] = json_encode([ + 'code' => (string)($error['code'] ?? 'UNKNOWN'), + 'message' => (string)($error['message'] ?? ''), + 'details' => $error['details'] ?? null, + 'request_id' => $error['request_id'] ?? null, + 'at' => (int)($error['at'] ?? time()), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $fields['last_error'] = $error; + } + store::update($fields); + } + + protected static function apply_mapped_error(\Throwable $e, bool $persist = true): array { + $hasgrace = self::get_verified_authorization() !== null; + $ex = registration_exception::from_throwable($e); + $mapped = error_mapper::from_exception($ex, $hasgrace); + if (!$persist) { + return $mapped; + } + $lasterror = $ex->to_last_error(); + $lasterror['message'] = $mapped['message'] ?? $lasterror['message']; + if (!empty($mapped['request_id'])) { + $lasterror['request_id'] = $mapped['request_id']; + } + if (!empty($mapped['clear_auth'])) { + self::invalidate_local($lasterror, $mapped['status']); + return $mapped; + } + store::update([ + 'status' => $mapped['status'], + 'last_error' => json_encode($lasterror, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'last_validated' => time(), + ]); + return $mapped; + } + + protected static function signed_request(\stdClass $identity, $body): array { + $timestamp = time(); + $nonce = crypto::random_nonce(); + $bodyforcanon = (is_array($body) && $body === []) ? new \stdClass() : $body; + $parts = [ + 'installation_id' => $identity->installation_id, + 'timestamp' => $timestamp, + 'nonce' => $nonce, + 'body' => $bodyforcanon, + ]; + $signature = crypto::sign($identity->private_key_pem, $parts); + $out = [ + 'installation_id' => $identity->installation_id, + 'timestamp' => $timestamp, + 'nonce' => $nonce, + 'signature' => $signature, + ]; + if (is_array($body)) { + foreach ($body as $k => $v) { + $out[$k] = $v; + } + } + return $out; + } + + /** + * @param array|null $lasterror + */ + protected static function status_error_message(string $status, ?array $lasterror): ?string { + if ($status === error_mapper::STATUS_EXPIRED) { + return get_string('registration_err_expired', 'meetletacbfree'); + } + return $lasterror['message'] ?? null; + } + + protected static function site_company(): string { + global $SITE; + $name = trim(strip_tags((string)($SITE->fullname ?? ''))); + if ($name === '') { + $name = trim(strip_tags((string)($SITE->shortname ?? ''))); + } + return $name !== '' ? $name : self::site_domain(); + } + + protected static function site_admin_email(): string { + global $CFG, $USER; + $candidates = [trim((string)($CFG->supportemail ?? ''))]; + $admin = get_admin(); + if ($admin && !empty($admin->email)) { + $candidates[] = trim((string)$admin->email); + } + if (!empty($USER->email)) { + $candidates[] = trim((string)$USER->email); + } + foreach ($candidates as $email) { + if ($email !== '' && validate_email($email)) { + return $email; + } + } + return ''; + } + + protected static function environment(): string { + global $CFG; + if (!empty($CFG->debugdeveloper)) { + return 'development'; + } + return 'production'; + } + + protected static function moodle_release(): string { + global $CFG; + return isset($CFG->release) ? (string)$CFG->release : ''; + } + + public static function plugin_release(): string { + global $CFG; + $plugin = new \stdClass(); + $versionfile = $CFG->dirroot . '/mod/meetletacbfree/version.php'; + if (is_readable($versionfile)) { + include($versionfile); + } + if (!empty($plugin->release)) { + return (string)$plugin->release; + } + $ver = get_config('meetletacbfree', 'version'); + return $ver !== false ? (string)$ver : '0'; + } +} diff --git a/classes/registration/registration_exception.php b/classes/registration/registration_exception.php new file mode 100644 index 0000000..39d19bc --- /dev/null +++ b/classes/registration/registration_exception.php @@ -0,0 +1,88 @@ +apicode = $code; + $this->apimessage = trim($message) !== '' ? trim($message) : $code; + $this->details = is_array($details) ? $details : null; + $this->requestid = $requestid !== null && $requestid !== '' ? $requestid : null; + parent::__construct( + 'registration_api_error', + 'meetletacbfree', + '', + $this->apicode . ': ' . $this->apimessage + ); + } + + public function get_error_code(): string { + return $this->apicode; + } + + public function get_api_message(): string { + return $this->apimessage; + } + + public function get_details(): ?array { + return $this->details; + } + + public function get_request_id(): ?string { + return $this->requestid; + } + + /** + * @return array{code:string,message:string,details:?array,request_id:?string,at:int} + */ + public function to_last_error(): array { + return [ + 'code' => $this->apicode, + 'message' => $this->apimessage, + 'details' => $this->details, + 'request_id' => $this->requestid, + 'at' => time(), + ]; + } + + public static function from_throwable(\Throwable $e): self { + if ($e instanceof self) { + return $e; + } + $msg = $e->getMessage(); + if (preg_match('/^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$/s', $msg, $m)) { + return new self($m[1], $m[2]); + } + return new self('UNKNOWN', $msg); + } +} diff --git a/classes/registration/store.php b/classes/registration/store.php new file mode 100644 index 0000000..4381836 --- /dev/null +++ b/classes/registration/store.php @@ -0,0 +1,139 @@ +get_manager()->table_exists(self::TABLE)) { + return null; + } + $rows = $DB->get_records(self::TABLE, null, 'id ASC', '*', 0, 1); + return $rows ? reset($rows) : null; + } + + /** + * @return \stdClass + */ + public static function ensure_identity(): \stdClass { + global $DB; + $row = self::get_record(); + if ($row && !empty($row->installation_id) && !empty($row->private_key_pem)) { + return $row; + } + $kp = crypto::generate_installation_keypair(); + $now = time(); + if ($row) { + $row->installation_id = $row->installation_id ?: crypto::random_installation_id(); + $row->private_key_pem = $kp['private_pem']; + $row->public_key_pem = $kp['public_pem']; + $row->timemodified = $now; + $DB->update_record(self::TABLE, $row); + return $row; + } + $rec = (object)[ + 'installation_id' => crypto::random_installation_id(), + 'private_key_pem' => $kp['private_pem'], + 'public_key_pem' => $kp['public_pem'], + 'auth_json' => null, + 'license_uuid' => null, + 'status' => 'inactive', + 'last_error' => null, + 'last_validated' => 0, + 'timecreated' => $now, + 'timemodified' => $now, + ]; + $rec->id = $DB->insert_record(self::TABLE, $rec); + return $rec; + } + + /** + * @param array $fields + */ + public static function update(array $fields): \stdClass { + global $DB; + $row = self::ensure_identity(); + foreach ($fields as $k => $v) { + $row->{$k} = $v; + } + $row->timemodified = time(); + $DB->update_record(self::TABLE, $row); + return $row; + } + + /** + * @return array{payload:array,signature:string}|null + */ + public static function get_authorization(): ?array { + $row = self::get_record(); + if (!$row || empty($row->auth_json)) { + return null; + } + $decoded = json_decode($row->auth_json, true); + if (!is_array($decoded) || empty($decoded['payload']) || empty($decoded['signature'])) { + return null; + } + return $decoded; + } + + /** + * @return array{code:string,message:string,details?:?array,request_id?:?string,at:int}|null + */ + public static function get_last_error(): ?array { + $row = self::get_record(); + if (!$row || empty($row->last_error)) { + return null; + } + $raw = (string)$row->last_error; + $decoded = json_decode($raw, true); + if (is_array($decoded) && !empty($decoded['code'])) { + return [ + 'code' => (string)$decoded['code'], + 'message' => (string)($decoded['message'] ?? $decoded['code']), + 'details' => isset($decoded['details']) && is_array($decoded['details']) ? $decoded['details'] : null, + 'request_id' => isset($decoded['request_id']) ? (string)$decoded['request_id'] : null, + 'at' => (int)($decoded['at'] ?? ($row->timemodified ?? 0)), + ]; + } + return [ + 'code' => 'UNKNOWN', + 'message' => $raw, + 'details' => null, + 'request_id' => null, + 'at' => (int)($row->timemodified ?? 0), + ]; + } + + public static function rotate_identity(): \stdClass { + $kp = crypto::generate_installation_keypair(); + return self::update([ + 'installation_id' => crypto::random_installation_id(), + 'private_key_pem' => $kp['private_pem'], + 'public_key_pem' => $kp['public_pem'], + 'auth_json' => null, + 'license_uuid' => null, + 'status' => 'inactive', + 'last_error' => null, + 'last_validated' => 0, + ]); + } +} diff --git a/classes/task/registration_heartbeat.php b/classes/task/registration_heartbeat.php new file mode 100644 index 0000000..24ebd0d --- /dev/null +++ b/classes/task/registration_heartbeat.php @@ -0,0 +1,42 @@ +. + +/** + * Scheduled task: optional MeetLet site registration heartbeat. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\task; + +use mod_meetletacbfree\registration\manager; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Re-validate site registration once per day when the admin has opted in. + */ +class registration_heartbeat extends \core\task\scheduled_task { + public function get_name() { + return get_string('task_registration_heartbeat', 'meetletacbfree'); + } + + public function execute() { + manager::heartbeat(); + } +} diff --git a/classes/task/sync_recordings.php b/classes/task/sync_recordings.php new file mode 100644 index 0000000..f520295 --- /dev/null +++ b/classes/task/sync_recordings.php @@ -0,0 +1,61 @@ +. + +/** + * Scheduled task: sync Adobe Connect recordings into Moodle. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_meetletacbfree\task; + +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\recordings; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Sync recordings for all activity instances. + */ +class sync_recordings extends \core\task\scheduled_task { + public function get_name() { + return get_string('tasksync', 'meetletacbfree'); + } + + public function execute() { + if (!\mod_meetletacbfree\registration\manager::is_registered()) { + mtrace('MeetLet registration: inactive — skip recording sync.'); + return; + } + global $DB; + foreach ($DB->get_records('meetletacbfree') as $activity) { + foreach ($DB->get_records('meetletacbfree_meetings', ['instanceid' => $activity->id]) as $map) { + try { + $inst = instance::get_from_instanceid((int)$activity->id, (int)$map->groupid); + $service = new service(); + $service->login_admin(); + recordings::sync($inst, $service); + $service->logout(); + } catch (\Throwable $e) { + mtrace('MeetLet marketplace sync failed for instance ' . $activity->id . ': ' . $e->getMessage()); + } + } + } + } +} diff --git a/conntest.php b/conntest.php new file mode 100644 index 0000000..a62a78a --- /dev/null +++ b/conntest.php @@ -0,0 +1,132 @@ +set_context(context_system::instance()); +$PAGE->set_url(new moodle_url('/mod/meetletacbfree/conntest.php')); +$PAGE->set_title(get_string('conntesttitle', 'meetletacbfree')); +$PAGE->set_pagelayout('admin'); +$PAGE->requires->css(new moodle_url('/mod/meetletacbfree/styles.css', ['v' => '2026081415'])); + +$results = []; +$push = static function(string $label, bool $ok, string $detail = '') use (&$results): void { + $results[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail]; +}; + +$logo = $OUTPUT->image_url('adobeconnect', 'meetletacbfree'); + +echo $OUTPUT->header(); +echo html_writer::start_div('meetlet-conntest'); +echo html_writer::start_div('meetlet-conntest__hero'); +echo html_writer::img($logo, 'Adobe Connect', ['class' => 'meetlet-conntest__logo']); +echo html_writer::tag('h2', get_string('conntesttitle', 'meetletacbfree'), ['class' => 'meetlet-conntest__title']); +echo html_writer::tag('p', get_string('conntestintro', 'meetletacbfree'), ['class' => 'meetlet-conntest__intro']); +echo html_writer::end_div(); + +if (!config::is_configured()) { + echo html_writer::div(get_string('notconfigured', 'meetletacbfree'), 'meetlet-alert'); +} else { + try { + $service = new service(); + $client = $service->get_client(); + $service->login_admin(); + $push(get_string('testlogin', 'meetletacbfree'), true); + + $content = $service->get_folder_scoid('content'); + $archives = $service->get_folder_scoid('forced-archives'); + $meetings = $service->get_folder_scoid('meetings'); + $push(get_string('testfolders', 'meetletacbfree'), ($content && $archives && $meetings), + "content={$content} · archives={$archives} · meetings={$meetings}"); + + $meeting = (object)[ + 'name' => 'meetlet_test_' . time(), + 'starttime' => time(), + 'endtime' => time() + HOURSECS, + 'meeturl' => '', + 'templatescoid' => 0, + ]; + $scoid = $service->create_meeting($meeting, $meetings); + $user = (object)[ + 'id' => random_int(900000000, 999999999), + 'firstname' => 'MeetLet', + 'lastname' => 'Test', + 'username' => 'meetlettest' . random_int(1000, 9999), + 'email' => 'meetlet.test.' . random_int(1000, 9999) . '@example.com', + ]; + $principal = $service->ensure_user($user); + + $assigned = $service->assign_permission($principal->principalid, $scoid, service::ROLE_PRESENTER); + $push(get_string('testuser', 'meetletacbfree'), $assigned); + + // SSO: prefer admin (known good), then disposable created login/ext-login. + $ssook = false; + $ssodetail = ''; + try { + $sso = new client(); + $cookie = $sso->login_external(config::get_admin_login()); + $ssook = ($cookie !== ''); + $ssodetail = get_string('testsso_adminok', 'meetletacbfree'); + } catch (Throwable $e) { + $ssodetail = $e->getMessage(); + } + if ($ssook) { + try { + $sso2 = new client(); + $cookie2 = $sso2->login_external($principal->login); + $ssodetail .= ' · ' . get_string('testsso_userok', 'meetletacbfree'); + } catch (Throwable $e) { + // Admin SSO is enough for infrastructure health. + $ssodetail .= ' · ' . get_string('testsso_userwarn', 'meetletacbfree'); + } + } + $push(get_string('testsso', 'meetletacbfree'), $ssook, $ssodetail); + + $service->delete_sco($scoid); + $client->request(['action' => 'principals-delete', 'principal-id' => $principal->principalid]); + $push(get_string('testmeeting', 'meetletacbfree'), true); + + $service->logout(); + } catch (Throwable $e) { + $push(get_string('error', 'meetletacbfree'), false, $e->getMessage()); + } + + echo html_writer::start_div('meetlet-conntest__grid'); + foreach ($results as $row) { + $state = $row['ok'] ? 'ok' : 'fail'; + $badge = html_writer::span( + $row['ok'] ? get_string('testok', 'meetletacbfree') : get_string('testfail', 'meetletacbfree'), + 'meetlet-pill meetlet-pill--' . $state + ); + echo html_writer::div( + html_writer::div( + html_writer::span('', 'meetlet-dot meetlet-dot--' . $state) . + html_writer::span(s($row['label']), 'meetlet-conntest__label') . + $badge, + 'meetlet-conntest__rowtop' + ) . + ($row['detail'] !== '' ? html_writer::div(s($row['detail']), 'meetlet-conntest__detail') : ''), + 'meetlet-conntest__card meetlet-conntest__card--' . $state + ); + } + echo html_writer::end_div(); +} + +echo html_writer::div( + html_writer::link( + new moodle_url('/admin/settings.php', ['section' => 'modsettingmeetletacbfree']), + get_string('pluginadministration', 'meetletacbfree'), + ['class' => 'meetlet-admin-testbtn meetlet-admin-testbtn--ghost'] + ), + 'meetlet-conntest__footer' +); +echo html_writer::end_div(); +echo $OUTPUT->footer(); diff --git a/db/access.php b/db/access.php new file mode 100644 index 0000000..49f4a28 --- /dev/null +++ b/db/access.php @@ -0,0 +1,98 @@ +. + +/** + * Capability definitions for MeetLet. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$capabilities = [ + 'mod/meetletacbfree:addinstance' => [ + 'riskbitmask' => RISK_XSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + 'clonepermissionsfrom' => 'moodle/course:manageactivities', + ], + 'mod/meetletacbfree:view' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'guest' => CAP_ALLOW, + 'frontpage' => CAP_ALLOW, + 'student' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + ], + 'mod/meetletacbfree:join' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'student' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + ], + 'mod/meetletacbfree:host' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + ], + 'mod/meetletacbfree:presenter' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + ], + 'mod/meetletacbfree:viewrecordings' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'student' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + ], + 'mod/meetletacbfree:grade' => [ + 'riskbitmask' => RISK_XSS, + 'captype' => 'write', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + 'clonepermissionsfrom' => 'moodle/grade:edit', + ], +]; diff --git a/db/caches.php b/db/caches.php new file mode 100644 index 0000000..45e35a4 --- /dev/null +++ b/db/caches.php @@ -0,0 +1,52 @@ +. + +/** + * Cache definitions for faster Adobe Connect lookups. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$definitions = [ + 'folders' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'ttl' => 300, + ], + 'meetinginfo' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => false, + 'ttl' => 60, + ], + 'templates' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => false, + 'ttl' => 600, + ], + 'license_webhook_nonces' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'ttl' => 900, + ], +]; diff --git a/db/install.php b/db/install.php new file mode 100644 index 0000000..82f9991 --- /dev/null +++ b/db/install.php @@ -0,0 +1,37 @@ +. + +/** + * Post-install: migrate legacy mod_meetletacbmdlrules data when present. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Runs after install.xml on first install of this component. + */ +function xmldb_meetletacbfree_install() { + global $DB; + require_once(__DIR__ . '/upgrade.php'); + $dbman = $DB->get_manager(); + xmldb_meetletacbfree_rename_legacy_main_table($dbman); + xmldb_meetletacbfree_migrate_from_mdlrules($dbman); + xmldb_meetletacbfree_migrate_from_market($dbman); +} diff --git a/db/install.xml b/db/install.xml new file mode 100644 index 0000000..38c8d05 --- /dev/null +++ b/db/install.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/db/services.php b/db/services.php new file mode 100644 index 0000000..1a3a66d --- /dev/null +++ b/db/services.php @@ -0,0 +1,84 @@ +. + +/** + * External services for MeetLet Ajax tabs. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = [ + 'mod_meetletacbfree_get_tab' => [ + 'classname' => 'mod_meetletacbfree\external\get_tab', + 'methodname' => 'execute', + 'description' => 'Load MeetLet activity tab content (info, recordings, content)', + 'type' => 'read', + 'ajax' => true, + 'capabilities' => 'mod/meetletacbfree:view', + ], + 'mod_meetletacbfree_get_meeting_status' => [ + 'classname' => 'mod_meetletacbfree\external\get_meeting_status', + 'methodname' => 'execute', + 'description' => 'Poll MeetLet meeting join status for the activity hero', + 'type' => 'read', + 'ajax' => true, + 'capabilities' => 'mod/meetletacbfree:view', + ], + 'mod_meetletacbfree_get_join_url' => [ + 'classname' => 'mod_meetletacbfree\external\get_join_url', + 'methodname' => 'execute', + 'description' => 'Prepare Adobe Connect SSO join URL for the current user', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => 'mod/meetletacbfree:join', + ], + 'mod_meetletacbfree_get_recording_launch_url' => [ + 'classname' => 'mod_meetletacbfree\external\get_recording_launch_url', + 'methodname' => 'execute', + 'description' => 'Prepare Adobe Connect SSO URL to play or edit a recording', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => 'mod/meetletacbfree:view', + ], + 'mod_meetletacbfree_get_content_launch_url' => [ + 'classname' => 'mod_meetletacbfree\external\get_content_launch_url', + 'methodname' => 'execute', + 'description' => 'Prepare Adobe Connect SSO URL to view uploaded meeting content', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => 'mod/meetletacbfree:view', + ], + 'mod_meetletacbfree_search_server_meetings' => [ + 'classname' => 'mod_meetletacbfree\external\search_server_meetings', + 'methodname' => 'execute', + 'description' => 'Search or list recent Adobe Connect meetings for the activity form', + 'type' => 'read', + 'ajax' => true, + 'capabilities' => 'moodle/course:manageactivities,mod/meetletacbfree:addinstance', + ], + 'mod_meetletacbfree_rename_recording' => [ + 'classname' => 'mod_meetletacbfree\external\rename_recording', + 'methodname' => 'execute', + 'description' => 'Rename an Adobe Connect recording', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => 'mod/meetletacbfree:host', + ], +]; diff --git a/db/tasks.php b/db/tasks.php new file mode 100644 index 0000000..0172b60 --- /dev/null +++ b/db/tasks.php @@ -0,0 +1,46 @@ +. + +/** + * Scheduled tasks for mod_meetletacbfree. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'mod_meetletacbfree\\task\\sync_recordings', + 'blocking' => 0, + 'minute' => '*/15', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], + [ + 'classname' => 'mod_meetletacbfree\\task\\registration_heartbeat', + 'blocking' => 0, + 'minute' => '15', + 'hour' => '3', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], +]; diff --git a/db/upgrade.php b/db/upgrade.php new file mode 100644 index 0000000..8c115c0 --- /dev/null +++ b/db/upgrade.php @@ -0,0 +1,1063 @@ +. + +/** + * Upgrade steps for mod_meetletacbfree. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * xmldb upgrade. + * + * @param int $oldversion + * @return bool + */ +function xmldb_meetletacbfree_upgrade($oldversion) { + global $DB; + + $dbman = $DB->get_manager(); + + // Plugin table is 10 chars; never alter core {modules}/{event} (CHAR(20) is enough). + xmldb_meetletacbfree_rename_legacy_main_table($dbman); + + if ($oldversion < 2026080906) { + foreach (['meetlet_meetings', 'meetlet_recordings'] as $tname) { + if (!$dbman->table_exists($tname)) { + continue; + } + $table = new xmldb_table($tname); + $key = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetlet', ['id']); + if ($dbman->find_key_name($table, $key)) { + $dbman->drop_key($table, $key); + } + } + + $map = [ + 'meetlet' => 'meetletacbmarket', + 'meetlet_meetings' => 'meetlet_marketplace_meetings', + 'meetlet_recordings' => 'meetlet_marketplace_recordings', + ]; + foreach ($map as $from => $to) { + $table = new xmldb_table($from); + if ($dbman->table_exists($from) && !$dbman->table_exists($to)) { + $dbman->rename_table($table, $to); + } + } + + foreach (['meetlet_marketplace_meetings', 'meetlet_marketplace_recordings'] as $tname) { + if ($dbman->table_exists($tname) && $dbman->table_exists('meetletacbmarket')) { + $table = new xmldb_table($tname); + $key = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], + 'meetletacbmarket', ['id']); + if (!$dbman->find_key_name($table, $key)) { + $dbman->add_key($table, $key); + } + } + } + + foreach (['meetlet', 'meetletacbmarket'] as $oldmodname) { + if ($mod = $DB->get_record('modules', ['name' => $oldmodname])) { + $mod->name = 'meetletacbmarket'; + $DB->update_record('modules', $mod); + } + $DB->set_field('event', 'modulename', 'meetletacbmarket', ['modulename' => $oldmodname]); + if ($dbman->table_exists('grade_items')) { + $DB->set_field('grade_items', 'itemmodule', 'meetletacbmarket', ['itemmodule' => $oldmodname]); + } + } + + upgrade_mod_savepoint(true, 2026080906, 'meetletacbmarket'); + } + + if ($oldversion < 2026080908) { + $table = new xmldb_table('meetletacbmarket'); + $fields = [ + new xmldb_field('showzip', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'autooffline'), + new xmldb_field('showofflineplayer', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'showzip'), + new xmldb_field('showrecordingstab', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'showofflineplayer'), + new xmldb_field('showcontenttab', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'showrecordingstab'), + ]; + foreach ($fields as $field) { + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + upgrade_mod_savepoint(true, 2026080908, 'meetletacbmarket'); + } + if ($oldversion < 2026080909) { + $table = new xmldb_table('meetlet_adobe_connect_bridge'); + if ($dbman->table_exists('meetlet_adobe_connect_bridge') && !$dbman->table_exists('meetletacbmarket')) { + $dbman->rename_table($table, 'meetletacbmarket'); + } + // Re-point FKs on related tables if needed - drop old FK and add new + foreach (['meetlet_marketplace_meetings', 'meetlet_marketplace_recordings'] as $tname) { + if ($dbman->table_exists($tname) && $dbman->table_exists('meetletacbmarket')) { + $t = new xmldb_table($tname); + $oldkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetlet_adobe_connect_bridge', ['id']); + if ($dbman->find_key_name($t, $oldkey)) { + $dbman->drop_key($t, $oldkey); + } + $newkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbmarket', ['id']); + if (!$dbman->find_key_name($t, $newkey)) { + $dbman->add_key($t, $newkey); + } + } + } + upgrade_mod_savepoint(true, 2026080909, 'meetletacbmarket'); + } + + if ($oldversion < 2026080910) { + $table = new xmldb_table('meetletacbmarket'); + $field = new xmldb_field('showmp4', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'showcontenttab'); + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Migrate legacy allow_manual_offline into enable_mp4 when unset. + $enablemp4 = get_config('meetletacbmarket', 'enable_mp4'); + if ($enablemp4 === false) { + $legacy = get_config('meetletacbmarket', 'allow_manual_offline'); + set_config('enable_mp4', ($legacy === false || (bool)$legacy) ? 1 : 0, 'meetletacbmarket'); + } + if (get_config('meetletacbmarket', 'allow_override_mp4') === false) { + set_config('allow_override_mp4', 1, 'meetletacbmarket'); + } + + upgrade_mod_savepoint(true, 2026080910, 'meetletacbmarket'); + } + + if ($oldversion < 2026080911) { + // ZIP download is opt-in from this release. + set_config('enable_zip', 0, 'meetletacbmarket'); + upgrade_mod_savepoint(true, 2026080911, 'meetletacbmarket'); + } + + if ($oldversion < 2026080912) { + // Remove Adobe offline player (pbMode) from product settings. + set_config('enable_offlineplayer', 0, 'meetletacbmarket'); + set_config('allow_override_offlineplayer', 0, 'meetletacbmarket'); + upgrade_mod_savepoint(true, 2026080912, 'meetletacbmarket'); + } + + if ($oldversion < 2026080913) { + $table = new xmldb_table('meetletacbmarket'); + $fields = [ + new xmldb_field('linkmode', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'showmp4'), + new xmldb_field('existingscoid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'linkmode'), + new xmldb_field('meetinglang', XMLDB_TYPE_CHAR, '20', null, null, null, null, 'existingscoid'), + new xmldb_field('waitforhost', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'meetinglang'), + ]; + foreach ($fields as $field) { + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + upgrade_mod_savepoint(true, 2026080913, 'meetletacbmarket'); + } + + if ($oldversion < 2026080914) { + $table = new xmldb_table('meetletacbmarket'); + $field = new xmldb_field('featureaccess', XMLDB_TYPE_TEXT, null, null, null, null, null, 'waitforhost'); + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + if (get_config('meetletacbmarket', 'featureaccess') === false) { + set_config( + 'featureaccess', + json_encode(\mod_meetletacbfree\local\featureaccess::builtin_defaults(), JSON_UNESCAPED_UNICODE), + 'meetletacbmarket' + ); + } + upgrade_mod_savepoint(true, 2026080914, 'meetletacbmarket'); + } + + if ($oldversion < 2026080915) { + if (get_config('meetletacbmarket', 'activity_featureaccess') === false) { + set_config('activity_featureaccess', 1, 'meetletacbmarket'); + } + // Ensure adobeeditor key exists in stored site matrix. + $raw = get_config('meetletacbmarket', 'featureaccess'); + if ($raw !== false && $raw !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $normalized = \mod_meetletacbfree\local\featureaccess::normalize($decoded); + set_config( + 'featureaccess', + json_encode($normalized, JSON_UNESCAPED_UNICODE), + 'meetletacbmarket' + ); + } + } + upgrade_mod_savepoint(true, 2026080915, 'meetletacbmarket'); + } + + if ($oldversion < 2026080916) { + $raw = get_config('meetletacbmarket', 'featureaccess'); + if ($raw !== false && $raw !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + set_config( + 'featureaccess', + json_encode( + \mod_meetletacbfree\local\featureaccess::normalize($decoded), + JSON_UNESCAPED_UNICODE + ), + 'meetletacbmarket' + ); + } + } + upgrade_mod_savepoint(true, 2026080916, 'meetletacbmarket'); + } + + if ($oldversion < 2026080917) { + upgrade_mod_savepoint(true, 2026080917, 'meetletacbmarket'); + } + + if ($oldversion < 2026080918) { + upgrade_mod_savepoint(true, 2026080918, 'meetletacbmarket'); + } + + if ($oldversion < 2026080919) { + upgrade_mod_savepoint(true, 2026080919, 'meetletacbmarket'); + } + + if ($oldversion < 2026080920) { + upgrade_mod_savepoint(true, 2026080920, 'meetletacbmarket'); + } + + if ($oldversion < 2026080921) { + // Expand stored matrix to include every Moodle role. + $raw = get_config('meetletacbmarket', 'featureaccess'); + if ($raw !== false && $raw !== null && $raw !== '') { + $decoded = json_decode((string)$raw, true); + if (is_array($decoded)) { + set_config( + 'featureaccess', + json_encode( + \mod_meetletacbfree\local\featureaccess::normalize($decoded), + JSON_UNESCAPED_UNICODE + ), + 'meetletacbmarket' + ); + } + } else { + set_config( + 'featureaccess', + json_encode( + \mod_meetletacbfree\local\featureaccess::builtin_defaults(), + JSON_UNESCAPED_UNICODE + ), + 'meetletacbmarket' + ); + } + upgrade_mod_savepoint(true, 2026080921, 'meetletacbmarket'); + } + + if ($oldversion < 2026080922) { + // Apply sensible defaults to blank role rows (e.g. newly listed custom roles). + $raw = get_config('meetletacbmarket', 'featureaccess'); + $decoded = []; + if ($raw !== false && $raw !== null && $raw !== '') { + $tmp = json_decode((string)$raw, true); + if (is_array($tmp)) { + $decoded = $tmp; + } + } + set_config( + 'featureaccess', + json_encode( + \mod_meetletacbfree\local\featureaccess::normalize_fill_blank_roles($decoded), + JSON_UNESCAPED_UNICODE + ), + 'meetletacbmarket' + ); + upgrade_mod_savepoint(true, 2026080922, 'meetletacbmarket'); + } + + if ($oldversion < 2026080923) { + upgrade_mod_savepoint(true, 2026080923, 'meetletacbmarket'); + } + + if ($oldversion < 2026080924) { + upgrade_mod_savepoint(true, 2026080924, 'meetletacbmarket'); + } + + if ($oldversion < 2026080925) { + // Site ZIP was enabled while activities kept showzip=0 (form default), which hid links. + if ((bool)get_config('meetletacbmarket', 'enable_zip')) { + $DB->set_field('meetletacbmarket', 'showzip', 1); + } + upgrade_mod_savepoint(true, 2026080925, 'meetletacbmarket'); + } + + if ($oldversion < 2026080926) { + upgrade_mod_savepoint(true, 2026080926, 'meetletacbmarket'); + } + + if ($oldversion < 2026081110) { + $table = new xmldb_table('meetlet_marketplace_license'); + if (!$dbman->table_exists($table)) { + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('installation_id', XMLDB_TYPE_CHAR, '128', null, XMLDB_NOTNULL); + $table->add_field('private_key_pem', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL); + $table->add_field('public_key_pem', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL); + $table->add_field('auth_json', XMLDB_TYPE_TEXT, null, null, null); + $table->add_field('license_uuid', XMLDB_TYPE_CHAR, '64', null, null); + $table->add_field('status', XMLDB_TYPE_CHAR, '32', null, XMLDB_NOTNULL, null, 'inactive'); + $table->add_field('last_error', XMLDB_TYPE_TEXT, null, null, null); + $table->add_field('last_validated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_index('installation_uniq', XMLDB_INDEX_UNIQUE, ['installation_id']); + $dbman->create_table($table); + } + upgrade_mod_savepoint(true, 2026081110, 'meetletacbmarket'); + } + + if ($oldversion < 2026081120) { + $table = new xmldb_table('meetlet_marketplace_recordings'); + $field = new xmldb_field('agentjobid', XMLDB_TYPE_CHAR, '64', null, null, null, null, 'jobstatus'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + upgrade_mod_savepoint(true, 2026081120, 'meetletacbmarket'); + } + + if ($oldversion < 2026081305) { + // Offline MP4 / Render Agent is opt-in. Preserve existing agent_url sites. + $enabled = get_config('meetletacbmarket', 'offline_render_enabled'); + if ($enabled === false) { + $agenturl = trim((string)get_config('meetletacbmarket', 'agent_url')); + set_config( + 'offline_render_enabled', + $agenturl !== '' ? 1 : 0, + 'meetletacbmarket' + ); + } + upgrade_mod_savepoint(true, 2026081305, 'meetletacbmarket'); + } + + if ($oldversion < 2026081401) { + unset_config('offline_render_enabled', 'meetletacbmarket'); + upgrade_mod_savepoint(true, 2026081401, 'meetletacbmarket'); + } + + if ($oldversion < 2026081402) { + upgrade_mod_savepoint(true, 2026081402, 'meetletacbmarket'); + } + + if ($oldversion < 2026081410) { + $table = new xmldb_table('meetletacbmarket'); + $field = new xmldb_field('showmakeoffline', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'showmp4'); + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + if (get_config('meetletacbmarket', 'enable_makeoffline') === false) { + set_config('enable_makeoffline', 1, 'meetletacbmarket'); + } + if (get_config('meetletacbmarket', 'allow_override_makeoffline') === false) { + set_config('allow_override_makeoffline', 1, 'meetletacbmarket'); + } + + $raw = get_config('meetletacbmarket', 'featureaccess'); + $decoded = []; + if ($raw !== false && $raw !== null && $raw !== '') { + $tmp = json_decode((string)$raw, true); + if (is_array($tmp)) { + $decoded = $tmp; + } + } + set_config( + 'featureaccess', + json_encode( + \mod_meetletacbfree\local\featureaccess::copy_missing_feature_from( + $decoded, + 'mp4', + 'makeoffline' + ), + JSON_UNESCAPED_UNICODE + ), + 'meetletacbmarket' + ); + + $activities = $dbman->table_exists('meetletacbmarket') + ? $DB->get_records_select('meetletacbmarket', 'featureaccess IS NOT NULL AND featureaccess <> \'\'') + : []; + foreach ($activities as $activity) { + $matrix = json_decode((string)$activity->featureaccess, true); + if (!is_array($matrix)) { + continue; + } + $updated = \mod_meetletacbfree\local\featureaccess::copy_missing_feature_from( + $matrix, + 'mp4', + 'makeoffline' + ); + $DB->set_field( + 'meetletacbmarket', + 'featureaccess', + json_encode($updated, JSON_UNESCAPED_UNICODE), + ['id' => $activity->id] + ); + } + + upgrade_mod_savepoint(true, 2026081410, 'meetletacbmarket'); + } + + if ($oldversion < 2026081411) { + xmldb_meetletacbfree_rename_legacy_main_table($dbman); + foreach (['meetletadobeconnectbridge', 'meetlet_adobe_connect_bridge', 'meetlet'] as $oldmodname) { + if ($mod = $DB->get_record('modules', ['name' => $oldmodname])) { + $mod->name = 'meetletacbmarket'; + $DB->update_record('modules', $mod); + } + if ($dbman->table_exists('event')) { + $DB->set_field('event', 'modulename', 'meetletacbmarket', ['modulename' => $oldmodname]); + } + if ($dbman->table_exists('grade_items')) { + $DB->set_field('grade_items', 'itemmodule', 'meetletacbmarket', ['itemmodule' => $oldmodname]); + } + } + upgrade_mod_savepoint(true, 2026081411, 'meetletacbmarket'); + } + + if ($oldversion < 2026081702) { + upgrade_mod_savepoint(true, 2026081702, 'meetletacbmarket'); + } + + if ($oldversion < 2026081703) { + upgrade_mod_savepoint(true, 2026081703, 'meetletacbmarket'); + } + + if ($oldversion < 2026081704) { + upgrade_mod_savepoint(true, 2026081704, 'meetletacbmarket'); + } + + if ($oldversion < 2026081800) { + $table = new xmldb_table('meetlet_marketplace_meetings'); + $fields = [ + new xmldb_field('islive', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'urlpath'), + new xmldb_field('liveat', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'islive'), + new xmldb_field('closedat', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'liveat'), + new xmldb_field('sweepuntil', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'closedat'), + new xmldb_field('sweptat', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'sweepuntil'), + ]; + foreach ($fields as $field) { + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + if (get_config('meetletacbmarket', 'cron_interval') === false) { + set_config('cron_interval', 5, 'meetletacbmarket'); + } + \mod_meetletacbfree\local\config::apply_cron_interval(); + upgrade_mod_savepoint(true, 2026081800, 'meetletacbmarket'); + } + + if ($oldversion < 2026081901) { + $table = new xmldb_table('meetlet_marketplace_recordings'); + $field = new xmldb_field('jobmessage', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'jobstatus'); + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + upgrade_mod_savepoint(true, 2026081901, 'meetletacbmarket'); + } + + if ($oldversion < 2026081902) { + upgrade_mod_savepoint(true, 2026081902, 'meetletacbmarket'); + } + + if ($oldversion < 2026081903) { + $table = new xmldb_table('meetlet_marketplace_jobqueue'); + if (!$dbman->table_exists($table)) { + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('instanceid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('recordingscoid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('groupid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('status', XMLDB_TYPE_CHAR, '20', null, XMLDB_NOTNULL, null, 'pending'); + $table->add_field('attempts', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('lasterror', XMLDB_TYPE_CHAR, '255', null, null, null, null); + $table->add_field('timesent', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_index('instance_sco', XMLDB_INDEX_UNIQUE, ['instanceid', 'recordingscoid']); + $table->add_index('status_idx', XMLDB_INDEX_NOTUNIQUE, ['status']); + $dbman->create_table($table); + } + if (get_config('meetletacbmarket', 'agent_dispatch') === false) { + set_config('agent_dispatch', 'immediate', 'meetletacbmarket'); + } + if (get_config('meetletacbmarket', 'agent_dispatch_hours') === false) { + set_config('agent_dispatch_hours', '22,23,0,1,2,3,4,5', 'meetletacbmarket'); + } + upgrade_mod_savepoint(true, 2026081903, 'meetletacbmarket'); + } + + if ($oldversion < 2026081904) { + upgrade_mod_savepoint(true, 2026081904, 'meetletacbmarket'); + } + + if ($oldversion < 2026081905) { + upgrade_mod_savepoint(true, 2026081905, 'meetletacbmarket'); + } + + if ($oldversion < 2026081906) { + upgrade_mod_savepoint(true, 2026081906, 'meetletacbmarket'); + } + + if ($oldversion < 2026081912) { + $table = new xmldb_table('meetletacbmarket'); + $fields = [ + new xmldb_field('settingoverrides', XMLDB_TYPE_TEXT, null, null, null, null, null, 'featureaccess'), + new xmldb_field('participantsmode', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'settingoverrides'), + ]; + foreach ($fields as $field) { + if ($dbman->table_exists($table) && !$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + if ($dbman->table_exists($table)) { + $activities = $DB->get_records('meetletacbmarket'); + foreach ($activities as $activity) { + $overrides = []; + foreach ([ + 'waitforhost', + 'autooffline', + 'showzip', + 'showrecordingstab', + 'showcontenttab', + 'showmp4', + 'showmakeoffline', + ] as $field) { + $overrides[$field] = !empty($activity->{$field}) + ? \mod_meetletacbfree\local\config::OVERRIDE_ON + : \mod_meetletacbfree\local\config::OVERRIDE_OFF; + } + $update = (object)[ + 'id' => $activity->id, + 'settingoverrides' => \mod_meetletacbfree\local\config::setting_overrides_json($overrides), + 'participantsmode' => 1, + ]; + $DB->update_record('meetletacbmarket', $update); + } + } + upgrade_mod_savepoint(true, 2026081912, 'meetletacbmarket'); + } + + // 1.5.45 — MP4 import download_one fix + agent-gone reconciliation (no schema). + if ($oldversion < 2026082127) { + upgrade_mod_savepoint(true, 2026082127, 'meetletacbmarket'); + } + + // 1.5.50 — Agent download without forced Import + Delete from Moodle (no schema). + if ($oldversion < 2026082132) { + upgrade_mod_savepoint(true, 2026082132, 'meetletacbmarket'); + } + + // 1.5.51 — Dual Moodle/Agent MP4 download buttons + source tooltips (no schema). + if ($oldversion < 2026082133) { + upgrade_mod_savepoint(true, 2026082133, 'meetletacbmarket'); + } + + // 1.5.54 — Agent MP4 proxy: send_temp_file path fix + stored ftyp validation (no schema). + if ($oldversion < 2026082236) { + upgrade_mod_savepoint(true, 2026082236, 'meetletacbmarket'); + } + + // 1.5.63 — Content download via Moodle SSO proxy + content view launch URL (no schema). + if ($oldversion < 2026082245) { + upgrade_mod_savepoint(true, 2026082245, 'meetletacbmarket'); + } + + // 1.5.66 — Content download button loading state (no schema). + if ($oldversion < 2026082248) { + upgrade_mod_savepoint(true, 2026082248, 'meetletacbmarket'); + } + + // 1.5.68 — Free license: recordings view/edit; MP4 Premium-only (no schema). + if ($oldversion < 2026082250) { + upgrade_mod_savepoint(true, 2026082250, 'meetletacbmarket'); + } + + // 1.0.1-marketplace — rename component from mod_meetletacbmdlrules. + if ($oldversion < 2026090122) { + xmldb_meetletacbfree_migrate_from_mdlrules($dbman); + upgrade_mod_savepoint(true, 2026090122, 'meetletacbmarket'); + } + + // 1.0.1-marketplace — UI cleanup, role defaults, lang prune. + if ($oldversion < 2026090123) { + upgrade_mod_savepoint(true, 2026090123, 'meetletacbmarket'); + } + + // 1.0.1-marketplace — plugin display titles (fa/en). + if ($oldversion < 2026090124) { + upgrade_mod_savepoint(true, 2026090124, 'meetletacbmarket'); + } + + // 1.0.1-marketplace — dedupe modules row after component rename. + if ($oldversion < 2026090125) { + $marketmods = $DB->get_records('modules', ['name' => 'meetletacbmarket'], 'id ASC'); + if (count($marketmods) > 1) { + array_shift($marketmods); + foreach ($marketmods as $dup) { + $DB->delete_records('modules', ['id' => $dup->id]); + } + } + upgrade_mod_savepoint(true, 2026090125, 'meetletacbmarket'); + } + + // 1.0.1-marketplace — MeetLet logo + visit website button on About tab (no schema). + if ($oldversion < 2026090127) { + upgrade_mod_savepoint(true, 2026090127, 'meetletacbmarket'); + } + + // 1.0.2-marketplace — optional transparent site registration tab + daily heartbeat. + if ($oldversion < 2026090128) { + $table = new xmldb_table('meetlet_marketplace_license'); + if (!$dbman->table_exists($table)) { + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('installation_id', XMLDB_TYPE_CHAR, '128', null, XMLDB_NOTNULL); + $table->add_field('private_key_pem', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL); + $table->add_field('public_key_pem', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL); + $table->add_field('auth_json', XMLDB_TYPE_TEXT, null, null, null); + $table->add_field('license_uuid', XMLDB_TYPE_CHAR, '64', null, null); + $table->add_field('status', XMLDB_TYPE_CHAR, '32', null, XMLDB_NOTNULL, null, 'inactive'); + $table->add_field('last_error', XMLDB_TYPE_TEXT, null, null, null); + $table->add_field('last_validated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_index('installation_uniq', XMLDB_INDEX_UNIQUE, ['installation_id']); + $dbman->create_table($table); + } + upgrade_mod_savepoint(true, 2026090128, 'meetletacbmarket'); + } + + if ($oldversion < 2026090129) { + upgrade_mod_savepoint(true, 2026090129, 'meetletacbmarket'); + } + + if ($oldversion < 2026090130) { + upgrade_mod_savepoint(true, 2026090130, 'meetletacbmarket'); + } + + if ($oldversion < 2026090131) { + upgrade_mod_savepoint(true, 2026090131, 'meetletacbmarket'); + } + + // 1.0.3-marketplace — optional registration: remove feature gating (no schema). + if ($oldversion < 2026090132) { + upgrade_mod_savepoint(true, 2026090132, 'meetletacbmarket'); + } + + // 1.0.3-marketplace — hide locked settings tabs until online activation (no schema). + if ($oldversion < 2026090134) { + upgrade_mod_savepoint(true, 2026090134, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — activity icon badge to distinguish from commercial meetletacb (no schema). + if ($oldversion < 2026090135) { + upgrade_mod_savepoint(true, 2026090135, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — in-plugin Privacy admin tab; remove external policy link (no schema). + if ($oldversion < 2026090136) { + upgrade_mod_savepoint(true, 2026090136, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — Privacy tab visible before online activation (no schema). + if ($oldversion < 2026090137) { + upgrade_mod_savepoint(true, 2026090137, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — fix privacy tab deep-link; enlarge activity icon M badge (no schema). + if ($oldversion < 2026090138) { + upgrade_mod_savepoint(true, 2026090138, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — privacy tab copy fixes (no schema). + if ($oldversion < 2026090139) { + upgrade_mod_savepoint(true, 2026090139, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — remove vague local-data privacy paragraph (no schema). + if ($oldversion < 2026090140) { + upgrade_mod_savepoint(true, 2026090140, 'meetletacbmarket'); + } + + // 1.0.4-marketplace — privacy tab deep-link uses hash anchor (no schema). + if ($oldversion < 2026090141) { + upgrade_mod_savepoint(true, 2026090141, 'meetletacbmarket'); + } + + // 1.0.5-marketplace — hide activity chooser until online registration (no schema). + if ($oldversion < 2026090143) { + upgrade_mod_savepoint(true, 2026090143, 'meetletacbmarket'); + } + + // 1.0.6-marketplace — registration confirm modal UI (no schema). + if ($oldversion < 2026090145) { + upgrade_mod_savepoint(true, 2026090145, 'meetletacbmarket'); + } + + // 1.0.6-marketplace — plugin activation terminology (no schema). + if ($oldversion < 2026090146) { + upgrade_mod_savepoint(true, 2026090146, 'meetletacbmarket'); + } + + // 1.0.6-marketplace — server guide: no live credentials in admin UI (no schema). + if ($oldversion < 2026090147) { + upgrade_mod_savepoint(true, 2026090147, 'meetletacbmarket'); + } + + if ($oldversion < 2026090148) { + upgrade_mod_savepoint(true, 2026090148, 'meetletacbmarket'); + } + + // 1.0.8-marketplace — CI: prefix auxiliary tables with meetletacbmarket_. + if ($oldversion < 2026090149) { + $renames = [ + 'meetlet_marketplace_meetings' => 'meetletacbmarket_meetings', + 'meetlet_marketplace_recordings' => 'meetletacbmarket_recordings', + 'meetlet_marketplace_license' => 'meetletacbmarket_license', + 'meetlet_marketplace_jobqueue' => 'meetletacbmarket_jobqueue', + ]; + $fktables = [ + 'meetlet_marketplace_meetings' => 'meetletacbmarket_meetings', + 'meetlet_marketplace_recordings' => 'meetletacbmarket_recordings', + ]; + foreach ($fktables as $from => $to) { + if (!$dbman->table_exists($from)) { + continue; + } + $t = new xmldb_table($from); + $oldkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbmarket', ['id']); + if ($dbman->find_key_name($t, $oldkey)) { + $dbman->drop_key($t, $oldkey); + } + } + foreach ($renames as $from => $to) { + if ($dbman->table_exists($from) && !$dbman->table_exists($to)) { + $dbman->rename_table(new xmldb_table($from), $to); + } + } + if ($dbman->table_exists('meetletacbmarket')) { + foreach (['meetletacbmarket_meetings', 'meetletacbmarket_recordings'] as $tname) { + if (!$dbman->table_exists($tname)) { + continue; + } + $t = new xmldb_table($tname); + $newkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbmarket', ['id']); + if (!$dbman->find_key_name($t, $newkey)) { + $dbman->add_key($t, $newkey); + } + } + } + upgrade_mod_savepoint(true, 2026090149, 'meetletacbmarket'); + } + + // 1.0.9-marketplace — rename component mod_meetletacbmarket → mod_meetletacbfree (fits mdl_modules.name). + if ($oldversion < 2026090150) { + xmldb_meetletacbfree_migrate_from_market($dbman); + upgrade_mod_savepoint(true, 2026090150, 'meetletacbfree'); + } + + if ($oldversion < 2026090151) { + // 10.1.0 marketplace release — version bump only. + upgrade_mod_savepoint(true, 2026090151, 'meetletacbfree'); + } + + if ($oldversion < 2026090152) { + // 1.1.2 marketplace release — version bump only. + upgrade_mod_savepoint(true, 2026090152, 'meetletacbfree'); + } + + if ($oldversion < 2026090153) { + upgrade_mod_savepoint(true, 2026090153, 'meetletacbfree'); + } + + return true; +} + +/** + * Migrate legacy meetletacbmdlrules / meetlet_mdlrules_* tables and config. + * + * @param \database_manager $dbman + * @return void + */ +function xmldb_meetletacbfree_migrate_from_mdlrules($dbman): void { + global $DB; + + $legacymain = new xmldb_table('meetletacbmdlrules'); + $newmain = new xmldb_table('meetletacbmarket'); + if ($dbman->table_exists($legacymain)) { + if ($dbman->table_exists($newmain)) { + $dbman->drop_table($newmain); + } + $dbman->rename_table($legacymain, 'meetletacbmarket'); + } + + $tablemap = [ + 'meetlet_mdlrules_meetings' => 'meetlet_marketplace_meetings', + 'meetlet_mdlrules_recordings' => 'meetlet_marketplace_recordings', + ]; + foreach ($tablemap as $from => $to) { + $fromtable = new xmldb_table($from); + $totable = new xmldb_table($to); + if ($dbman->table_exists($fromtable)) { + if ($dbman->table_exists($totable)) { + $dbman->drop_table($totable); + } + $dbman->rename_table($fromtable, $to); + } + } + + if ($dbman->table_exists('meetletacbmarket')) { + foreach (['meetlet_marketplace_meetings', 'meetlet_marketplace_recordings'] as $tname) { + if (!$dbman->table_exists($tname)) { + continue; + } + $t = new xmldb_table($tname); + $oldkeys = [ + new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbmdlrules', ['id']), + new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletadobeconnectbridge', ['id']), + ]; + foreach ($oldkeys as $oldkey) { + if ($dbman->find_key_name($t, $oldkey)) { + $dbman->drop_key($t, $oldkey); + } + } + $newkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbmarket', ['id']); + if (!$dbman->find_key_name($t, $newkey)) { + $dbman->add_key($t, $newkey); + } + } + } + + if ($mod = $DB->get_record('modules', ['name' => 'meetletacbmdlrules'])) { + $mod->name = 'meetletacbmarket'; + $DB->update_record('modules', $mod); + } + + $marketmods = $DB->get_records('modules', ['name' => 'meetletacbmarket'], 'id ASC'); + if (count($marketmods) > 1) { + $keep = array_shift($marketmods); + foreach ($marketmods as $dup) { + $DB->delete_records('modules', ['id' => $dup->id]); + } + } else if (empty($marketmods) && !$DB->record_exists('modules', ['name' => 'meetletacbmarket'])) { + $mod = (object)['name' => 'meetletacbmarket', 'cron' => 0, 'visible' => 1]; + $DB->insert_record('modules', $mod); + } + + foreach (['meetletacbmdlrules', 'meetletadobeconnectbridge', 'meetlet'] as $oldmodname) { + if ($dbman->table_exists('event')) { + $DB->set_field('event', 'modulename', 'meetletacbmarket', ['modulename' => $oldmodname]); + } + if ($dbman->table_exists('grade_items')) { + $DB->set_field('grade_items', 'itemmodule', 'meetletacbmarket', ['itemmodule' => $oldmodname]); + } + if ($dbman->table_exists('course_modules')) { + $DB->execute( + "UPDATE {course_modules} SET module = ( + SELECT id FROM {modules} WHERE name = 'meetletacbmarket' + ) WHERE module = ( + SELECT id FROM {modules} WHERE name = ? + )", + [$oldmodname] + ); + } + } + + $configs = $DB->get_records('config_plugins', ['plugin' => 'meetletacbmdlrules']); + foreach ($configs as $cfg) { + if ($DB->record_exists('config_plugins', ['plugin' => 'meetletacbmarket', 'name' => $cfg->name])) { + continue; + } + $cfg->plugin = 'meetletacbmarket'; + $DB->insert_record('config_plugins', $cfg); + } + $DB->delete_records('config_plugins', ['plugin' => 'meetletacbmdlrules']); +} + +/** + * Rename pre-Marketplace main table via XMLDB (MySQL and PostgreSQL). + * + * @param \database_manager $dbman + * @return void + */ +function xmldb_meetletacbfree_rename_legacy_main_table($dbman): void { + foreach (['meetlet_marketplace_meetings', 'meetlet_marketplace_recordings'] as $tname) { + if (!$dbman->table_exists($tname)) { + continue; + } + $t = new xmldb_table($tname); + $oldkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], + 'meetletadobeconnectbridge', ['id']); + if ($dbman->find_key_name($t, $oldkey)) { + $dbman->drop_key($t, $oldkey); + } + } + + $oldtable = new xmldb_table('meetletadobeconnectbridge'); + if ($dbman->table_exists('meetletadobeconnectbridge') && !$dbman->table_exists('meetletacbmarket')) { + $dbman->rename_table($oldtable, 'meetletacbmarket'); + } + + foreach (['meetlet_marketplace_meetings', 'meetlet_marketplace_recordings'] as $tname) { + if (!$dbman->table_exists($tname) || !$dbman->table_exists('meetletacbmarket')) { + continue; + } + $t = new xmldb_table($tname); + $newkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbmarket', ['id']); + if (!$dbman->find_key_name($t, $newkey)) { + $dbman->add_key($t, $newkey); + } + } +} + +/** + * Rename mod_meetletacbmarket tables/config to mod_meetletacbfree. + * + * Handles meetletacbmarket*, meetlet_marketplace_* legacy names, and modules row. + * + * @param \database_manager $dbman + * @return void + */ +function xmldb_meetletacbfree_migrate_from_market($dbman): void { + global $DB; + + // Ensure legacy meetlet_marketplace_* tables use meetletacbmarket_* prefix first. + $legacyrenames = [ + 'meetlet_marketplace_meetings' => 'meetletacbmarket_meetings', + 'meetlet_marketplace_recordings' => 'meetletacbmarket_recordings', + 'meetlet_marketplace_license' => 'meetletacbmarket_license', + 'meetlet_marketplace_jobqueue' => 'meetletacbmarket_jobqueue', + ]; + foreach ($legacyrenames as $from => $to) { + if ($dbman->table_exists($from) && !$dbman->table_exists($to)) { + $dbman->rename_table(new xmldb_table($from), $to); + } + } + + // Drop FKs on auxiliary tables before main-table rename. + $fksources = ['meetletacbmarket_meetings', 'meetletacbfree_meetings']; + foreach ($fksources as $tname) { + if (!$dbman->table_exists($tname)) { + continue; + } + $t = new xmldb_table($tname); + foreach (['meetletacbmarket', 'meetletacbfree'] as $reftable) { + $oldkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], $reftable, ['id']); + if ($dbman->find_key_name($t, $oldkey)) { + $dbman->drop_key($t, $oldkey); + } + } + } + + $tablerenames = [ + 'meetletacbmarket' => 'meetletacbfree', + 'meetletacbmarket_meetings' => 'meetletacbfree_meetings', + 'meetletacbmarket_recordings' => 'meetletacbfree_recordings', + 'meetletacbmarket_license' => 'meetletacbfree_license', + 'meetletacbmarket_jobqueue' => 'meetletacbfree_jobqueue', + ]; + foreach ($tablerenames as $from => $to) { + if ($dbman->table_exists($from) && !$dbman->table_exists($to)) { + $dbman->rename_table(new xmldb_table($from), $to); + } + } + + if ($dbman->table_exists('meetletacbfree')) { + foreach (['meetletacbfree_meetings', 'meetletacbfree_recordings'] as $tname) { + if (!$dbman->table_exists($tname)) { + continue; + } + $t = new xmldb_table($tname); + $newkey = new xmldb_key('fk_instance', XMLDB_KEY_FOREIGN, ['instanceid'], 'meetletacbfree', ['id']); + if (!$dbman->find_key_name($t, $newkey)) { + $dbman->add_key($t, $newkey); + } + } + } + + foreach (['meetletacbmarket', 'meetletacbmarketplace', 'meetletacbmdlrules'] as $oldmodname) { + if ($mod = $DB->get_record('modules', ['name' => $oldmodname])) { + if ($existing = $DB->get_record('modules', ['name' => 'meetletacbfree'])) { + if ($dbman->table_exists('course_modules')) { + $DB->execute( + "UPDATE {course_modules} SET module = ? WHERE module = ?", + [$existing->id, $mod->id] + ); + } + $DB->delete_records('modules', ['id' => $mod->id]); + } else { + $mod->name = 'meetletacbfree'; + $DB->update_record('modules', $mod); + } + } + if ($dbman->table_exists('event')) { + $DB->set_field('event', 'modulename', 'meetletacbfree', ['modulename' => $oldmodname]); + } + if ($dbman->table_exists('grade_items')) { + $DB->set_field('grade_items', 'itemmodule', 'meetletacbfree', ['itemmodule' => $oldmodname]); + } + if ($dbman->table_exists('course_modules') && $DB->record_exists('modules', ['name' => 'meetletacbfree'])) { + $DB->execute( + "UPDATE {course_modules} SET module = ( + SELECT id FROM {modules} WHERE name = 'meetletacbfree' + ) WHERE module = ( + SELECT id FROM {modules} WHERE name = ? + )", + [$oldmodname] + ); + } + } + + if (!$DB->record_exists('modules', ['name' => 'meetletacbfree'])) { + $mod = (object)['name' => 'meetletacbfree', 'cron' => 0, 'visible' => 1]; + $DB->insert_record('modules', $mod); + } + + $freeids = $DB->get_records('modules', ['name' => 'meetletacbfree'], 'id ASC'); + if (count($freeids) > 1) { + $keep = array_shift($freeids); + foreach ($freeids as $dup) { + $DB->delete_records('modules', ['id' => $dup->id]); + } + } + + foreach (['meetletacbmarket', 'meetletacbmarketplace', 'meetletacbmdlrules'] as $oldplugin) { + $configs = $DB->get_records('config_plugins', ['plugin' => $oldplugin]); + foreach ($configs as $cfg) { + if ($DB->record_exists('config_plugins', ['plugin' => 'meetletacbfree', 'name' => $cfg->name])) { + continue; + } + $cfg->plugin = 'meetletacbfree'; + $DB->insert_record('config_plugins', $cfg); + } + $DB->delete_records('config_plugins', ['plugin' => $oldplugin]); + } +} diff --git a/download_content.php b/download_content.php new file mode 100644 index 0000000..fe377d1 --- /dev/null +++ b/download_content.php @@ -0,0 +1,51 @@ +. + +/** + * Serve uploaded meeting content via Moodle (Adobe Connect SSO proxy). + * + * Authenticated, current-user bound. The URL carries an opaque HMAC token. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require(__DIR__ . '/../../config.php'); + +$k = required_param('k', PARAM_RAW_TRIMMED); +$claims = \mod_meetletacbfree\local\content::unpack_download_token($k); + +$cm = get_coursemodule_from_id('meetletacbfree', $claims['cmid'], 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); +$instance = $DB->get_record('meetletacbfree', ['id' => $cm->instance], '*', MUST_EXIST); + +require_login($course, false, $cm); +if ((int)$USER->id !== (int)$claims['userid'] || isguestuser()) { + throw new moodle_exception('errorbadcontenttoken', 'meetletacbfree'); +} + +$context = context_module::instance($cm->id); +require_capability('mod/meetletacbfree:view', $context); +\mod_meetletacbfree\registration\manager::require_registered(); +\mod_meetletacbfree\local\featureaccess::require_feature( + \mod_meetletacbfree\local\featureaccess::CONTENT, + $context, + $instance +); + +$inst = \mod_meetletacbfree\local\instance::get_from_cmid((int)$cm->id); +\mod_meetletacbfree\local\content::serve_download($inst, (int)$claims['cid']); diff --git a/index.php b/index.php new file mode 100644 index 0000000..5e3f134 --- /dev/null +++ b/index.php @@ -0,0 +1,55 @@ +. + +/** + * List all MeetLet instances in a course. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../../config.php'); + +$id = required_param('id', PARAM_INT); +$course = $DB->get_record('course', ['id' => $id], '*', MUST_EXIST); +require_course_login($course); +\mod_meetletacbfree\registration\manager::require_registered(); +$PAGE->set_url('/mod/meetletacbfree/index.php', ['id' => $id]); +$PAGE->set_title(get_string('modulenameplural', 'meetletacbfree')); +$PAGE->set_heading($course->fullname); + +echo $OUTPUT->header(); +echo $OUTPUT->heading(get_string('modulenameplural', 'meetletacbfree')); + +if (!$instances = get_all_instances_in_course('meetletacbfree', $course)) { + notice(get_string('thereareno', 'moodle', get_string('modulenameplural', 'meetletacbfree')), + new moodle_url('/course/view.php', ['id' => $course->id])); + echo $OUTPUT->footer(); + die(); +} + +$table = new html_table(); +$table->head = [get_string('meetingname', 'meetletacbfree'), get_string('starttime', 'meetletacbfree')]; +foreach ($instances as $instance) { + $url = new moodle_url('/mod/meetletacbfree/view.php', ['id' => $instance->coursemodule]); + $table->data[] = [ + html_writer::link($url, format_string($instance->name)), + $instance->starttime ? userdate($instance->starttime) : '-', + ]; +} +echo html_writer::table($table); +echo $OUTPUT->footer(); diff --git a/javascript/featureaccess_matrix.js b/javascript/featureaccess_matrix.js new file mode 100644 index 0000000..0c73e42 --- /dev/null +++ b/javascript/featureaccess_matrix.js @@ -0,0 +1,54 @@ +/** + * MeetLet feature-access matrix: row/column "select all" toggles. + * Page-local (admin settings + mod form). Not AMD. + */ +(function() { + const bindMatrix = (matrix) => { + if (!matrix || matrix.getAttribute('data-access-bound') === '1') { + return; + } + matrix.setAttribute('data-access-bound', '1'); + + const setGroup = (checks, forceOn) => { + const list = Array.from(checks).filter((el) => !el.disabled); + if (!list.length) { + return; + } + const turnOn = (typeof forceOn === 'boolean') + ? forceOn + : list.some((el) => !el.checked); + list.forEach((el) => { + if (el.checked !== turnOn) { + el.checked = turnOn; + el.dispatchEvent(new Event('change', {bubbles: true})); + } + }); + }; + + matrix.addEventListener('click', (e) => { + const btn = e.target.closest('[data-access-toggle]'); + if (!btn || !matrix.contains(btn) || matrix.classList.contains('is-disabled')) { + return; + } + e.preventDefault(); + const mode = btn.getAttribute('data-access-toggle'); + if (mode === 'row') { + const role = btn.getAttribute('data-role'); + setGroup(matrix.querySelectorAll('.meetlet-access-check[data-role="' + role + '"]')); + } else if (mode === 'col') { + const feature = btn.getAttribute('data-feature'); + setGroup(matrix.querySelectorAll('.meetlet-access-check[data-feature="' + feature + '"]')); + } + }); + }; + + const init = () => { + document.querySelectorAll('[data-meetlet-access-matrix]').forEach(bindMatrix); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/javascript/meetlet_modal_scroll.js b/javascript/meetlet_modal_scroll.js new file mode 100644 index 0000000..50b9378 --- /dev/null +++ b/javascript/meetlet_modal_scroll.js @@ -0,0 +1,110 @@ +/** + * Shared scroll lock for MeetLet modals. + * Uses body overflow + scroll guards — no position:fixed on body (avoids white-page flash). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +(function (global) { + 'use strict'; + + var scrollLockY = 0; + var guardsActive = false; + + function scrollbarWidth() { + return Math.max(0, global.innerWidth - document.documentElement.clientWidth); + } + + function focusNoScroll(el) { + if (!el || typeof el.focus !== 'function') { + return; + } + try { + el.focus({preventScroll: true}); + } catch (e) { + el.focus(); + } + } + + function restoreScroll() { + if (scrollLockY > 0) { + global.scrollTo(0, scrollLockY); + } + } + + function onScrollGuard() { + if (scrollLockY && global.scrollY !== scrollLockY) { + global.scrollTo(0, scrollLockY); + } + } + + function onMoveGuard(e) { + if (e.target.closest && e.target.closest('.meetlet-modal__dialog')) { + return; + } + e.preventDefault(); + } + + function bindGuards() { + if (guardsActive) { + return; + } + guardsActive = true; + global.addEventListener('scroll', onScrollGuard, true); + global.addEventListener('wheel', onMoveGuard, {passive: false, capture: true}); + global.addEventListener('touchmove', onMoveGuard, {passive: false, capture: true}); + } + + function unbindGuards() { + if (!guardsActive) { + return; + } + guardsActive = false; + global.removeEventListener('scroll', onScrollGuard, true); + global.removeEventListener('wheel', onMoveGuard, true); + global.removeEventListener('touchmove', onMoveGuard, true); + } + + function lockPageScroll() { + if (document.documentElement.classList.contains('meetlet-modal-open')) { + return; + } + scrollLockY = global.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; + var pad = scrollbarWidth(); + if (pad > 0) { + document.body.style.paddingRight = pad + 'px'; + } + document.documentElement.classList.add('meetlet-modal-open'); + restoreScroll(); + if (typeof global.requestAnimationFrame === 'function') { + global.requestAnimationFrame(restoreScroll); + } + bindGuards(); + } + + function unlockPageScroll() { + if (!document.documentElement.classList.contains('meetlet-modal-open')) { + return; + } + unbindGuards(); + var y = scrollLockY; + document.documentElement.classList.remove('meetlet-modal-open'); + document.body.style.paddingRight = ''; + scrollLockY = 0; + if (y) { + global.scrollTo(0, y); + if (typeof global.requestAnimationFrame === 'function') { + global.requestAnimationFrame(function () { + global.scrollTo(0, y); + }); + } + } + } + + global.MeetletModalScroll = { + lock: lockPageScroll, + unlock: unlockPageScroll, + focusNoScroll: focusNoScroll, + }; +}(typeof window !== 'undefined' ? window : this)); diff --git a/javascript/modform.js b/javascript/modform.js new file mode 100644 index 0000000..82fea1b --- /dev/null +++ b/javascript/modform.js @@ -0,0 +1,480 @@ +/** + * MeetLet mod form participants — page-local only (not AMD). + */ +require(['core/str', 'core/notification', 'core/templates', 'core/ajax'], function(Str, Notification, Templates, Ajax) { + const ELEMENT_SELECTOR = { + participantData: () => document.querySelector('[data-participant-data]'), + participantList: () => document.getElementsByName('participants')[0], + participantTable: () => document.getElementById('participant_list_table'), + participantSelectionType: () => document.getElementsByName('meetlet_participant_selection_type')[0], + participantSelection: () => document.getElementsByName('meetlet_participant_selection')[0], + participantAddButton: () => document.getElementsByName('meetlet_participant_selection_add')[0], + }; + + const getParticipantList = () => { + const value = ELEMENT_SELECTOR.participantList().value; + return value ? JSON.parse(value) : []; + }; + + const participantListUpdate = (list) => { + ELEMENT_SELECTOR.participantList().value = JSON.stringify(list); + }; + + const participantRemove = (selectionTypeValue, selectionValue) => { + const pList = getParticipantList(); + const id = 'participant_list_tr_' + selectionTypeValue + '-' + selectionValue; + const table = ELEMENT_SELECTOR.participantTable(); + for (let i = 0; i < pList.length; i++) { + if (pList[i].selectiontype === selectionTypeValue && + String(pList[i].selectionid) === String(selectionValue)) { + pList.splice(i, 1); + break; + } + } + for (let i = 0; i < table.rows.length; i++) { + if (table.rows[i].id === id) { + table.deleteRow(i); + break; + } + } + participantListUpdate(pList); + }; + + const participantListRoleUpdate = (type, id) => { + const select = document.querySelector('#participant_list_tr_' + type + '-' + id + ' .participant-select'); + const pList = getParticipantList(); + for (let i = 0; i < pList.length; i++) { + if (pList[i].selectiontype === type && String(pList[i].selectionid) === String(id)) { + pList[i].role = select.value; + } + } + participantListUpdate(pList); + }; + + const participantAddToForm = async(selectionTypeValue, selectionValue, selectedRole, canRemove) => { + const participantData = JSON.parse(ELEMENT_SELECTOR.participantData().dataset.participantData); + const [shost, spresenter, sparticipant] = await Promise.all([ + Str.get_string('role_host', 'meetletacbfree'), + Str.get_string('role_presenter', 'meetletacbfree'), + Str.get_string('role_participant', 'meetletacbfree'), + ]); + const roles = { + host: {id: 'host', label: shost}, + presenter: {id: 'presenter', label: spresenter}, + participant: {id: 'participant', label: sparticipant}, + }; + if (roles[selectedRole]) { + roles[selectedRole].isselected = true; + } else { + roles.participant.isselected = true; + } + + try { + const listTable = document.querySelector('#participant_list_table tbody'); + const templateContext = { + selectiontypevalue: selectionTypeValue, + selectionvalue: selectionValue, + participanttype: participantData[selectionTypeValue].name, + participantvalue: (selectionTypeValue !== 'all') + ? participantData[selectionTypeValue].children[selectionValue].name : null, + roles: Object.values(roles), + canRemove: canRemove, + }; + const {html, js} = await Templates.renderForPromise('mod_meetletacbfree/participant_form_add', templateContext); + const newNode = Templates.appendNodeContents(listTable, html, js)[0]; + newNode.querySelector('.participant-select').addEventListener('change', () => { + participantListRoleUpdate(selectionTypeValue, selectionValue); + }); + const removeNode = newNode.querySelector('.remove-button'); + if (removeNode) { + removeNode.addEventListener('click', (e) => { + e.preventDefault(); + participantRemove(selectionTypeValue, selectionValue); + }); + } + } catch (e) { + Notification.exception(e); + } + }; + + const participantAddFromCurrentSelection = () => { + const selectionType = ELEMENT_SELECTOR.participantSelectionType(); + const selection = ELEMENT_SELECTOR.participantSelection(); + const pList = getParticipantList(); + for (let i = 0; i < pList.length; i++) { + if (pList[i].selectiontype === selectionType.value && + String(pList[i].selectionid) === String(selection.value)) { + return; + } + } + const role = 'participant'; + pList.push({ + selectiontype: selectionType.value, + selectionid: selection.value, + role: role, + }); + participantAddToForm(selectionType.value, selection.value, role, true); + participantListUpdate(pList); + }; + + const updateSelectionFromType = (currentTypeSelect) => { + const participantData = JSON.parse(ELEMENT_SELECTOR.participantData().dataset.participantData); + const participantSelect = ELEMENT_SELECTOR.participantSelection(); + while (participantSelect.firstChild) { + participantSelect.removeChild(participantSelect.firstChild); + } + if (currentTypeSelect.value === 'all') { + const option = document.createElement('option'); + option.text = '---------------'; + option.value = 'all'; + participantSelect.add(option); + return; + } + const options = Object.values(participantData[currentTypeSelect.value].children || {}); + options.forEach((opt) => { + const option = document.createElement('option'); + option.text = opt.name; + option.value = opt.id; + participantSelect.add(option); + }); + }; + + const participantListInit = () => { + const participantData = JSON.parse(ELEMENT_SELECTOR.participantData().dataset.participantData); + getParticipantList().forEach((participant) => { + if (participant.selectiontype === 'all' || + (participantData[participant.selectiontype] && + typeof participantData[participant.selectiontype].children[participant.selectionid] !== 'undefined')) { + participantAddToForm(participant.selectiontype, participant.selectionid, participant.role, true); + } + }); + }; + + const syncAccessMatrix = () => { + // Moodle advcheckbox renders a hidden input with the same name first — use the real checkbox. + const override = document.querySelector('#id_featureaccess_override') || + document.querySelector('input[type="checkbox"][name="featureaccess_override"]'); + const matrix = document.querySelector('.meetlet-access-matrix'); + if (!override || !matrix) { + return; + } + const enabled = !!override.checked; + matrix.classList.toggle('is-disabled', !enabled); + matrix.querySelectorAll('input.meetlet-access-check').forEach((el) => { + el.disabled = !enabled; + }); + matrix.querySelectorAll('.meetlet-access-toggle').forEach((el) => { + el.disabled = !enabled; + }); + }; + const accessOverride = document.querySelector('#id_featureaccess_override') || + document.querySelector('input[type="checkbox"][name="featureaccess_override"]'); + if (accessOverride) { + accessOverride.addEventListener('change', syncAccessMatrix); + accessOverride.addEventListener('click', syncAccessMatrix); + syncAccessMatrix(); + } + + const setDatetimeFields = (basename, unix) => { + if (!unix) { + return; + } + const d = new Date(unix * 1000); + const set = (suffix, value) => { + const el = document.querySelector('[name="' + basename + '[' + suffix + ']"]'); + if (el) { + el.value = String(value); + el.dispatchEvent(new Event('change', {bubbles: true})); + } + }; + set('year', d.getFullYear()); + set('month', d.getMonth() + 1); + set('day', d.getDate()); + set('hour', d.getHours()); + set('minute', d.getMinutes()); + }; + + const applyExistingMeeting = () => { + const picker = document.querySelector('[data-meeting-picker]'); + const metaEl = document.querySelector('[data-meetings-meta]'); + const scoInput = document.querySelector('#id_existingscoid') || document.querySelector('input[name="existingscoid"]'); + const linkmode = document.querySelector('#id_linkmode') || document.querySelector('[name="linkmode"]'); + const nameInput = document.querySelector('#id_name') || document.querySelector('[name="name"]'); + if (!picker || !scoInput || !linkmode) { + return; + } + + const toggle = picker.querySelector('[data-meeting-toggle]'); + const panel = picker.querySelector('[data-meeting-panel]'); + const searchInput = picker.querySelector('[data-meeting-search]'); + const listEl = picker.querySelector('[data-meeting-list]'); + const hint = picker.querySelector('[data-meeting-hint]'); + const labelEl = picker.querySelector('[data-meeting-label]'); + const readonly = picker.getAttribute('data-readonly') === '1'; + const strChoose = picker.getAttribute('data-str-choose') || '---'; + const strLoading = picker.getAttribute('data-str-loading') || '...'; + const strEmpty = picker.getAttribute('data-str-empty') || ''; + const courseid = parseInt(picker.getAttribute('data-courseid') || '0', 10); + + let meta = {}; + try { + meta = JSON.parse((metaEl && metaEl.getAttribute('data-meetings-meta')) || '{}'); + } catch (e) { + meta = {}; + } + + let loadedOnce = false; + let searchTimer = null; + let requestSeq = 0; + + const setOpen = (open) => { + if (!panel || !toggle || readonly) { + return; + } + if (open) { + panel.removeAttribute('hidden'); + toggle.setAttribute('aria-expanded', 'true'); + picker.classList.add('is-open'); + if (searchInput) { + searchInput.focus(); + } + } else { + panel.setAttribute('hidden', 'hidden'); + toggle.setAttribute('aria-expanded', 'false'); + picker.classList.remove('is-open'); + } + }; + + const applySelected = (scoid) => { + const info = meta[String(scoid)]; + if (!info || !nameInput || String(linkmode.value) !== '1') { + return; + } + if (info.name) { + nameInput.value = info.name; + nameInput.dispatchEvent(new Event('input', {bubbles: true})); + nameInput.dispatchEvent(new Event('change', {bubbles: true})); + } + if (info.starttime) { + setDatetimeFields('starttime', info.starttime); + } + if (info.endtime) { + const endEnable = document.querySelector('[name="endtimeenable"]'); + if (endEnable && !endEnable.checked) { + endEnable.checked = true; + endEnable.dispatchEvent(new Event('change', {bubbles: true})); + } + setDatetimeFields('endtime', info.endtime); + } + }; + + const selectMeeting = (m) => { + const scoid = String(m.scoid || '0'); + scoInput.value = scoid; + meta[scoid] = { + name: m.name || '', + urlpath: m.urlpath || '', + starttime: m.starttime || 0, + endtime: m.endtime || 0, + }; + if (metaEl) { + metaEl.setAttribute('data-meetings-meta', JSON.stringify(meta)); + } + if (labelEl) { + labelEl.textContent = m.label || m.name || strChoose; + } + if (listEl) { + listEl.querySelectorAll('.meetlet-combobox__option').forEach((btn) => { + btn.classList.toggle('is-selected', btn.getAttribute('data-scoid') === scoid); + }); + } + setOpen(false); + applySelected(scoid); + }; + + const renderList = (meetings) => { + if (!listEl) { + return; + } + const selected = String(scoInput.value || '0'); + listEl.innerHTML = ''; + const rows = meetings || []; + if (!rows.length) { + return; + } + rows.forEach((m) => { + const scoid = String(m.scoid); + meta[scoid] = { + name: m.name || '', + urlpath: m.urlpath || '', + starttime: m.starttime || 0, + endtime: m.endtime || 0, + }; + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'meetlet-combobox__option' + (scoid === selected ? ' is-selected' : ''); + btn.setAttribute('data-scoid', scoid); + btn.textContent = m.label || m.name || scoid; + btn.addEventListener('click', (e) => { + e.preventDefault(); + selectMeeting(m); + }); + li.appendChild(btn); + listEl.appendChild(li); + }); + if (metaEl) { + metaEl.setAttribute('data-meetings-meta', JSON.stringify(meta)); + } + }; + + const fetchMeetings = (query) => { + if (String(linkmode.value) !== '1' || readonly) { + return Promise.resolve(); + } + const seq = ++requestSeq; + if (hint) { + hint.textContent = strLoading; + } + return Ajax.call([{ + methodname: 'mod_meetletacbfree_search_server_meetings', + args: { + query: query || '', + limit: 10, + courseid: courseid, + }, + }])[0].then((res) => { + if (seq !== requestSeq) { + return null; + } + const meetings = (res && res.meetings) ? res.meetings : []; + renderList(meetings); + if (hint) { + hint.textContent = meetings.length ? '' : strEmpty; + } + loadedOnce = true; + return res; + }).catch((err) => { + if (hint) { + hint.textContent = (err && err.message) ? err.message : strEmpty; + } + Notification.exception(err); + }); + }; + + if (toggle) { + toggle.addEventListener('click', (e) => { + e.preventDefault(); + if (readonly || String(linkmode.value) !== '1') { + return; + } + const willOpen = panel.hasAttribute('hidden'); + setOpen(willOpen); + if (willOpen) { + fetchMeetings(searchInput ? String(searchInput.value || '').trim() : ''); + } + }); + } + + if (searchInput) { + searchInput.addEventListener('input', () => { + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + fetchMeetings(String(searchInput.value || '').trim()); + }, 350); + }); + } + + document.addEventListener('click', (e) => { + if (!picker.contains(e.target)) { + setOpen(false); + } + }); + + linkmode.addEventListener('change', () => { + if (String(linkmode.value) === '1') { + loadedOnce = false; + if (!readonly) { + setOpen(true); + fetchMeetings(''); + } + } else { + setOpen(false); + scoInput.value = '0'; + if (labelEl) { + labelEl.textContent = strChoose; + } + } + }); + + if (String(linkmode.value) === '1' && !readonly) { + // Prefetch quietly so first open is fast; do not force panel open on edit. + fetchMeetings(''); + } + }; + applyExistingMeeting(); + + const syncSettingOverrideInputs = () => { + document.querySelectorAll('[data-meetlet-setting-radio]').forEach((input) => { + if (input.getAttribute('data-meetlet-setting-bound') === '1') { + return; + } + input.setAttribute('data-meetlet-setting-bound', '1'); + input.addEventListener('change', () => { + const baseName = input.name.replace(/_state$/, ''); + const hidden = document.querySelector('input[type="hidden"][name="' + baseName + '"]'); + if (hidden && input.checked) { + if (baseName === 'participantsmode') { + hidden.value = input.value; + } else { + hidden.value = input.value === '2' ? '1' : '0'; + } + } + syncParticipantOverride(); + }); + }); + }; + + const participantModeInput = () => document.querySelector('input[name="participantsmode_state"]:checked'); + + const syncParticipantOverride = () => { + const mode = participantModeInput(); + const enabled = !mode || mode.value === '1'; + const container = document.querySelector('#fitem_id_static_participant_list'); + if (container) { + container.classList.toggle('is-disabled', !enabled); + } + const participantForm = document.querySelector('.meetlet-participant-form') || container; + if (participantForm) { + participantForm.querySelectorAll('select, button, input.participant-select').forEach((el) => { + el.disabled = !enabled; + }); + } + const table = ELEMENT_SELECTOR.participantTable(); + if (table) { + table.querySelectorAll('select, button').forEach((el) => { + el.disabled = !enabled; + }); + } + }; + syncSettingOverrideInputs(); + syncParticipantOverride(); + + const typeSelect = ELEMENT_SELECTOR.participantSelectionType(); + const addButton = ELEMENT_SELECTOR.participantAddButton(); + if (!typeSelect || !addButton || !ELEMENT_SELECTOR.participantData()) { + return; + } + + typeSelect.addEventListener('change', (e) => updateSelectionFromType(e.target)); + addButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + participantAddFromCurrentSelection(); + }); + document.querySelectorAll('input[name="participantsmode_state"]').forEach((input) => { + input.addEventListener('change', syncParticipantOverride); + }); + updateSelectionFromType(typeSelect); + participantListInit(); +}); diff --git a/javascript/settings_access_reset.js b/javascript/settings_access_reset.js new file mode 100644 index 0000000..e0c1b43 --- /dev/null +++ b/javascript/settings_access_reset.js @@ -0,0 +1,420 @@ +/** + * Reset-to-default for site role mapping and feature-access matrix (admin settings). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +(function () { + 'use strict'; + + var modalState = { + open: false, + resolve: null, + lastFocus: null, + onKey: null, + mode: 'confirm', + pendingSection: null, + }; + + function modalScroll() { + return window.MeetletModalScroll || { + lock: function () {}, + unlock: function () {}, + focusNoScroll: function (el) { + if (el && typeof el.focus === 'function') { + el.focus(); + } + }, + }; + } + + function byId(id) { + return document.getElementById(id); + } + + function parseStrings(root) { + if (!root) { + return {}; + } + try { + return JSON.parse(root.getAttribute('data-strings') || '{}') || {}; + } catch (e) { + return {}; + } + } + + function sectionContainer(section) { + if (section === 'rolemapping') { + return document.querySelector('[data-meetlet-rolemapping]'); + } + if (section === 'featureaccess') { + return document.querySelector('[data-meetlet-access-matrix]'); + } + return null; + } + + function applyRoleMatrix(container, matrix) { + if (!container || !matrix) { + return; + } + Object.keys(matrix).forEach(function (rowkey) { + var row = container.querySelector('[data-role-row="' + rowkey + '"]'); + if (!row) { + return; + } + var val = matrix[rowkey]; + var radio = row.querySelector('input[type="radio"][value="' + val + '"]'); + if (radio) { + radio.checked = true; + } + }); + } + + function applyFeatureMatrix(container, matrix) { + if (!container || !matrix) { + return; + } + container.querySelectorAll('.meetlet-access-check').forEach(function (cb) { + var role = cb.getAttribute('data-role'); + var feature = cb.getAttribute('data-feature'); + var on = matrix[role] && matrix[role][feature]; + cb.checked = !!on; + }); + } + + function renderModalMessage(bodyEl, mode, message) { + if (!bodyEl) { + return; + } + bodyEl.textContent = ''; + if (mode === 'busy') { + var wrap = document.createElement('div'); + wrap.className = 'meetlet-modal__busy'; + var spinner = document.createElement('div'); + spinner.className = 'meetlet-modal__spinner'; + spinner.setAttribute('aria-hidden', 'true'); + var text = document.createElement('p'); + text.className = 'meetlet-modal__lead'; + text.textContent = message || ''; + wrap.appendChild(spinner); + wrap.appendChild(text); + bodyEl.appendChild(wrap); + return; + } + var lead = document.createElement('p'); + lead.className = 'meetlet-modal__lead'; + lead.textContent = message || ''; + bodyEl.appendChild(lead); + } + + function closeModal(confirmed) { + var modal = byId('meetlet-access-reset-modal'); + if (!modal || !modalState.open) { + return; + } + modal.setAttribute('hidden', 'hidden'); + modal.style.display = 'none'; + modal.classList.remove('is-busy'); + modal.setAttribute('aria-busy', 'false'); + modalState.open = false; + modalScroll().unlock(); + if (modalState.onKey) { + document.removeEventListener('keydown', modalState.onKey, true); + modalState.onKey = null; + } + var resolve = modalState.resolve; + modalState.resolve = null; + modalState.pendingSection = null; + if (resolve) { + resolve(!!confirmed); + } + if (modalState.lastFocus && typeof modalState.lastFocus.focus === 'function') { + modalScroll().focusNoScroll(modalState.lastFocus); + } + modalState.lastFocus = null; + } + + function openModal(opts) { + opts = opts || {}; + var mode = opts.mode === 'alert' ? 'alert' : (opts.mode === 'busy' ? 'busy' : 'confirm'); + var modal = byId('meetlet-access-reset-modal'); + if (!modal) { + return Promise.resolve(false); + } + + if (modalState.open) { + if (modalState.mode === 'busy' && mode === 'alert') { + modalState.resolve = null; + } else { + closeModal(false); + } + } + + var str = opts.strings || {}; + var titleEl = byId('meetlet-access-reset-modal-title'); + var bodyEl = byId('meetlet-access-reset-modal-body'); + var confirmBtn = byId('meetlet-access-reset-modal-confirm'); + var cancelBtn = modal.querySelector('[data-meetlet-modal-dismiss].btn'); + var closeBtn = modal.querySelector('.meetlet-modal__close'); + + if (titleEl) { + titleEl.textContent = opts.title || ''; + } + renderModalMessage(bodyEl, mode, opts.message || ''); + modal.classList.toggle('is-busy', mode === 'busy'); + modal.setAttribute('aria-busy', mode === 'busy' ? 'true' : 'false'); + + if (confirmBtn) { + confirmBtn.textContent = opts.confirmLabel || (mode === 'alert' ? (str.modal_ok || 'OK') : (str.confirm_reset || 'Reset')); + confirmBtn.className = opts.confirmClass || (mode === 'alert' ? 'btn btn-primary' : 'btn btn-outline-danger'); + confirmBtn.hidden = mode === 'busy'; + confirmBtn.disabled = mode === 'busy'; + confirmBtn.style.display = mode === 'busy' ? 'none' : ''; + } + if (cancelBtn) { + cancelBtn.textContent = str.modal_cancel || 'Cancel'; + cancelBtn.hidden = mode !== 'confirm'; + cancelBtn.style.display = mode !== 'confirm' ? 'none' : ''; + } + if (closeBtn) { + if (str.modal_close) { + closeBtn.setAttribute('aria-label', str.modal_close); + } + closeBtn.hidden = mode === 'busy'; + closeBtn.style.display = mode === 'busy' ? 'none' : ''; + } + + var dir = document.documentElement.getAttribute('dir') || 'ltr'; + modal.setAttribute('dir', dir); + var dialog = modal.querySelector('.meetlet-modal__dialog'); + if (dialog) { + dialog.setAttribute('dir', dir); + } + + modalState.mode = mode; + if (!modalState.lastFocus) { + modalState.lastFocus = document.activeElement; + } + modalScroll().lock(); + modal.removeAttribute('hidden'); + modal.style.display = 'flex'; + modalState.open = true; + + if (modalState.onKey) { + document.removeEventListener('keydown', modalState.onKey, true); + } + modalState.onKey = function (e) { + if (!modalState.open) { + return; + } + if (e.key === 'Escape') { + if (modalState.mode === 'busy') { + e.preventDefault(); + e.stopPropagation(); + return; + } + e.preventDefault(); + e.stopPropagation(); + closeModal(false); + return; + } + if (e.key !== 'Tab' || modalState.mode === 'busy') { + return; + } + var focusable = modal.querySelectorAll( + 'button:not([disabled]):not([hidden]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + var visible = []; + for (var i = 0; i < focusable.length; i++) { + if (focusable[i].style.display === 'none') { + continue; + } + visible.push(focusable[i]); + } + if (!visible.length) { + return; + } + var first = visible[0]; + var last = visible[visible.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + modalScroll().focusNoScroll(last); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + modalScroll().focusNoScroll(first); + } + }; + document.addEventListener('keydown', modalState.onKey, true); + + if (mode !== 'busy' && confirmBtn && !confirmBtn.hidden) { + window.setTimeout(function () { + modalScroll().focusNoScroll(confirmBtn); + }, 20); + } + + if (mode === 'busy') { + modalState.resolve = null; + return Promise.resolve(true); + } + + return new Promise(function (resolve) { + modalState.resolve = resolve; + }); + } + + function bindModal() { + var modal = byId('meetlet-access-reset-modal'); + if (!modal || modal.dataset.bound === '1') { + return; + } + modal.dataset.bound = '1'; + modal.addEventListener('click', function (e) { + if (modalState.mode === 'busy') { + e.preventDefault(); + return; + } + if (e.target.closest('[data-meetlet-modal-dismiss]')) { + e.preventDefault(); + closeModal(false); + } + }); + var confirmBtn = byId('meetlet-access-reset-modal-confirm'); + if (confirmBtn) { + confirmBtn.addEventListener('click', function (e) { + e.preventDefault(); + if (modalState.mode === 'busy') { + return; + } + closeModal(true); + }); + } + } + + function postReset(root, section, str) { + var endpoint = root.getAttribute('data-endpoint') || ''; + var sesskey = root.getAttribute('data-sesskey') || ''; + if (!endpoint || !sesskey) { + return Promise.reject(new Error(str.error || 'Error')); + } + var url = endpoint + (endpoint.indexOf('?') >= 0 ? '&' : '?') + + 'sesskey=' + encodeURIComponent(sesskey) + + '§ion=' + encodeURIComponent(section); + return fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + }, + }).then(function (resp) { + return resp.json(); + }); + } + + function confirmCopy(section, str) { + if (section === 'rolemapping') { + return { + title: str.confirm_rolemapping_title || '', + message: str.confirm_rolemapping || '', + }; + } + return { + title: str.confirm_featureaccess_title || '', + message: str.confirm_featureaccess || '', + }; + } + + function successCopy(section, str, serverMessage) { + if (serverMessage) { + return serverMessage; + } + if (section === 'rolemapping') { + return str.success_rolemapping || ''; + } + return str.success_featureaccess || ''; + } + + function handleResetClick(btn, root, str) { + var section = btn.getAttribute('data-meetlet-reset-section') || ''; + if (!section) { + return; + } + var copy = confirmCopy(section, str); + openModal({ + mode: 'confirm', + strings: str, + title: copy.title, + message: copy.message, + confirmClass: 'btn btn-outline-danger', + }).then(function (confirmed) { + if (!confirmed) { + return; + } + openModal({ + mode: 'busy', + strings: str, + title: str.busy || '', + message: str.busy || '', + }); + btn.disabled = true; + postReset(root, section, str).then(function (data) { + btn.disabled = false; + if (!data || !data.success) { + openModal({ + mode: 'alert', + strings: str, + title: str.error || 'Error', + message: (data && data.error) || str.error || 'Error', + confirmClass: 'btn btn-primary', + }); + return; + } + var container = sectionContainer(section); + if (section === 'rolemapping') { + applyRoleMatrix(container, data.matrix); + } else { + applyFeatureMatrix(container, data.matrix); + } + openModal({ + mode: 'alert', + strings: str, + title: copy.title, + message: successCopy(section, str, data.message), + confirmClass: 'btn btn-primary', + }); + }).catch(function () { + btn.disabled = false; + openModal({ + mode: 'alert', + strings: str, + title: str.error || 'Error', + message: str.error || 'Error', + confirmClass: 'btn btn-primary', + }); + }); + }); + } + + function init() { + var root = byId('meetlet-access-reset-root'); + if (!root) { + return; + } + var str = parseStrings(root); + bindModal(); + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-meetlet-reset-section]'); + if (!btn || btn.disabled) { + return; + } + e.preventDefault(); + handleResetClick(btn, root, str); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/javascript/settings_conntest.js b/javascript/settings_conntest.js new file mode 100644 index 0000000..4f9874b --- /dev/null +++ b/javascript/settings_conntest.js @@ -0,0 +1,307 @@ +(function () { + 'use strict'; + + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + function el(tag, cls) { + var node = document.createElement(tag); + if (cls) { + node.className = cls; + } + return node; + } + + function text(tag, cls, value) { + var node = el(tag, cls); + node.textContent = value || ''; + return node; + } + + function parseLabels(raw) { + try { + return JSON.parse(raw || '{}') || {}; + } catch (e) { + return {}; + } + } + + function setProgress(root, current, total, strings) { + var fill = root.querySelector('[data-meetlet-progress-fill]'); + var label = root.querySelector('[data-meetlet-progress-label]'); + var pct = total ? Math.round((current / total) * 100) : 0; + if (fill) { + fill.style.width = pct + '%'; + } + if (label) { + label.textContent = strings.progress + .replace('{$a->current}', String(current)) + .replace('{$a->total}', String(total)) + .replace('{$a->percent}', String(pct)); + } + } + + function buildCard(index, total, step, label, strings) { + var wrap = el('div', 'meetlet-conntest__card meetlet-conntest__card--pending'); + wrap.setAttribute('data-step', step); + wrap.setAttribute('data-index', String(index)); + + var top = el('div', 'meetlet-conntest__rowtop'); + top.appendChild(text('span', 'meetlet-conntest__num', String(index + 1))); + top.appendChild(el('span', 'meetlet-dot meetlet-dot--pending')); + top.appendChild(text('span', 'meetlet-conntest__label', label)); + top.appendChild(text('span', 'meetlet-pill meetlet-pill--pending', strings.waiting)); + wrap.appendChild(top); + wrap.appendChild(text('div', 'meetlet-conntest__detail meetlet-conntest__card-detail', '')); + return wrap; + } + + function setCardState(card, state, strings, detail) { + card.className = 'meetlet-conntest__card meetlet-conntest__card--' + state; + var dot = card.querySelector('.meetlet-dot'); + var pill = card.querySelector('.meetlet-pill'); + var detailNode = card.querySelector('.meetlet-conntest__card-detail'); + if (dot) { + dot.className = 'meetlet-dot meetlet-dot--' + state; + } + if (pill) { + pill.className = 'meetlet-pill meetlet-pill--' + state; + if (state === 'pending') { + pill.textContent = strings.waiting; + } else if (state === 'running') { + pill.textContent = strings.runningstep; + } else if (state === 'ok') { + pill.textContent = strings.ok; + } else if (state === 'fail') { + pill.textContent = strings.fail; + } else if (state === 'skipped') { + pill.textContent = strings.skipped; + } + } + if (detailNode) { + detailNode.textContent = detail || ''; + detailNode.style.display = detail ? '' : 'none'; + } + } + + function setSubResults(card, subresults, strings) { + var wrap = card.querySelector('[data-meetlet-subresults]'); + if (!wrap) { + wrap = el('div', 'meetlet-conntest__subs'); + wrap.setAttribute('data-meetlet-subresults', '1'); + card.appendChild(wrap); + } + wrap.innerHTML = ''; + if (!subresults || !subresults.length) { + wrap.style.display = 'none'; + return; + } + wrap.style.display = ''; + subresults.forEach(function (sub) { + var substate = 'fail'; + var pillText = strings.fail; + if (sub.skipped) { + substate = 'skipped'; + pillText = strings.skipped; + } else if (sub.ok) { + substate = 'ok'; + pillText = strings.ok; + } + var subcard = el('div', 'meetlet-conntest__sub meetlet-conntest__sub--' + substate); + var top = el('div', 'meetlet-conntest__rowtop'); + top.appendChild(el('span', 'meetlet-dot meetlet-dot--' + substate)); + top.appendChild(text('span', 'meetlet-conntest__sublabel', sub.label || sub.id || '')); + top.appendChild(text('span', 'meetlet-pill meetlet-pill--' + substate, pillText)); + subcard.appendChild(top); + if (sub.detail) { + subcard.appendChild(text('div', 'meetlet-conntest__detail', sub.detail)); + } + wrap.appendChild(subcard); + }); + } + + function sleep(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); + } + + ready(function () { + var SAVE_ICON = ''; + + document.querySelectorAll('#adminsettings .offset-sm-3').forEach(function (el) { + if (!el.querySelector('button.btn-primary, input.btn-primary[type="submit"]')) { + return; + } + el.classList.remove('offset-sm-3', 'col-sm-3', 'col-md-3'); + el.classList.add('col-12', 'meetlet-savebar'); + }); + + document.querySelectorAll( + '#adminsettings button.btn-primary[type="submit"],' + + ' #adminsettings input.btn-primary[type="submit"]' + ).forEach(function (btn) { + if (btn.getAttribute('data-meetlet-save-icon') === '1') { + return; + } + btn.setAttribute('data-meetlet-save-icon', '1'); + btn.classList.add('meetlet-btn-with-icon'); + if (btn.tagName === 'INPUT') { + var replacement = document.createElement('button'); + replacement.type = 'submit'; + replacement.name = btn.name || 'submitbutton'; + replacement.value = btn.value || ''; + replacement.id = btn.id || ''; + replacement.className = btn.className; + replacement.setAttribute('data-meetlet-save-icon', '1'); + replacement.innerHTML = SAVE_ICON + + '' + + (btn.value || btn.getAttribute('value') || 'Save changes') + + ''; + btn.parentNode.replaceChild(replacement, btn); + return; + } + if (btn.querySelector('svg')) { + return; + } + var label = (btn.textContent || '').trim() || 'Save changes'; + btn.textContent = ''; + btn.insertAdjacentHTML('afterbegin', SAVE_ICON); + var span = document.createElement('span'); + span.className = 'meetlet-btn-with-icon__label'; + span.textContent = label; + btn.appendChild(span); + }); + + var root = document.getElementById('meetlet-acb-conntest'); + if (!root) { + return; + } + var btn = root.querySelector('[data-meetlet-run-test]'); + var grid = root.querySelector('[data-meetlet-test-grid]'); + var status = root.querySelector('[data-meetlet-test-status]'); + var progressWrap = root.querySelector('[data-meetlet-progress]'); + if (!btn || !grid) { + return; + } + + var endpoint = root.getAttribute('data-endpoint'); + var sesskey = root.getAttribute('data-sesskey'); + var steps = (root.getAttribute('data-steps') || 'login,folders,user,sso,cleanup') + .split(',') + .map(function (s) { return s.trim(); }) + .filter(Boolean); + var labels = parseLabels(root.getAttribute('data-step-labels')); + var strings = { + ok: root.getAttribute('data-str-ok') || 'OK', + fail: root.getAttribute('data-str-fail') || 'Fail', + running: root.getAttribute('data-str-running') || 'Running…', + idle: root.getAttribute('data-str-idle') || '', + waiting: root.getAttribute('data-str-waiting') || 'Waiting', + runningstep: root.getAttribute('data-str-runningstep') || 'Testing', + skipped: root.getAttribute('data-str-skipped') || 'Skipped', + progress: root.getAttribute('data-str-progress') || 'Step {$a->current} of {$a->total} ({$a->percent}%)' + }; + + btn.addEventListener('click', function () { + if (btn.disabled) { + return; + } + btn.disabled = true; + grid.innerHTML = ''; + if (progressWrap) { + progressWrap.hidden = false; + } + if (status) { + status.textContent = strings.running; + } + + var cards = {}; + steps.forEach(function (step, index) { + var label = labels[step] || step; + var card = buildCard(index, steps.length, step, label, strings); + setCardState(card, 'pending', strings, ''); + grid.appendChild(card); + cards[step] = card; + }); + setProgress(root, 0, steps.length, strings); + + (async function () { + var skipToCleanup = false; + var completed = 0; + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + var card = cards[step]; + + if (skipToCleanup && step !== 'cleanup') { + setCardState(card, 'skipped', strings, ''); + completed++; + setProgress(root, completed, steps.length, strings); + continue; + } + + setCardState(card, 'running', strings, ''); + setProgress(root, completed, steps.length, strings); + + var url = endpoint + + (endpoint.indexOf('?') === -1 ? '?' : '&') + + 'step=' + encodeURIComponent(step) + + '&sesskey=' + encodeURIComponent(sesskey); + var response = await fetch(url, { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } + }); + var result = await response.json(); + setCardState( + card, + result.ok ? 'ok' : 'fail', + strings, + result.detail || '' + ); + if (result.subresults && result.subresults.length) { + setSubResults(card, result.subresults, strings); + } + completed++; + setProgress(root, completed, steps.length, strings); + await sleep(220); + + if (result.abort || (result.done && step !== 'cleanup')) { + skipToCleanup = true; + if (result.done && step !== 'cleanup') { + // Mark remaining non-cleanup as skipped, then stop if no cleanup needed. + for (var j = i + 1; j < steps.length; j++) { + if (steps[j] === 'cleanup') { + continue; + } + setCardState(cards[steps[j]], 'skipped', strings, ''); + completed++; + } + // Still run cleanup if present later in loop via skipToCleanup. + } + if (step === 'cleanup') { + break; + } + } + } + setProgress(root, steps.length, steps.length, strings); + })().catch(function (err) { + var firstPending = grid.querySelector('.meetlet-conntest__card--pending, .meetlet-conntest__card--running'); + if (firstPending) { + setCardState(firstPending, 'fail', strings, String(err && err.message ? err.message : err)); + } + }).finally(function () { + btn.disabled = false; + if (status) { + status.textContent = strings.idle; + } + }); + }); + }); +})(); diff --git a/javascript/settings_registration.js b/javascript/settings_registration.js new file mode 100644 index 0000000..defe0c8 --- /dev/null +++ b/javascript/settings_registration.js @@ -0,0 +1,334 @@ +/** + * Inline Ajax site registration on plugin settings tab (Marketplace free edition). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +(function () { + 'use strict'; + + function byId(id) { + return document.getElementById(id); + } + + function setFlash(el, ok, message) { + if (!el) { + return; + } + el.className = 'alert mt-3 ' + (ok ? 'alert-success' : 'alert-danger'); + el.textContent = message || ''; + el.classList.toggle('d-none', !message); + } + + function setActiveUi(active) { + var inactive = byId('meetlet-registration-actions-inactive'); + var activeEl = byId('meetlet-registration-actions-active'); + var product = byId('meetlet-registration-product'); + if (inactive) { + inactive.classList.toggle('d-none', !!active); + } + if (activeEl) { + activeEl.classList.toggle('d-none', !active); + } + if (product) { + product.classList.toggle('d-none', !active); + } + } + + function renderSummary(summary) { + if (!summary) { + return; + } + var badge = byId('meetlet-registration-status-badge'); + var cfg = byId('meetlet-registration-config'); + if (badge) { + badge.className = 'badge meetlet-license-badge ' + + (summary.active ? 'meetlet-license-badge--active' : 'meetlet-license-badge--inactive'); + badge.textContent = summary.status_label + || (summary.active + ? (cfg && cfg.dataset.labelActive) || 'Active' + : (cfg && cfg.dataset.labelInactive) || 'Inactive'); + } + var domain = byId('meetlet-registration-domain'); + if (domain && cfg && cfg.dataset.labelDomain) { + domain.textContent = cfg.dataset.labelDomain.replace('__V__', summary.domain || '—'); + } + var installation = byId('meetlet-registration-installation'); + if (installation && cfg && cfg.dataset.labelInstallation) { + installation.textContent = cfg.dataset.labelInstallation.replace('__V__', summary.installation_id || '—'); + } + var product = byId('meetlet-registration-product'); + if (product) { + product.textContent = summary.product_label || ''; + } + var err = byId('meetlet-registration-error'); + if (err) { + if (summary.error) { + err.textContent = summary.error; + err.classList.remove('d-none'); + } else { + err.textContent = ''; + err.classList.add('d-none'); + } + } + setActiveUi(!!summary.active); + } + + function currentApiUrl(cfg) { + var input = cfg && cfg.dataset.apiInput ? document.getElementById(cfg.dataset.apiInput) : null; + if (input && input.value) { + return input.value.trim(); + } + return ''; + } + + var modalState = { + open: false, + resolve: null, + lastFocus: null, + onKey: null, + }; + + function ensureModalOnBody(modal) { + if (modal && modal.parentNode !== document.body) { + document.body.appendChild(modal); + } + return modal; + } + + function modalScroll() { + return window.MeetletModalScroll || { + lock: function () {}, + unlock: function () {}, + focusNoScroll: function (el) { + if (el && typeof el.focus === 'function') { + el.focus(); + } + }, + }; + } + + function closeRegistrationModal(result) { + var modal = byId('meetlet-registration-modal'); + if (!modal) { + return; + } + modal.setAttribute('hidden', 'hidden'); + modal.style.display = 'none'; + modalScroll().unlock(); + if (modalState.onKey) { + document.removeEventListener('keydown', modalState.onKey, true); + modalState.onKey = null; + } + modalState.open = false; + var resolve = modalState.resolve; + modalState.resolve = null; + if (modalState.lastFocus) { + modalScroll().focusNoScroll(modalState.lastFocus); + } + modalState.lastFocus = null; + if (typeof resolve === 'function') { + resolve(!!result); + } + } + + function openRegistrationModal(opts) { + var modal = ensureModalOnBody(byId('meetlet-registration-modal')); + var cfg = byId('meetlet-registration-config'); + if (!modal) { + return Promise.resolve(false); + } + if (modalState.open) { + closeRegistrationModal(false); + } + + var title = byId('meetlet-registration-modal-title'); + var body = byId('meetlet-registration-modal-body'); + var confirmBtn = byId('meetlet-registration-modal-confirm'); + var tpl = opts.templateId ? document.getElementById(opts.templateId) : null; + if (!title || !body || !confirmBtn) { + return Promise.resolve(false); + } + + title.textContent = opts.title || ''; + if (tpl) { + body.innerHTML = ''; + if (tpl.content && tpl.content.cloneNode) { + body.appendChild(tpl.content.cloneNode(true)); + } else { + body.innerHTML = tpl.innerHTML; + } + } else { + body.innerHTML = ''; + } + confirmBtn.textContent = opts.confirmLabel || ''; + confirmBtn.className = 'btn ' + (opts.danger ? 'btn-outline-danger' : 'btn-primary'); + + modalState.lastFocus = document.activeElement; + modalScroll().lock(); + modal.removeAttribute('hidden'); + modal.style.display = 'flex'; + modalState.open = true; + + modalState.onKey = function (e) { + if (!modalState.open) { + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeRegistrationModal(false); + return; + } + if (e.key !== 'Tab') { + return; + } + var focusable = modal.querySelectorAll( + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (!focusable.length) { + return; + } + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + modalScroll().focusNoScroll(last); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + modalScroll().focusNoScroll(first); + } + }; + document.addEventListener('keydown', modalState.onKey, true); + + window.setTimeout(function () { + modalScroll().focusNoScroll(confirmBtn); + }, 20); + + return new Promise(function (resolve) { + modalState.resolve = resolve; + }); + } + + function postAction(action) { + var cfg = byId('meetlet-registration-config'); + var flash = byId('meetlet-registration-flash'); + if (!cfg) { + return; + } + var body = new URLSearchParams(); + body.set('action', action); + body.set('sesskey', cfg.dataset.sesskey || ''); + body.set('registration_api_url', currentApiUrl(cfg)); + + setFlash(flash, true, '…'); + fetch(cfg.dataset.ajaxurl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + credentials: 'same-origin', + body: body.toString(), + }) + .then(function (res) { + return res.json(); + }) + .then(function (data) { + if (data.summary) { + renderSummary(data.summary); + } + if (data.success) { + setFlash(flash, true, data.message); + if (action === 'activate' || action === 'deactivate') { + window.setTimeout(function () { + window.location.reload(); + }, 600); + } + } else { + setFlash(flash, false, data.error || 'Request failed'); + } + }) + .catch(function (err) { + setFlash(flash, false, err && err.message ? err.message : 'Request failed'); + }); + } + + function bindModalChrome() { + var modal = byId('meetlet-registration-modal'); + if (!modal || modal.dataset.bound === '1') { + return; + } + modal.dataset.bound = '1'; + modal.addEventListener('click', function (e) { + if (e.target.closest('[data-meetlet-modal-dismiss]')) { + e.preventDefault(); + closeRegistrationModal(false); + } + }); + var confirmBtn = byId('meetlet-registration-modal-confirm'); + if (confirmBtn) { + confirmBtn.addEventListener('click', function (e) { + e.preventDefault(); + closeRegistrationModal(true); + }); + } + } + + function bindDeactivateButton(btn) { + if (!btn || btn.dataset.bound === '1') { + return; + } + var cfg = byId('meetlet-registration-config'); + btn.dataset.bound = '1'; + btn.addEventListener('click', function () { + openRegistrationModal({ + title: (cfg && cfg.dataset.confirmDeactivateTitle) || '', + templateId: 'meetlet-registration-tpl-deactivate', + confirmLabel: (cfg && cfg.dataset.confirmDeactivateOk) || '', + danger: true, + }).then(function (ok) { + if (ok) { + postAction('deactivate'); + } + }); + }); + } + + function bind() { + var activate = byId('meetlet-registration-activate'); + var recheck = byId('meetlet-registration-recheck'); + var deactivate = byId('meetlet-registration-deactivate'); + var cfg = byId('meetlet-registration-config'); + if ((!activate && !recheck) || (activate && activate.dataset.bound === '1')) { + return; + } + bindModalChrome(); + if (activate) { + activate.dataset.bound = '1'; + activate.addEventListener('click', function () { + openRegistrationModal({ + title: (cfg && cfg.dataset.confirmActivateTitle) || '', + templateId: 'meetlet-registration-tpl-activate', + confirmLabel: (cfg && cfg.dataset.confirmActivateOk) || '', + danger: false, + }).then(function (ok) { + if (ok) { + postAction('activate'); + } + }); + }); + } + if (recheck) { + recheck.dataset.bound = '1'; + recheck.addEventListener('click', function () { + postAction('recheck'); + }); + } + bindDeactivateButton(deactivate); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bind); + } else { + bind(); + } +})(); diff --git a/javascript/settings_toggles.js b/javascript/settings_toggles.js new file mode 100644 index 0000000..d5fdf08 --- /dev/null +++ b/javascript/settings_toggles.js @@ -0,0 +1,146 @@ +/** + * Replace Moodle checkboxes with MeetLet pill switches (check / cross). + */ +(function () { + 'use strict'; + + function enhanceInput(input) { + if (!input || input.getAttribute('data-meetlet-switch') === '1') { + return; + } + if (input.type !== 'checkbox') { + return; + } + input.setAttribute('data-meetlet-switch', '1'); + input.classList.add('meetlet-switch__input'); + + var compact = input.classList.contains('meetlet-access-check'); + var switchClass = compact ? 'meetlet-switch meetlet-switch--compact' : 'meetlet-switch'; + + var track = document.createElement('span'); + track.className = 'meetlet-switch__track'; + track.setAttribute('aria-hidden', 'true'); + var thumb = document.createElement('span'); + thumb.className = 'meetlet-switch__thumb'; + track.appendChild(thumb); + + var parentLabel = input.closest('label'); + if (parentLabel && parentLabel.contains(input)) { + parentLabel.classList.add('meetlet-switch'); + if (compact) { + parentLabel.classList.add('meetlet-switch--compact'); + } + if (input.nextSibling) { + parentLabel.insertBefore(track, input.nextSibling); + } else { + parentLabel.appendChild(track); + } + return; + } + + var wrap = document.createElement('label'); + wrap.className = switchClass; + if (input.id) { + wrap.setAttribute('for', input.id); + } + input.parentNode.insertBefore(wrap, input); + wrap.appendChild(input); + wrap.appendChild(track); + } + + function run() { + var adminRoot = document.querySelector('#adminsettings'); + if (adminRoot) { + adminRoot.querySelectorAll('.form-setting input[type="checkbox"]').forEach(enhanceInput); + } + document.querySelectorAll('.meetlet-access-check').forEach(enhanceInput); + var form = document.getElementById('mform1') || document.querySelector('form.mform'); + if (form) { + form.querySelectorAll( + ' input[name="autooffline"], input[name="showzip"], input[name="showmp4"],' + + ' input[name="showmakeoffline"], input[name="showrecordingstab"], input[name="showcontenttab"],' + + ' input[name="featureaccess_override"], input[name="default_waitforhost"],' + + ' input[name="waitforhost"], input[name="https"]' + ).forEach(enhanceInput); + } + bindHourPicker(); + } + + function selectedHours(wrap) { + var hours = []; + wrap.querySelectorAll('.meetlet-hourchip.is-on').forEach(function (btn) { + hours.push(parseInt(btn.getAttribute('data-hour'), 10)); + }); + hours.sort(function (a, b) { return a - b; }); + return hours; + } + + function applyHours(wrap, list) { + var set = {}; + (list || []).forEach(function (h) { set[String(h)] = true; }); + wrap.querySelectorAll('.meetlet-hourchip').forEach(function (btn) { + var on = !!set[btn.getAttribute('data-hour')]; + btn.classList.toggle('is-on', on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + var input = wrap.querySelector('[data-meetlet-hours-input]'); + if (input) { + input.value = selectedHours(wrap).join(','); + } + } + + function syncHoursEnabled() { + var sel = document.querySelector('select[name="s_meetletacbfree_agent_dispatch"]') + || document.getElementById('id_s_meetletacbfree_agent_dispatch'); + var wrap = document.querySelector('[data-meetlet-hours-wrap]'); + if (!wrap) { + return; + } + var item = wrap.closest('.form-item'); + var windowMode = !sel || sel.value === 'window'; + wrap.classList.toggle('is-dimmed', !windowMode); + if (item) { + item.classList.toggle('is-dimmed', !windowMode); + } + } + + function bindHourPicker() { + var wrap = document.querySelector('[data-meetlet-hours-wrap]'); + if (!wrap || wrap.getAttribute('data-bound') === '1') { + return; + } + wrap.setAttribute('data-bound', '1'); + wrap.addEventListener('click', function (e) { + var chip = e.target.closest('.meetlet-hourchip'); + if (chip && wrap.contains(chip)) { + e.preventDefault(); + chip.classList.toggle('is-on'); + chip.setAttribute('aria-pressed', chip.classList.contains('is-on') ? 'true' : 'false'); + applyHours(wrap, selectedHours(wrap)); + return; + } + var preset = e.target.closest('.meetlet-hourpreset'); + if (preset && wrap.contains(preset)) { + e.preventDefault(); + var raw = preset.getAttribute('data-hours') || ''; + var list = raw === '' ? [] : raw.split(',').map(function (x) { return parseInt(x, 10); }); + applyHours(wrap, list); + } + }); + var sel = document.querySelector('select[name="s_meetletacbfree_agent_dispatch"]') + || document.getElementById('id_s_meetletacbfree_agent_dispatch'); + if (sel) { + sel.addEventListener('change', syncHoursEnabled); + if (document.documentElement.getAttribute('dir') === 'rtl') { + sel.setAttribute('dir', 'rtl'); + } + } + syncHoursEnabled(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } +})(); diff --git a/javascript/view.js b/javascript/view.js new file mode 100644 index 0000000..b5a748d --- /dev/null +++ b/javascript/view.js @@ -0,0 +1,1029 @@ +/** + * MeetLet activity view — page-local only (not AMD). + * Loaded only on mod/meetletacbfree/view.php via $PAGE->requires->js(). + */ +require(['core/ajax', 'core/notification', 'core/str'], function(Ajax, Notification, Str) { + const SELECTORS = { + config: '[data-meetlet-config]', + app: '.meetlet-app', + tab: '.meetlet-tab', + panel: '[data-panel]', + join: '[data-action="join"]', + offline: '[data-action="makeoffline"]', + launchRecording: '[data-action="launch-recording"]', + launchContent: '[data-action="launch-content"]', + downloadContent: '[data-action="download-content"]', + refresh: '[data-action="refresh-tab"]', + overlay: '[data-join-overlay]', + sessionDetails: '[data-action="session-details"]', + modal: '[data-attendance-modal]', + modalBody: '[data-attendance-modal-body]', + modalTitle: '[data-attendance-modal-title]', + modalClose: '[data-attendance-modal-close]', + }; + + let state = { + cmid: 0, + groupid: 0, + current: 'details', + cache: {}, + joinable: true, + status: 'open', + statusMessage: '', + pollSeconds: 15, + statusTimer: null, + recPollTimer: null, + licenseUsable: true, + }; + + const RECORDINGS_POLL_MS = 60000; + const DOWNLOAD_CONTENT_TIMEOUT_MS = 320000; + let modalLabels = { + name: '', + login: '', + timein: '', + timeout: '', + empty: '', + }; + + const detailsHtml = () => { + const tpl = document.querySelector('[data-meetlet-details-html]'); + return tpl ? tpl.innerHTML : ''; + }; + + const setLoading = (panel, text) => { + panel.innerHTML = '
' + + text + '
'; + }; + + const showDetails = (panel) => { + panel.innerHTML = detailsHtml(); + }; + + const escapeHtml = (value) => { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + + let floatTipEl = null; + let floatTipTarget = null; + + const ensureFloatTip = () => { + if (floatTipEl && document.body.contains(floatTipEl)) { + return floatTipEl; + } + floatTipEl = document.createElement('div'); + floatTipEl.className = 'meetlet-floattip'; + floatTipEl.setAttribute('role', 'tooltip'); + floatTipEl.hidden = true; + document.body.appendChild(floatTipEl); + return floatTipEl; + }; + + const hideFloatTip = () => { + floatTipTarget = null; + const el = ensureFloatTip(); + el.classList.remove('is-visible', 'is-below'); + el.hidden = true; + el.textContent = ''; + }; + + const placeFloatTip = (target) => { + const text = (target.getAttribute('data-tip') || '').trim(); + if (!text) { + hideFloatTip(); + return; + } + floatTipTarget = target; + const el = ensureFloatTip(); + el.textContent = text; + el.hidden = false; + el.classList.remove('is-visible', 'is-below'); + el.style.left = '0px'; + el.style.top = '0px'; + const tipW = el.offsetWidth; + const tipH = el.offsetHeight; + const r = target.getBoundingClientRect(); + let left = r.left + (r.width / 2) - (tipW / 2); + left = Math.max(8, Math.min(left, window.innerWidth - tipW - 8)); + let top = r.top - tipH - 10; + let below = false; + if (top < 8) { + top = r.bottom + 10; + below = true; + } + if (top + tipH > window.innerHeight - 8 && !below) { + top = Math.max(8, r.bottom + 10); + below = true; + } + el.style.left = left + 'px'; + el.style.top = top + 'px'; + el.classList.toggle('is-below', below); + requestAnimationFrame(() => { + if (floatTipTarget === target) { + el.classList.add('is-visible'); + } + }); + }; + + const bindFloatTips = (root) => { + if (!root || root.dataset.meetletTipsBound === '1') { + return; + } + root.dataset.meetletTipsBound = '1'; + root.addEventListener('pointerover', (e) => { + const tip = e.target.closest('[data-tip]'); + if (!tip || !root.contains(tip)) { + return; + } + placeFloatTip(tip); + }); + root.addEventListener('pointerout', (e) => { + const tip = e.target.closest('[data-tip]'); + if (!tip || !root.contains(tip)) { + return; + } + const next = e.relatedTarget; + if (next && tip.contains(next)) { + return; + } + if (floatTipTarget === tip) { + hideFloatTip(); + } + }); + root.addEventListener('focusin', (e) => { + const tip = e.target.closest('[data-tip]'); + if (tip && root.contains(tip)) { + placeFloatTip(tip); + } + }); + root.addEventListener('focusout', (e) => { + const tip = e.target.closest('[data-tip]'); + if (!tip) { + return; + } + const next = e.relatedTarget; + if (next && tip.contains(next)) { + return; + } + if (floatTipTarget === tip) { + hideFloatTip(); + } + }); + }; + + const modalScroll = () => window.MeetletModalScroll || { + lock: () => {}, + unlock: () => {}, + focusNoScroll: (el) => { + if (el && typeof el.focus === 'function') { + el.focus(); + } + }, + }; + + const closeModal = () => { + const modal = document.querySelector(SELECTORS.modal); + if (!modal) { + return; + } + modal.setAttribute('hidden', 'hidden'); + modal.style.display = 'none'; + modalScroll().unlock(); + }; + + const ensureModalShell = () => { + let modal = document.querySelector(SELECTORS.modal); + if (modal) { + return modal; + } + modal = document.createElement('div'); + modal.className = 'meetlet-modal'; + modal.setAttribute('data-attendance-modal', '1'); + modal.setAttribute('hidden', 'hidden'); + modal.style.display = 'none'; + modal.innerHTML = + '
' + + ''; + document.body.appendChild(modal); + modal.addEventListener('click', (e) => { + if (e.target.closest(SELECTORS.modalClose)) { + closeModal(); + } + }); + return modal; + }; + + const openModal = (title, attendees) => { + const modal = ensureModalShell(); + const titleEl = modal.querySelector(SELECTORS.modalTitle); + const body = modal.querySelector(SELECTORS.modalBody); + if (titleEl) { + titleEl.textContent = title || ''; + } + if (!attendees || !attendees.length) { + body.innerHTML = '
' + escapeHtml(modalLabels.empty) + '
'; + } else { + let rows = ''; + attendees.forEach((row) => { + rows += '' + + '' + escapeHtml(row.name || '') + '' + + '' + escapeHtml(row.login || '') + '' + + '' + escapeHtml(row.timein || '-') + '' + + '' + escapeHtml(row.timeout || '-') + '' + + ''; + }); + body.innerHTML = + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '
' + escapeHtml(modalLabels.name) + '' + escapeHtml(modalLabels.login) + '' + escapeHtml(modalLabels.timein) + '' + escapeHtml(modalLabels.timeout) + '
'; + } + modalScroll().lock(); + modal.removeAttribute('hidden'); + modal.style.display = 'flex'; + }; + + const readSessionsPayload = (panel) => { + const holder = panel.querySelector('[data-meetlet-sessions-json]'); + if (!holder) { + return []; + } + try { + return JSON.parse(holder.textContent || '[]'); + } catch (e) { + return []; + } + }; + + const setButtonBusy = (btn, busy) => { + if (!btn) { + return; + } + if (btn.tagName !== 'A') { + btn.disabled = !!busy; + } + btn.classList.toggle('is-busy', !!busy); + if (busy) { + btn.setAttribute('aria-busy', 'true'); + if (!btn.querySelector('.meetlet-iconbtn__spinner')) { + const spin = document.createElement('span'); + spin.className = 'meetlet-iconbtn__spinner'; + spin.setAttribute('aria-hidden', 'true'); + btn.appendChild(spin); + } + } else { + btn.removeAttribute('aria-busy'); + const spin = btn.querySelector('.meetlet-iconbtn__spinner'); + if (spin) { + spin.remove(); + } + } + }; + + const parseDownloadFilename = (disposition, fallback) => { + if (!disposition) { + return fallback; + } + const utf8 = disposition.match(/filename\*=UTF-8''([^;\n]+)/i); + if (utf8 && utf8[1]) { + try { + return decodeURIComponent(utf8[1].trim()); + } catch (e) { + return utf8[1].trim(); + } + } + const quoted = disposition.match(/filename="([^"]+)"/i); + if (quoted && quoted[1]) { + return quoted[1].trim(); + } + const plain = disposition.match(/filename=([^;\n]+)/i); + if (plain && plain[1]) { + return plain[1].trim().replace(/^"|"$/g, ''); + } + return fallback; + }; + + const triggerFileDownload = (blob, filename) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename || 'download'; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + const notifyDownloadError = (err) => { + const isAbort = err && err.name === 'AbortError'; + const key = isAbort ? 'downloadcontenttimeout' : 'downloadcontentfailed'; + return Str.get_string(key, 'meetletacbfree').then((message) => { + Notification.addNotification({message: message, type: 'error'}); + return null; + }).catch(() => { + Notification.addNotification({ + message: isAbort ? 'Download timed out.' : 'Download failed.', + type: 'error', + }); + }); + }; + + const downloadContentFile = (link) => { + const url = link.getAttribute('href'); + const fallbackName = link.getAttribute('download') || 'download'; + if (!url || link.classList.contains('is-busy')) { + return Promise.resolve(); + } + setButtonBusy(link, true); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), DOWNLOAD_CONTENT_TIMEOUT_MS); + return fetch(url, { + credentials: 'same-origin', + signal: controller.signal, + }).then((response) => { + if (!response.ok) { + throw new Error('http-' + response.status); + } + const filename = parseDownloadFilename( + response.headers.get('Content-Disposition'), + fallbackName + ); + // Proxy delay is over once headers arrive — re-enable before blob finishes. + setButtonBusy(link, false); + return response.blob().then((blob) => { + if (!blob || blob.size <= 0) { + throw new Error('empty'); + } + triggerFileDownload(blob, filename); + }); + }).catch((err) => { + setButtonBusy(link, false); + return notifyDownloadError(err); + }).finally(() => { + window.clearTimeout(timer); + }); + }; + + const endInlineRename = (wrap, restore) => { + const text = wrap.querySelector('.meetlet-recname__text'); + const input = wrap.querySelector('.meetlet-recname__input'); + const btn = wrap.querySelector('[data-action="inline-rename"]'); + wrap.classList.remove('is-editing'); + if (input) { + input.hidden = true; + if (restore && text) { + input.value = wrap.getAttribute('data-name') || text.textContent || ''; + } + } + if (text) { + text.hidden = false; + } + if (btn) { + btn.hidden = false; + setButtonBusy(btn, false); + } + }; + + const saveInlineRename = (wrap) => { + const input = wrap.querySelector('.meetlet-recname__input'); + const text = wrap.querySelector('.meetlet-recname__text'); + const btn = wrap.querySelector('[data-action="inline-rename"]'); + if (!input || wrap.dataset.saving === '1') { + return; + } + const current = wrap.getAttribute('data-name') || ''; + const name = String(input.value || '').trim(); + if (name === '' || name === current) { + endInlineRename(wrap, true); + return; + } + wrap.dataset.saving = '1'; + if (btn) { + setButtonBusy(btn, true); + } + input.disabled = true; + Ajax.call([{ + methodname: 'mod_meetletacbfree_rename_recording', + args: { + cmid: state.cmid, + recordingscoid: parseInt(wrap.getAttribute('data-recording'), 10), + name: name, + groupid: state.groupid, + }, + }])[0].then((res) => { + wrap.setAttribute('data-name', name); + if (text) { + text.textContent = name; + } + input.value = name; + Notification.addNotification({message: res.message, type: 'success'}); + endInlineRename(wrap, false); + const app = document.querySelector(SELECTORS.app); + const panel = app && app.querySelector(SELECTORS.panel); + if (state.current === 'recordings' && panel) { + state.cache.recordings = panel.innerHTML; + } + }).catch((err) => { + Notification.exception(err); + endInlineRename(wrap, true); + }).finally(() => { + wrap.dataset.saving = '0'; + input.disabled = false; + }); + }; + + const startInlineRename = (wrap) => { + const text = wrap.querySelector('.meetlet-recname__text'); + const input = wrap.querySelector('.meetlet-recname__input'); + const btn = wrap.querySelector('[data-action="inline-rename"]'); + if (!input) { + return; + } + wrap.classList.add('is-editing'); + if (text) { + text.hidden = true; + } + if (btn) { + btn.hidden = true; + } + input.hidden = false; + input.value = wrap.getAttribute('data-name') || (text ? text.textContent : '') || ''; + input.focus(); + input.select(); + const onKey = (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + input.removeEventListener('keydown', onKey); + input.removeEventListener('blur', onBlur); + saveInlineRename(wrap); + } else if (ev.key === 'Escape') { + ev.preventDefault(); + input.removeEventListener('keydown', onKey); + input.removeEventListener('blur', onBlur); + endInlineRename(wrap, true); + } + }; + const onBlur = () => { + input.removeEventListener('keydown', onKey); + input.removeEventListener('blur', onBlur); + saveInlineRename(wrap); + }; + input.addEventListener('keydown', onKey); + input.addEventListener('blur', onBlur); + }; + + const renderCached = (panel, tab) => { + panel.innerHTML = state.cache[tab]; + bindPanelActions(panel); + }; + + const bindPanelActions = (panel) => { + panel.querySelectorAll(SELECTORS.offline).forEach((btn) => { + btn.addEventListener('click', () => { + setButtonBusy(btn, true); + Ajax.call([{ + methodname: 'mod_meetletacbfree_make_offline', + args: { + cmid: state.cmid, + recordingscoid: parseInt(btn.dataset.recording, 10), + groupid: state.groupid, + }, + }])[0].then((res) => { + setButtonBusy(btn, false); + Notification.addNotification({ + message: res.message, + type: res.success ? 'success' : 'warning', + }); + // Force re-fetch recordings after job request. + return loadTab('recordings', true); + }).catch((err) => { + setButtonBusy(btn, false); + const msg = (err && (err.message || err.error)) ? (err.message || err.error) : ''; + if (msg) { + Notification.addNotification({message: msg, type: 'error'}); + } else { + Notification.exception(err); + } + }); + }); + }); + + panel.querySelectorAll(SELECTORS.launchRecording).forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const scoid = parseInt(btn.dataset.recording, 10); + const mode = (btn.dataset.mode || 'play').toLowerCase(); + if (!scoid) { + return; + } + setButtonBusy(btn, true); + setOverlay(true, 'recording'); + Ajax.call([{ + methodname: 'mod_meetletacbfree_get_recording_launch_url', + args: { + cmid: state.cmid, + recordingscoid: scoid, + mode: mode, + groupid: state.groupid, + }, + }])[0].then((response) => { + setOverlay(false); + setButtonBusy(btn, false); + handleLaunchResponse(response); + return null; + }).catch((err) => { + setOverlay(false); + setButtonBusy(btn, false); + handleLaunchResponse(null, err); + }); + }); + }); + + panel.querySelectorAll(SELECTORS.launchContent).forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const scoid = parseInt(btn.dataset.content, 10); + if (!scoid) { + return; + } + setButtonBusy(btn, true); + setOverlay(true, 'content'); + Ajax.call([{ + methodname: 'mod_meetletacbfree_get_content_launch_url', + args: { + cmid: state.cmid, + contentscoid: scoid, + groupid: state.groupid, + }, + }])[0].then((response) => { + setOverlay(false); + setButtonBusy(btn, false); + handleLaunchResponse(response); + return null; + }).catch((err) => { + setOverlay(false); + setButtonBusy(btn, false); + handleLaunchResponse(null, err); + }); + }); + }); + + panel.querySelectorAll(SELECTORS.downloadContent).forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + downloadContentFile(link); + }); + }); + + panel.querySelectorAll('[data-action="inline-rename"]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const wrap = btn.closest('.meetlet-recname'); + if (!wrap || wrap.classList.contains('is-editing')) { + return; + } + startInlineRename(wrap); + }); + }); + + panel.onclick = (e) => { + const refreshBtn = e.target.closest(SELECTORS.refresh); + if (refreshBtn && panel.contains(refreshBtn)) { + e.preventDefault(); + const tab = refreshBtn.getAttribute('data-tab') || state.current; + loadTab(tab, true); + return; + } + const btn = e.target.closest(SELECTORS.sessionDetails); + if (!btn || !panel.contains(btn)) { + return; + } + e.preventDefault(); + const sessions = readSessionsPayload(panel); + const index = parseInt(btn.getAttribute('data-session-index') || '0', 10); + const session = sessions[index] || {title: '', attendees: []}; + openModal(session.title || '', session.attendees || []); + }; + }; + + const loadTab = (tab, force) => { + const app = document.querySelector(SELECTORS.app); + if (!app) { + return Promise.resolve(); + } + const panel = app.querySelector(SELECTORS.panel); + state.current = tab; + app.querySelectorAll(SELECTORS.tab).forEach((btn) => { + const active = btn.dataset.tab === tab; + btn.classList.toggle('is-active', active); + btn.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + + if (tab === 'details') { + showDetails(panel); + return Promise.resolve(); + } + + if (!force && state.cache[tab]) { + renderCached(panel, tab); + if (tab === 'recordings') { + ensureRecordingsPoll(); + } + return Promise.resolve(); + } + + return Str.get_string('loading', 'meetletacbfree').then((loading) => { + setLoading(panel, loading); + return Ajax.call([{ + methodname: 'mod_meetletacbfree_get_tab', + args: {cmid: state.cmid, tab: tab, groupid: state.groupid}, + }])[0]; + }).then((response) => { + state.cache[tab] = response.html; + panel.innerHTML = response.html; + bindPanelActions(panel); + if (tab === 'recordings') { + resetRecordingsPoll(); + } + return null; + }).catch(Notification.exception); + }; + + const pollRecordingsBackground = () => { + if (!state.cache.recordings) { + return; + } + Ajax.call([{ + methodname: 'mod_meetletacbfree_get_tab', + args: {cmid: state.cmid, tab: 'recordings', groupid: state.groupid}, + }])[0].then((response) => { + state.cache.recordings = response.html; + if (state.current !== 'recordings') { + return null; + } + const app = document.querySelector(SELECTORS.app); + const panel = app && app.querySelector(SELECTORS.panel); + if (panel) { + panel.innerHTML = response.html; + bindPanelActions(panel); + } + return null; + }).catch(() => null); + }; + + const stopRecordingsPoll = () => { + if (state.recPollTimer) { + window.clearInterval(state.recPollTimer); + state.recPollTimer = null; + } + }; + + const resetRecordingsPoll = () => { + stopRecordingsPoll(); + state.recPollTimer = window.setInterval(pollRecordingsBackground, RECORDINGS_POLL_MS); + }; + + const ensureRecordingsPoll = () => { + if (!state.recPollTimer && state.cache.recordings) { + resetRecordingsPoll(); + } + }; + + const setOverlay = (show, kind) => { + const overlay = document.querySelector(SELECTORS.overlay); + if (!overlay) { + return; + } + if (show) { + const titleEl = overlay.querySelector('.meetlet-joinoverlay__title'); + const textEl = overlay.querySelector('.meetlet-joinoverlay__text'); + const isRecording = kind === 'recording'; + if (titleEl) { + titleEl.textContent = isRecording + ? (state.strRecordingTitle || titleEl.textContent) + : (state.strJoiningTitle || titleEl.textContent); + } + if (textEl) { + textEl.textContent = isRecording + ? (state.strRecordingText || textEl.textContent) + : (state.strJoiningText || textEl.textContent); + } + overlay.removeAttribute('hidden'); + } else { + overlay.setAttribute('hidden', 'hidden'); + } + }; + + const showGateModal = (title, message) => { + showHelpError({ + message: message || '', + helpcode: '', + detailhtml: '', + }, title); + }; + + const bindCopyCode = (root) => { + if (!root) { + return; + } + root.querySelectorAll('[data-copy-code]').forEach((el) => { + el.style.cursor = 'pointer'; + el.title = el.getAttribute('data-copy-hint') || el.textContent || ''; + el.addEventListener('click', () => { + const code = el.getAttribute('data-copy-code') || el.textContent || ''; + if (!code || !navigator.clipboard) { + return; + } + navigator.clipboard.writeText(code).then(() => { + el.classList.add('is-copied'); + window.setTimeout(() => el.classList.remove('is-copied'), 1200); + }).catch(() => null); + }); + }); + }; + + const showHelpError = (payload, title) => { + const modal = ensureModalShell(); + const titleEl = modal.querySelector(SELECTORS.modalTitle); + const body = modal.querySelector(SELECTORS.modalBody); + if (titleEl) { + titleEl.textContent = title || ''; + } + const code = (payload && (payload.helpcode || payload.code)) || ''; + const message = (payload && payload.message) || ''; + const detail = (payload && payload.detailhtml) || ''; + if (body) { + if (detail) { + body.innerHTML = detail; + } else { + let html = '
'; + html += '

' + escapeHtml(message) + '

'; + if (code) { + html += '
'; + html += ''; + html += '' + escapeHtml(code) + ''; + html += '
'; + } + html += '
'; + body.innerHTML = html; + } + bindCopyCode(body); + } + modalScroll().lock(); + modal.removeAttribute('hidden'); + modal.style.display = 'flex'; + }; + + const handleLaunchResponse = (response, fallbackErr) => { + if (response && response.ok && response.url) { + window.open(response.url, '_blank', 'noopener'); + return; + } + if (response && (response.message || response.detailhtml || response.helpcode)) { + Str.get_string('joindeniedtitle', 'meetletacbfree').then((title) => { + showHelpError(response, title); + return null; + }).catch(() => showHelpError(response, '')); + return; + } + if (fallbackErr) { + const msg = (fallbackErr.message || fallbackErr.error) ? (fallbackErr.message || fallbackErr.error) : ''; + if (msg) { + Str.get_string('joindeniedtitle', 'meetletacbfree').then((title) => { + showHelpError({message: msg}, title); + return null; + }).catch(() => Notification.exception(fallbackErr)); + } else { + Notification.exception(fallbackErr); + } + } + }; + + const applyMeetingStatus = (info) => { + state.joinable = !!info.joinable; + state.status = info.status || state.status; + let message = info.message || ''; + if (!state.joinable && (info.status === 'waitinghost' || info.errorcode === 'waitingforhost')) { + // Prefer the clearer moderator-wait explanation for the join tip. + message = state.strJoinWaiting || message; + } + state.statusMessage = message; + const wrap = document.querySelector('[data-meetlet-status-wrap]'); + const badge = document.querySelector('[data-meetlet-status]'); + const joinBtn = document.querySelector(SELECTORS.join); + const joinWrap = document.querySelector('[data-join-wrap]'); + if (wrap) { + wrap.setAttribute('data-status', state.status); + } + if (badge) { + badge.className = 'meetlet-status meetlet-status--' + state.status; + badge.textContent = info.statuslabel || badge.textContent; + if (!state.joinable && state.statusMessage) { + badge.setAttribute('data-tip', state.statusMessage); + badge.setAttribute('tabindex', '0'); + } else { + badge.removeAttribute('data-tip'); + badge.removeAttribute('tabindex'); + } + } + if (joinBtn) { + joinBtn.classList.toggle('is-disabled', !state.joinable); + if (state.joinable) { + joinBtn.removeAttribute('aria-disabled'); + } else { + joinBtn.setAttribute('aria-disabled', 'true'); + } + } + if (joinWrap) { + joinWrap.classList.toggle('is-blocked', !state.joinable); + joinWrap.removeAttribute('data-tip'); + } + }; + + const CIRC = 2 * Math.PI * 16; + const setRingProgress = (remaining, total) => { + const ring = document.querySelector('[data-status-ring]'); + const count = document.querySelector('[data-status-countdown]'); + if (count) { + count.textContent = String(remaining); + } + if (ring) { + const ratio = total > 0 ? (remaining / total) : 0; + ring.style.strokeDasharray = String(CIRC); + ring.style.strokeDashoffset = String(CIRC * (1 - ratio)); + } + }; + + const pollMeetingStatus = () => { + return Ajax.call([{ + methodname: 'mod_meetletacbfree_get_meeting_status', + args: {cmid: state.cmid, groupid: state.groupid}, + }])[0].then((info) => { + applyMeetingStatus(info); + return info; + }).catch(() => null); + }; + + const startStatusTicker = () => { + if (!state.licenseUsable || state.pollSeconds <= 0) { + return; + } + const total = state.pollSeconds || 15; + let remaining = total; + setRingProgress(remaining, total); + if (state.statusTimer) { + window.clearInterval(state.statusTimer); + } + const tick = () => { + remaining -= 1; + if (remaining <= 0) { + remaining = total; + setRingProgress(remaining, total); + pollMeetingStatus(); + return; + } + setRingProgress(remaining, total); + }; + state.statusTimer = window.setInterval(tick, 1000); + // After first paint / idle: ask Adobe for real room status, then every 15s via ticker. + const kickoff = () => { + pollMeetingStatus().then(() => { + remaining = total; + setRingProgress(remaining, total); + }); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(kickoff, {timeout: 1200}); + } else { + window.setTimeout(kickoff, 0); + } + }; + + const joinMeeting = () => { + if (!state.joinable) { + Str.get_strings([ + {key: 'joindeniedtitle', component: 'meetletacbfree'}, + ]).then((strings) => { + const msg = (state.status === 'waitinghost' || state.status === 'checking') + ? (state.strJoinWaiting || state.statusMessage || '') + : (state.statusMessage || ''); + showGateModal(strings[0], msg); + return null; + }).catch(() => { + showGateModal('', state.statusMessage || ''); + }); + return; + } + const joinBtn = document.querySelector(SELECTORS.join); + if (joinBtn) { + joinBtn.classList.add('is-busy'); + } + setOverlay(true, 'meeting'); + Ajax.call([{ + methodname: 'mod_meetletacbfree_get_join_url', + args: {cmid: state.cmid, groupid: state.groupid}, + }])[0].then((response) => { + setOverlay(false); + if (joinBtn) { + joinBtn.classList.remove('is-busy'); + } + handleLaunchResponse(response); + return null; + }).catch((err) => { + setOverlay(false); + if (joinBtn) { + joinBtn.classList.remove('is-busy'); + } + handleLaunchResponse(null, err); + pollMeetingStatus(); + }); + }; + + const cfgEl = document.querySelector(SELECTORS.config); + if (!cfgEl) { + return; + } + let config = {}; + try { + config = JSON.parse(cfgEl.getAttribute('data-meetlet-config')); + } catch (e) { + return; + } + + state.cmid = config.cmid; + state.groupid = config.groupid || 0; + state.pollSeconds = parseInt(config.statusPollSeconds || '15', 10) || 15; + state.strJoinWaiting = config.strJoinWaiting || ''; + state.strJoiningTitle = config.strJoiningTitle || ''; + state.strJoiningText = config.strJoiningText || ''; + state.strRecordingTitle = config.strRecordingTitle || ''; + state.strRecordingText = config.strRecordingText || ''; + state.licenseUsable = config.licenseUsable !== false; + if (typeof config.joinable === 'boolean') { + state.joinable = config.joinable; + } + if (config.status) { + state.status = config.status; + } + if (config.statusMessage) { + state.statusMessage = config.statusMessage; + } + modalLabels = { + name: config.strAttendeeName || '', + login: config.strAttendeeLogin || '', + timein: config.strTimeIn || '', + timeout: config.strTimeOut || '', + empty: config.strNoAttendance || '', + }; + + const app = document.querySelector(SELECTORS.app); + if (!app) { + return; + } + + bindFloatTips(app); + window.addEventListener('scroll', () => { + if (floatTipTarget) { + hideFloatTip(); + } + }, true); + window.addEventListener('resize', hideFloatTip); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + hideFloatTip(); + closeModal(); + } + }); + + window.addEventListener('pagehide', stopRecordingsPoll); + + app.querySelectorAll(SELECTORS.tab).forEach((btn) => { + btn.addEventListener('click', () => { + loadTab(btn.dataset.tab, false); + }); + }); + const joinBtn = app.querySelector(SELECTORS.join); + if (joinBtn) { + joinBtn.addEventListener('click', joinMeeting); + } + + startStatusTicker(); + loadTab('details', false); +}); diff --git a/lang/en/meetletacbfree.php b/lang/en/meetletacbfree.php new file mode 100644 index 0000000..8322233 --- /dev/null +++ b/lang/en/meetletacbfree.php @@ -0,0 +1,570 @@ +. + +/** + * English language strings for mod_meetletacbfree. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['pluginname'] = 'Adobe Connect Bridge (Marketplace edition)'; +$string['modulename'] = 'Adobe Connect'; +$string['modulenameplural'] = 'Adobe Connect'; +$string['modulename_help'] = 'Create or link Adobe Connect meetings with SSO join, role mapping, recordings and shared content.'; +$string['pluginadministration'] = 'Adobe Connect Bridge (Marketplace edition) administration'; +$string['settingsintro'] = 'Connect Moodle to Adobe Connect for meeting creation, SSO join, role mapping, recordings and shared content.'; +$string['marketplace_commercial_note'] = 'Attendance reports, offline MP4 video, Windows Agent integration and premium support are available in the commercial MeetLet Adobe Connect Bridge.'; +$string['about_visit_website'] = 'Visit the MeetLet website'; +$string['marketplace_commercial_visit'] = 'meetlet.net'; +$string['meetletacbfree:addinstance'] = 'Add a new MeetLet meeting'; +$string['meetletacbfree:view'] = 'View MeetLet meeting'; +$string['meetletacbfree:join'] = 'Join MeetLet meeting'; +$string['meetletacbfree:host'] = 'Act as Adobe Connect host'; +$string['meetletacbfree:presenter'] = 'Act as Adobe Connect presenter'; +$string['meetletacbfree:viewrecordings'] = 'View recordings'; +$string['meetletacbfree:grade'] = 'Grade MeetLet activity'; + +$string['meetingname'] = 'Meeting name'; +$string['meetingintro'] = 'Meeting summary'; +$string['roomsettings'] = 'Room settings'; +$string['meeturl'] = 'Custom meeting URL'; +$string['meeturl_help'] = 'Optional custom path for the Adobe Connect room (letters, numbers and dashes only). Leave empty to let Connect assign one.'; +$string['meetingtype'] = 'Access type'; +$string['public'] = 'Public'; +$string['private'] = 'Private'; +$string['meettemplates'] = 'Meeting template'; +$string['defaulttemplate'] = 'Default template'; +$string['starttime'] = 'Start time'; +$string['endtime'] = 'End time'; +$string['endtimeenable'] = 'Set an end time'; +$string['greaterstarttime'] = 'End time must be after start time'; +$string['invalidurl'] = 'Invalid meeting URL. Use letters, numbers and dashes only, starting with a letter.'; + +$string['rolemapping'] = 'Role mapping'; +$string['rolemapping_desc'] = 'Define a default rule for all participants, then map specific Moodle roles or users to Host or Presenter. Site defaults come from the Access settings tab and can be overridden here for this activity.'; +$string['rolemapping_mode'] = 'Mapping source'; +$string['rolemapping_mode_help'] = 'Follow site uses the global Moodle→Connect mapping (activity creator is host). Custom lets you define role/user rules only for this activity.'; +$string['rolemapping_mode_inherit'] = 'Follow site mapping'; +$string['rolemapping_mode_inherit_desc'] = 'Use the plugin-wide mapping, plus the activity owner host convenience rule.'; +$string['rolemapping_mode_override'] = 'Custom for this activity'; +$string['rolemapping_mode_override_short'] = 'Custom'; +$string['rolemapping_mode_override_desc'] = 'Manually define role/user mapping rules only for this activity.'; +$string['siterolemapping'] = 'Moodle role → Adobe Connect role mapping'; +$string['siterolemapping_desc'] = 'Choose which Adobe Connect room role (Host / Presenter / Participant) each Moodle role gets when joining or acting in Connect. This is the default for new activities; each activity form can override it.'; +$string['siterolemapping_defaultinfo'] = 'Built-in default: manager, course creator & editing teacher = Host, teacher = Presenter, everyone else = Participant'; +$string['reset_rolemapping'] = 'Reset to default'; +$string['reset_rolemapping_confirm_title'] = 'Reset role mapping?'; +$string['reset_rolemapping_confirm'] = 'Restore the built-in Moodle → Adobe Connect role mapping? Your custom mapping will be replaced.'; +$string['reset_featureaccess'] = 'Reset to default'; +$string['reset_featureaccess_confirm_title'] = 'Reset feature access?'; +$string['reset_featureaccess_confirm'] = 'Restore the built-in role feature access matrix? Your custom permissions will be replaced.'; +$string['accessreset_confirm_reset'] = 'Reset to default'; +$string['accessreset_modal_cancel'] = 'Cancel'; +$string['accessreset_modal_close'] = 'Close'; +$string['accessreset_modal_ok'] = 'OK'; +$string['accessreset_busy'] = 'Restoring defaults…'; +$string['reset_success_rolemapping'] = 'Role mapping restored to built-in defaults.'; +$string['reset_success_featureaccess'] = 'Feature access matrix restored to built-in defaults.'; +$string['reset_error'] = 'Could not restore defaults. Please try again.'; +$string['adoberole'] = 'Adobe Connect role'; +$string['participant_list'] = 'Participant role list'; +$string['participant_type_all'] = 'All participants'; +$string['participant_type_role'] = 'Role'; +$string['participant_type_user'] = 'User'; +$string['participant_add'] = 'Add'; +$string['participant_remove'] = 'Remove'; +$string['participant_as'] = 'as'; +$string['role_host'] = 'Host'; +$string['role_presenter'] = 'Presenter'; +$string['role_participant'] = 'Participant'; + +$string['allow_manual_offline'] = 'Show offline session video download button'; +$string['allow_manual_offline_desc'] = 'Show a per-recording button to request offline session video creation.'; +$string['server_group_api'] = 'API connection'; +$string['server_group_api_desc'] = 'XML API URL and meeting host.'; +$string['server_group_admin'] = 'Server admin account'; +$string['server_group_admin_desc'] = 'Adobe Connect administrator used for API calls.'; +$string['featureaccess'] = 'Role feature access'; +$string['featureaccess_desc'] = 'Choose which meeting features each Moodle role may use.'; +$string['featureaccess_activity'] = 'Role feature access for this activity'; +$string['featureaccess_override'] = 'Override site-wide access for this activity'; +$string['featureaccess_override_help'] = 'When enabled, the matrix below applies only to this activity. Otherwise the plugin site defaults are used.'; +$string['featureaccess_usesite'] = 'Built-in plugin defaults'; +$string['activity_featureaccess'] = 'Show access matrix on activity form'; +$string['activity_featureaccess_desc'] = 'If disabled, the per-activity role access section is hidden when adding/editing an activity and only the site-wide matrix applies.'; +$string['accessrole'] = 'Moodle role'; +$string['accessfeature_join'] = 'Join meeting'; +$string['accessfeature_recordings'] = 'View and play recordings'; +$string['accessfeature_content'] = 'View and play content'; +$string['accessfeature_editrecording'] = 'Edit recording name'; +$string['accessfeature_adobeeditor'] = 'Edit recording in Adobe Connect'; +$string['accessfeature_enterbeforehost'] = 'Bypass schedule / wait for host'; +$string['accessmatrix_all'] = 'All'; +$string['accessmatrix_toggle_row'] = 'Toggle all features for this role'; +$string['accessmatrix_toggle_col'] = 'Toggle this feature for all roles'; +$string['adobeeditor'] = 'Edit in Adobe Connect'; +$string['defaultssettings'] = 'Meeting defaults'; +$string['tab_meetingsettings'] = 'Meeting settings'; +$string['tab_accesssettings'] = 'Access settings'; +$string['roomdefaults'] = 'Room defaults'; +$string['roomdefaults_desc'] = 'These values are used as form defaults for new “new meeting” and “server meetings” activities site-wide; each activity can still override them.'; +$string['default_meetinglang'] = 'Default meeting language'; +$string['default_meetinglang_desc'] = 'Default Adobe Connect room UI language for new activities across the site.'; +$string['default_waitforhost'] = 'Block entry before host (site default)'; +$string['default_waitforhost_desc'] = 'When enabled, new activities block non-host join until a host is present in the room (unless a role may enter early via the access matrix).'; + +$string['settingsheader'] = 'Adobe Connect connection'; +$string['settingsversion'] = 'v{$a}'; +$string['settings_connection'] = 'Server settings'; +$string['settings_connection_desc'] = 'Adobe Connect server connection and admin account.'; +$string['host'] = 'API host'; +$string['host_desc'] = 'Full XML API endpoint, e.g. https://connect.example.edu/api/xml'; +$string['meethost'] = 'Meeting domain'; +$string['meethost_desc'] = 'Domain users open for meetings, e.g. connect.example.edu'; +$string['port'] = 'Port'; +$string['port_desc'] = 'Usually 443 for HTTPS'; +$string['admin_login'] = 'Server admin email'; +$string['admin_login_desc'] = 'Adobe Connect account email used for API calls'; +$string['admin_password'] = 'Server admin password'; +$string['admin_password_desc'] = 'Password for the server API admin account'; +$string['admin_httpauth'] = 'HTTP authentication header'; +$string['admin_httpauth_desc'] = 'Must match HTTP_AUTH_HEADER in Adobe Connect custom.ini (default: Generate-Safe-Random-Token)'; +$string['email_login'] = 'Match legacy Connect accounts by email or username'; +$string['email_login_desc'] = 'SSO normally signs users into Adobe Connect as mlu{userid}. Enable this when Connect accounts were originally created with each person\'s email address or Moodle username instead. The plugin will find those legacy accounts during join and link them to the current mlu{userid} login.'; +$string['https'] = 'HTTPS connection'; +$string['https_desc'] = 'Use HTTPS for API and meeting URLs'; +$string['about_heading'] = 'About the plugin developer'; +$string['about_title'] = 'MeetLet'; +$string['about_body'] = 'MeetLet provides online classroom services and Adobe Connect infrastructure.'; +$string['about_body_p1'] = 'MeetLet specialises in Adobe Connect-based online classroom infrastructure. From reliable hosting and performance tuning to operational support for universities, training centres and organisations that treat session quality as non-negotiable, we stay beside the teams that run live classes every day. We focus on the real classroom experience: fast join, stable audio and video, and tools that both instructors and learners actually need.'; +$string['about_body_p2'] = 'This Marketplace edition connects Moodle to Adobe Connect for meeting creation, role mapping, SSO join, recordings and shared content.'; +$string['about_body_p3'] = 'The MeetLet engineering team builds with a clear understanding of educational environments and the need for transparent UX, so the product stays dependable for site admins and simple for teachers. The MeetLet brand stands for responsiveness, infrastructure quality and hands-on support during real deployments, with a practical view of day-to-day online teaching needs.'; +$string['about_body_p4'] = 'For hosting, deployment consulting and specialist support, visit the official website. This plugin is part of the MeetLet ecosystem for e-learning integration and is designed, maintained and updated with that same standard.'; +$string['runtest'] = 'Run connection test'; +$string['testrunning'] = 'Running test steps…'; +$string['testwaiting'] = 'Waiting'; +$string['testrunningstep'] = 'Testing'; +$string['testskipped'] = 'Skipped'; +$string['testprogress'] = 'Step {$a->current} of {$a->total} ({$a->percent}%)'; +$string['conntesttitle'] = 'Adobe Connect connection test'; +$string['conntestintro'] = 'These checks verify that Moodle can talk to your Adobe Connect server. Click the button below to show every step and follow live progress.'; +$string['testok'] = 'Passed'; +$string['testfail'] = 'Failed'; +$string['testsession'] = 'Session key'; +$string['testlogin'] = 'Admin login'; +$string['testfolders'] = 'Shared / recordings / meetings folders'; +$string['testcleanup'] = 'Remove test meeting and user'; +$string['testuser'] = 'Create, assign and remove test user'; +$string['testsso'] = 'Single sign-on (SSO)'; +$string['testsso_adminok'] = 'Admin SSO succeeded'; +$string['testsso_userok'] = 'Test-user SSO succeeded'; +$string['testsso_userwarn'] = 'Test-user SSO warned (admin SSO infrastructure is healthy)'; + +$string['tabinfo'] = 'Overview'; +$string['tabdetails'] = 'Session details'; +$string['emptydetails'] = 'No description has been added for this session.'; +$string['sessiondatetime'] = 'Session start'; +$string['sessionend'] = 'Session end'; +$string['sessiondetails'] = 'Actions'; +$string['tabrecordings'] = 'Recordings'; +$string['tabcontent'] = 'Content'; +$string['joinmeeting'] = 'Join meeting'; +$string['joining'] = 'Preparing a secure meeting session…'; +$string['joiningtitle'] = 'Preparing meeting'; +$string['preparingrecording'] = 'Preparing a secure recording session…'; +$string['preparingrecordingtitle'] = 'Preparing recording'; +$string['meetingdetails'] = 'Meeting details'; +$string['meetingurl'] = 'Meeting URL'; +$string['statusopen'] = 'Ready to join'; +$string['statuslive'] = 'In progress'; +$string['statusscheduled'] = 'Scheduled'; +$string['statusended'] = 'Ended'; +$string['statuswaitinghost'] = 'Waiting for host'; +$string['statuschecking'] = 'Checking…'; +$string['statushelp'] = 'Status is refreshed from the server every 15 seconds. If people are in the Adobe room it shows In progress, even after the scheduled end time.'; +$string['statuschecking_help'] = 'Fetching room status from Adobe Connect. Join stays disabled until the result is known.'; +$string['statuscountdown_help'] = 'Countdown until the next server status check. At zero, meeting status (for example Ended or Waiting for host) is refreshed.'; +$string['meetingended'] = 'This meeting has ended. You cannot join.'; +$string['meetingnotstarted'] = 'This meeting has not started yet.'; +$string['joindeniedtitle'] = 'Join not allowed'; +$string['joinblocked_waitinghost'] = 'You cannot join until the host/moderator starts the session (Waiting for moderator to start session).'; +$string['noscormapping'] = 'No Adobe meeting is linked to this activity yet.'; +$string['nomeeting'] = 'No meeting exists on the Adobe Connect server'; +$string['notparticipant'] = 'You are not allowed to join this meeting'; +$string['usergrouprequired'] = 'You must be a member of a group to join this meeting'; +$string['norecordings'] = 'No recordings yet'; +$string['nocontent'] = 'No uploaded content in this meeting'; +$string['recordingname'] = 'Recording'; +$string['play'] = 'Play'; +$string['downloadcontent'] = 'Download'; +$string['downloadcontentfailed'] = 'Content download failed. Please try again.'; +$string['downloadcontenttimeout'] = 'Content download timed out. Please try again.'; +$string['errorbadcontenttoken'] = 'This content download link is invalid or was issued for a different user.'; +$string['duration'] = 'Duration'; +$string['refresh'] = 'Refresh'; +$string['refreshtab'] = 'Refresh'; +$string['jobpollwait'] = 'Checking video status… next refresh in 1 minute'; +$string['loading'] = 'Loading…'; +$string['error'] = 'Something went wrong'; +$string['erroremptyxml'] = 'Unable to reach Adobe Connect API'; +$string['errorlogin'] = 'Adobe Connect admin login failed'; +$string['errorhttpauth'] = 'Single sign-on to Adobe Connect failed.'; +$string['errorhttpauth_hint_ip'] = 'Moodle egress IP that Adobe must trust: {$a}'; +$string['error_user_unavailable'] = 'This meeting cannot be opened right now. Give this code to your site administrator: {$a}'; +$string['error_helpcode_label'] = 'Help code'; +$string['error_adobe_response'] = 'Adobe Connect response'; +$string['error_see_serverguide'] = 'This code is explained on the plugin settings “Server setup guide” tab.'; +$string['errorfact_login'] = 'Connect login'; +$string['errorfact_adobe_code'] = 'Adobe code'; +$string['errorfact_adobe_subcode'] = 'Adobe subcode'; +$string['errorfact_egress_ip'] = 'Moodle egress IP'; +$string['helpcode_sso_01_title'] = 'HTTP header single sign-on failed'; +$string['helpcode_sso_01_fix'] = '
  1. Confirm HTTP_AUTH_HEADER in custom.ini matches the plugin setting.
  2. Add this Moodle server’s public IP to HTTP_AUTH_TRUSTED_HOSTS (keep 127.0.0.1).
  3. Enable HeaderAuthenticationFilter in web.xml and do not ignore /api/.
  4. Restart the ConnectPro service after changing trusted hosts.
'; +$string['helpcode_api_01_title'] = 'Adobe Connect API could not be reached'; +$string['helpcode_api_01_fix'] = 'Check the plugin API URL, port, HTTPS, and firewall from the Moodle server to Adobe Connect.'; +$string['helpcode_api_02_title'] = 'Adobe Connect administrator login failed'; +$string['helpcode_api_02_fix'] = 'Check the plugin “Server admin account” email and password. The account must be able to call the XML API.'; +$string['helpcode_usr_01_title'] = 'The Adobe Connect user could not be created or found'; +$string['helpcode_usr_01_fix'] = 'Check the Moodle user’s email and that the Connect admin account can create users.'; +$string['helpcode_mtg_01_title'] = 'The Adobe Connect meeting was not found'; +$string['helpcode_mtg_01_fix'] = 'Open the activity settings and confirm the meeting still exists on the Connect server.'; +$string['helpcode_cfg_01_title'] = 'The plugin is not configured yet'; +$string['helpcode_cfg_01_fix'] = 'Complete Adobe Connect settings in Site administration and run the Connection test tab.'; +$string['helpcode_err_01_title'] = 'Unexpected MeetLet error'; +$string['helpcode_err_01_fix'] = 'Retry once. If it continues, check Moodle logs and the Connection test tab.'; +$string['serverguide_errorcodes_title'] = 'Help codes reported by users'; +$string['serverguide_errorcodes_intro'] = 'Students only see a short “cannot open” message plus one of these codes. Match the code here to see what to fix.'; +$string['serverguide_errorcodes_col_code'] = 'Code'; +$string['serverguide_errorcodes_col_meaning'] = 'Meaning'; +$string['serverguide_errorcodes_col_fix'] = 'What to check'; +$string['errorcreatemeeting'] = 'Could not create the Adobe Connect meeting'; +$string['errorcreateuser'] = 'Could not create or locate your Adobe Connect user. Check the Moodle user email and that the server admin API account can create users.'; +$string['errorexistingsco'] = 'The selected server meeting was not found. Choose a valid meeting from the list.'; +$string['errorrenamerecording'] = 'Could not rename the recording'; +$string['recordingrenamed'] = 'Recording renamed'; +$string['editrecording'] = 'Edit recording name'; +$string['recordingdate'] = 'Recording date'; +$string['linkmode'] = 'Meeting connection mode'; +$string['linkmode_help'] = 'Attach an existing Adobe Connect meeting from the server, or create a new one.'; +$string['linkmode_create'] = 'Create a new meeting on the server'; +$string['linkmode_existing'] = 'Existing meeting on server'; +$string['existingscoid'] = 'Existing server meeting'; +$string['chooseservermeeting'] = 'Select a meeting…'; +$string['searchservermeetings'] = 'Search server meetings…'; +$string['loadingmeetings'] = 'Loading meetings…'; +$string['nomeetingsfound'] = 'No meetings found'; +$string['meetinglang'] = 'Meeting language'; +$string['meetinglang_help'] = 'Default Adobe Connect room UI language when the server supports it.'; +$string['meetinglang_default'] = 'Server default'; +$string['waitforhost'] = 'Block entry before host'; +$string['waitforhost_help'] = 'Until a host is present in the room, others cannot join. Roles granted “Bypass schedule / wait for host” in the access matrix may bypass wait-for-host and also join when the meeting is scheduled or marked ended.'; +$string['settingstate_inherit'] = 'Follow site'; +$string['settingstate_inherit_on'] = 'Site: On'; +$string['settingstate_inherit_off'] = 'Site: Off'; +$string['settingstate_enable'] = 'On'; +$string['settingstate_disable'] = 'Off'; +$string['waitingforhost'] = 'The meeting has not been opened by a host yet. Please wait.'; +$string['contenttype'] = 'Type'; +$string['contentsize'] = 'Size'; +$string['contentdate'] = 'Modified'; +$string['contenticon_image'] = 'Image'; +$string['contenticon_jpg'] = 'Image'; +$string['contenticon_jpeg'] = 'Image'; +$string['contenticon_png'] = 'Image'; +$string['contenticon_gif'] = 'Image'; +$string['contenticon_presentation'] = 'Presentation'; +$string['contenticon_producer'] = 'Presentation'; +$string['contenticon_ppt'] = 'Presentation'; +$string['contenticon_pptx'] = 'Presentation'; +$string['contenticon_pdf'] = 'PDF'; +$string['contenticon_mp3'] = 'Audio'; +$string['contenticon_mp4'] = 'Video'; +$string['contenticon_flv'] = 'Video'; +$string['contenticon_swf'] = 'Flash'; +$string['contenticon_html'] = 'HTML'; +$string['contenticon_zip'] = 'ZIP'; +$string['contenticon_attachment'] = 'Attachment'; +$string['contenticon_file'] = 'File'; +$string['contenticon_content'] = 'Content'; +$string['errornofolder'] = 'Could not find the Adobe Connect meetings folder'; +$string['notconfigured'] = 'MeetLet is not configured yet. Ask an administrator to complete Adobe Connect settings.'; +$string['privacy:metadata'] = 'MeetLet stores meeting mappings and recording metadata locally, and exchanges user profile fields with Adobe Connect to enable SSO.'; +$string['privacy:metadata:meetletacbfree'] = 'MeetLet activity instances stored in Moodle.'; +$string['privacy:metadata:meetletacbfree:userid'] = 'The user who created the activity instance.'; +$string['privacy:metadata:meetletacbfree:name'] = 'The activity name.'; +$string['privacy:metadata:meetletacbfree:intro'] = 'The activity description.'; +$string['privacy:metadata:meetletacbfree:participants'] = 'Role mapping rules, which may include specific Moodle user ids.'; +$string['privacy:metadata:meetletacbfree:timecreated'] = 'When the activity was created.'; +$string['privacy:metadata:meetletacbfree:timemodified'] = 'When the activity was last modified.'; +$string['privacy:metadata:meetletacbfree_meetings'] = 'Adobe Connect meeting SCO mapping per Moodle group.'; +$string['privacy:metadata:meetletacbfree_meetings:groupid'] = 'Moodle group linked to the Adobe Connect meeting.'; +$string['privacy:metadata:meetletacbfree_meetings:meetingscoid'] = 'Adobe Connect meeting SCO id.'; +$string['privacy:metadata:meetletacbfree_meetings:urlpath'] = 'Adobe Connect meeting URL path.'; +$string['privacy:metadata:meetletacbfree_recordings'] = 'Cached recording and offline video metadata.'; +$string['privacy:metadata:meetletacbfree_recordings:name'] = 'Recording title cached from Adobe Connect.'; +$string['privacy:metadata:meetletacbfree_recordings:urlpath'] = 'Recording URL path.'; +$string['privacy:metadata:meetletacbfree_recordings:jobstatus'] = 'Offline video job status.'; +$string['privacy:metadata:adobeconnect'] = 'User name, email and login are sent to Adobe Connect for account provisioning and meeting access.'; +$string['privacy:metadata:adobeconnect:firstname'] = 'User first name sent to Adobe Connect.'; +$string['privacy:metadata:adobeconnect:lastname'] = 'User last name sent to Adobe Connect.'; +$string['privacy:metadata:adobeconnect:email'] = 'User email sent to Adobe Connect.'; +$string['privacy:metadata:adobeconnect:username'] = 'User login / username sent to Adobe Connect.'; +$string['privacy:export:adobeconnect'] = 'Profile fields are also sent to Adobe Connect for SSO. MeetLet cannot delete accounts on the Adobe Connect server from Moodle.'; +$string['search:activity'] = 'MeetLet activities'; +$string['calendar'] = 'MeetLet meeting'; +$string['view'] = 'View meeting'; +$string['emptytab'] = 'Nothing to show'; +$string['cachedef_folders'] = 'Adobe Connect folder sco-ids'; +$string['cachedef_meetinginfo'] = 'Adobe Connect meeting info'; +$string['cachedef_templates'] = 'Adobe Connect meeting templates'; +$string['tasksync'] = 'Sync MeetLet recordings'; +$string['brand'] = 'MeetLet'; +$string['subtitle'] = 'Adobe Connect'; +$string['featuresheading'] = 'Feature visibility'; +$string['featuresheading_desc'] = 'Each section has two controls: site-wide visibility, and whether teachers may override it per activity.'; +$string['featuregroup_recordingstab'] = 'Recordings tab'; +$string['featuregroup_recordingstab_desc'] = 'Show or hide the recordings tab on the activity page.'; +$string['featuregroup_contenttab'] = 'Content tab'; +$string['featuregroup_contenttab_desc'] = 'Show or hide the meeting content tab.'; +$string['enable_recordingstab'] = 'Show recordings tab'; +$string['enable_recordingstab_desc'] = 'Master switch for the recordings tab. Roles still need “View and play recordings” in the access matrix.'; +$string['allow_override_recordingstab'] = 'Allow per-activity recordings tab setting'; +$string['allow_override_recordingstab_desc'] = 'Teachers may enable/disable the recordings tab for each activity.'; +$string['enable_contenttab'] = 'Show content tab'; +$string['enable_contenttab_desc'] = 'Master switch for the content tab. Roles still need “View and play content” in the access matrix.'; +$string['allow_override_contenttab'] = 'Allow per-activity content tab setting'; +$string['allow_override_contenttab_desc'] = 'Teachers may enable/disable the content tab for each activity.'; +$string['showrecordingstab'] = 'Show recordings tab'; +$string['showcontenttab'] = 'Show content tab'; +$string['activityfeatures'] = 'Links and tabs visibility'; +$string['featuredisabled'] = 'This section has been disabled by the site administrator.'; +$string['tab_connectiontest'] = 'Connection test'; +$string['tab_aboutdev'] = 'About the plugin developer'; +$string['tab_serverguide'] = 'Server setup guide'; + +$string['serverguide_heading'] = 'Required Adobe Connect server settings'; +$string['serverguide_intro'] = 'Only the files and lines needed for this plugin (SSO and HTTPS) on the Connect host. Restart the ConnectPro service after each change.'; +$string['serverguide_match_title'] = 'Fields that must match the server'; +$string['serverguide_match_intro'] = 'Set these on the Server settings tab. The examples below are placeholders — use your own Connect host and secrets.'; +$string['serverguide_placeholder_httpauth'] = 'your-secret-header-value'; +$string['serverguide_placeholder_apiurl'] = 'https://connect.example.edu/api/xml'; +$string['serverguide_placeholder_meethost'] = 'connect.example.edu'; + +$string['serverguide_customini_title'] = 'custom.ini'; +$string['serverguide_customini_body'] = '

Path: C:\\Connect\\11.2.0\\custom.ini (the version folder name may stay the same after a CPS patch).

  • HTTP_AUTH_HEADER = same as the plugin “HTTP authentication header”
  • HTTP_AUTH_TRUSTED_HOSTS = 127.0.0.1 plus Moodle egress IP(s)
  • ADMIN_HOST = meeting domain · for HTTPS: ADMIN_PROTOCOL=https:// and SSL_ONLY=yes
'; +$string['serverguide_customini_sample'] = 'ADMIN_HOST=connect.example.edu +ADMIN_PROTOCOL=https:// +SSL_ONLY=yes +HTTP_AUTH_HEADER=your-secret-header-value +HTTP_AUTH_TRUSTED_HOSTS=127.0.0.1,::1,203.0.113.10'; + +$string['serverguide_webxml_title'] = 'web.xml — HeaderAuthenticationFilter'; +$string['serverguide_webxml_body'] = '

Path: C:\\Connect\\11.2.0\\appserv\\web\\WEB-INF\\web.xml

  • Uncomment the HeaderAuthenticationFilter <filter> and <filter-mapping> blocks.
  • Do not list /api/ in this filter’s ignore-pattern values. The plugin sends the user’s SSO login request to Adobe’s XML API URL; if /api/ is ignored, SSO fails.
  • Keep the NtlmAuthenticationFilter block commented. That filter is for Windows / Active Directory (domain) login and is not required for this plugin. Uncomment it only if your organisation intentionally uses NTLM on Connect; otherwise leave it alone and keep HeaderAuthenticationFilter enabled.
'; +$string['serverguide_webxml_sample'] = ' + + HeaderAuthenticationFilter + com.macromedia.airspeed.servlet.filter.HeaderAuthenticationFilter + + ignore-pattern-0 + /common/ + + + ignore-pattern-1 + /servlet/gateway/ + + + ignore-pattern-2 + /servlet/mirror + + + ignore-pattern-3 + /servlet/testbuilder + + + ignore-pattern-4 + /main + + + + HeaderAuthenticationFilter + /* + + +'; + +$string['serverguide_serverxml_title'] = 'server.xml — SSL / HTTPS connectors'; +$string['serverguide_serverxml_body'] = '

Path: C:\\Connect\\11.2.0\\appserv\\conf\\server.xml

In the stock install file, two blocks marked Uncomment for SSL support are commented — uncomment both:

  1. <Executor name="httpsThreadPool" ...>
  2. <Connector port="8443" ...> (often with SSLEnabled="false" when stunnel terminates TLS on 443)

On the port 80 Connector keep redirectPort="443".

'; +$string['serverguide_serverxml_sample'] = ' + + + +redirectPort="443" + + +'; + +$string['serverguide_upgrade_title'] = 'Note after a Connect upgrade / patch: '; +$string['serverguide_upgrade_body'] = '
  • The file appserv\\web\\WEB-INF\\web.xml usually breaks after an upgrade; the updater often re-comments the HeaderAuthenticationFilter block. Uncomment that block again and remove /api/ from the ignore-pattern list.
  • Open custom.ini and confirm that HTTP_AUTH_HEADER and HTTP_AUTH_TRUSTED_HOSTS are still set correctly.
  • Open appserv\\conf\\server.xml and confirm that both the httpsThreadPool block and the port 8443 Connector are still uncommented.
  • After you apply the fixes, restart the Windows ConnectPro service and run the Connection test tab in Moodle again.
'; + +$string['tab_registration'] = 'Plugin activation'; +$string['registration_required'] = 'This plugin requires free plugin activation before meetings, SSO join, recordings and other features can be used. A site administrator can activate under Site administration → Plugins → Activity modules → Adobe Connect → Plugin activation.'; +$string['registration_heading'] = 'Plugin activation'; +$string['registration_heading_desc'] = 'Activate this plugin with MeetLet to unlock meetings, SSO join, recordings sync and all Marketplace features. Activation is voluntary and happens only when you click the button below — nothing is sent automatically on install.'; +$string['registration_gate_title'] = 'Plugin activation required'; +$string['registration_gate_body'] = 'Meetings, SSO, recordings and other Adobe Connect features are locked until an administrator completes free plugin activation.'; +$string['registration_gate_link'] = 'Open Plugin activation tab'; +$string['registration_hero_notice'] = 'Core features are locked until you complete free plugin activation.'; +$string['registration_why_title'] = 'Why activation is requested'; +$string['registration_why'] = 'MeetLet asks for one-time plugin activation so we can deliver security updates, provide support when you need help, and understand free-edition usage. This edition does not use a paid license key.'; +$string['registration_data_title'] = 'What is sent'; +$string['registration_data_sent'] = 'When you activate, only the following technical and contact metadata is sent to MeetLet:'; +$string['registration_data_item_domain'] = 'Site domain: {$a}'; +$string['registration_data_item_moodle'] = 'Moodle version: {$a}'; +$string['registration_data_item_plugin'] = 'Plugin version: {$a}'; +$string['registration_data_item_installation'] = 'Installation ID: {$a}'; +$string['registration_data_item_sitename'] = 'Site name: {$a}'; +$string['registration_data_item_email'] = 'Administrator email (if available): {$a}'; +$string['registration_data_optional'] = 'not sent'; +$string['registration_privacy'] = 'For full details, see the {$a}.'; +$string['registration_privacy_tablink'] = 'Privacy tab'; +$string['tab_privacy'] = 'Privacy'; +$string['privacy_tab_heading'] = 'Privacy policy — Marketplace edition'; +$string['privacy_tab_intro'] = 'This policy describes how the MeetLet Adobe Connect Bridge plugin (Marketplace / free edition) handles personal and technical data when installed in Moodle. It applies to site administrators who configure the plugin and to end users who join Adobe Connect meetings through Moodle activities.'; +$string['privacy_tab_effective'] = 'Effective date: September 2026. MeetLet may update this in-plugin policy when the plugin changes; the version installed on your site is the authoritative copy.'; +$string['privacy_tab_scope_title'] = '1. Scope'; +$string['privacy_tab_scope_body'] = '

This policy covers data processed by the mod_meetletacbfree activity module and its site-wide administration settings. It does not replace your organisation\'s own privacy notices to learners, your Adobe Connect server operator\'s terms, or Moodle core privacy documentation.

MeetLet develops and maintains this plugin. For questions about this policy, contact info@meetlet.net.

'; +$string['privacy_tab_registration_title'] = '2. Optional plugin activation'; +$string['privacy_tab_registration_body'] = '

Plugin activation is voluntary. No data is sent to MeetLet until a site administrator explicitly clicks Activate plugin on the Plugin activation tab. Until then, the plugin does not contact the MeetLet activation service.

When an administrator activates the plugin, the following metadata may be transmitted to the MeetLet activation service:

  • Public site URL / domain
  • A unique installation identifier generated by the plugin
  • Moodle platform name and release version
  • Plugin component name and release version
  • Site or organisation name (from Moodle site settings), if available
  • Site administrator or support contact email, if available in Moodle

No learner personal data, course content, meeting recordings, or Adobe Connect credentials are included in activation requests.

'; +$string['privacy_tab_purpose_title'] = '3. Purpose of activation data'; +$string['privacy_tab_purpose_body'] = '

MeetLet uses activation metadata solely to:

  • Identify your installation for support and troubleshooting when you contact us
  • Deliver security and compatibility updates relevant to your Moodle and plugin versions
  • Maintain aggregate, non-identifying statistics about free Marketplace edition adoption

MeetLet does not use activation information to send promotional emails or make marketing contact without your request. Activating does not oblige you to purchase paid products.

'; +$string['privacy_tab_service_title'] = '4. MeetLet activation service'; +$string['privacy_tab_service_body'] = '

Activation data is sent over HTTPS to the MeetLet activation service endpoint configured on the Plugin activation tab (production default: MeetLet\'s hosted activation API). The service stores installation records linked to your installation ID and domain.

While activated, the plugin may send periodic heartbeat requests containing the same categories of metadata to confirm activation status. You can review the exact API URL in plugin settings; only administrators with site configuration rights can change it.

'; +$string['privacy_tab_adobe_title'] = '5. Adobe Connect single sign-on (SSO)'; +$string['privacy_tab_adobe_body'] = '

When users open or join a meeting, the plugin exchanges selected Moodle profile fields with the Adobe Connect server you configure (your own host or a host operated by your provider). This typically includes first name, last name, email address, and Moodle username/login so Adobe Connect can provision accounts and grant meeting access.

That transmission is directed to your Adobe Connect instance, not to MeetLet\'s activation service. MeetLet does not store Adobe Connect user accounts on MeetLet servers as part of this plugin.

'; +$string['privacy_tab_local_title'] = '6. Data stored locally in Moodle'; +$string['privacy_tab_local_body'] = '

The plugin stores the following in your Moodle database:

  • Activity settings, meeting mappings, cached recording metadata, and optional per-activity participant rules
  • Activation status, installation ID, and cryptographic keys used for activation (when activated)
'; +$string['privacy_tab_retention_title'] = '7. Data retention'; +$string['privacy_tab_retention_body'] = '

Local Moodle data is retained until deleted through normal Moodle course/activity administration or Privacy API deletion requests.

Activation records on the MeetLet activation service are retained while your plugin remains activated and for a reasonable period afterward to prevent abuse and support re-activation. When you deactivate via Deactivate plugin, the plugin stops sending heartbeats; MeetLet may retain historical activation logs in anonymised or aggregated form for security and statistics.

Data on your Adobe Connect server is governed by that server\'s retention policies, not by MeetLet.

'; +$string['privacy_tab_sharing_title'] = '8. Sharing and sale of data'; +$string['privacy_tab_sharing_body'] = '

MeetLet does not sell activation metadata or plugin usage data to third parties. Data is shared only as necessary to operate the activation service (e.g. hosting infrastructure) or when required by applicable law.

Adobe Connect SSO data is shared only with the Adobe Connect endpoint configured by your administrator.

'; +$string['privacy_tab_control_title'] = '9. Administrator choices'; +$string['privacy_tab_control_body'] = '

Site administrators can:

  • Decline plugin activation and still read this policy before deciding
  • Deactivate at any time from the Plugin activation tab, which stops further activation traffic
  • Configure which Adobe Connect server receives SSO data
  • Use Moodle\'s Privacy API tools to export or delete user data held by this plugin
'; +$string['privacy_tab_contact_title'] = '10. Contact'; +$string['privacy_tab_contact_body'] = '

For privacy questions, data subject requests relating to MeetLet activation records, or support regarding this plugin, contact MeetLet at info@meetlet.net.

For requests about data held on your Adobe Connect server, contact your Adobe Connect administrator or hosting provider.

'; + +$string['registration_product_free'] = 'Product: free-adobe-connect-bridge (Marketplace edition)'; +$string['registration_activate'] = 'Activate plugin'; +$string['registration_activate_confirm_ok'] = 'Activate plugin'; +$string['registration_activate_confirm_lead'] = 'You are about to activate this plugin with MeetLet for the free Marketplace edition.'; +$string['registration_activate_confirm_data'] = 'Only the metadata listed on this tab will be sent. After activation, all plugin features unlock.'; +$string['registration_deactivate_confirm_ok'] = 'Deactivate'; +$string['registration_deactivate_confirm_lead'] = 'Deactivate this plugin with MeetLet?'; +$string['registration_deactivate_confirm_warning'] = 'All plugin features will be locked again until you re-activate.'; +$string['registration_api_url'] = 'Activation API URL'; +$string['registration_api_url_desc'] = 'MeetLet activation service base URL (no trailing slash). Production default: https://license.webatsign.com'; +$string['registration_status_label'] = 'Activation status:'; +$string['registration_status_active'] = 'Activated'; +$string['registration_status_inactive'] = 'Not activated'; +$string['registration_status_network_error'] = 'Activation server unreachable'; +$string['registration_status_invalid_key'] = 'Activation invalid'; +$string['registration_status_expired'] = 'Activation expired'; +$string['registration_status_suspended'] = 'Activation suspended'; +$string['registration_status_revoked'] = 'Activation revoked'; +$string['registration_status_domain_mismatch'] = 'Domain not allowed'; +$string['registration_status_activation_limit'] = 'Activation limit reached'; +$string['registration_status_misconfigured'] = 'Activation misconfigured'; +$string['registration_status_unknown'] = 'Activation error'; +$string['registration_product_label'] = 'Product: Adobe Connect Bridge — Marketplace (free activation)'; +$string['registration_domain'] = 'Site domain: {$a}'; +$string['registration_installation'] = 'Installation ID: {$a}'; +$string['registration_last_validated'] = 'Last checked with MeetLet: {$a}'; +$string['registration_last_validated_never'] = 'Never'; +$string['registration_activate_help'] = 'Send site metadata to MeetLet and activate this installation on the free Marketplace product.'; +$string['registration_activate_confirm_title'] = 'Activate this plugin?'; +$string['registration_activate_confirm_item_domain'] = 'Public site domain and a unique installation ID'; +$string['registration_activate_confirm_item_plugin'] = 'Plugin component (mod_meetletacbfree) and release version'; +$string['registration_activate_confirm_item_contact'] = 'Site name and administrator contact email (for support statistics only)'; +$string['registration_activate_confirm_optional'] = 'You can deactivate at any time. A daily scheduled task re-validates activation only while activated.'; +$string['registration_recheck'] = 'Re-check activation'; +$string['registration_recheck_help'] = 'Contact the MeetLet server to refresh activation status (does not re-activate).'; +$string['registration_deactivate'] = 'Deactivate plugin'; +$string['registration_deactivate_confirm'] = 'Remove this plugin activation from MeetLet? Plugin features will be locked again.'; +$string['registration_deactivate_confirm_title'] = 'Deactivate plugin?'; +$string['registration_modal_cancel'] = 'Cancel'; +$string['registration_modal_confirm_activate'] = 'Activate plugin'; +$string['registration_modal_confirm_deactivate'] = 'Deactivate'; +$string['registration_modal_close'] = 'Close'; +$string['registration_activated'] = 'Plugin activated successfully with MeetLet.'; +$string['registration_rechecked'] = 'Activation status refreshed.'; +$string['registration_deactivated'] = 'Plugin deactivated from MeetLet.'; +$string['registration_already_active'] = 'This plugin is already activated.'; +$string['registration_not_active'] = 'This plugin is not activated yet.'; +$string['registration_request_id'] = 'request_id={$a}'; +$string['registration_privacy_title'] = 'What is sent and why'; +$string['registration_privacy_body'] = 'When you activate, MeetLet receives your site domain, installation ID, Moodle and plugin versions, and site contact details. Data is sent to {$a} for update notifications and aggregate support statistics only. No automatic contact occurs on install.'; +$string['registration_err_invalid_key'] = 'Activation could not be validated.'; +$string['registration_err_expired'] = 'Activation has expired.'; +$string['registration_err_revoked'] = 'Activation was revoked.'; +$string['registration_err_suspended'] = 'Activation is suspended.'; +$string['registration_err_product_mismatch'] = 'Product configuration mismatch.'; +$string['registration_err_domain'] = 'This site domain is not allowed for activation.'; +$string['registration_err_activation_limit'] = 'Plugin activation limit reached.'; +$string['registration_err_misconfigured'] = 'Activation request misconfigured.'; +$string['registration_err_bad_signature'] = 'Invalid authorization signature from MeetLet.'; +$string['registration_err_network'] = 'Could not reach the MeetLet activation server.'; +$string['registration_err_unknown'] = 'An unknown activation error occurred.'; +$string['registration_crypto_error'] = 'Activation cryptography error.'; +$string['registration_sodium_required'] = 'PHP sodium extension is required for plugin activation.'; +$string['task_registration_heartbeat'] = 'MeetLet plugin activation heartbeat'; +$string['privacy:metadata:meetletacbfree_license'] = 'Optional MeetLet plugin activation identity stored locally when an administrator activates the plugin.'; +$string['privacy:metadata:meetletacbfree_license:installation_id'] = 'Unique installation identifier generated by the plugin.'; +$string['privacy:metadata:meetletacbfree_license:status'] = 'Last known activation status.'; +$string['privacy:metadata:meetletacbfree_license:last_validated'] = 'When activation was last validated with MeetLet.'; +$string['privacy:metadata:meetlet_registration'] = 'When an administrator opts in, site metadata is sent to the MeetLet activation service for update notices and support statistics.'; +$string['privacy:metadata:meetlet_registration:domain'] = 'Public Moodle site domain.'; +$string['privacy:metadata:meetlet_registration:installation_id'] = 'Unique installation identifier.'; +$string['privacy:metadata:meetlet_registration:company'] = 'Site or organisation name.'; +$string['privacy:metadata:meetlet_registration:email'] = 'Administrator or support contact email.'; +$string['privacy:metadata:meetlet_registration:plugin'] = 'Moodle plugin component name.'; +$string['privacy:metadata:meetlet_registration:plugin_release'] = 'Plugin release version.'; +$string['privacy:metadata:meetlet_registration:platform'] = 'Platform name (moodle).'; +$string['privacy:metadata:meetlet_registration:platform_version'] = 'Moodle release version.'; + diff --git a/lang/fa/meetletacbfree.php b/lang/fa/meetletacbfree.php new file mode 100644 index 0000000..b4da938 --- /dev/null +++ b/lang/fa/meetletacbfree.php @@ -0,0 +1,572 @@ +. + +/** + * Persian language strings for mod_meetletacbfree. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['pluginname'] = 'پل ارتباطی ادوبی کانکت (نسخهٔ Marketplace)'; +$string['modulename'] = 'ادوبی کانکت'; +$string['modulenameplural'] = 'ادوبی کانکت'; +$string['modulename_help'] = 'ایجاد یا اتصال جلسه Adobe Connect با ورود SSO، نگاشت نقش، ضبط‌ها و محتوای مشترک.'; +$string['pluginadministration'] = 'مدیریت پل ارتباطی ادوبی کانکت (نسخهٔ Marketplace)'; +$string['settingsintro'] = 'اتصال مودل به Adobe Connect برای ایجاد جلسه، ورود SSO، نگاشت نقش، ضبط‌ها و محتوا.'; +$string['marketplace_commercial_note'] = 'گزارش حضور و غیاب، ویدیوی MP4 آفلاین، Agent ویندوز و پشتیبانی حرفه‌ای در نسخه تجاری MeetLet Adobe Connect Bridge موجود است.'; +$string['about_visit_website'] = 'ورود به وب‌سایت MeetLet'; +$string['marketplace_commercial_visit'] = 'meetlet.net'; +$string['meetletacbfree:addinstance'] = 'افزودن جلسه MeetLet'; +$string['meetletacbfree:view'] = 'مشاهده جلسه MeetLet'; +$string['meetletacbfree:join'] = 'پیوستن به جلسه MeetLet'; +$string['meetletacbfree:host'] = 'میزبان Adobe Connect'; +$string['meetletacbfree:presenter'] = 'ارائه‌دهنده Adobe Connect'; +$string['meetletacbfree:viewrecordings'] = 'مشاهده ضبط‌ها'; +$string['meetletacbfree:grade'] = 'نمره‌دهی فعالیت MeetLet'; + +$string['meetingname'] = 'نام جلسه'; +$string['meetingintro'] = 'خلاصه جلسه'; +$string['roomsettings'] = 'تنظیمات اتاق'; +$string['meeturl'] = 'آدرس سفارشی جلسه'; +$string['meeturl_help'] = 'مسیر اختیاری اتاق در Adobe Connect (فقط حروف، عدد و خط تیره). خالی بگذارید تا سرور خودش تعیین کند.'; +$string['meetingtype'] = 'نوع دسترسی'; +$string['public'] = 'عمومی'; +$string['private'] = 'خصوصی'; +$string['meettemplates'] = 'قالب جلسه'; +$string['defaulttemplate'] = 'قالب پیش‌فرض'; +$string['starttime'] = 'زمان شروع'; +$string['endtime'] = 'زمان پایان'; +$string['endtimeenable'] = 'تعیین زمان پایان'; +$string['greaterstarttime'] = 'زمان پایان باید بعد از زمان شروع باشد'; +$string['invalidurl'] = 'آدرس جلسه نامعتبر است. فقط حروف، عدد و خط تیره مجاز است و باید با حرف شروع شود.'; + +$string['rolemapping'] = 'نگاشت نقش‌ها'; +$string['rolemapping_desc'] = 'یک قانون پیش‌فرض برای همه شرکت‌کنندگان تعریف کنید، سپس نقش/کاربرهای خاص را به میزبان یا ارائه‌دهنده نگاشت کنید. پیش‌فرض سراسری از تب «تنظیمات دسترسی» می‌آید و اینجا برای همین فعالیت قابل تغییر است.'; +$string['rolemapping_mode'] = 'منبع نگاشت'; +$string['rolemapping_mode_help'] = 'پیروی از سایت: نگاشت سراسری پلاگین (سازنده فعالیت میزبان). اختصاصی: تعریف قوانین نقش/کاربر فقط برای همین فعالیت.'; +$string['rolemapping_mode_inherit'] = 'پیروی از نگاشت سراسری'; +$string['rolemapping_mode_inherit_desc'] = 'از نگاشت سراسری پلاگین استفاده می‌شود و قانون کمکی میزبان بودن سازنده فعالیت هم اعمال می‌شود.'; +$string['rolemapping_mode_override'] = 'تنظیم اختصاصی برای این فعالیت'; +$string['rolemapping_mode_override_short'] = 'اختصاصی'; +$string['rolemapping_mode_override_desc'] = 'قوانین نقش/کاربر فقط برای همین فعالیت به‌صورت دستی تعریف می‌شود.'; +$string['siterolemapping'] = 'نگاشت نقش مودل به Adobe Connect'; +$string['siterolemapping_desc'] = 'مشخص کنید هر نقش مودل هنگام ورود به جلسه یا کار با ادوبی، با کدام نقش Adobe Connect (میزبان / ارائه‌دهنده / شرکت‌کننده) وارد شود. این پیش‌فرض فعالیت‌های جدید است؛ در فرم هر فعالیت هم قابل بازنویسی است.'; +$string['siterolemapping_defaultinfo'] = 'پیش‌فرض داخلی: مدیر، ایجادکننده درس و مدرس ویرایشگر = میزبان، مدرس = ارائه‌دهنده، بقیه = شرکت‌کننده'; +$string['reset_rolemapping'] = 'بازگردانی به پیش‌فرض'; +$string['reset_rolemapping_confirm_title'] = 'بازگردانی نگاشت نقش؟'; +$string['reset_rolemapping_confirm'] = 'نگاشت داخلی مودل به Adobe Connect بازگردانده شود؟ نگاشت سفارشی شما جایگزین می‌شود.'; +$string['reset_featureaccess'] = 'بازگردانی به پیش‌فرض'; +$string['reset_featureaccess_confirm_title'] = 'بازگردانی دسترسی امکانات؟'; +$string['reset_featureaccess_confirm'] = 'ماتریس داخلی دسترسی نقش‌ها به امکانات بازگردانده شود؟ دسترسی‌های سفارشی شما جایگزین می‌شوند.'; +$string['accessreset_confirm_reset'] = 'بازگردانی به پیش‌فرض'; +$string['accessreset_modal_cancel'] = 'انصراف'; +$string['accessreset_modal_close'] = 'بستن'; +$string['accessreset_modal_ok'] = 'تأیید'; +$string['accessreset_busy'] = 'در حال بازگردانی پیش‌فرض…'; +$string['reset_success_rolemapping'] = 'نگاشت نقش به پیش‌فرض داخلی بازگردانده شد.'; +$string['reset_success_featureaccess'] = 'ماتریس دسترسی امکانات به پیش‌فرض داخلی بازگردانده شد.'; +$string['reset_error'] = 'بازگردانی پیش‌فرض ممکن نشد. دوباره تلاش کنید.'; +$string['adoberole'] = 'نقش Adobe Connect'; +$string['participant_list'] = 'فهرست نقش‌ها در جلسه'; +$string['participant_type_all'] = 'همه شرکت‌کنندگان'; +$string['participant_type_role'] = 'نقش'; +$string['participant_type_user'] = 'کاربر'; +$string['participant_add'] = 'افزودن'; +$string['participant_remove'] = 'حذف'; +$string['participant_as'] = 'به‌عنوان'; +$string['role_host'] = 'میزبان'; +$string['role_presenter'] = 'ارائه‌دهنده'; +$string['role_participant'] = 'شرکت‌کننده'; + +$string['allow_manual_offline'] = 'نمایش دکمه دانلود ویدیوی آفلاین جلسات ضبط شده'; +$string['allow_manual_offline_desc'] = 'کنار هر ضبط دکمه‌ای برای درخواست ساخت ویدیوی آفلاین نشان داده شود.'; +$string['server_group_api'] = 'اتصال API'; +$string['server_group_api_desc'] = 'آدرس XML API و دامنه ورود به جلسات.'; +$string['server_group_admin'] = 'حساب مدیر سرور'; +$string['server_group_admin_desc'] = 'حساب مدیر Adobe Connect برای فراخوانی API.'; +$string['featureaccess'] = 'دسترسی نقش‌ها به امکانات'; +$string['featureaccess_desc'] = 'برای هر نقش مودل مشخص کنید کدام امکانات جلسه در دسترس باشد.'; +$string['featureaccess_activity'] = 'دسترسی نقش‌ها به امکانات این فعالیت'; +$string['featureaccess_override'] = 'بازنویسی دسترسی‌های سراسری برای این فعالیت'; +$string['featureaccess_override_help'] = 'اگر فعال باشد، ماتریس زیر فقط برای همین فعالیت اعمال می‌شود. در غیر این صورت پیش‌فرض تنظیمات پلاگین استفاده می‌شود.'; +$string['featureaccess_usesite'] = 'پیش‌فرض داخلی پلاگین'; +$string['activity_featureaccess'] = 'نمایش ماتریس دسترسی در فرم فعالیت'; +$string['activity_featureaccess_desc'] = 'اگر خاموش باشد، بخش «دسترسی نقش‌ها به امکانات این فعالیت» هنگام افزودن/ویرایش فعالیت نشان داده نمی‌شود و فقط ماتریس سراسری اعمال می‌شود.'; +$string['accessrole'] = 'نقش مودل'; +$string['accessfeature_join'] = 'ورود به جلسه'; +$string['accessfeature_recordings'] = 'مشاهده و پخش ضبط‌ها'; +$string['accessfeature_content'] = 'مشاهده و پخش محتوا'; +$string['accessfeature_editrecording'] = 'ویرایش نام ضبط'; +$string['accessfeature_adobeeditor'] = 'ویرایش ضبط در ادوبی کانکت'; +$string['accessmatrix_all'] = 'همه'; +$string['accessmatrix_toggle_row'] = 'فعال/غیرفعال کردن همه امکانات این نقش'; +$string['accessmatrix_toggle_col'] = 'فعال/غیرفعال کردن این امکان برای همه نقش‌ها'; +$string['adobeeditor'] = 'ویرایش در ادوبی کانکت'; +$string['defaultssettings'] = 'پیش‌فرض‌های جلسه'; +$string['tab_meetingsettings'] = 'تنظیمات جلسات'; +$string['tab_accesssettings'] = 'تنظیمات دسترسی'; +$string['roomdefaults'] = 'پیش‌فرض‌های اتاق جلسه'; +$string['roomdefaults_desc'] = 'این مقادیر برای فعالیت‌های جدید هر دو نوع «جلسه جدید» و «جلسات سرور» به‌عنوان پیش‌فرض فرم اعمال می‌شوند؛ در هر فعالیت قابل تغییرند.'; +$string['default_meetinglang'] = 'زبان پیش‌فرض جلسات'; +$string['default_meetinglang_desc'] = 'زبان پیش‌فرض رابط اتاق Adobe Connect برای فعالیت‌های جدید سراسر سایت.'; +$string['default_waitforhost'] = 'محدودیت ورود قبل از مدرس (پیش‌فرض سایت)'; +$string['default_waitforhost_desc'] = 'اگر فعال باشد، در فعالیت‌های جدید تا حضور میزبان در اتاق، ورود دیگران مسدود می‌شود (مگر نقشی که در ماتریس اجازهٔ ورود زودتر داشته باشد).'; + +$string['settingsheader'] = 'اتصال Adobe Connect'; +$string['settingsversion'] = 'نسخه {$a}'; +$string['settings_connection'] = 'تنظیمات سرور'; +$string['settings_connection_desc'] = 'اتصال به سرور Adobe Connect و حساب مدیر.'; +$string['host'] = 'میزبان API'; +$string['host_desc'] = 'آدرس کامل XML API، مثل https://connect.example.edu/api/xml'; +$string['meethost'] = 'دامنه جلسات'; +$string['meethost_desc'] = 'دامنه‌ای که کاربران برای ورود به جلسه باز می‌کنند، مثل connect.example.edu'; +$string['port'] = 'پورت'; +$string['port_desc'] = 'معمولاً ۴۴۳ برای HTTPS'; +$string['admin_login'] = 'ایمیل مدیر سرور'; +$string['admin_login_desc'] = 'ایمیل حساب Adobe Connect برای فراخوانی API'; +$string['admin_password'] = 'رمز عبور مدیر سرور'; +$string['admin_password_desc'] = 'رمز عبور حساب مدیر API روی سرور'; +$string['admin_httpauth'] = 'هدر احراز هویت HTTP'; +$string['admin_httpauth_desc'] = 'باید با HTTP_AUTH_HEADER در custom.ini ادوبی یکی باشد (پیش‌فرض: Generate-Safe-Random-Token)'; +$string['email_login'] = 'تطبیق حساب قدیمی Connect با ایمیل یا نام کاربری'; +$string['email_login_desc'] = 'در ورود SSO، کاربر معمولاً با شناسه mlu{userid} وارد Adobe Connect می‌شود. اگر قبلاً حساب‌های Connect با ایمیل یا نام کاربری مودل ساخته شده‌اند، این گزینه را فعال کنید تا هنگام ورود، همان حساب‌های قدیمی پیدا و به لاگین فعلی mlu{userid} وصل شوند.'; +$string['https'] = 'اتصال HTTPS'; +$string['https_desc'] = 'استفاده از HTTPS برای API و لینک جلسات'; +$string['about_heading'] = 'دربارهٔ برنامه‌نویس پلاگین'; +$string['about_title'] = 'MeetLet'; +$string['about_body'] = 'MeetLet ارائه‌دهنده سرویس‌های کلاس آنلاین و زیرساخت Adobe Connect است.'; +$string['about_body_p1'] = 'میت‌لت (MeetLet) ارائه‌دهنده تخصصی زیرساخت کلاس آنلاین مبتنی بر Adobe Connect است. از میزبانی پایدار و بهینه‌سازی عملکرد تا پشتیبانی عملیاتی برای دانشگاه‌ها، آموزشگاه‌ها و سازمان‌هایی که کیفیت جلسه برایشان خط قرمز است، کنارشان هستیم. تمرکز ما روی تجربه واقعی کلاس است: ورود سریع، پایداری تصویر و صدا، و ابزارهایی که مدرس و فراگیر هر دو به آن نیاز دارند.'; +$string['about_body_p2'] = 'این نسخه Marketplace، مودل را به Adobe Connect برای ساخت جلسه، نگاشت نقش‌ها، ورود SSO، ضبط‌ها و محتوا وصل می‌کند.'; +$string['about_body_p3'] = 'تیم توسعه MeetLet با درک عمیق از محدودیت‌های محیط‌های آموزشی ایران و نیاز به رابط کاربری شفاف، این محصول را طوری پیاده‌سازی کرده که هم برای مدیر سایت قابل اتکا باشد و هم برای مدرس ساده بماند. برند MeetLet یعنی پاسخ‌گویی، کیفیت زیرساخت، و همراهی فنی در مسیر استقرار واقعی، با نگاهی عملی به نیازهای روزمره آموزش آنلاین.'; +$string['about_body_p4'] = 'برای آشنایی بیشتر با خدمات میزبانی Adobe Connect، مشاوره استقرار و پشتیبانی تخصصی می‌توانید از وب‌سایت رسمی دیدن کنید. این افزونه بخشی از اکوسیستم MeetLet برای یکپارچه‌سازی آموزش الکترونیکی است و با همین نگاه طراحی، نگهداری و به‌روزرسانی می‌شود.'; + +$string['runtest'] = 'اجرای تست اتصال'; +$string['testrunning'] = 'در حال اجرای گام‌های تست…'; +$string['testwaiting'] = 'در انتظار'; +$string['testrunningstep'] = 'در حال تست'; +$string['testskipped'] = 'رد شده'; +$string['testprogress'] = 'مرحله {$a->current} از {$a->total} ({$a->percent}٪)'; +$string['conntesttitle'] = 'تست اتصال ادوبی کانکت'; +$string['conntestintro'] = 'این بررسی‌ها سلامت اتصال Moodle به سرور Adobe Connect را می‌سنجد. با زدن دکمه زیر، همه مراحل نمایش داده می‌شوند و پیشرفت هر گام به‌صورت زنده مشخص می‌گردد.'; +$string['testok'] = 'موفق'; +$string['testfail'] = 'ناموفق'; +$string['testsession'] = 'کلید نشست'; +$string['testlogin'] = 'ورود مدیر'; +$string['testfolders'] = 'پوشه‌های مشترک / ضبط / جلسات'; +$string['testcleanup'] = 'حذف جلسه و کاربر آزمایشی'; +$string['testuser'] = 'ساخت، نقش‌دهی و حذف کاربر آزمایشی'; +$string['testsso'] = 'ورود یکپارچه (SSO)'; +$string['testsso_adminok'] = 'SSO مدیر موفق بود'; +$string['testsso_userok'] = 'SSO کاربر آزمایشی موفق بود'; +$string['testsso_userwarn'] = 'SSO کاربر آزمایشی هشدار داد (زیرساخت SSO مدیر سالم است)'; + +$string['tabinfo'] = 'نمای کلی'; +$string['tabdetails'] = 'توضیحات جلسه'; +$string['emptydetails'] = 'برای این جلسه توضیحی ثبت نشده است.'; +$string['sessiondatetime'] = 'شروع جلسه'; +$string['sessionend'] = 'پایان جلسه'; +$string['sessiondetails'] = 'عملیات'; +$string['tabrecordings'] = 'ضبط‌ها'; +$string['tabcontent'] = 'محتوا'; +$string['joinmeeting'] = 'ورود به جلسه'; +$string['joining'] = 'در حال آماده‌سازی نشست امن جلسه…'; +$string['joiningtitle'] = 'آماده‌سازی جلسه'; +$string['preparingrecording'] = 'در حال آماده‌سازی نشست امن ضبط جلسه…'; +$string['preparingrecordingtitle'] = 'آماده‌سازی ضبط جلسه'; +$string['meetingdetails'] = 'جزئیات جلسه'; +$string['meetingurl'] = 'آدرس جلسه'; +$string['statusopen'] = 'آماده پیوستن'; +$string['statuslive'] = 'در حال برگزاری'; +$string['statusscheduled'] = 'زمان‌بندی‌شده'; +$string['statusended'] = 'پایان‌یافته'; +$string['statuswaitinghost'] = 'در انتظار مدرس'; +$string['statuschecking'] = 'در حال بررسی…'; +$string['statushelp'] = 'وضعیت هر ۱۵ ثانیه از سرور بررسی می‌شود. اگر کسی داخل اتاق Adobe باشد، حتی بعد از زمان پایان زمان‌بندی‌شده، وضعیت «در حال برگزاری» نشان داده می‌شود.'; +$string['statuschecking_help'] = 'وضعیت اتاق از سرور Adobe در حال دریافت است. تا مشخص شدن نتیجه، ورود غیرفعال می‌ماند.'; +$string['statuscountdown_help'] = 'شمارش معکوس تا بررسی دوبارهٔ وضعیت جلسه از سرور. وقتی به صفر برسد، وضعیت (مثلاً پایان‌یافته یا در انتظار مدرس) تازه می‌شود.'; +$string['meetingended'] = 'این جلسه پایان یافته است و امکان ورود وجود ندارد.'; +$string['meetingnotstarted'] = 'زمان شروع جلسه هنوز نرسیده است.'; +$string['joindeniedtitle'] = 'ورود مجاز نیست'; +$string['waitingforhost'] = 'جلسه هنوز توسط مدرس/میزبان باز نشده است. لطفاً صبر کنید.'; +$string['noscormapping'] = 'هنوز جلسه‌ای در Adobe به این فعالیت وصل نشده است.'; +$string['nomeeting'] = 'جلسه‌ای روی سرور Adobe Connect برای این فعالیت وجود ندارد'; +$string['notparticipant'] = 'اجازه پیوستن به این جلسه را ندارید'; +$string['usergrouprequired'] = 'برای پیوستن باید عضو یک گروه باشید'; +$string['norecordings'] = 'هنوز ضبطی نیست'; +$string['nocontent'] = 'محتوای آپلودشده‌ای در این جلسه نیست'; +$string['recordingname'] = 'ضبط'; +$string['play'] = 'پخش'; +$string['downloadcontent'] = 'دانلود'; +$string['downloadcontentfailed'] = 'دانلود محتوا ناموفق بود. لطفاً دوباره تلاش کنید.'; +$string['downloadcontenttimeout'] = 'زمان دانلود محتوا به پایان رسید. لطفاً دوباره تلاش کنید.'; +$string['errorbadcontenttoken'] = 'لینک دانلود محتوا نامعتبر است یا برای کاربر دیگری صادر شده است.'; +$string['duration'] = 'مدت'; +$string['refresh'] = 'بازنشانی'; +$string['refreshtab'] = 'به‌روزرسانی'; +$string['jobpollwait'] = 'در حال بررسی وضعیت ساخت… تا یک دقیقه دیگر تازه می‌شود'; +$string['loading'] = 'در حال بارگذاری…'; +$string['error'] = 'خطایی رخ داد'; +$string['erroremptyxml'] = 'ارتباط با API ادوبی برقرار نشد'; +$string['errorlogin'] = 'ورود مدیر Adobe Connect ناموفق بود'; +$string['errorhttpauth'] = 'ورود یکپارچه به Adobe Connect ناموفق بود.'; +$string['errorhttpauth_hint_ip'] = 'IP خروجی مودل که باید در trusted hosts ادوبی باشد: {$a}'; +$string['error_user_unavailable'] = 'الان امکان باز شدن جلسه نیست. این کد را به مدیر سایت بدهید: {$a}'; +$string['error_helpcode_label'] = 'کد راهنما'; +$string['error_adobe_response'] = 'پاسخ Adobe Connect'; +$string['error_see_serverguide'] = 'معنی این کد در تب «راهنمای سرور» تنظیمات پلاگین آمده است.'; +$string['errorfact_login'] = 'لاگین Connect'; +$string['errorfact_adobe_code'] = 'کد Adobe'; +$string['errorfact_adobe_subcode'] = 'زیرکد Adobe'; +$string['errorfact_egress_ip'] = 'IP خروجی مودل'; +$string['helpcode_sso_01_title'] = 'ورود یکپارچه با هدر HTTP ناموفق بود'; +$string['helpcode_sso_01_fix'] = '
  1. مطمئن شوید HTTP_AUTH_HEADER در custom.ini با تنظیم پلاگین یکی است.
  2. IP عمومی همین سرور مودل را به HTTP_AUTH_TRUSTED_HOSTS اضافه کنید (127.0.0.1 را نگه دارید).
  3. HeaderAuthenticationFilter را در web.xml فعال کنید و /api/ را ignore نکنید.
  4. پس از تغییر trusted hosts سرویس ConnectPro را ری‌استارت کنید.
'; +$string['helpcode_api_01_title'] = 'ارتباط با API ادوبی برقرار نشد'; +$string['helpcode_api_01_fix'] = 'آدرس API، پورت، HTTPS و فایروال بین سرور مودل و Adobe Connect را بررسی کنید.'; +$string['helpcode_api_02_title'] = 'ورود حساب مدیر Adobe Connect ناموفق بود'; +$string['helpcode_api_02_fix'] = 'ایمیل و رمز «حساب مدیر سرور» در تنظیمات پلاگین را بررسی کنید. این حساب باید به XML API دسترسی داشته باشد.'; +$string['helpcode_usr_01_title'] = 'ساخت یا یافتن کاربر روی Adobe Connect ممکن نشد'; +$string['helpcode_usr_01_fix'] = 'ایمیل کاربر مودل را بررسی کنید و مطمئن شوید حساب مدیر ادوبی اجازه ساخت کاربر دارد.'; +$string['helpcode_mtg_01_title'] = 'جلسه روی سرور Adobe Connect پیدا نشد'; +$string['helpcode_mtg_01_fix'] = 'تنظیمات فعالیت را باز کنید و مطمئن شوید جلسه هنوز روی سرور ادوبی وجود دارد.'; +$string['helpcode_cfg_01_title'] = 'پلاگین هنوز پیکربندی نشده است'; +$string['helpcode_cfg_01_fix'] = 'تنظیمات Adobe Connect را در مدیریت سایت کامل کنید و تب «تست اتصال» را اجرا کنید.'; +$string['helpcode_err_01_title'] = 'خطای پیش‌بینی‌نشدهٔ MeetLet'; +$string['helpcode_err_01_fix'] = 'یک‌بار دوباره تلاش کنید. اگر تکرار شد، لاگ مودل و تب تست اتصال را ببینید.'; +$string['serverguide_errorcodes_title'] = 'کدهای راهنمایی که کاربر می‌بیند'; +$string['serverguide_errorcodes_intro'] = 'کاربر عادی فقط می‌فهمد جلسه باز نمی‌شود و یک کد می‌بیند. با تطبیق آن کد در این جدول مشخص می‌شود چه چیزی را باید بررسی کنید.'; +$string['serverguide_errorcodes_col_code'] = 'کد'; +$string['serverguide_errorcodes_col_meaning'] = 'معنی'; +$string['serverguide_errorcodes_col_fix'] = 'چه چیزی را بررسی کنید'; +$string['errorcreatemeeting'] = 'ساخت جلسه روی سرور Adobe Connect ممکن نشد'; +$string['errorcreateuser'] = 'ساخت یا یافتن حساب شما روی سرور Adobe Connect ممکن نشد. ایمیل کاربر مودل را بررسی کنید و مطمئن شوید حساب «ایمیل مدیر سرور» اجازه ساخت کاربر دارد.'; +$string['errorexistingsco'] = 'جلسه انتخاب‌شده روی سرور پیدا نشد. یک جلسه معتبر از فهرست انتخاب کنید.'; +$string['errorrenamerecording'] = 'تغییر نام ضبط ممکن نشد'; +$string['recordingrenamed'] = 'نام ضبط به‌روز شد'; +$string['editrecording'] = 'ویرایش نام ضبط'; +$string['recordingdate'] = 'تاریخ ضبط'; +$string['linkmode'] = 'نحوه اتصال جلسه'; +$string['linkmode_help'] = 'می‌توانید یکی از جلسات موجود روی سرور Adobe Connect را وصل کنید، یا جلسهٔ جدید بسازید.'; +$string['linkmode_create'] = 'ساخت جلسه جدید روی سرور'; +$string['linkmode_existing'] = 'جلسه موجود روی سرور'; +$string['existingscoid'] = 'جلسه موجود روی سرور'; +$string['chooseservermeeting'] = 'یک جلسه را انتخاب کنید…'; +$string['searchservermeetings'] = 'جستجوی جلسه روی سرور…'; +$string['loadingmeetings'] = 'در حال دریافت جلسات…'; +$string['nomeetingsfound'] = 'جلسه‌ای پیدا نشد'; +$string['meetinglang'] = 'زبان جلسه'; +$string['meetinglang_help'] = 'زبان پیش‌فرض رابط اتاق Adobe Connect (در صورت پشتیبانی سرور).'; +$string['meetinglang_default'] = 'پیش‌فرض سرور'; +$string['waitforhost'] = 'محدودیت ورود قبل از مدرس'; +$string['waitforhost_help'] = 'تا وقتی میزبان در اتاق حاضر نباشد، دیگران نمی‌توانند وارد شوند. نقش‌هایی که در ماتریس «عبور از زمان‌بندی / انتظار مدرس» دارند از این محدودیت و همچنین از وضعیت زمان‌بندی‌شده/پایان‌یافته مستثنی‌اند.'; +$string['settingstate_inherit'] = 'پیروی از سایت'; +$string['settingstate_inherit_on'] = 'سایت: روشن'; +$string['settingstate_inherit_off'] = 'سایت: خاموش'; +$string['settingstate_enable'] = 'روشن'; +$string['settingstate_disable'] = 'خاموش'; +$string['waitingforhost'] = 'جلسه هنوز توسط مدرس/میزبان باز نشده است. لطفاً صبر کنید.'; +$string['joinblocked_waitinghost'] = 'تا شروع جلسه توسط مدرس/میزبان امکان ورود نیست (Waiting for moderator to start session).'; +$string['accessfeature_enterbeforehost'] = 'عبور از زمان‌بندی / انتظار مدرس'; +$string['contenttype'] = 'نوع'; +$string['contentsize'] = 'حجم'; +$string['contentdate'] = 'تاریخ اصلاح'; +$string['contenticon_image'] = 'تصویر'; +$string['contenticon_jpg'] = 'تصویر'; +$string['contenticon_jpeg'] = 'تصویر'; +$string['contenticon_png'] = 'تصویر'; +$string['contenticon_gif'] = 'تصویر'; +$string['contenticon_presentation'] = 'ارائه'; +$string['contenticon_producer'] = 'ارائه'; +$string['contenticon_ppt'] = 'ارائه'; +$string['contenticon_pptx'] = 'ارائه'; +$string['contenticon_pdf'] = 'PDF'; +$string['contenticon_mp3'] = 'صوت'; +$string['contenticon_mp4'] = 'ویدیو'; +$string['contenticon_flv'] = 'ویدیو'; +$string['contenticon_swf'] = 'فلش'; +$string['contenticon_html'] = 'HTML'; +$string['contenticon_zip'] = 'ZIP'; +$string['contenticon_attachment'] = 'پیوست'; +$string['contenticon_file'] = 'فایل'; +$string['contenticon_content'] = 'محتوا'; +$string['errornofolder'] = 'پوشه جلسات Adobe Connect پیدا نشد'; +$string['notconfigured'] = 'اتصال Adobe Connect هنوز پیکربندی نشده است. از مدیر سایت بخواهید تنظیمات را کامل کند.'; +$string['privacy:metadata'] = 'MeetLet نگاشت جلسات و متادیتای ضبط را محلی نگه می‌دارد و برای SSO فیلدهای پروفایل را با Adobe Connect مبادله می‌کند.'; +$string['privacy:metadata:meetletacbfree'] = 'نمونه‌های فعالیت MeetLet که در مودل ذخیره می‌شوند.'; +$string['privacy:metadata:meetletacbfree:userid'] = 'کاربری که نمونه فعالیت را ساخته است.'; +$string['privacy:metadata:meetletacbfree:name'] = 'نام فعالیت.'; +$string['privacy:metadata:meetletacbfree:intro'] = 'توضیح فعالیت.'; +$string['privacy:metadata:meetletacbfree:participants'] = 'قواعد نگاشت نقش که ممکن است شامل شناسه کاربران مودل باشد.'; +$string['privacy:metadata:meetletacbfree:timecreated'] = 'زمان ایجاد فعالیت.'; +$string['privacy:metadata:meetletacbfree:timemodified'] = 'زمان آخرین ویرایش فعالیت.'; +$string['privacy:metadata:meetletacbfree_meetings'] = 'نگاشت SCO جلسه Adobe Connect به ازای هر گروه مودل.'; +$string['privacy:metadata:meetletacbfree_meetings:groupid'] = 'گروه مودل متصل به جلسه Adobe Connect.'; +$string['privacy:metadata:meetletacbfree_meetings:meetingscoid'] = 'شناسه SCO جلسه در Adobe Connect.'; +$string['privacy:metadata:meetletacbfree_meetings:urlpath'] = 'مسیر URL جلسه Adobe Connect.'; +$string['privacy:metadata:meetletacbfree_recordings'] = 'متادیتای کش‌شده ضبط و ساخت ویدیوی آفلاین.'; +$string['privacy:metadata:meetletacbfree_recordings:name'] = 'عنوان ضبط کش‌شده از Adobe Connect.'; +$string['privacy:metadata:meetletacbfree_recordings:urlpath'] = 'مسیر URL ضبط.'; +$string['privacy:metadata:meetletacbfree_recordings:jobstatus'] = 'وضعیت جاب ساخت ویدیوی آفلاین.'; +$string['privacy:metadata:adobeconnect'] = 'نام، ایمیل و لاگین کاربر برای ساخت حساب و دسترسی جلسه به Adobe Connect ارسال می‌شود.'; +$string['privacy:metadata:adobeconnect:firstname'] = 'نام کاربر که به Adobe Connect ارسال می‌شود.'; +$string['privacy:metadata:adobeconnect:lastname'] = 'نام خانوادگی کاربر که به Adobe Connect ارسال می‌شود.'; +$string['privacy:metadata:adobeconnect:email'] = 'ایمیل کاربر که به Adobe Connect ارسال می‌شود.'; +$string['privacy:metadata:adobeconnect:username'] = 'نام کاربری / لاگین که به Adobe Connect ارسال می‌شود.'; +$string['privacy:export:adobeconnect'] = 'فیلدهای پروفایل برای SSO به Adobe Connect هم ارسال می‌شوند. MeetLet نمی‌تواند حساب‌های سرور Adobe Connect را از داخل مودل حذف کند.'; +$string['search:activity'] = 'فعالیت‌های MeetLet'; +$string['calendar'] = 'جلسه MeetLet'; +$string['view'] = 'مشاهده جلسه'; +$string['emptytab'] = 'موردی برای نمایش نیست'; +$string['cachedef_folders'] = 'شناسه پوشه‌های Adobe Connect'; +$string['cachedef_meetinginfo'] = 'اطلاعات جلسه Adobe Connect'; +$string['cachedef_templates'] = 'قالب‌های جلسه Adobe Connect'; +$string['tasksync'] = 'همگام‌سازی ضبط‌های MeetLet'; +$string['brand'] = 'MeetLet'; +$string['subtitle'] = 'Adobe Connect'; +$string['featuresheading'] = 'نمایش بخش‌ها'; +$string['featuresheading_desc'] = 'هر بخش دو تنظیم دارد: نمایش سراسری، و اجازه کنترل جداگانه در هر فعالیت.'; +$string['featuregroup_recordingstab'] = 'تب ضبط‌ها'; +$string['featuregroup_recordingstab_desc'] = 'نمایش یا مخفی‌سازی تب ضبط‌ها در صفحه فعالیت.'; +$string['featuregroup_contenttab'] = 'تب محتوا'; +$string['featuregroup_contenttab_desc'] = 'نمایش یا مخفی‌سازی تب محتوای جلسه.'; +$string['enable_recordingstab'] = 'نمایش تب ضبط‌ها'; +$string['enable_recordingstab_desc'] = 'کلید اصلی تب ضبط‌ها. نقش کاربر همچنان باید در ماتریس «مشاهده و پخش ضبط‌ها» را داشته باشد.'; +$string['allow_override_recordingstab'] = 'اجازه تنظیم تب ضبط‌ها در هر فعالیت'; +$string['allow_override_recordingstab_desc'] = 'مدرس بتواند برای هر جلسه نمایش تب ضبط‌ها را جداگانه کنترل کند.'; +$string['enable_contenttab'] = 'نمایش تب محتوا'; +$string['enable_contenttab_desc'] = 'کلید اصلی تب محتوا. نقش کاربر همچنان باید در ماتریس «مشاهده و پخش محتوا» را داشته باشد.'; +$string['allow_override_contenttab'] = 'اجازه تنظیم تب محتوا در هر فعالیت'; +$string['allow_override_contenttab_desc'] = 'مدرس بتواند برای هر جلسه نمایش تب محتوا را جداگانه کنترل کند.'; +$string['showrecordingstab'] = 'نمایش تب ضبط‌ها'; +$string['showcontenttab'] = 'نمایش تب محتوا'; +$string['activityfeatures'] = 'نمایش لینک‌ها و تب‌ها'; +$string['featuredisabled'] = 'این بخش توسط مدیر سایت غیرفعال شده است.'; +$string['tab_connectiontest'] = 'تست اتصال'; +$string['tab_aboutdev'] = 'دربارهٔ برنامه‌نویس پلاگین'; +$string['tab_serverguide'] = 'راهنمای سرور'; + +$string['serverguide_heading'] = 'تنظیمات لازم روی سرور Adobe Connect'; +$string['serverguide_intro'] = 'فقط فایل‌ها و خطوطی که برای کار پلاگین (SSO و HTTPS) باید روی میزبان Connect تنظیم شوند. بعد از هر تغییر، سرویس ConnectPro را ری‌استارت کنید.'; +$string['serverguide_match_title'] = 'فیلدهایی که باید با سرور یکی باشند'; +$string['serverguide_match_intro'] = 'این موارد را در تب تنظیمات سرور تنظیم کنید. مقادیر زیر فقط مثال هستند — میزبان Connect و مقادیر محرمانه خود را وارد کنید.'; +$string['serverguide_placeholder_httpauth'] = 'your-secret-header-value'; +$string['serverguide_placeholder_apiurl'] = 'https://connect.example.edu/api/xml'; +$string['serverguide_placeholder_meethost'] = 'connect.example.edu'; + +$string['serverguide_customini_title'] = 'custom.ini'; +$string['serverguide_customini_body'] = '

مسیر: C:\\Connect\\11.2.0\\custom.ini (نام پوشه نسخه ممکن است بعد از پچ عوض نشود).

  • HTTP_AUTH_HEADER = همان «هدر احراز هویت HTTP» در پلاگین
  • HTTP_AUTH_TRUSTED_HOSTS = 127.0.0.1 و IP خروجی Moodle
  • ADMIN_HOST = دامنه جلسه · برای HTTPS: ADMIN_PROTOCOL=https:// و SSL_ONLY=yes
'; +$string['serverguide_customini_sample'] = 'ADMIN_HOST=connect.example.edu +ADMIN_PROTOCOL=https:// +SSL_ONLY=yes +HTTP_AUTH_HEADER=your-secret-header-value +HTTP_AUTH_TRUSTED_HOSTS=127.0.0.1,::1,203.0.113.10'; + +$string['serverguide_webxml_title'] = 'web.xml — HeaderAuthenticationFilter'; +$string['serverguide_webxml_body'] = '

مسیر: C:\\Connect\\11.2.0\\appserv\\web\\WEB-INF\\web.xml

  • بلوک HeaderAuthenticationFilter (هم <filter> و هم <filter-mapping>) را از کامنت خارج کنید.
  • در ignore-patternهای این فیلتر، مسیر /api/ را نگذارید. پلاگین برای ورود یکپارچه کاربر، درخواست را به آدرس XML API ادوبی می‌فرستد؛ اگر /api/ ignore شود، SSO کار نمی‌کند.
  • بلوک NtlmAuthenticationFilter را کامنت‌شده نگه دارید. آن فیلتر برای ورود با حساب ویندوز/دامنه (Active Directory) است و برای این پلاگین لازم نیست. فقط اگر سازمان شما عمداً NTLM را روی Connect فعال کرده، آن را uncomment کنید؛ در غیر این صورت دست نزنید و HeaderAuthenticationFilter را فعال نگه دارید.
'; +$string['serverguide_webxml_sample'] = ' + + HeaderAuthenticationFilter + com.macromedia.airspeed.servlet.filter.HeaderAuthenticationFilter + + ignore-pattern-0 + /common/ + + + ignore-pattern-1 + /servlet/gateway/ + + + ignore-pattern-2 + /servlet/mirror + + + ignore-pattern-3 + /servlet/testbuilder + + + ignore-pattern-4 + /main + + + + HeaderAuthenticationFilter + /* + + +'; + +$string['serverguide_serverxml_title'] = 'server.xml — کانکتور SSL / HTTPS'; +$string['serverguide_serverxml_body'] = '

مسیر: C:\\Connect\\11.2.0\\appserv\\conf\\server.xml

در فایل پیش‌فرض نصب، دو بلوک با برچسب Uncomment for SSL support کامنت‌اند و هر دو باید uncomment شوند:

  1. <Executor name="httpsThreadPool" ...>
  2. <Connector port="8443" ...> (معمولاً با SSLEnabled="false" وقتی stunnel روی ۴۴۳ TLS را تمام می‌کند)

روی Connector پورت 80 هم redirectPort="443" باشد.

'; +$string['serverguide_serverxml_sample'] = ' + + + +redirectPort="443" + + +'; + +$string['serverguide_upgrade_title'] = 'نکته بعد از آپدیت / پچ Connect: '; +$string['serverguide_upgrade_body'] = '
  • فایل appserv\\web\\WEB-INF\\web.xml معمولاً بعد از آپدیت خراب می‌شود؛ آپدیت کننده اغلب بلوک HeaderAuthenticationFilter را دوباره کامنت می‌کند. آن بلوک را دوباره از کامنت خارج کنید و مسیر /api/ را از فهرست ignore-pattern حذف کنید.
  • فایل custom.ini را باز کنید و مطمئن شوید مقادیر HTTP_AUTH_HEADER و HTTP_AUTH_TRUSTED_HOSTS هنوز درست و کامل هستند.
  • فایل appserv\\conf\\server.xml را باز کنید و مطمئن شوید هر دو بلوک httpsThreadPool و Connector پورت 8443 هنوز از حالت کامنت خارج مانده‌اند.
  • بعد از اعمال اصلاحات، سرویس ویندوز ConnectPro را ری‌استارت کنید و در مودل تب «تست اتصال» را دوباره اجرا کنید.
'; + +$string['tab_registration'] = 'فعال‌سازی پلاگین'; +$string['registration_required'] = 'این افزونه قبل از استفاده از جلسات، ورود SSO، ضبط‌ها و سایر امکانات به فعال‌سازی رایگان پلاگین نیاز دارد. مدیر سایت می‌تواند از مسیر مدیریت سایت ← افزونه‌ها ← ماژول‌های فعالیت ← Adobe Connect ← فعال‌سازی پلاگین اقدام کند.'; +$string['registration_heading'] = 'فعال‌سازی پلاگین'; +$string['registration_heading_desc'] = 'برای باز شدن جلسات، SSO، همگام‌سازی ضبط‌ها و همه امکانات Marketplace، این افزونه را در MeetLet فعال کنید. فعال‌سازی داوطلبانه است و فقط با کلیک روی دکمه زیر انجام می‌شود — هیچ داده‌ای به‌صورت خودکار هنگام نصب ارسال نمی‌شود.'; +$string['registration_gate_title'] = 'فعال‌سازی پلاگین لازم است'; +$string['registration_gate_body'] = 'تا زمانی که مدیر فعال‌سازی رایگان پلاگین را انجام ندهد، جلسات، SSO، ضبط‌ها و سایر امکانات Adobe Connect قفل هستند.'; +$string['registration_gate_link'] = 'رفتن به تب فعال‌سازی پلاگین'; +$string['registration_hero_notice'] = 'تا تکمیل فعال‌سازی رایگان پلاگین، امکانات اصلی قفل هستند.'; +$string['registration_why_title'] = 'چرا فعال‌سازی کنیم؟'; +$string['registration_why'] = 'MeetLet از فعال‌سازی داوطلبانه پلاگین برای ارائه به‌روزرسانی امنیتی، پشتیبانی در صورت نیاز و آمار نسخه رایگان استفاده می‌کند. این نسخه کلید لایسنس پولی ندارد.'; +$string['registration_data_title'] = 'چه داده‌ای ارسال می‌شود'; +$string['registration_data_sent'] = 'با فعال‌سازی، فقط متادیتای فنی و تماس زیر به MeetLet ارسال می‌شود:'; +$string['registration_data_item_domain'] = 'دامنه سایت: {$a}'; +$string['registration_data_item_moodle'] = 'نسخه مودل: {$a}'; +$string['registration_data_item_plugin'] = 'نسخه افزونه: {$a}'; +$string['registration_data_item_installation'] = 'شناسه نصب: {$a}'; +$string['registration_data_item_sitename'] = 'نام سایت: {$a}'; +$string['registration_data_item_email'] = 'ایمیل مدیر (در صورت موجود بودن): {$a}'; +$string['registration_data_optional'] = 'ارسال نمی‌شود'; +$string['registration_privacy'] = '{$a}'; +$string['registration_privacy_tablink'] = 'جزئیات در تب حریم خصوصی'; +$string['tab_privacy'] = 'حریم خصوصی'; +$string['privacy_tab_heading'] = 'سیاست حریم خصوصی — نسخه Marketplace'; +$string['privacy_tab_intro'] = 'این سیاست نحوه پردازش داده‌های فنی و شخصی توسط افزونه پل Adobe Connect میت‌لت (نسخه Marketplace / رایگان) در مودل را توضیح می‌دهد. شامل مدیرانی است که افزونه را پیکربندی می‌کنند و کاربرانی که از طریق فعالیت‌های مودل به جلسات Adobe Connect می‌پیوندند.'; +$string['privacy_tab_effective'] = 'تاریخ اجرا: شهریور ۱۴۰۵ (سپتامبر ۲۰۲۶). میت‌لت ممکن است این سیاست درون‌افزونه‌ای را هنگام به‌روزرسانی افزونه اصلاح کند؛ نسخه نصب‌شده روی سایت شما مرجع است.'; +$string['privacy_tab_scope_title'] = '۱. حوزه شمول'; +$string['privacy_tab_scope_body'] = '

این سیاست داده‌های پردازش‌شده توسط ماژول فعالیت mod_meetletacbfree و تنظیمات سراسری آن را پوشش می‌دهد. جایگزین اعلامیه حریم خصوصی سازمان شما نسبت به فراگیران، شرایط اپراتور سرور Adobe Connect، یا مستندات حریم خصوصی هسته مودل نیست.

میت‌لت (MeetLet) این افزونه را توسعه و نگهداری می‌کند. برای پرسش درباره این سیاست با info@meetlet.net تماس بگیرید.

'; +$string['privacy_tab_registration_title'] = '۲. فعال‌سازی اختیاری پلاگین'; +$string['privacy_tab_registration_body'] = '

فعال‌سازی پلاگین اختیاری است. تا زمانی که مدیر سایت صریحاً روی فعال‌سازی پلاگین در تب فعال‌سازی پلاگین کلیک نکند، هیچ داده‌ای به میت‌لت ارسال نمی‌شود و افزونه با سرویس فعال‌سازی میت‌لت تماس نمی‌گیرد.

هنگام فعال‌سازی، متادیتای زیر ممکن است به سرویس فعال‌سازی میت‌لت ارسال شود:

  • آدرس/دامنه عمومی سایت
  • شناسه یکتای نصب تولیدشده توسط افزونه
  • نام و نسخه انتشار مودل
  • نام کامپوننت و نسخه انتشار افزونه
  • نام سایت یا سازمان (از تنظیمات مودل)، در صورت موجود بودن
  • ایمیل مدیر یا پشتیبانی سایت، در صورت موجود بودن در مودل

داده شخصی فراگیران، محتوای درس، ضبط جلسات یا اعتبارنامه Adobe Connect در درخواست‌های فعال‌سازی گنجانده نمی‌شود.

'; +$string['privacy_tab_purpose_title'] = '۳. هدف از داده‌های فعال‌سازی'; +$string['privacy_tab_purpose_body'] = '

میت‌لت از متادیتای فعال‌سازی صرفاً برای موارد زیر استفاده می‌کند:

  • شناسایی نصب شما برای پشتیبانی و عیب‌یابی هنگام تماس با ما
  • ارائه به‌روزرسانی‌های امنیتی و سازگاری متناسب با نسخه مودل و افزونه
  • نگهداری آمار تجمیعی و غیرشناسا از پذیرش نسخه رایگان Marketplace

میت‌لت از اطلاعات فعال‌سازی برای ارسال ایمیل تبلیغاتی یا تماس بازاریابی بدون درخواست شما استفاده نمی‌کند. فعال‌سازی شما را ملزم به خرید محصولات پولی نمی‌کند.

'; +$string['privacy_tab_service_title'] = '۴. سرویس فعال‌سازی میت‌لت'; +$string['privacy_tab_service_body'] = '

داده‌های فعال‌سازی از طریق HTTPS به نقطه پایانی سرویس فعال‌سازی میت‌لت که در تب فعال‌سازی پلاگین پیکربندی شده ارسال می‌شود (پیش‌فرض تولید: API فعال‌سازی میزبانی‌شده میت‌لت). این سرویس رکوردهای نصب مرتبط با شناسه نصب و دامنه شما را نگه می‌دارد.

در حالت فعال‌شده، افزونه ممکن است درخواست‌های ضربان دوره‌ای با همان دسته‌های متادیتا برای تأیید وضعیت فعال‌سازی ارسال کند. آدرس دقیق API در تنظیمات افزونه قابل مشاهده است؛ فقط مدیران دارای حق پیکربندی سایت می‌توانند آن را تغییر دهند.

'; +$string['privacy_tab_adobe_title'] = '۵. ورود یکپارچه Adobe Connect (SSO)'; +$string['privacy_tab_adobe_body'] = '

هنگام ورود یا پیوستن به جلسه، افزونه فیلدهای انتخاب‌شده پروفایل مودل را با سرور Adobe Connectی که شما پیکربندی می‌کنید (میزبان خودتان یا میزبان ارائه‌دهنده) مبادله می‌کند. معمولاً شامل نام، نام خانوادگی، ایمیل و نام کاربری/لاگین مودل است تا Adobe Connect حساب بسازد و دسترسی جلسه بدهد.

این ارسال به نمونه Adobe Connect شما هدایت می‌شود، نه به سرویس فعال‌سازی میت‌لت. میت‌لت حساب‌های کاربری Adobe Connect را روی سرورهای خود ذخیره نمی‌کند.

'; +$string['privacy_tab_local_title'] = '۶. داده ذخیره‌شده محلی در مودل'; +$string['privacy_tab_local_body'] = '

افزونه موارد زیر را در پایگاه داده مودل شما نگه می‌دارد:

  • تنظیمات فعالیت، نگاشت جلسات، متادیتای کش‌شده ضبط و قواعد اختیاری شرکت‌کنندگان
  • وضعیت فعال‌سازی، شناسه نصب و کلیدهای رمزنگاری فعال‌سازی (پس از فعال‌سازی)
'; +$string['privacy_tab_retention_title'] = '۷. نگهداری داده'; +$string['privacy_tab_retention_body'] = '

داده محلی مودل تا زمان حذف از طریق مدیریت عادی درس/فعالیت یا درخواست‌های حذف Privacy API نگه داشته می‌شود.

رکوردهای فعال‌سازی در سرویس فعال‌سازی میت‌لت تا زمانی که پلاگین فعال‌شده بماند و مدت معقول پس از آن برای جلوگیری از سوءاستفاده و پشتیبانی فعال‌سازی مجدد نگه داشته می‌شوند. با غیرفعال‌سازی پلاگین، افزونه ارسال ضربان را متوقف می‌کند؛ میت‌لت ممکن است گزارش‌های تاریخی فعال‌سازی را به‌صورت ناشناس یا تجمیعی برای امنیت و آمار نگه دارد.

داده روی سرور Adobe Connect شما تابع سیاست نگهداری همان سرور است، نه میت‌لت.

'; +$string['privacy_tab_sharing_title'] = '۸. اشتراک‌گذاری و فروش داده'; +$string['privacy_tab_sharing_body'] = '

میت‌لت متادیتای فعال‌سازی یا داده استفاده از افزونه را به اشخاص ثالث نمی‌فروشد. داده فقط در حد لازم برای به‌کار انداختن سرویس فعال‌سازی (مثلاً زیرساخت میزبانی) یا در صورت الزام قانون به اشتراک گذاشته می‌شود.

داده SSO Adobe Connect فقط با نقطه پایانی Adobe Connectی که مدیر شما پیکربندی کرده به اشتراک گذاشته می‌شود.

'; +$string['privacy_tab_control_title'] = '۹. انتخاب‌های مدیر'; +$string['privacy_tab_control_body'] = '

مدیران سایت می‌توانند:

  • از فعال‌سازی پلاگین خودداری کنند و پیش از تصمیم این سیاست را بخوانند
  • هر زمان از تب فعال‌سازی پلاگین غیرفعال کنند تا ترافیک فعال‌سازی متوقف شود
  • مشخص کنند کدام سرور Adobe Connect داده SSO را دریافت کند
  • از ابزارهای Privacy API مودل برای صادرات یا حذف داده کاربران نگه‌داشته‌شده توسط این افزونه استفاده کنند
'; +$string['privacy_tab_contact_title'] = '۱۰. تماس'; +$string['privacy_tab_contact_body'] = '

برای پرسش حریم خصوصی، درخواست‌های مربوط به رکوردهای فعال‌سازی میت‌لت یا پشتیبانی این افزونه با میت‌لت در info@meetlet.net تماس بگیرید.

برای درخواست‌های مربوط به داده روی سرور Adobe Connect خود، با مدیر یا میزبان Adobe Connect تماس بگیرید.

'; + +$string['registration_product_free'] = 'محصول: free-adobe-connect-bridge (نسخه Marketplace)'; +$string['registration_activate'] = 'فعال‌سازی پلاگین'; +$string['registration_activate_confirm_ok'] = 'فعال‌سازی'; +$string['registration_activate_confirm_lead'] = 'در حال فعال‌سازی این افزونه در MeetLet برای نسخه رایگان Marketplace هستید.'; +$string['registration_activate_confirm_data'] = 'فقط متادیتای فهرست‌شده در این تب ارسال می‌شود. پس از فعال‌سازی، همه امکانات افزونه باز می‌شوند.'; +$string['registration_deactivate_confirm_ok'] = 'غیرفعال‌سازی'; +$string['registration_deactivate_confirm_lead'] = 'فعال‌سازی این افزونه در MeetLet لغو شود؟'; +$string['registration_deactivate_confirm_warning'] = 'جلسات، SSO، ضبط‌ها و سایر امکانات دوباره قفل می‌شوند تا فعال‌سازی مجدد انجام شود.'; +$string['registration_api_url'] = 'آدرس API فعال‌سازی'; +$string['registration_api_url_desc'] = 'آدرس پایه سرویس فعال‌سازی MeetLet (بدون / انتهایی). پیش‌فرض: https://license.webatsign.com'; +$string['registration_status_label'] = 'وضعیت فعال‌سازی:'; +$string['registration_status_active'] = 'فعال‌شده'; +$string['registration_status_inactive'] = 'فعال نشده'; +$string['registration_status_network_error'] = 'سرور فعال‌سازی در دسترس نیست'; +$string['registration_status_invalid_key'] = 'فعال‌سازی نامعتبر'; +$string['registration_status_expired'] = 'فعال‌سازی منقضی شده'; +$string['registration_status_suspended'] = 'فعال‌سازی معلق'; +$string['registration_status_revoked'] = 'فعال‌سازی لغو شده'; +$string['registration_status_domain_mismatch'] = 'دامنه مجاز نیست'; +$string['registration_status_activation_limit'] = 'سقف فعال‌سازی'; +$string['registration_status_misconfigured'] = 'پیکربندی فعال‌سازی نادرست'; +$string['registration_status_unknown'] = 'خطای فعال‌سازی'; +$string['registration_product_label'] = 'محصول: Adobe Connect Bridge — Marketplace (فعال‌سازی رایگان)'; +$string['registration_domain'] = 'دامنه سایت: {$a}'; +$string['registration_installation'] = 'شناسه نصب: {$a}'; +$string['registration_last_validated'] = 'آخرین بررسی با MeetLet: {$a}'; +$string['registration_last_validated_never'] = 'هرگز'; +$string['registration_activate_help'] = 'ارسال متادیتای سایت به MeetLet و فعال‌سازی این نصب روی محصول رایگان Marketplace.'; +$string['registration_activate_confirm_title'] = 'این افزونه فعال شود؟'; +$string['registration_activate_confirm_item_domain'] = 'دامنه عمومی سایت و شناسه یکتای نصب'; +$string['registration_activate_confirm_item_plugin'] = 'کامپوننت افزونه (mod_meetletacbfree) و نسخه انتشار'; +$string['registration_activate_confirm_item_contact'] = 'نام سایت و ایمیل تماس مدیر (فقط برای آمار پشتیبانی)'; +$string['registration_activate_confirm_optional'] = 'هر زمان می‌توانید غیرفعال کنید. وظیفه زمان‌بندی‌شده روزانه فقط در صورت فعال‌سازی، وضعیت را تازه می‌کند.'; +$string['registration_recheck'] = 'بررسی مجدد فعال‌سازی'; +$string['registration_recheck_help'] = 'تماس با سرور MeetLet برای به‌روزرسانی وضعیت فعال‌سازی (بدون فعال‌سازی مجدد).'; +$string['registration_deactivate'] = 'غیرفعال‌سازی پلاگین'; +$string['registration_deactivate_confirm'] = 'فعال‌سازی این افزونه در MeetLet حذف شود؟ امکانات افزونه دوباره قفل می‌شوند.'; +$string['registration_deactivate_confirm_title'] = 'پلاگین غیرفعال شود؟'; +$string['registration_modal_cancel'] = 'انصراف'; +$string['registration_modal_confirm_activate'] = 'فعال‌سازی'; +$string['registration_modal_confirm_deactivate'] = 'غیرفعال‌سازی'; +$string['registration_modal_close'] = 'بستن'; +$string['registration_activated'] = 'افزونه با موفقیت در MeetLet فعال شد.'; +$string['registration_rechecked'] = 'وضعیت فعال‌سازی به‌روزرسانی شد.'; +$string['registration_deactivated'] = 'فعال‌سازی افزونه از MeetLet حذف شد.'; +$string['registration_already_active'] = 'این افزونه قبلاً فعال شده است.'; +$string['registration_not_active'] = 'این افزونه هنوز فعال نشده است.'; +$string['registration_request_id'] = 'request_id={$a}'; +$string['registration_privacy_title'] = 'چه داده‌ای ارسال می‌شود و چرا'; +$string['registration_privacy_body'] = 'با فعال‌سازی، MeetLet دامنه سایت، شناسه نصب، نسخه مودل و افزونه و اطلاعات تماس سایت را دریافت می‌کند. داده‌ها به {$a} فقط برای اعلان به‌روزرسانی و آمار پشتیبانی ارسال می‌شوند. هنگام نصب هیچ تماس خودکاری انجام نمی‌شود.'; +$string['registration_err_invalid_key'] = 'فعال‌سازی تأیید نشد.'; +$string['registration_err_expired'] = 'فعال‌سازی منقضی شده است.'; +$string['registration_err_revoked'] = 'فعال‌سازی لغو شده است.'; +$string['registration_err_suspended'] = 'فعال‌سازی معلق است.'; +$string['registration_err_product_mismatch'] = 'عدم تطابق پیکربندی محصول.'; +$string['registration_err_domain'] = 'دامنه این سایت برای فعال‌سازی مجاز نیست.'; +$string['registration_err_activation_limit'] = 'سقف فعال‌سازی پلاگین پر شده است.'; +$string['registration_err_misconfigured'] = 'درخواست فعال‌سازی نادرست پیکربندی شده است.'; +$string['registration_err_bad_signature'] = 'امضای مجوز از MeetLet نامعتبر است.'; +$string['registration_err_network'] = 'اتصال به سرور فعال‌سازی MeetLet برقرار نشد.'; +$string['registration_err_unknown'] = 'خطای ناشناخته در فعال‌سازی رخ داد.'; +$string['registration_crypto_error'] = 'خطای رمزنگاری فعال‌سازی.'; +$string['registration_sodium_required'] = 'افزونه sodium در PHP برای فعال‌سازی پلاگین لازم است.'; +$string['task_registration_heartbeat'] = 'بررسی روزانه فعال‌سازی پلاگین MeetLet'; +$string['privacy:metadata:meetletacbfree_license'] = 'هویت فعال‌سازی اختیاری MeetLet که فقط پس از فعال‌سازی توسط مدیر ذخیره می‌شود.'; +$string['privacy:metadata:meetletacbfree_license:installation_id'] = 'شناسه یکتای نصب تولیدشده توسط افزونه.'; +$string['privacy:metadata:meetletacbfree_license:status'] = 'آخرین وضعیت فعال‌سازی شناخته‌شده.'; +$string['privacy:metadata:meetletacbfree_license:last_validated'] = 'زمان آخرین اعتبارسنجی فعال‌سازی با MeetLet.'; +$string['privacy:metadata:meetlet_registration'] = 'در صورت فعال‌سازی اختیاری توسط مدیر، متادیتای سایت به سرویس فعال‌سازی MeetLet برای اعلان به‌روزرسانی و آمار پشتیبانی ارسال می‌شود.'; +$string['privacy:metadata:meetlet_registration:domain'] = 'دامنه عمومی سایت مودل.'; +$string['privacy:metadata:meetlet_registration:installation_id'] = 'شناسه یکتای نصب.'; +$string['privacy:metadata:meetlet_registration:company'] = 'نام سایت یا سازمان.'; +$string['privacy:metadata:meetlet_registration:email'] = 'ایمیل مدیر یا پشتیبانی.'; +$string['privacy:metadata:meetlet_registration:plugin'] = 'نام کامپوننت افزونه مودل.'; +$string['privacy:metadata:meetlet_registration:plugin_release'] = 'نسخه انتشار افزونه.'; +$string['privacy:metadata:meetlet_registration:platform'] = 'نام پلتفرم (moodle).'; +$string['privacy:metadata:meetlet_registration:platform_version'] = 'نسخه انتشار مودل.'; + diff --git a/lib.php b/lib.php new file mode 100644 index 0000000..ac4769d --- /dev/null +++ b/lib.php @@ -0,0 +1,584 @@ +. + +/** + * Library functions for MeetLet Adobe Connect Bridge. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\roles; + +/** + * Feature support. + * + * @param string $feature + * @return mixed + */ +function meetletacbfree_supports($feature) { + switch ($feature) { + case FEATURE_GROUPS: + return true; + case FEATURE_GROUPINGS: + return true; + case FEATURE_MOD_INTRO: + return true; + case FEATURE_COMPLETION_TRACKS_VIEWS: + return true; + case FEATURE_GRADE_HAS_GRADE: + return true; + case FEATURE_GRADE_OUTCOMES: + return false; + case FEATURE_BACKUP_MOODLE2: + return true; + case FEATURE_SHOW_DESCRIPTION: + return true; + case FEATURE_MOD_PURPOSE: + return MOD_PURPOSE_COMMUNICATION; + default: + return null; + } +} + +/** + * Declare branded monologo so Moodle does not recolor it (orange communication filter). + * + * @return bool + */ +function meetletacbfree_is_branded(): bool { + return true; +} + +/** + * Control activity chooser visibility until online registration is complete. + * + * @param \core_course\local\entity\content_item $contentitem + * @param \stdClass $user + * @param \stdClass $course + * @return array + */ +function mod_meetletacbfree_get_course_content_items( + \core_course\local\entity\content_item $contentitem, + \stdClass $user, + \stdClass $course +): array { + if (!\mod_meetletacbfree\registration\manager::is_registered()) { + return []; + } + return [$contentitem]; +} + +/** + * Add instance. + * + * @param stdClass $data + * @param mod_meetletacbfree_mod_form|null $mform + * @return int + */ +function meetletacbfree_add_instance($data, $mform = null) { + global $DB, $USER; + + \mod_meetletacbfree\registration\manager::require_registered(); + + $data->timecreated = time(); + $data->timemodified = $data->timecreated; + $data->userid = $USER->id; + if (empty($data->endtimeenable)) { + $data->endtime = 0; + } + $coursecontext = context_course::instance($data->course); + $data->participantsmode = empty($data->participantsmode) ? 0 : 1; + if (empty($data->participantsmode)) { + $data->participants = ''; + } else if (empty($data->participants)) { + $data->participants = json_encode(roles::get_participant_list_default($coursecontext, $USER->id)); + } + $togglefields = meetletacbfree_activity_override_fields(); + $data->settingoverrides = config::setting_overrides_json(meetletacbfree_collect_setting_override_states($data, $togglefields)); + $data->autooffline = meetletacbfree_legacy_setting_value($data, 'autooffline'); + $data->meetingpublic = empty($data->meetingpublic) ? 0 : 1; + $data->showzip = meetletacbfree_legacy_setting_value($data, 'showzip'); + $data->showofflineplayer = empty($data->showofflineplayer) ? 0 : 1; + $data->showrecordingstab = meetletacbfree_legacy_setting_value($data, 'showrecordingstab'); + $data->showcontenttab = meetletacbfree_legacy_setting_value($data, 'showcontenttab'); + $data->showmp4 = meetletacbfree_legacy_setting_value($data, 'showmp4'); + $data->showmakeoffline = meetletacbfree_legacy_setting_value($data, 'showmakeoffline'); + $data->linkmode = empty($data->linkmode) ? 0 : 1; + $data->existingscoid = (int)($data->existingscoid ?? 0); + $data->waitforhost = meetletacbfree_legacy_setting_value($data, 'waitforhost'); + $data->meetinglang = clean_param($data->meetinglang ?? '', PARAM_ALPHANUMEXT); + if ($data->linkmode) { + $data->templatescoid = 0; + $data->meeturl = ''; + } + $data->meeturl = meetletacbfree_clean_meet_url($data->meeturl ?? ''); + if (empty($data->featureaccess_override)) { + $data->featureaccess = ''; + } else { + $matrix = optional_param_array('featureaccess_matrix', [], PARAM_RAW); + $data->featureaccess = \mod_meetletacbfree\local\featureaccess::matrix_from_post($matrix); + } + unset($data->featureaccess_override, $data->featureaccess_matrix); + meetletacbfree_clear_override_form_fields($data, $togglefields); + + $id = $DB->insert_record('meetletacbfree', $data); + $data->id = $id; + + meetletacbfree_create_remote_meetings($data); + meetletacbfree_grade_item_update($data); + meetletacbfree_update_calendar($data); + + return $id; +} + +/** + * Update instance. + * + * @param stdClass $data + * @param mod_meetletacbfree_mod_form|null $mform + * @return bool + */ +function meetletacbfree_update_instance($data, $mform = null) { + global $DB, $USER; + + \mod_meetletacbfree\registration\manager::require_registered(); + + $data->timemodified = time(); + $data->id = $data->instance; + if (empty($data->endtimeenable)) { + $data->endtime = 0; + } + $coursecontext = context_course::instance($data->course); + $data->participantsmode = empty($data->participantsmode) ? 0 : 1; + if (empty($data->participantsmode)) { + $data->participants = ''; + } else if (empty($data->participants)) { + $data->participants = json_encode(roles::get_participant_list_default($coursecontext, $USER->id ?? 0)); + } + $togglefields = meetletacbfree_activity_override_fields(); + $data->settingoverrides = config::setting_overrides_json(meetletacbfree_collect_setting_override_states($data, $togglefields)); + $data->autooffline = meetletacbfree_legacy_setting_value($data, 'autooffline'); + $data->meetingpublic = empty($data->meetingpublic) ? 0 : 1; + $data->showzip = meetletacbfree_legacy_setting_value($data, 'showzip'); + $data->showofflineplayer = empty($data->showofflineplayer) ? 0 : 1; + $data->showrecordingstab = meetletacbfree_legacy_setting_value($data, 'showrecordingstab'); + $data->showcontenttab = meetletacbfree_legacy_setting_value($data, 'showcontenttab'); + $data->showmp4 = meetletacbfree_legacy_setting_value($data, 'showmp4'); + $data->showmakeoffline = meetletacbfree_legacy_setting_value($data, 'showmakeoffline'); + $data->waitforhost = meetletacbfree_legacy_setting_value($data, 'waitforhost'); + $data->meetinglang = clean_param($data->meetinglang ?? '', PARAM_ALPHANUMEXT); + // linkmode / existingscoid are immutable after create. + unset($data->linkmode, $data->existingscoid); + if (empty($data->featureaccess_override)) { + $data->featureaccess = ''; + } else { + $matrix = optional_param_array('featureaccess_matrix', [], PARAM_RAW); + $data->featureaccess = \mod_meetletacbfree\local\featureaccess::matrix_from_post($matrix); + } + unset($data->featureaccess_override, $data->featureaccess_matrix); + meetletacbfree_clear_override_form_fields($data, $togglefields); + + $DB->update_record('meetletacbfree', $data); + + if (config::is_configured()) { + $service = new service(); + $service->login_admin(); + $maps = $DB->get_records('meetletacbfree_meetings', ['instanceid' => $data->id]); + foreach ($maps as $map) { + $payload = clone $data; + if (!empty($map->groupid)) { + $group = groups_get_group($map->groupid); + if ($group) { + $payload->name = $data->name . '_' . $group->name; + } + } + // Create or linked: push name/times/options to Adobe. + $service->update_meeting((int)$map->meetingscoid, $payload); + $service->set_meeting_public((int)$map->meetingscoid, !empty($data->meetingpublic)); + } + $service->logout(); + } + + meetletacbfree_grade_item_update($data); + meetletacbfree_update_calendar($data); + return true; +} + +/** + * Delete instance. + * + * @param int $id + * @return bool + */ +function meetletacbfree_delete_instance($id) { + global $DB; + + \mod_meetletacbfree\registration\manager::require_registered(); + + if (!$record = $DB->get_record('meetletacbfree', ['id' => $id])) { + return false; + } + + if (config::is_configured()) { + try { + $service = new service(); + $service->login_admin(); + $maps = $DB->get_records('meetletacbfree_meetings', ['instanceid' => $id]); + $deleteremote = empty($record->linkmode); + foreach ($maps as $map) { + if ($deleteremote) { + $service->delete_sco((int)$map->meetingscoid); + } + } + $service->logout(); + } catch (Throwable $e) { + debugging('MeetLet delete remote meeting failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + } + } + + $DB->delete_records('meetletacbfree_recordings', ['instanceid' => $id]); + $DB->delete_records('meetletacbfree_meetings', ['instanceid' => $id]); + $DB->delete_records('meetletacbfree', ['id' => $id]); + + meetletacbfree_grade_item_delete($record); + return true; +} + +/** + * Create Adobe meetings for activity (and groups). + * + * @param stdClass $data + */ +function meetletacbfree_create_remote_meetings(stdClass $data): void { + global $DB, $COURSE, $USER; + + if (!config::is_configured()) { + return; + } + + $service = new service(); + $service->login_admin(); + $coursecontext = context_course::instance((int)$data->course); + $creatorrole = roles::resolve_adobe_role($data, $coursecontext, (int)($USER->id ?? 0)); + if ($creatorrole === null) { + $creatorrole = service::ROLE_HOST; + } + $provisionscoids = []; + + // Link an existing Adobe meeting instead of creating one. + if (!empty($data->linkmode) && !empty($data->existingscoid)) { + $scoid = (int)$data->existingscoid; + $info = $service->meeting_info($scoid); + if (!$info) { + $service->logout(); + throw new moodle_exception('errorexistingsco', 'meetletacbfree'); + } + $service->set_meeting_public($scoid, !empty($data->meetingpublic)); + // Push Moodle form values (name/times/options) onto the existing Adobe meeting. + $service->update_meeting($scoid, $data); + $map = (object)[ + 'instanceid' => $data->id, + 'meetingscoid' => $scoid, + 'groupid' => 0, + 'urlpath' => $info->url ?? '', + ]; + $DB->insert_record('meetletacbfree_meetings', $map); + $provisionscoids[] = $scoid; + if (!empty($info->url)) { + $DB->set_field('meetletacbfree', 'meeturl', trim($info->url, '/'), ['id' => $data->id]); + } + meetletacbfree_provision_creator_on_meetings($service, $data, $provisionscoids, $creatorrole); + $service->logout(); + return; + } + + $folder = $service->get_folder_scoid('meetings'); + if (!$folder) { + $service->logout(); + throw new moodle_exception('errornofolder', 'meetletacbfree'); + } + + // During add_instance, course_modules.instance is not linked yet — use form data. + $groupmode = (int)($data->groupmode ?? 0); + + if ($groupmode) { + $groups = groups_get_all_groups($data->course); + foreach ($groups as $group) { + $payload = clone $data; + $payload->name = $data->name . '_' . $group->name; + if (!empty($data->meeturl)) { + $payload->meeturl = meetletacbfree_clean_meet_url($data->meeturl . '_' . $group->name); + } + $scoid = $service->create_meeting($payload, $folder); + $service->set_meeting_public($scoid, !empty($data->meetingpublic)); + $info = $service->meeting_info($scoid); + $map = (object)[ + 'instanceid' => $data->id, + 'meetingscoid' => $scoid, + 'groupid' => $group->id, + 'urlpath' => $info->url ?? '', + ]; + $DB->insert_record('meetletacbfree_meetings', $map); + $provisionscoids[] = $scoid; + } + } else { + $scoid = $service->create_meeting($data, $folder); + $service->set_meeting_public($scoid, !empty($data->meetingpublic)); + $info = $service->meeting_info($scoid); + $map = (object)[ + 'instanceid' => $data->id, + 'meetingscoid' => $scoid, + 'groupid' => 0, + 'urlpath' => $info->url ?? '', + ]; + $DB->insert_record('meetletacbfree_meetings', $map); + $provisionscoids[] = $scoid; + if (empty($data->meeturl) && !empty($info->url)) { + $DB->set_field('meetletacbfree', 'meeturl', trim($info->url, '/'), ['id' => $data->id]); + } + } + + meetletacbfree_provision_creator_on_meetings($service, $data, $provisionscoids, $creatorrole); + $service->logout(); +} + +/** + * Provision the activity creator on newly created Adobe meetings (admin API). + * + * @param int[] $scoids + */ +function meetletacbfree_provision_creator_on_meetings(service $service, stdClass $data, array $scoids, string $permission): void { + global $USER; + if (empty($scoids) || empty($USER->id)) { + return; + } + try { + foreach (array_unique(array_map('intval', $scoids)) as $scoid) { + if ($scoid > 0) { + $service->provision_user_on_meeting($USER, $scoid, $permission); + } + } + } catch (Throwable $e) { + debugging('MeetLet provision creator on meeting failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + } +} + +/** + * Clean custom meeting URL path. + */ +function meetletacbfree_clean_meet_url(string $url): string { + $url = trim($url); + $url = trim($url, '/'); + $url = preg_replace('/[^a-zA-Z0-9\-]/', '', $url); + return substr((string)$url, 0, 60); +} + +/** + * Grade item update. + */ +function meetletacbfree_grade_item_update($meetlet, $grades = null) { + global $CFG; + require_once($CFG->libdir . '/gradelib.php'); + + $params = [ + 'itemname' => $meetlet->name, + 'idnumber' => $meetlet->cmidnumber ?? '', + ]; + + if (isset($meetlet->grade) && $meetlet->grade > 0) { + $params['gradetype'] = GRADE_TYPE_VALUE; + $params['grademax'] = $meetlet->grade; + $params['grademin'] = 0; + } else if (isset($meetlet->grade) && $meetlet->grade < 0) { + $params['gradetype'] = GRADE_TYPE_SCALE; + $params['scaleid'] = -$meetlet->grade; + } else { + $params['gradetype'] = GRADE_TYPE_NONE; + } + + if ($grades === 'reset') { + $params['reset'] = true; + $grades = null; + } + + return grade_update('mod/meetletacbfree', $meetlet->course, 'mod', 'meetletacbfree', $meetlet->id, 0, $grades, $params); +} + +/** + * Delete grade item. + */ +function meetletacbfree_grade_item_delete($meetlet) { + global $CFG; + require_once($CFG->libdir . '/gradelib.php'); + return grade_update('mod/meetletacbfree', $meetlet->course, 'mod', 'meetletacbfree', $meetlet->id, 0, null, ['deleted' => 1]); +} + +/** + * Update calendar event. + */ +function meetletacbfree_update_calendar($meetlet): void { + global $DB, $CFG; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $event = $DB->get_record('event', [ + 'modulename' => 'meetletacbfree', + 'instance' => $meetlet->id, + ]); + if (!$event) { + // Pre-rename calendar rows. + $event = $DB->get_record('event', [ + 'modulename' => 'meetlet', + 'instance' => $meetlet->id, + ]); + } + + if (empty($meetlet->starttime)) { + if ($event) { + $calendarevent = calendar_event::load($event->id); + $calendarevent->delete(); + } + return; + } + + $eventdata = new stdClass(); + $eventdata->name = $meetlet->name; + $eventdata->description = format_module_intro('meetletacbfree', $meetlet, $meetlet->coursemodule ?? 0, false); + $eventdata->format = FORMAT_HTML; + $eventdata->courseid = $meetlet->course; + $eventdata->groupid = 0; + $eventdata->userid = 0; + $eventdata->modulename = 'meetletacbfree'; + $eventdata->instance = $meetlet->id; + $eventdata->eventtype = 'course'; + $eventdata->timestart = $meetlet->starttime; + $eventdata->timeduration = !empty($meetlet->endtime) ? max(0, $meetlet->endtime - $meetlet->starttime) : 0; + $eventdata->visible = 1; + + if ($event) { + $calendarevent = calendar_event::load($event->id); + $calendarevent->update($eventdata); + } else { + calendar_event::create($eventdata); + } +} + +/** + * Extend settings navigation (optional). + */ +function meetletacbfree_extend_settings_navigation(settings_navigation $settingsnav, navigation_node $meetletnode) { + // Intentionally minimal ? roles are configured in activity settings. +} + +/** + * Course module info for course page. + */ +function meetletacbfree_get_coursemodule_info($coursemodule) { + global $DB; + if (!$record = $DB->get_record('meetletacbfree', ['id' => $coursemodule->instance], 'id, name, intro, introformat, starttime, endtime')) { + return false; + } + $info = new cached_cm_info(); + $info->name = $record->name; + if ($coursemodule->showdescription) { + $info->content = format_module_intro('meetletacbfree', $record, $coursemodule->id, false); + } + // Branded monologo: skip Moodle purpose colour filter (orange on course page). + $info->customdata['filtericon'] = false; + return $info; +} + +/** + * Apply plugin cron interval to the Moodle scheduled task row. + */ +function meetletacbfree_update_cron_interval() { + \mod_meetletacbfree\local\config::apply_cron_interval(); +} + +/** + * Activity boolean settings managed by the inherit/override model. + * + * @return string[] + */ +function meetletacbfree_activity_override_fields(): array { + return ['waitforhost', 'autooffline', 'showzip', 'showrecordingstab', 'showcontenttab', 'showmp4', 'showmakeoffline']; +} + +/** + * Collect posted three-state values for activity settings. + * + * @param stdClass $data + * @param string[] $fields + * @return array + */ +function meetletacbfree_collect_setting_override_states(stdClass $data, array $fields): array { + $states = []; + foreach ($fields as $field) { + $statefield = $field . '_state'; + $states[$field] = $data->{$statefield} ?? \mod_meetletacbfree\local\config::OVERRIDE_INHERIT; + } + return $states; +} + +/** + * Keep legacy columns in sync for older code, reports and backups. + */ +function meetletacbfree_legacy_setting_value(stdClass $data, string $field): int { + $statefield = $field . '_state'; + $state = \mod_meetletacbfree\local\config::normalize_override_state($data->{$statefield} ?? null); + if ($state === \mod_meetletacbfree\local\config::OVERRIDE_ON) { + return 1; + } + if ($state === \mod_meetletacbfree\local\config::OVERRIDE_OFF) { + return 0; + } + switch ($field) { + case 'waitforhost': + return \mod_meetletacbfree\local\config::default_wait_for_host() ? 1 : 0; + case 'autooffline': + return \mod_meetletacbfree\local\config::auto_offline_default() ? 1 : 0; + case 'showzip': + return \mod_meetletacbfree\local\config::feature_enabled('zip') ? 1 : 0; + case 'showrecordingstab': + return \mod_meetletacbfree\local\config::feature_enabled('recordingstab') ? 1 : 0; + case 'showcontenttab': + return \mod_meetletacbfree\local\config::feature_enabled('contenttab') ? 1 : 0; + case 'showmp4': + return \mod_meetletacbfree\local\config::feature_enabled('mp4') ? 1 : 0; + case 'showmakeoffline': + return \mod_meetletacbfree\local\config::feature_enabled('makeoffline') ? 1 : 0; + default: + return empty($data->{$field}) ? 0 : 1; + } +} + +/** + * Remove transient form-only fields before DB write. + * + * @param stdClass $data + * @param string[] $fields + */ +function meetletacbfree_clear_override_form_fields(stdClass $data, array $fields): void { + foreach ($fields as $field) { + $statefield = $field . '_state'; + unset($data->{$statefield}); + } +} diff --git a/mod_form.php b/mod_form.php new file mode 100644 index 0000000..430960c --- /dev/null +++ b/mod_form.php @@ -0,0 +1,464 @@ +dirroot . '/course/moodleform_mod.php'); + +use mod_meetletacbfree\api\service; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\roles; +use mod_meetletacbfree\registration\manager as registration_manager; + +/** + * MeetLet mod form. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_meetletacbfree_mod_form extends moodleform_mod { + public function definition() { + global $OUTPUT, $PAGE, $USER; + + $mform = $this->_form; + $context = context_course::instance($this->_course->id); + $current = empty($this->current->instance) ? null : $this->current; + $isnew = empty($this->current->instance); + + if (!registration_manager::is_registered()) { + $mform->addElement('static', 'registrationlock', '', + registration_manager::admin_gate_notice_html()); + $this->standard_hidden_coursemodule_elements(); + $this->add_action_buttons(false); + return; + } + + $PAGE->requires->js(new moodle_url('/mod/meetletacbfree/javascript/modform.js')); + $PAGE->requires->js(new moodle_url('/mod/meetletacbfree/javascript/featureaccess_matrix.js')); + $PAGE->requires->js(new moodle_url('/mod/meetletacbfree/javascript/settings_toggles.js')); + $PAGE->requires->css(new moodle_url('/mod/meetletacbfree/styles.css', ['v' => '2026081913'])); + + $mform->addElement('header', 'general', get_string('general', 'form')); + $mform->addElement('text', 'name', get_string('meetingname', 'meetletacbfree'), ['size' => '64']); + $mform->setType('name', PARAM_TEXT); + $mform->addRule('name', null, 'required', null, 'client'); + $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); + $this->standard_intro_elements(get_string('meetingintro', 'meetletacbfree')); + + $mform->addElement('header', 'meetletroom', get_string('roomsettings', 'meetletacbfree')); + + // Create-new is default; existing meetings are loaded lazily via Ajax. + $linkmodes = [ + 0 => get_string('linkmode_create', 'meetletacbfree'), + 1 => get_string('linkmode_existing', 'meetletacbfree'), + ]; + $mform->addElement('select', 'linkmode', get_string('linkmode', 'meetletacbfree'), $linkmodes); + $mform->setDefault('linkmode', 0); + $mform->addHelpButton('linkmode', 'linkmode', 'meetletacbfree'); + if (!$isnew) { + $mform->hardFreeze('linkmode'); + } + + $meetingsmeta = []; + $selectedsco = (int)($current->existingscoid ?? 0); + $selectedlabel = get_string('chooseservermeeting', 'meetletacbfree'); + $readonly = !$isnew; + // On edit of a linked activity, keep the selected meeting visible without listing all. + if (!$isnew && !empty($current->linkmode) && $selectedsco && config::is_configured()) { + try { + $service = new service(); + $service->login_admin(); + $info = $service->meeting_info($selectedsco); + $service->logout(); + if ($info) { + $selectedlabel = (string)$info->name; + $urlpath = trim((string)($info->url ?? ''), '/'); + if ($urlpath !== '') { + $selectedlabel .= ' (/' . $urlpath . '/)'; + } + $meetingsmeta[$selectedsco] = [ + 'name' => (string)$info->name, + 'urlpath' => $urlpath, + 'starttime' => !empty($info->starttime) ? (int)strtotime((string)$info->starttime) : 0, + 'endtime' => !empty($info->endtime) ? (int)strtotime((string)$info->endtime) : 0, + ]; + } + } catch (Throwable $e) { + $selectedlabel = 'SCO ' . $selectedsco; + } + } + + $mform->addElement('hidden', 'existingscoid', $selectedsco); + $mform->setType('existingscoid', PARAM_INT); + + $listhtml = ''; + if ($selectedsco) { + $listhtml = html_writer::tag('li', + html_writer::tag('button', s($selectedlabel), [ + 'type' => 'button', + 'class' => 'meetlet-combobox__option is-selected', + 'data-scoid' => $selectedsco, + 'data-name' => $meetingsmeta[$selectedsco]['name'] ?? $selectedlabel, + 'data-urlpath' => $meetingsmeta[$selectedsco]['urlpath'] ?? '', + 'data-starttime' => (string)($meetingsmeta[$selectedsco]['starttime'] ?? 0), + 'data-endtime' => (string)($meetingsmeta[$selectedsco]['endtime'] ?? 0), + ]) + ); + } + + $toggleattrs = [ + 'type' => 'button', + 'class' => 'meetlet-combobox__toggle', + 'data-meeting-toggle' => '1', + 'aria-expanded' => 'false', + ]; + if ($readonly) { + $toggleattrs['disabled'] = 'disabled'; + } + + $picker = html_writer::div( + html_writer::tag('button', + html_writer::span(s($selectedlabel), 'meetlet-combobox__value', ['data-meeting-label' => '1']) . + html_writer::span('▾', 'meetlet-combobox__caret', ['aria-hidden' => 'true']), + $toggleattrs + ) . + html_writer::div( + html_writer::empty_tag('input', [ + 'type' => 'search', + 'class' => 'meetlet-combobox__search', + 'data-meeting-search' => '1', + 'placeholder' => get_string('searchservermeetings', 'meetletacbfree'), + 'autocomplete' => 'off', + ]) . + html_writer::tag('ul', $listhtml, [ + 'class' => 'meetlet-combobox__list', + 'data-meeting-list' => '1', + ]) . + html_writer::div('', 'meetlet-combobox__hint', ['data-meeting-hint' => '1']), + 'meetlet-combobox__panel', + ['data-meeting-panel' => '1', 'hidden' => 'hidden'] + ), + 'meetlet-combobox' . ($readonly ? ' is-readonly' : ''), + [ + 'data-meeting-picker' => '1', + 'data-courseid' => (int)$this->_course->id, + 'data-readonly' => $readonly ? '1' : '0', + 'data-str-loading' => get_string('loadingmeetings', 'meetletacbfree'), + 'data-str-empty' => get_string('nomeetingsfound', 'meetletacbfree'), + 'data-str-choose' => get_string('chooseservermeeting', 'meetletacbfree'), + ] + ); + + $mform->addElement('static', 'existingscoid_ui', get_string('existingscoid', 'meetletacbfree'), $picker); + $mform->hideIf('existingscoid_ui', 'linkmode', 'neq', 1); + $mform->addElement('html', html_writer::div('', 'd-none', [ + 'data-meetings-meta' => json_encode($meetingsmeta, JSON_UNESCAPED_UNICODE), + ])); + + $mform->addElement('text', 'meeturl', get_string('meeturl', 'meetletacbfree'), ['size' => 40]); + $mform->setType('meeturl', PARAM_PATH); + $mform->addHelpButton('meeturl', 'meeturl', 'meetletacbfree'); + $mform->hideIf('meeturl', 'linkmode', 'eq', 1); + + $mform->addElement('select', 'meetingpublic', get_string('meetingtype', 'meetletacbfree'), [ + 0 => get_string('private', 'meetletacbfree'), + 1 => get_string('public', 'meetletacbfree'), + ]); + $mform->setDefault('meetingpublic', 0); + + $langs = config::meeting_lang_options(); + $mform->addElement('select', 'meetinglang', get_string('meetinglang', 'meetletacbfree'), $langs); + $mform->setType('meetinglang', PARAM_ALPHANUMEXT); + $mform->addHelpButton('meetinglang', 'meetinglang', 'meetletacbfree'); + $mform->setDefault('meetinglang', config::default_meeting_lang()); + + $waitstate = $this->resolve_toggle_state($current, 'waitforhost', $isnew); + $mform->addElement('static', 'waitforhost_state_ui', get_string('waitforhost', 'meetletacbfree'), + $this->render_override_chip_group( + 'waitforhost_state', + $waitstate, + get_string('waitforhost_help', 'meetletacbfree'), + [ + config::OVERRIDE_INHERIT => get_string( + config::default_wait_for_host() ? 'settingstate_inherit_on' : 'settingstate_inherit_off', + 'meetletacbfree' + ), + ] + ) + ); + $mform->addElement('hidden', 'waitforhost', $waitstate === config::OVERRIDE_ON ? 1 : 0); + $mform->setType('waitforhost', PARAM_INT); + + $templates = [0 => get_string('defaulttemplate', 'meetletacbfree')]; + if (config::is_configured()) { + try { + $service = new service(); + $service->login_admin(); + $templates = $templates + $service->get_templates(); + $service->logout(); + } catch (Throwable $e) { + // Ignore. + } + } + $mform->addElement('select', 'templatescoid', get_string('meettemplates', 'meetletacbfree'), $templates); + $mform->setType('templatescoid', PARAM_INT); + $mform->hideIf('templatescoid', 'linkmode', 'eq', 1); + + $mform->addElement('date_time_selector', 'starttime', get_string('starttime', 'meetletacbfree')); + $mform->addRule('starttime', null, 'required', null, 'client'); + $mform->addElement('advcheckbox', 'endtimeenable', get_string('endtimeenable', 'meetletacbfree')); + $mform->addElement('date_time_selector', 'endtime', get_string('endtime', 'meetletacbfree')); + $mform->disabledIf('endtime', 'endtimeenable'); + $mform->setDefault('endtime', time() + 2 * HOURSECS); + + // BBB-style participant mapping. + $participantlist = roles::get_participant_list($current, $context); + $participantselection = roles::get_participant_selection_data(); + $mform->addElement('header', 'permissions', get_string('rolemapping', 'meetletacbfree')); + $mform->setExpanded('permissions'); + $mform->addElement('static', 'rolemappingdesc', '', get_string('rolemapping_desc', 'meetletacbfree')); + $participantsmode = $this->resolve_participants_mode($current, $isnew); + $mform->addElement('static', 'participantsmode_ui', get_string('rolemapping_mode', 'meetletacbfree'), + $this->render_override_chip_group( + 'participantsmode_state', + $participantsmode, + get_string('rolemapping_mode_help', 'meetletacbfree'), + [], + [ + roles::MODE_INHERIT => get_string('settingstate_inherit', 'meetletacbfree'), + roles::MODE_OVERRIDE => get_string('rolemapping_mode_override_short', 'meetletacbfree'), + ] + ) + ); + $mform->addElement('hidden', 'participantsmode', $participantsmode === roles::MODE_OVERRIDE ? 1 : 0); + $mform->setType('participantsmode', PARAM_INT); + $mform->addElement('hidden', 'participants', json_encode($participantlist)); + $mform->setType('participants', PARAM_RAW); + + $selectiontype = new single_select(new moodle_url(qualified_me()), + 'meetlet_participant_selection_type', + $participantselection['type_options'], + $participantselection['type_selected']); + $selectionparticipants = new single_select(new moodle_url(qualified_me()), + 'meetlet_participant_selection', + $participantselection['options'], + $participantselection['selected']); + $action = new single_button(new moodle_url(qualified_me()), + get_string('participant_add', 'meetletacbfree'), + 'post', + single_button::BUTTON_SECONDARY, + ['name' => 'meetlet_participant_selection_add'] + ); + $html = $OUTPUT->render_from_template('mod_meetletacbfree/participant_form', [ + 'selectionType' => $selectiontype->export_for_template($OUTPUT), + 'selectionParticipant' => $selectionparticipants->export_for_template($OUTPUT), + 'action' => $action->export_for_template($OUTPUT), + ]); + $mform->addElement('static', 'static_participant_list', get_string('participant_list', 'meetletacbfree'), $html); + + $mform->addElement('html', html_writer::div('', 'd-none', [ + 'data-participant-data' => json_encode(roles::get_participant_data($context, $current ?: (object)['id' => 0])), + ])); + + if (featureaccess::activity_override_enabled()) { + $mform->addElement('header', 'meetletaccess', get_string('featureaccess_activity', 'meetletacbfree')); + $mform->addElement('advcheckbox', 'featureaccess_override', get_string('featureaccess_override', 'meetletacbfree'), + get_string('featureaccess_override_help', 'meetletacbfree')); + $hascustom = $current && !empty($current->featureaccess); + $mform->setDefault('featureaccess_override', $hascustom ? 1 : 0); + $matrix = featureaccess::matrix_for_activity($current); + $mform->addElement('html', featureaccess::render_matrix_html($matrix, 'featureaccess_matrix')); + $mform->addElement('hidden', 'featureaccess', ''); + $mform->setType('featureaccess', PARAM_RAW); + } + + if (config::offline_render_active()) { + $mform->addElement('header', 'meetletoffline', get_string('offlinesettings', 'meetletacbfree')); + $autostate = $this->resolve_toggle_state($current, 'autooffline', $isnew); + $mform->addElement('static', 'autooffline_state_ui', get_string('autooffline', 'meetletacbfree'), + $this->render_override_chip_group( + 'autooffline_state', + $autostate, + get_string('autooffline_help', 'meetletacbfree'), + [ + config::OVERRIDE_INHERIT => get_string( + config::auto_offline_default() ? 'settingstate_inherit_on' : 'settingstate_inherit_off', + 'meetletacbfree' + ), + ] + ) + ); + $mform->addElement('hidden', 'autooffline', $autostate === config::OVERRIDE_ON ? 1 : 0); + $mform->setType('autooffline', PARAM_INT); + } + + $mform->addElement('header', 'meetletfeatures', get_string('activityfeatures', 'meetletacbfree')); + $featurefields = [ + 'zip' => 'showzip', + 'recordingstab' => 'showrecordingstab', + 'contenttab' => 'showcontenttab', + 'mp4' => 'showmp4', + 'makeoffline' => 'showmakeoffline', + ]; + foreach ($featurefields as $feature => $field) { + if (!config::feature_enabled($feature)) { + continue; + } + if (!config::feature_allow_override($feature)) { + continue; + } + $state = $this->resolve_toggle_state($current, $field, $isnew); + $inheritkey = config::feature_enabled($feature) ? 'settingstate_inherit_on' : 'settingstate_inherit_off'; + $help = ($field === 'showzip') ? get_string('showzip_help', 'meetletacbfree') : ''; + $mform->addElement('static', $field . '_state_ui', get_string($field, 'meetletacbfree'), + $this->render_override_chip_group( + $field . '_state', + $state, + $help, + [config::OVERRIDE_INHERIT => get_string($inheritkey, 'meetletacbfree')] + ) + ); + $mform->addElement('hidden', $field, $state === config::OVERRIDE_ON ? 1 : 0); + $mform->setType($field, PARAM_INT); + if ($field === 'showzip') { + $mform->removeElement($field . '_state_ui'); + $mform->addElement('static', $field . '_state_ui', get_string($field, 'meetletacbfree'), + $this->render_override_chip_group( + $field . '_state', + $state, + get_string('showzip_help', 'meetletacbfree'), + [config::OVERRIDE_INHERIT => get_string($inheritkey, 'meetletacbfree')] + ) + ); + } + } + + $this->standard_grading_coursemodule_elements(); + $this->standard_coursemodule_elements(); + $this->add_action_buttons(); + } + + public function definition_after_data() { + if (!registration_manager::is_registered()) { + return; + } + parent::definition_after_data(); + } + + public function data_preprocessing(&$defaultvalues) { + parent::data_preprocessing($defaultvalues); + $defaultvalues['endtimeenable'] = !empty($defaultvalues['endtime']) ? 1 : 0; + foreach (\meetletacbfree_activity_override_fields() as $field) { + $defaultvalues[$field . '_state'] = $this->resolve_toggle_state((object)$defaultvalues, $field, false); + } + $defaultvalues['participantsmode'] = $this->resolve_participants_mode((object)$defaultvalues, false); + if (!empty($defaultvalues['participants'])) { + $rules = json_decode($defaultvalues['participants'], true); + if (is_array($rules)) { + $defaultvalues['participants'] = json_encode(roles::get_participant_list( + (object)$defaultvalues, + context_course::instance($this->_course->id) + )); + } + } + } + + public function validation($data, $files) { + $errors = parent::validation($data, $files); + if (!registration_manager::is_registered()) { + $errors['registrationlock'] = get_string('registration_required', 'meetletacbfree'); + return $errors; + } + if (!empty($data['endtimeenable']) && (int)$data['endtime'] <= (int)$data['starttime']) { + $errors['endtime'] = get_string('greaterstarttime', 'meetletacbfree'); + } + if (!empty($data['meeturl']) && empty($data['linkmode']) + && !preg_match('/^[a-zA-Z][a-zA-Z0-9\-]*$/', $data['meeturl'])) { + $errors['meeturl'] = get_string('invalidurl', 'meetletacbfree'); + } + if (!empty($data['linkmode']) && empty($data['existingscoid'])) { + $errors['existingscoid_ui'] = get_string('errorexistingsco', 'meetletacbfree'); + } + return $errors; + } + + /** + * Render a compact chip-style radio group inside the activity form. + * + * @param array $descriptions + * @param array|null $labels + */ + protected function render_override_chip_group( + string $name, + int $selected, + string $help = '', + array $descriptions = [], + ?array $labels = null + ): string { + $labels = $labels ?? config::override_state_options(); + $chips = ''; + foreach ($labels as $value => $label) { + $id = 'id_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $name . '_' . $value); + $description = $descriptions[$value] ?? ''; + $attrs = [ + 'type' => 'radio', + 'name' => $name, + 'id' => $id, + 'value' => (int)$value, + 'class' => 'meetlet-rolechip__input meetlet-settingchip__input', + 'data-meetlet-setting-radio' => '1', + ]; + if ((int)$selected === (int)$value) { + $attrs['checked'] = 'checked'; + } + if ($description !== '') { + $attrs['aria-label'] = $label . ' - ' . $description; + } + $body = \html_writer::empty_tag('input', $attrs) + . \html_writer::span( + \html_writer::span(s($label), 'meetlet-rolechip__text') + . ($description !== '' + ? \html_writer::span(s($description), 'meetlet-settingchip__meta', ['aria-hidden' => 'true']) + . \html_writer::span(s($description), 'meetlet-sr-only') + : ''), + 'meetlet-settingchip__content' + ); + $chips .= \html_writer::tag('label', $body, [ + 'class' => 'meetlet-rolechip meetlet-settingchip', + 'for' => $id, + ]); + } + $groupclasses = 'meetlet-rolechip-group meetlet-settingchip-group'; + if (count($labels) === 2) { + $groupclasses .= ' meetlet-settingchip-group--binary'; + } + $html = \html_writer::div($chips, $groupclasses, [ + 'role' => 'radiogroup', + ]); + if ($help !== '') { + $html .= \html_writer::div(s($help), 'meetlet-settinghint'); + } + return $html; + } + + protected function resolve_toggle_state(?object $current, string $field, bool $isnew): int { + if ($isnew || $current === null) { + return config::OVERRIDE_INHERIT; + } + if (property_exists($current, 'settingoverrides') || property_exists($current, 'participantsmode')) { + return config::activity_override_state($current, $field); + } + return !empty($current->{$field}) ? config::OVERRIDE_ON : config::OVERRIDE_OFF; + } + + protected function resolve_participants_mode(?object $current, bool $isnew): int { + if ($isnew || $current === null) { + return roles::MODE_INHERIT; + } + if (property_exists($current, 'participantsmode')) { + return roles::activity_inherits_defaults($current) ? roles::MODE_INHERIT : roles::MODE_OVERRIDE; + } + return roles::MODE_OVERRIDE; + } +} diff --git a/pix/adobeconnect.png b/pix/adobeconnect.png new file mode 100644 index 0000000000000000000000000000000000000000..008affe96efc34d8c65fb99edaa713223433cd1d GIT binary patch literal 35611 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 + + + + + + + + + + + + + diff --git a/pix/adobeconnect_official.svg b/pix/adobeconnect_official.svg new file mode 100644 index 0000000..3846c51 --- /dev/null +++ b/pix/adobeconnect_official.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pix/icon-128.png b/pix/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..6b26d3f376745cce4483c4f26a23313d6c93935f GIT binary patch literal 6159 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 Q46-uASN*FblPc|PG|1ivzHI& z-s$Zy{Q-LJ!@1|hL3p|BjmbmrJ_P{DD1dD=@b#PT7hs@(QP)GmWd$JNyMPdYVGkiGA(kK^$)``9 zY%*vDP%;8q2akd^gHXVbHw^%y88m(vVv_&{2m!T)iEA_=IM)Rx#26^qWEc+|lZc8C z7yz^*ox$tJ#=0$4jWL+~=JLwy%uE0RJ-J-+rB`3+vb17gaQV|OYjcHy0uW;WvQ2;( zFNFaz=`?zVpV|IbWgc53fjzhG)MjU9d;oZPcqsG7J})CXI=x;mjs9!>3v-1+uu<)G z?P9ZLgTE{l0oAcHZmn-;e{!)@3K~HGl5oV$MsK!dKn=BXo2kU=?YZjR*_-}DUJ9A; zqeF)eXFnXju<~&B`Bd_sIdAvg$30c68}OA6GO%$+5GWRlTL1ah%G|`&!qlnH?h=q) zyLNkpdxgMS3;V-#U{j{H4CtIDBqPg1gCM*$fJ&*?>N0*NUp;!_cxI)qR}!5a==RbmMl~ub zd~NSQV@@C>So3x2o%}ptU;quOH2@Uf_vQTQe672u+Xo>8WNB&1fdgV%@}L<2svsyP z%1RQA@@hPPBKcsgmEwF6blSw%1mGqTsluIzVvNz25-cr0Dq&2P==83f8(}+%s6_hF zelK28+x-IBl8_+_2d)4Tragn|-}BdZzIJfn!J5VOL8J(YFb7=Ec!48ocZ#$4-?V=L XIFKZo=KiU-00000NkvXXu0mjfT4&CZ literal 0 HcmV?d00001 diff --git a/pix/icon-32.png b/pix/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..dd17ccf523eeed6a6a70bea8a23a519be0871a50 GIT binary patch literal 1275 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 Q4 + + + + + + + + + + + + + + + + M + + diff --git a/pix/meetlet_logo.png b/pix/meetlet_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..581f682f2b4d3a8678a2c66fbf803c60f187561b GIT binary patch literal 6764 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�PPu0C$OxaW>0 zvDe%@N);|O4DruIT*0S4??$5*E5gIy)6A`1yb5^F6*)Y`v)v1fMobbkn65m(@p5!M zd;Q2xU71Ex^i|aGqX+4As>2Phlg_Jp85(>U=n`4nlTOmG7bw}-=Sv!3f5`D})W!+7 z{SiAcJ!!{bIPg`n5Tn7%ev__vH7;ugaod2E?uxAy^ZLS^=TmTLhL{Q=Uqz1RVuw%x9NJvg-)ZkBQ~(Zq5FHL$by`Ps2g^%$UlYf$4gf z-V&z^AU2MSozic<0XX#4u^?j$bH>i23LHJ&sJjty$eioCm_sSneaHF`*)M9Ri{^QD z&dSEZ&IONw-u_fW%~>iZ}s&b;+vXcqj0xAeE5*Exk=B>A4>`85!0L}h(7qVaCg+3oiDJ?K|do> z9sAfc!O>Ro@!Ew)bNsBh(iTJ#l2>7BQ_RBh|;1DL^8momV+%*81SSiK$aSN z2VEcY5K?}VRvm6`ZqA{lWw=Rt+j7}}tYB&)ZDA~BVPZm%$_0&CZ?23I;V~M5wqbVZ z&btn2_|+xy!pJf1$F;@ofI}P6e0J%_^8a3=Vg&MOnFObu+Zl%wY1OSn_O$6C5I$B>+ z;)(|;Twz0ZEyQf$lnOP0kDi`fSW*)GJt1Gkvd#(m6^lY<+0Qk0#qZneKx|WjDECpK z|47fwK&UDt!5A7YW*;qOc~zLdp%m%z{v4o-+tiSI5dvv~a;<$I11wzzh6l`k;~8(| z4BK@dEo*wZydaJ(Bt~7*lZKbu!^#o|3ZcYXm+`VDRb5ZN7BPc$?-yVus~97v1!}Y_cJ9_&{w53?vubwc{w@RkhUj=2r7^5D z+jZ5LlGY4DEhgOobum-rD@Z?}SDWi)t=j;NLcET+nALkX8@%&AC|BNxVD`$|=)659 zM(zcu5ya}6@*AJ=d#jKfDk1(<9ZT}oPFtFtxlu{2@f~^Xlh*`6qnTHJf-t3P6@1A) z)6CMb|LQ}KK9)OhG#2^~N%jXA4VUgOE>=g(ssX(gMHf&J_aza1Ejcth7wU1(a-rCA zt8VcQs61YvEY#Zhm0dNo!_E;mQh3=86Dsko+DTmKB{UWaLnfk4@IuhYdrwHm_AiFH zwYxRxHNZP`Ds5FH`rhlxf)fCmiMfv-2navbrc$QyEhVtAz9)mBEK1YZRH*9cGg%vI zSTdUidL(PY?ou9YqQg*O1}+bNc(jL4N%GEoa3sc#hoo# z-)Gm3@0|lkSnTG?=hr=w=kRf*_)tG)vEh!XtFNhTihS$;aXx^ChV9tFi+HG1Jm3c? qDw-GfVe=RN*Y95{s?cZ{%^AbdZ&CMy*+Tq({WX!g4{B6w!v6z6;l@$` literal 0 HcmV?d00001 diff --git a/pix/monologo.png b/pix/monologo.png new file mode 100644 index 0000000000000000000000000000000000000000..fca65350f3ccee8af71fcd4e228569fe12417c1b GIT binary patch literal 2864 zcmV-03(xe4P)k7RCwC$T77U-)fGSYvI*N{H-t2VKx1Hpsc1#9V`&SH6{SpD z1_u-@Ei+DmU_w4D6qQop4+|J&I)a3c(2z`Eql||D0RWiiJi6`&>$v+~w0;20M?0>w&N-j0dS<%+ z`kIro*FEQ!m5Knk-g_+%T#J%T%rC&YPJy`WTF>0F>&*Y$z$>2$Fc?onMaM&uRZ>KR z?!R3~eR%M5E>1+`#6 zYaTjo@vQLYZW={p_=HqiO7-=#?higr@~1 zKm#oOdwL+nT=Ql&0R#jF2+S>zQ+UP+&s@SY$1ibx7hP-3L+s;31S$dI^<0cQEB0&% z{s90)!zNL(7$N|~B@6)oLiArC_W#6jpYz_#Ajm#Jc^>8dA>_wVIT0l??HI>M22(J> zLzDW9Qv?G7R6j zrto4q5}_t+jRT=TkY@tN2$CVFDhJOjM>zNI1JH*yhzA&_A8>==wSN&4SY{Roone+C z$OD}b1qf!+zMJ+;ASCld1|k4fJ-dGDU_w@;p&r@DIk)~%QKwzkGxHtMkXks}M~k@jERoS&ahRdaoN#{HeKt5;>3 zX9NgEf?TGF!NMRqvTGuf-9f>&%kRP`{a3Ax3IjA#T9`TNR*`cu!=5#kJhrUp&u333 zUx{4KW5&G<(AY%jpU;2kVZY&!WoZJ=9iJbn0RW^KgF%*tL8ieVra@zA7)1S;u~Snf zFr!OL>38PLDQs$NjQzK^!}2^pj2crLpy50b?QJh|T*~0KA6s5k{Q7IJ^>%#Hk#cXF z1)C}fBupC4 z9VlQnCJ<~OfTewgPBvtkt*WM``1rA7@hew4HK79h6dNG;4N52IxEV9Ez}!W8t2>UF zJfPn)X;OIVSEuGJm^<&HM-jaCEiio~b1s1}P(8q;G71^PGMfO3eTifh%WG?jkF>SL zdwO~_Qy|{+i)~W97N_tCSrtSS^r5ae4uA;BQ|DuUI`&H3TNMJhXX)}{Q2}GhU3Y~i z-F92v+8?j!G>agEef{k3_4_XY0<|kV2st^q^x*{yBH=r8@{swog$!OWBquYvfFNF! z>_fDgtlct*zbHlSy6sTn+GR)28KB*VPu~M`$G>rlvOqeh+cX?fH@f}m9$>~;`iyh}DP%uv2bkSvNLrs4H5rU!XfB!p0MxHv-}Ui@3#l#Jx0Qv%VG00{ zlaoW2R8$lnI&dJqYV}ja`wkr%pG+p%cjwOQXxO|tW()6mp0pPr=?(ZQiclF_7@}l1 zi1MZdI)OlBblsYtboTZ3vioPwECfLM(1Q7eqf1M3Yaf5SWWX~P z_mh!mlz#c{yTe@gGd1SS7o0srtMzsE%=;-#)5OX{af%)w5dwK4bkn(f1E#`(lm`Zt}}riMj>}>QLeW>sec}o8HL?hmLjrP2`aQlA4p#|Ft(=1A2+z+~(k|*?;|J%YMV|M&y`ga2y z3V?KQFa>K?J<)aP(x=+a=G~z{jq1I!&K>dTs3NduNinTAE8rMvo48T3Kj=2h39tamIbcK0uWdQ&nLD z95$=~0f2(JNX9MPr8#3U7S)j4^ebQ~v-96Xr3mbeBxx9uzn zg+dwKyTBB^!e>_!1Gf)wg8*(II(DCk13=tI1Az(C zaFv}vjS)$x@#Oqg@6A3>2>R*VP=#+&utX_5zh=sZXHc~HLdrtA(`K214d4R9Uy`DB z#vUNhxf2CR&5=5_pG{euft}R>>aU(j%s?Ro3n;Ku>jH1@M|eHdLN*LQM>B#VL8jMe ziHHp{fo}){1eEtJyahsFAK)$}Ay6QCbS8Z0`wuffKzUG?+9hEYgVqG?78iBw00E4* zF2K!*8^!=ZDNf-N1eyo;o&tk6MtBF}aeU_F1|tk0{%r;*1kYM{ + + + + + + + + + + + + + + + + M + + diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..b1dbbb3 --- /dev/null +++ b/settings.php @@ -0,0 +1,967 @@ +. + +/** + * Admin settings for MeetLet Adobe Connect Bridge (tabbed). + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use mod_meetletacbfree\admin\settingspage_tabs; +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\conntest_runner; +use mod_meetletacbfree\local\privacy_policy; +use mod_meetletacbfree\local\server_guide; +use mod_meetletacbfree\registration\manager as registration_manager; + +// Ensure class is available even if Moodle classmap cache is stale after adding this file. +require_once(__DIR__ . '/classes/local/server_guide.php'); +require_once(__DIR__ . '/classes/local/privacy_policy.php'); + +if ($ADMIN->fulltree) { + global $OUTPUT; + + $settings = new settingspage_tabs( + $settings->name, + $settings->visiblename, + $settings->req_capability, + $settings->hidden + ); + + $logo = $OUTPUT->image_url('adobeconnect', 'meetletacbfree'); + $stylesurl = new moodle_url('/mod/meetletacbfree/styles.css', ['v' => '2026090146']); + $jsurl = new moodle_url('/mod/meetletacbfree/javascript/settings_conntest.js', ['v' => '2026082135']); + $togglejsurl = new moodle_url('/mod/meetletacbfree/javascript/settings_toggles.js', ['v' => '2026081907']); + $matrixjsurl = new moodle_url('/mod/meetletacbfree/javascript/featureaccess_matrix.js'); + $accessresetjsurl = new moodle_url('/mod/meetletacbfree/javascript/settings_access_reset.js', ['v' => '2026090115']); + $resetajaxurl = new moodle_url('/mod/meetletacbfree/ajax/settings_reset.php'); + $modalscrolljsurl = new moodle_url('/mod/meetletacbfree/javascript/meetlet_modal_scroll.js', ['v' => '2026082239']); + $registrationjsurl = new moodle_url('/mod/meetletacbfree/javascript/settings_registration.js', ['v' => '2026090144']); + $cronjsurl = null; + $ajaxurl = new moodle_url('/mod/meetletacbfree/ajax/conntest.php'); + + // Load styles.css explicitly on admin tabs (theme concatenate alone is not enough for switch CSS). + $styles = ' + + + + + + + +'; + + $plugin = new stdClass(); + include(__DIR__ . '/version.php'); + $release = $plugin->release ?? ''; + if ($release !== '' && strpos(current_language(), 'fa') === 0) { + $release = strtr($release, [ + '0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴', + '5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹', + ]); + } + $versionbadge = $release !== '' + ? html_writer::span(get_string('settingsversion', 'meetletacbfree', $release), 'meetlet-settings-version') + : ''; + $hero = $styles . html_writer::div( + html_writer::img($logo, 'Adobe Connect', ['alt' => 'Adobe Connect']) . + html_writer::div( + html_writer::tag('h3', get_string('pluginname', 'meetletacbfree')) . + html_writer::tag('p', get_string('settingsintro', 'meetletacbfree')), + 'meetlet-settings-copy' + ) . + $versionbadge, + 'meetlet-settings-hero' + ); + $settings->set_hero($hero); + + // Tab: Online activation (required before other features work). + $page = new admin_settingpage('meetletacbfree_registration', get_string('tab_registration', 'meetletacbfree')); + $page->add(new admin_setting_heading( + 'meetletacbfree/registrationheading', + get_string('registration_heading', 'meetletacbfree'), + get_string('registration_heading_desc', 'meetletacbfree') + )); + $page->add(new admin_setting_configtext( + 'meetletacbfree/registration_api_url', + get_string('registration_api_url', 'meetletacbfree'), + get_string('registration_api_url_desc', 'meetletacbfree'), + registration_manager::DEFAULT_API_URL, + PARAM_URL + )); + $page->add(new \mod_meetletacbfree\admin\setting_registration()); + $settings->add($page); + + // Tab: Privacy (always visible — read policy before activating). + $page = new admin_settingpage('meetletacbfree_privacy', get_string('tab_privacy', 'meetletacbfree')); + $page->add(new admin_setting_heading( + 'meetletacbfree/privacycard', + '', + privacy_policy::render() + )); + $settings->add($page); + + $registered = registration_manager::is_registered(); + + if ($registered) { + + // Tab: Server setup guide. + $page = new admin_settingpage('meetletacbfree_serverguide', get_string('tab_serverguide', 'meetletacbfree')); + $page->add(new admin_setting_heading( + 'meetletacbfree/serverguidecard', + '', + server_guide::render() + )); + $settings->add($page); + + // Tab 2: Server settings (API / admin / offline listener). + $page = new admin_settingpage('meetletacbfree_connection', get_string('settings_connection', 'meetletacbfree')); + $page->add(new admin_setting_heading( + 'meetletacbfree/connectionheading', + get_string('settings_connection', 'meetletacbfree'), + get_string('settings_connection_desc', 'meetletacbfree') + )); + + $page->add(new admin_setting_heading( + 'meetletacbfree/server_api_group', + '', + html_writer::div( + html_writer::tag('h4', get_string('server_group_api', 'meetletacbfree')) . + html_writer::tag('p', get_string('server_group_api_desc', 'meetletacbfree')), + 'meetlet-server-group' + ) + )); + $page->add(new admin_setting_configtext( + 'meetletacbfree/host', get_string('host', 'meetletacbfree'), get_string('host_desc', 'meetletacbfree'), + 'https://connect.example.edu/api/xml', PARAM_URL + )); + $page->add(new admin_setting_configtext( + 'meetletacbfree/meethost', get_string('meethost', 'meetletacbfree'), get_string('meethost_desc', 'meetletacbfree'), + 'connect.example.edu', PARAM_HOST + )); + $page->add(new admin_setting_configtext( + 'meetletacbfree/port', get_string('port', 'meetletacbfree'), get_string('port_desc', 'meetletacbfree'), + '443', PARAM_INT + )); + $page->add(new admin_setting_configcheckbox( + 'meetletacbfree/https', get_string('https', 'meetletacbfree'), get_string('https_desc', 'meetletacbfree'), 1 + )); + + $page->add(new admin_setting_heading( + 'meetletacbfree/server_admin_group', + '', + html_writer::div( + html_writer::tag('h4', get_string('server_group_admin', 'meetletacbfree')) . + html_writer::tag('p', get_string('server_group_admin_desc', 'meetletacbfree')), + 'meetlet-server-group' + ) + )); + $page->add(new admin_setting_configtext( + 'meetletacbfree/admin_login', get_string('admin_login', 'meetletacbfree'), get_string('admin_login_desc', 'meetletacbfree'), + '', PARAM_RAW_TRIMMED + )); + $page->add(new admin_setting_configpasswordunmask( + 'meetletacbfree/admin_password', get_string('admin_password', 'meetletacbfree'), get_string('admin_password_desc', 'meetletacbfree'), '' + )); + $page->add(new admin_setting_configtext( + 'meetletacbfree/admin_httpauth', get_string('admin_httpauth', 'meetletacbfree'), get_string('admin_httpauth_desc', 'meetletacbfree'), + 'Generate-Safe-Random-Token', PARAM_TEXT + )); + $page->add(new admin_setting_configcheckbox( + 'meetletacbfree/email_login', get_string('email_login', 'meetletacbfree'), get_string('email_login_desc', 'meetletacbfree'), 1 + )); + + $settings->add($page); + + // Tab 4: Connection test (Ajax). + $page = new admin_settingpage('meetletacbfree_test', get_string('tab_connectiontest', 'meetletacbfree')); + $steplabels = [ + 'login' => get_string('testlogin', 'meetletacbfree'), + 'folders' => get_string('testfolders', 'meetletacbfree'), + 'user' => get_string('testuser', 'meetletacbfree'), + 'sso' => get_string('testsso', 'meetletacbfree'), + 'cleanup' => get_string('testcleanup', 'meetletacbfree'), + ]; + $testui = html_writer::div( + html_writer::start_div('meetlet-conntest__hero') . + html_writer::img($logo, 'Adobe Connect', ['class' => 'meetlet-conntest__logo']) . + html_writer::tag('h2', get_string('conntesttitle', 'meetletacbfree'), ['class' => 'meetlet-conntest__title']) . + html_writer::tag('p', get_string('conntestintro', 'meetletacbfree'), ['class' => 'meetlet-conntest__intro']) . + html_writer::end_div() . + html_writer::div( + html_writer::tag('button', + '' + . html_writer::span(get_string('runtest', 'meetletacbfree'), 'meetlet-btn-with-icon__label'), + [ + 'type' => 'button', + 'class' => 'meetlet-settings-btn meetlet-btn-with-icon', + 'data-meetlet-run-test' => '1', + ] + ) . + html_writer::span('', 'meetlet-conntest__status', ['data-meetlet-test-status' => '1']), + 'meetlet-conntest__actions' + ) . + html_writer::div( + html_writer::div( + html_writer::span('', 'meetlet-conntest__progress-text', ['data-meetlet-progress-label' => '1']), + 'meetlet-conntest__progress-label' + ) . + html_writer::div( + html_writer::div('', 'meetlet-conntest__progress-fill', ['data-meetlet-progress-fill' => '1']), + 'meetlet-conntest__progress-track' + ), + 'meetlet-conntest__progress', + ['data-meetlet-progress' => '1', 'hidden' => 'hidden'] + ) . + html_writer::div('', 'meetlet-conntest__grid', ['data-meetlet-test-grid' => '1']), + 'meetlet-conntest', + [ + 'id' => 'meetlet-acb-conntest', + 'data-endpoint' => $ajaxurl->out(false), + 'data-sesskey' => sesskey(), + 'data-steps' => implode(',', conntest_runner::STEPS), + 'data-step-labels' => json_encode($steplabels, JSON_UNESCAPED_UNICODE), + 'data-str-ok' => get_string('testok', 'meetletacbfree'), + 'data-str-fail' => get_string('testfail', 'meetletacbfree'), + 'data-str-running' => get_string('testrunning', 'meetletacbfree'), + 'data-str-idle' => '', + 'data-str-waiting' => get_string('testwaiting', 'meetletacbfree'), + 'data-str-runningstep' => get_string('testrunningstep', 'meetletacbfree'), + 'data-str-skipped' => get_string('testskipped', 'meetletacbfree'), + 'data-str-progress' => get_string('testprogress', 'meetletacbfree'), + ] + ); + $page->add(new admin_setting_heading('meetletacbfree/testui', '', $testui)); + $settings->add($page); + + // Tab 4: Meeting settings (defaults + feature visibility). + $page = new admin_settingpage('meetletacbfree_meetings', get_string('tab_meetingsettings', 'meetletacbfree')); + $page->add(new admin_setting_heading( + 'meetletacbfree/roomdefaultsheading', + get_string('roomdefaults', 'meetletacbfree'), + get_string('roomdefaults_desc', 'meetletacbfree') + )); + $page->add(new admin_setting_configselect( + 'meetletacbfree/default_meetinglang', + get_string('default_meetinglang', 'meetletacbfree'), + get_string('default_meetinglang_desc', 'meetletacbfree'), + '', + config::meeting_lang_options() + )); + $page->add(new admin_setting_configcheckbox( + 'meetletacbfree/default_waitforhost', + get_string('default_waitforhost', 'meetletacbfree'), + get_string('default_waitforhost_desc', 'meetletacbfree'), + 0 + )); + $page->add(new admin_setting_heading( + 'meetletacbfree/featuresheading', + get_string('featuresheading', 'meetletacbfree'), + get_string('featuresheading_desc', 'meetletacbfree') + )); + + $featuregroups = ['recordingstab', 'contenttab']; + foreach ($featuregroups as $feature) { + $grouphtml = html_writer::div( + html_writer::tag('h4', get_string('featuregroup_' . $feature, 'meetletacbfree')) . + html_writer::tag('p', get_string('featuregroup_' . $feature . '_desc', 'meetletacbfree')), + 'meetlet-feature-group' + ); + $page->add(new admin_setting_heading( + 'meetletacbfree/featuregroup_' . $feature, + '', + $grouphtml + )); + // ZIP download is off by default; other features stay on. + $enabledefault = ($feature === 'zip') ? 0 : 1; + $page->add(new admin_setting_configcheckbox( + 'meetletacbfree/enable_' . $feature, + get_string('enable_' . $feature, 'meetletacbfree'), + get_string('enable_' . $feature . '_desc', 'meetletacbfree'), + $enabledefault + )); + $page->add(new admin_setting_configcheckbox( + 'meetletacbfree/allow_override_' . $feature, + get_string('allow_override_' . $feature, 'meetletacbfree'), + get_string('allow_override_' . $feature . '_desc', 'meetletacbfree'), + 1 + )); + } + $settings->add($page); + + // Tab 5: Role feature access + Adobe room role mapping. + $page = new admin_settingpage('meetletacbfree_access', get_string('tab_accesssettings', 'meetletacbfree')); + $page->add(new admin_setting_heading( + 'meetletacbfree/rolemappingheading', + get_string('siterolemapping', 'meetletacbfree'), + get_string('siterolemapping_desc', 'meetletacbfree') + )); + $page->add(new \mod_meetletacbfree\admin\setting_rolemapping()); + $page->add(new admin_setting_heading( + 'meetletacbfree/accessheading', + get_string('featureaccess', 'meetletacbfree'), + get_string('featureaccess_desc', 'meetletacbfree') + )); + $page->add(new admin_setting_configcheckbox( + 'meetletacbfree/activity_featureaccess', + get_string('activity_featureaccess', 'meetletacbfree'), + get_string('activity_featureaccess_desc', 'meetletacbfree'), + 1 + )); + $page->add(new \mod_meetletacbfree\admin\setting_featureaccess()); + $page->add(new admin_setting_heading( + 'meetletacbfree/accessresetassets', + '', + \mod_meetletacbfree\admin\access_reset_ui::page_assets($resetajaxurl) + )); + $settings->add($page); + + } // End tabs unlocked after online activation. + + // Tab: About developer / MeetLet (read-only; always visible). + $page = new admin_settingpage('meetletacbfree_about', get_string('tab_aboutdev', 'meetletacbfree')); + $meetletlogo = $OUTPUT->image_url('meetlet_logo', 'meetletacbfree'); + $abouturl = new moodle_url('https://meetlet.net/', [ + 'utm_source' => 'moodle', + 'utm_medium' => 'plugin', + 'utm_campaign' => 'meetletacbfree', + 'utm_content' => 'about_developer_tab', + ]); + $about = html_writer::div( + html_writer::div( + html_writer::img($meetletlogo, 'MeetLet', ['alt' => 'MeetLet']), + 'meetlet-about__brand' + ) . + html_writer::tag('h4', get_string('about_heading', 'meetletacbfree')) . + html_writer::tag('p', get_string('about_body_p1', 'meetletacbfree')) . + html_writer::tag('p', get_string('about_body_p2', 'meetletacbfree')) . + html_writer::tag('p', get_string('about_body_p3', 'meetletacbfree')) . + html_writer::tag('p', get_string('about_body_p4', 'meetletacbfree')) . + html_writer::tag('p', get_string('marketplace_commercial_note', 'meetletacbfree'), [ + 'class' => 'meetlet-about__commercial', + ]) . + html_writer::div( + html_writer::link($abouturl, get_string('about_visit_website', 'meetletacbfree'), [ + 'class' => 'meetlet-about__cta', + 'target' => '_blank', + 'rel' => 'noopener', + ]), + 'meetlet-about__cta-wrap' + ), + 'meetlet-about meetlet-settings-card' + ); + $page->add(new admin_setting_heading('meetletacbfree/aboutcard', '', $about)); + $settings->add($page); + +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..bef9d05 --- /dev/null +++ b/styles.css @@ -0,0 +1,2202 @@ +/* MeetLet Adobe Connect Bridge — scoped to .meetlet-* only */ + +.meetlet-app { + --ml-green: #52cca3; + --ml-green-deep: #00261a; + --ml-green-soft: #e6faf3; + --ml-ink: #00261a; + --ml-muted: #4a6b5f; + --ml-line: #cfe8de; + --ml-bg: #f3fbf8; + --ml-card: #ffffff; + --ml-radius: 16px; + color: var(--ml-ink); +} + +.meetlet-hero { + display: grid; + gap: 1rem; + grid-template-columns: 1.5fr 1fr auto; + align-items: center; + background: + radial-gradient(900px 240px at 100% 0%, rgba(82, 204, 163, 0.28), transparent 50%), + linear-gradient(135deg, #00261a 0%, #0a3d2e 55%, #145c45 100%); + color: #fff; + border-radius: var(--ml-radius); + padding: 1.35rem 1.5rem; + margin-bottom: 1.1rem; +} + +.meetlet-hero__brand { + display: flex; + gap: 0.95rem; + align-items: center; +} + +.meetlet-hero__logo { + width: 52px; + height: 52px; + object-fit: contain; + border-radius: 12px; + background: rgba(255, 255, 255, 0.95); + padding: 6px; + box-shadow: 0 8px 20px rgba(0, 38, 26, 0.25); +} + +.meetlet-title { + margin: 0.2rem 0; + font-size: 1.4rem; + font-weight: 700; +} + +.meetlet-subtitle { opacity: 0.78; font-size: 0.9rem; } + +.meetlet-status { + display: inline-flex; + padding: 0.25rem 0.7rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 650; + background: rgba(255, 255, 255, 0.14); +} +.meetlet-status--open { background: var(--ml-green); color: var(--ml-green-deep); } +.meetlet-status--live { background: #0d9f6e; color: #fff; } +.meetlet-status--scheduled { background: #c2a15a; color: #2a2418; } +.meetlet-status--ended { background: #7a8480; color: #fff; } +.meetlet-status--waitinghost { background: #d9a441; color: #2a2418; } +.meetlet-status--checking { background: #d7e3de; color: #35564a; } + +.meetlet-statuswrap { + display: inline-flex; + align-items: center; + gap: .55rem; + min-height: 2.6rem; +} +.meetlet-statusring-wrap { + position: relative; + display: inline-flex; + flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + outline: none; +} +.meetlet-statusring-wrap:focus-visible { + box-shadow: 0 0 0 2px rgba(82, 204, 163, 0.85); +} +.meetlet-status[data-tip] { + cursor: help; + outline: none; +} +.meetlet-status[data-tip]:focus-visible { + box-shadow: 0 0 0 2px rgba(82, 204, 163, 0.85); + border-radius: 999px; +} +.meetlet-statusring { + width: 2.5rem; + height: 2.5rem; + transform: rotate(-90deg); + flex: 0 0 auto; +} +.meetlet-statusring__track, +.meetlet-statusring__prog { + fill: none; + stroke-width: 3.5; +} +.meetlet-statusring__track { stroke: rgba(0, 38, 26, .12); } +.meetlet-statusring__prog { + stroke: #0f8f63; + stroke-linecap: round; + stroke-dasharray: 100.53; + stroke-dashoffset: 0; + transition: stroke-dashoffset .2s linear, stroke .2s ease; +} +.meetlet-statuswrap[data-status="live"] .meetlet-statusring__prog { stroke: #0d9f6e; } +.meetlet-statuswrap[data-status="ended"] .meetlet-statusring__prog { stroke: #6b7470; } +.meetlet-statuswrap[data-status="scheduled"] .meetlet-statusring__prog { stroke: #b8892f; } +.meetlet-statuswrap[data-status="waitinghost"] .meetlet-statusring__prog { stroke: #c48a1a; } +.meetlet-statuscountdown { + position: absolute; + inset-inline-start: 0; + width: 2.5rem; + text-align: center; + font-size: .72rem; + font-weight: 700; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, .55); + pointer-events: none; + line-height: 2.5rem; +} +.meetlet-alert--error, +.meetlet-alert.meetlet-alert--error { + border-style: solid; + border-color: #f1b4b4; + background: #fdecec; + color: #b71c1c; +} +.meetlet-tip { + position: relative; +} +.meetlet-tip[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(4px); + padding: .45rem .65rem; + border-radius: 8px; + background: #1f2d28; + color: #fff; + font-size: .75rem; + font-weight: 650; + line-height: 1.35; + white-space: normal; + width: max-content; + max-width: 16rem; + text-align: center; + opacity: 0; + pointer-events: none; + transition: opacity .15s ease, transform .15s ease; + z-index: 20; +} +.meetlet-tip[data-tip]::before { + content: ""; + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: #1f2d28; + opacity: 0; + pointer-events: none; + transition: opacity .15s ease; + z-index: 20; +} +.meetlet-tip[data-tip]:hover::after, +.meetlet-tip[data-tip]:focus-within::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.meetlet-tip[data-tip]:hover::before, +.meetlet-tip[data-tip]:focus-within::before { + opacity: 1; +} +.meetlet-joinwrap { + display: inline-flex; +} +.meetlet-btn--join.is-disabled, +.meetlet-btn--join[aria-disabled="true"] { + opacity: .55; + cursor: not-allowed; + pointer-events: none; +} +.meetlet-btn--join.is-busy { + cursor: wait; +} + +.meetlet-meta-line { margin-top: 0.35rem; font-size: 0.86rem; opacity: 0.9; } + +.meetlet-btn { + border: 0; + border-radius: 999px; + padding: 0.75rem 1.35rem; + font-weight: 700; + cursor: pointer; + transition: transform .15s ease, box-shadow .15s ease, background .15s ease; +} +.meetlet-btn--join { + background: linear-gradient(180deg, #6fd9b5 0%, var(--ml-green) 45%, #3db890 100%); + color: var(--ml-green-deep); + box-shadow: 0 10px 22px rgba(82, 204, 163, 0.35); +} +.meetlet-btn--join:hover { + transform: translateY(-1px); + background: linear-gradient(180deg, #7fe0bf 0%, #5fd4ab 100%); +} +.meetlet-btn--ghost { + background: transparent; + color: var(--ml-green-deep); + border: 1px solid rgba(0, 38, 26, 0.25); + padding: 0.35rem 0.75rem; + border-radius: 10px; +} + +.meetlet-intro, +.meetlet-card { + background: var(--ml-card); + border: 1px solid var(--ml-line); + border-radius: var(--ml-radius); + padding: 1rem 1.15rem; + margin-bottom: 1rem; +} +.meetlet-intro--plain { + background: transparent; + border: 0; + border-radius: 0; + padding: .35rem 0 0; + margin: 0; + line-height: 1.75; + color: var(--ml-ink); +} +.meetlet-panel { + min-height: 4rem; +} +.meetlet-tablewrap--full { + width: 100%; + max-width: 100%; + overflow: visible; + border: 1px solid var(--ml-line); + border-radius: 14px; + background: #fff; +} +.meetlet-table--sessions { + width: 100% !important; + table-layout: auto; +} +.meetlet-table--sessions th, +.meetlet-table--sessions td { + padding: .85rem .95rem !important; +} +.meetlet-table--sessions th:nth-child(1), +.meetlet-table--sessions td:nth-child(1), +.meetlet-table--sessions th:nth-child(2), +.meetlet-table--sessions td:nth-child(2) { + white-space: normal; + overflow-wrap: break-word; + word-break: normal; +} +.meetlet-table--sessions .meetlet-table__num { + width: 1%; + text-align: center; + font-weight: 700; + white-space: nowrap; +} +.meetlet-table--sessions .meetlet-table__actions { + width: 1%; + text-align: end; + white-space: nowrap; + vertical-align: middle; +} +.meetlet-table--sessions .meetlet-table__actions .meetlet-iconactions { + justify-content: flex-end; +} +.meetlet-attendance__title { + margin: 0 0 .45rem; +} +.meetlet-recname { + display: inline-flex; + align-items: center; + gap: .35rem; + max-width: 100%; +} +.meetlet-recname__text { + word-break: break-word; +} +.meetlet-recname__input { + min-width: 12rem; + max-width: 100%; + padding: .25rem .45rem; + border: 1px solid #7bb9a4; + border-radius: 8px; + font: inherit; +} +.meetlet-iconbtn--inline { + width: 1.7rem; + height: 1.7rem; + border-radius: 8px; + flex: 0 0 auto; +} +.meetlet-iconbtn--adobeedit { + color: #0b5cab; +} +.meetlet-access-matrix { + overflow: auto; + max-height: min(70vh, 36rem); + margin: .75rem 0 1.25rem; + border: 1px solid #cfe8de; + border-radius: 8px; +} +.meetlet-access-table { + width: max-content; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; + background: #fff; +} +.meetlet-access-table th, +.meetlet-access-table td { + padding: .4rem .35rem; + border: 1px solid #cfe8de; + text-align: center; + vertical-align: middle; + font-size: .82rem; +} +.meetlet-access-table thead th { + background: #f3fbf8; + color: #00261a; + font-weight: 700; + max-width: 8.5rem; + position: sticky; + top: 0; + z-index: 2; +} +.meetlet-access-table thead th:first-child, +.meetlet-access-table tbody th { + position: sticky; + inset-inline-start: 0; + z-index: 1; + text-align: start; + background: #f7fcfa; + white-space: nowrap; + min-width: 11rem; +} +.meetlet-access-table thead th:first-child { + z-index: 3; +} +.meetlet-access-col__label { + line-height: 1.25; + margin-bottom: .35rem; +} +.meetlet-access-rowhead { + display: table-cell; +} +.meetlet-access-role { + display: inline-block; + margin-inline-end: .4rem; + vertical-align: middle; +} +.meetlet-access-toggle { + display: inline-block; + margin: .15rem 0 0; + padding: .12rem .45rem; + border: 1px solid #9bb8ad; + border-radius: 999px; + background: #fff; + color: #0f3d2e; + font-size: .68rem; + font-weight: 700; + line-height: 1.2; + cursor: pointer; + vertical-align: middle; +} +.meetlet-access-toggle:hover { + background: #e8f7f1; +} +.meetlet-access-matrix.is-disabled .meetlet-access-toggle { + opacity: .55; + cursor: not-allowed; +} +.meetlet-access-matrix.is-disabled { + opacity: .55; + pointer-events: none; +} +.path-admin .meetlet-access-matrix { + max-width: 100%; +} + +.meetlet-attendance__export { + display: flex; + flex-wrap: wrap; + gap: .65rem; + margin: 0 0 1.45rem; + padding: .85rem 0 1rem; + border-bottom: 1px solid var(--ml-line); +} +.meetlet-attendance__export .meetlet-btn { + text-decoration: none !important; +} +.meetlet-attendance__export.meetlet-iconactions { + gap: .45rem; +} + +.meetlet-tabtoolbar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: .75rem; + margin: 0 0 1rem; + padding: 0 0 .75rem; + border-bottom: 1px solid var(--ml-line); +} +.meetlet-tabtoolbar .meetlet-btn { + text-decoration: none !important; + flex: 0 0 auto; +} +.meetlet-tabtoolbar__refresh svg { + width: 1.05rem; + height: 1.05rem; + flex: 0 0 auto; + display: block; +} +.meetlet-jobpoll { + flex: 1 1 auto; + min-width: 8rem; + display: flex; + flex-direction: column; + gap: .3rem; +} +.meetlet-jobpoll__label { + font-size: .75rem; + font-weight: 650; + color: #3d5c52; + line-height: 1.3; +} +.meetlet-jobpoll__track { + height: 8px; + border-radius: 99px; + background: #e4f4ee; + overflow: hidden; + box-shadow: inset 0 0 0 1px #c6e5d8; +} +.meetlet-jobpoll__bar { + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, #52cca3, #0f766e); + animation: meetlet-jobpoll-fill 60s linear forwards; +} +@keyframes meetlet-jobpoll-fill { + from { width: 0; } + to { width: 100%; } +} + +.meetlet-license-badge { + font-weight: 700; + border-radius: 999px; + padding: .35rem .65rem; +} +.meetlet-license-badge--active { + background: var(--ml-green); + color: var(--ml-green-deep); +} +.meetlet-license-badge--inactive { + background: #fff4d6; + color: #5a4700; +} + +.meetlet-iconactions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: .35rem; +} +.meetlet-iconbtn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.15rem; + height: 2.15rem; + flex: 0 0 auto; + border-radius: 10px; + border: 1px solid #9fdcc4; + background: #fff; + color: var(--ml-green-deep); + text-decoration: none !important; + cursor: pointer; + padding: 0; + transition: background .15s ease, border-color .15s ease; +} +.meetlet-iconbtn svg { + width: 1.1rem; + height: 1.1rem; + display: block; +} +.meetlet-iconbtn:hover, +.meetlet-iconbtn:focus-visible { + background: var(--ml-green-soft); + border-color: #52cca3; + outline: none; +} +.meetlet-iconbtn.is-busy { + opacity: .7; + cursor: wait; + pointer-events: none; +} +.meetlet-iconbtn--disabled, +.meetlet-iconbtn:disabled { + opacity: .48; + cursor: not-allowed; + filter: grayscale(0.35); +} +.meetlet-iconbtn--disabled:hover, +.meetlet-iconbtn--disabled:focus-visible, +.meetlet-iconbtn:disabled:hover, +.meetlet-iconbtn:disabled:focus-visible { + transform: none; + box-shadow: none; +} +.meetlet-iconbtn--request { + color: #b45309; + border-color: #f0c48a; + background: #fffaf3; +} +.meetlet-iconbtn--request:hover, +.meetlet-iconbtn--request:focus-visible { + background: #fff3e0; + border-color: #e0a04a; +} +.meetlet-iconbtn--mp4 { + color: #0f766e; + border-color: #7dd3c7; + background: #f2fffb; +} +.meetlet-iconbtn--mp4:hover, +.meetlet-iconbtn--mp4:focus-visible { + background: #dffaf3; + border-color: #2bb89a; +} +.meetlet-iconbtn--mp4-agent { + color: #1d4ed8; + border-color: #93c5fd; + background: #eff6ff; +} +.meetlet-iconbtn--mp4-agent:hover, +.meetlet-iconbtn--mp4-agent:focus-visible { + background: #dbeafe; + border-color: #3b82f6; +} +.meetlet-iconbtn--import { + color: #0b5cab; + border-color: #bfd7f5; + background: #f5faff; +} +.meetlet-iconbtn--import:hover, +.meetlet-iconbtn--import:focus-visible { + background: #e8f2ff; + border-color: #7daee8; +} +.meetlet-iconbtn--update { + color: #9a6700; + border-color: #e8c97a; + background: #fffbeb; +} +.meetlet-iconbtn--update:hover, +.meetlet-iconbtn--update:focus-visible { + background: #fff3cd; + border-color: #d4a017; +} +.meetlet-iconbtn--deletemoodle { + color: #9f1239; + border-color: #f0b4c4; + background: #fff5f7; +} +.meetlet-iconbtn--deletemoodle:hover, +.meetlet-iconbtn--deletemoodle:focus-visible { + background: #ffe4ea; + border-color: #e07a93; +} +.meetlet-iconbtn--danger { + color: #b42318; + border-color: #f2b8b3; + background: #fff8f7; +} +.meetlet-iconbtn--danger:hover, +.meetlet-iconbtn--danger:focus-visible { + background: #ffeceb; + border-color: #df7c74; +} +.meetlet-iconbtn--play { + color: var(--ml-green-deep); +} +.meetlet-iconbtn--users { + color: #0f766e; + border-color: #7dd3c7; + background: #f2fffb; +} +.meetlet-iconbtn--users:hover, +.meetlet-iconbtn--users:focus-visible { + background: #dffaf3; + border-color: #2bb89a; +} +.meetlet-iconbtn--csv { + color: #0b5cab; + border-color: #bfd7f5; + background: #f5faff; +} +.meetlet-iconbtn--csv:hover, +.meetlet-iconbtn--csv:focus-visible { + background: #e8f2ff; + border-color: #7daee8; +} +.meetlet-iconbtn--zip { + color: #7c3aed; + border-color: #c4b5fd; + background: #f5f3ff; +} +.meetlet-iconbtn--zip:hover, +.meetlet-iconbtn--zip:focus-visible { + background: #ede9fe; + border-color: #a78bfa; +} +.meetlet-iconbtn--download { + color: #0b5cab; + border-color: #bfd7f5; + background: #f5faff; +} +.meetlet-iconbtn--download:hover, +.meetlet-iconbtn--download:focus-visible { + background: #e8f2ff; + border-color: #7daee8; +} +.meetlet-iconbtn__spinner { + position: absolute; + inset: 0; + margin: auto; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid rgba(82, 204, 163, 0.25); + border-top-color: var(--ml-green-deep); + animation: meetlet-spin .8s linear infinite; + background: #fff; +} +/* Activity view + icon buttons: JS .meetlet-floattip (viewport coords, RTL-safe). */ +.meetlet-app [data-tip]::after, +.meetlet-app [data-tip]::before { + content: none !important; + display: none !important; +} +.meetlet-floattip { + position: fixed; + z-index: 2147483000; + padding: .4rem .65rem; + border-radius: 8px; + background: #1f2d28; + color: #fff; + font-size: .75rem; + font-weight: 650; + line-height: 1.35; + max-width: min(16rem, calc(100vw - 16px)); + text-align: center; + box-shadow: 0 10px 24px rgba(0, 0, 0, .28); + pointer-events: none; + opacity: 0; + transform: translateY(4px); + transition: opacity .12s ease, transform .12s ease; +} +.meetlet-floattip--wide { + max-width: min(22rem, calc(100vw - 16px)); + text-align: start; +} +.meetlet-floattip.is-visible { + opacity: 1; + transform: translateY(0); +} +.meetlet-floattip.is-below { + transform: translateY(-4px); +} +.meetlet-floattip.is-below.is-visible { + transform: translateY(0); +} +.meetlet-sr-only { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.meetlet-btn-with-icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .45rem; +} +.meetlet-btn-with-icon svg { + width: 1.1rem; + height: 1.1rem; + flex: 0 0 auto; + display: block; +} +.meetlet-btn-with-icon__label { + line-height: 1.2; +} + +.meetlet-jobstatus { + display: inline-flex; + align-items: center; + padding: .25rem .55rem; + border-radius: 999px; + font-size: .82rem; + font-weight: 650; + background: #eef3f1; + color: #456257; +} +.meetlet-jobstatus--warn { + background: #fff3cd; + color: #7a5b00; + border: 1px solid #f0d78c; +} +.meetlet-jobstatus--ok { + background: #e6faf3; + color: #0a3d2e; + border: 1px solid #9fdcc4; +} +.meetlet-jobstatus--info { + background: #eef6ff; + color: #2a4a6b; + border: 1px solid #c5daf5; +} + +.meetlet-alert--warning, +.meetlet-alert.meetlet-alert--warning { + border-style: solid; + border-color: #e6b422; + background: #fff8e6; + color: #5c4700; +} + +.meetlet-tabs { display: flex; gap: .45rem; flex-wrap: wrap; margin-bottom: .85rem; } +.meetlet-tab { + display: inline-flex; + align-items: center; + gap: .4rem; + border: 1px solid var(--ml-line); + background: #fff; + color: var(--ml-muted); + border-radius: 999px; + padding: .45rem .95rem; + cursor: pointer; + font-weight: 650; +} +.meetlet-tab__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} +.meetlet-tab__icon svg { + width: 1rem; + height: 1rem; + display: block; +} +.meetlet-tab__label { + line-height: 1.2; +} +.meetlet-tab.is-active { + background: var(--ml-green-deep); + border-color: var(--ml-green-deep); + color: #fff; +} + +.meetlet-loading { + display: flex; + align-items: center; + gap: .75rem; + padding: 1.25rem; + border-radius: var(--ml-radius); + background: var(--ml-bg); + border: 1px dashed var(--ml-line); + color: var(--ml-muted); +} +.meetlet-loading__spinner, +.meetlet-joinoverlay__spinner { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid rgba(82, 204, 163, 0.25); + border-top-color: var(--ml-green-deep); + animation: meetlet-spin .8s linear infinite; +} +@keyframes meetlet-spin { to { transform: rotate(360deg); } } + +.meetlet-empty, +.meetlet-alert { + padding: 1.1rem; + border-radius: var(--ml-radius); + background: var(--ml-bg); + border: 1px dashed var(--ml-line); + color: var(--ml-muted); +} +.meetlet-alert { + border-style: solid; + border-color: #e7cfc0; + background: #fff8f2; + color: #7a4e32; +} + +.meetlet-joinoverlay { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: center; + justify-content: center; + background: rgba(24, 32, 30, 0.45); + backdrop-filter: blur(3px); +} +.meetlet-joinoverlay[hidden] { display: none !important; } +.meetlet-joinoverlay__card { + width: min(420px, calc(100vw - 2rem)); + background: #fff; + border-radius: 18px; + padding: 1.6rem 1.4rem; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); +} +.meetlet-joinoverlay__logo { + width: 56px; + height: 56px; + object-fit: contain; + margin-bottom: .85rem; +} +.meetlet-joinoverlay__spinner { + margin: 0 auto .85rem; + width: 28px; + height: 28px; +} +.meetlet-joinoverlay__title { + font-weight: 750; + font-size: 1.05rem; + margin-bottom: .35rem; +} +.meetlet-joinoverlay__text { color: var(--ml-muted); font-size: .92rem; } + +.meetlet-conntest { max-width: 880px; margin: 0 auto 2rem; } +.meetlet-conntest__hero { + text-align: center; + padding: 1.5rem 1rem 1rem; + border-radius: 18px; + background: linear-gradient(180deg, #e6faf3, #ffffff); + border: 1px solid #b8e8d6; + margin-bottom: 1rem; +} +.meetlet-conntest__logo { width: 72px; height: 72px; object-fit: contain; margin-bottom: .6rem; } +.meetlet-conntest__title { margin: 0 0 .4rem; } +.meetlet-conntest__intro { margin: 0 auto; max-width: 38rem; color: #4a6b5f; } +.meetlet-conntest__grid { display: grid; gap: .7rem; } +.meetlet-conntest__card { + background: #fff; + border: 1px solid #cfe8de; + border-radius: 14px; + padding: .95rem 1rem; +} +.meetlet-conntest__card--ok { border-color: #9fdcc4; background: #f3fbf8; } +.meetlet-conntest__card--fail { border-color: #ead3d0; background: #fff8f7; } +.meetlet-conntest__rowtop { + display: flex; + align-items: center; + gap: .65rem; +} +.meetlet-conntest__label { flex: 1; font-weight: 650; } +.meetlet-conntest__detail { + margin-top: .45rem; + margin-inline-start: 1.55rem; + color: #4a6b5f; + font-size: .88rem; +} +.meetlet-conntest__subs { + display: grid; + gap: .55rem; + margin-top: .75rem; + margin-inline-start: 1.55rem; +} +.meetlet-conntest__sub { + border: 1px solid #dbece4; + border-radius: 10px; + padding: .65rem .75rem; + background: #fff; +} +.meetlet-conntest__sub--ok { border-color: #bfe8d4; background: #f8fcfa; } +.meetlet-conntest__sub--fail { border-color: #ead3d0; background: #fffaf9; } +.meetlet-conntest__sub--skipped { opacity: .72; } +.meetlet-conntest__sublabel { flex: 1; font-weight: 600; font-size: .92rem; } +.meetlet-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} +.meetlet-dot--ok { background: #52cca3; box-shadow: 0 0 0 4px rgba(82, 204, 163, 0.2); } +.meetlet-dot--fail { background: #b42318; box-shadow: 0 0 0 4px rgba(180, 35, 24, 0.12); } +.meetlet-pill { + font-size: .72rem; + font-weight: 750; + letter-spacing: .02em; + border-radius: 999px; + padding: .2rem .55rem; +} +.meetlet-pill--ok { background: #d4f5e8; color: #00261a; } +.meetlet-pill--fail { background: #f8e2df; color: #8f2d24; } +.meetlet-conntest__footer { margin-top: 1.2rem; text-align: center; } + +.meetlet-participant-form .singleselect, +.meetlet-participant-form .form-select, +.meetlet-participant-form .btn { margin-inline-end: .4rem; margin-bottom: .4rem; } + +.meetlet-card__title { + margin: 0 0 .75rem; + font-size: 1.05rem; + font-weight: 750; + color: var(--ml-green-deep); +} +.meetlet-intro { + line-height: 1.75; + color: var(--ml-ink); +} +.meetlet-attendance__title { + margin: 0 0 .35rem; + font-size: 1.08rem; + font-weight: 750; +} +.meetlet-attendance__help { + margin: 0 0 .9rem; + color: var(--ml-muted); + font-size: .92rem; +} +.meetlet-tablewrap { + max-width: 100%; + overflow: visible; +} +.meetlet-table { + width: 100%; + max-width: 100%; + table-layout: auto; + border-collapse: separate; + border-spacing: 0; + background: #fff; +} +.meetlet-table th, +.meetlet-table td { + padding: .85rem 1rem; + border-bottom: 1px solid var(--ml-line); + text-align: start; + vertical-align: middle; +} +.meetlet-table thead th { + background: #f3fbf8; + color: var(--ml-green-deep); + font-weight: 700; + font-size: .86rem; +} +.meetlet-table tbody tr:hover { + background: #f7fcfa; +} +.meetlet-table .meetlet-table__actions, +.meetlet-table--recordings td:last-child, +.meetlet-table--recordings th:last-child, +.meetlet-table--content td:last-child, +.meetlet-table--content th:last-child { + width: 1%; + white-space: nowrap; + text-align: end; + vertical-align: middle; +} +.meetlet-table--recordings td:last-child .meetlet-iconactions { + justify-content: flex-end; +} +.meetlet-table--content td:last-child .meetlet-iconactions { + justify-content: flex-end; +} +.meetlet-table--recordings td:first-child { + overflow-wrap: break-word; + word-break: normal; +} +.meetlet-card:has(.meetlet-table) { + overflow: visible; +} +.meetlet-btn--sm { + padding: .4rem .85rem; + border-radius: 10px; + font-size: .86rem; +} +.meetlet-btn--ghost { + background: #fff; + color: var(--ml-green-deep); + border: 1px solid #9fdcc4; +} +.meetlet-btn--ghost:hover { + background: var(--ml-green-soft); +} + +.meetlet-modal[hidden] { display: none !important; } +.meetlet-modal { + position: fixed !important; + inset: 0 !important; + z-index: 10060 !important; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(24, 32, 30, 0.58); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} +.meetlet-modal__backdrop { + position: absolute; + inset: 0; + background: transparent; +} +.meetlet-modal__dialog { + position: relative; + z-index: 1; + width: min(860px, 100%); + max-height: min(82vh, 760px); + overflow: auto; + background: #fff; + border-radius: 18px; + border: 1px solid #cfe8de; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); +} +.meetlet-modal__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.15rem; + border-bottom: 1px solid var(--ml-line); + background: linear-gradient(180deg, #f3fbf8, #fff); + position: sticky; + top: 0; +} +.meetlet-modal__title { + margin: 0; + font-size: 1.05rem; + font-weight: 750; + color: var(--ml-green-deep); +} +.meetlet-modal__close { + border: 0; + background: #e8efec; + width: 34px; + height: 34px; + border-radius: 10px; + font-size: 1.3rem; + line-height: 1; + cursor: pointer; + color: var(--ml-green-deep); +} +.meetlet-modal__body { padding: 1rem 1.15rem 1.25rem; } +.meetlet-modal--confirm .meetlet-modal__dialog { + width: min(480px, 100%); + max-height: min(86vh, 640px); + overflow: hidden; + display: flex; + flex-direction: column; +} +.meetlet-modal--confirm .meetlet-modal__head { + padding: 1.2rem 1.15rem 1rem; + border-radius: 18px 18px 0 0; +} +.meetlet-modal--confirm .meetlet-modal__body { + overflow: auto; +} +.meetlet-modal__lead { + margin: 0 0 .85rem; + font-size: 1rem; + font-weight: 650; + line-height: 1.65; + color: #00261a; +} +.meetlet-modal.is-busy .meetlet-modal__close, +.meetlet-modal.is-busy .meetlet-modal__foot { + display: none !important; +} +.meetlet-modal.is-busy .meetlet-modal__backdrop { + pointer-events: none; +} +.meetlet-modal__busy { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .85rem; + padding: .35rem 0 .15rem; + text-align: center; +} +.meetlet-modal__busy .meetlet-modal__lead { + margin: 0; +} +.meetlet-modal__spinner { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid rgba(82, 204, 163, 0.25); + border-top-color: var(--ml-green-deep); + animation: meetlet-spin .8s linear infinite; +} +.meetlet-modal__callout { + margin: 0 0 .9rem; + padding: .85rem 1rem .7rem; + border-radius: 12px; + border: 1px solid #e6c56a; + background: #fff8e6; + color: #5a4700; +} +.meetlet-modal__callout-title { + margin: 0 0 .4rem; + font-weight: 700; + color: #5a4700; +} +.meetlet-modal__limits { + margin: 0 0 .55rem; + padding-inline-start: 1.2rem; +} +.meetlet-modal__limits li { + margin: 0 0 .28rem; + line-height: 1.5; +} +.meetlet-modal__upgrade { + margin: 0; + line-height: 1.6; +} +.meetlet-modal__keep { + margin: 0; + color: #334942; + line-height: 1.65; +} +.meetlet-modal__foot { + display: flex; + justify-content: flex-end; + align-items: center; + gap: .6rem; + padding: .9rem 1.15rem 1.05rem; + border-top: 1px solid #cfe8de; + background: linear-gradient(180deg, #f7fcfa 0%, #ffffff 70%); +} +.meetlet-modal__foot .btn { + border-radius: 10px; + font-weight: 700; + padding: .5rem 1.15rem; +} +.meetlet-modal__foot .btn-success { + background: #52cca3; + border-color: #3db892; + color: #00261a; +} +.meetlet-modal__foot .btn-success:hover, +.meetlet-modal__foot .btn-success:focus { + background: #6fd9b5; + border-color: #52cca3; + color: #00261a; +} +.meetlet-modal__foot .btn-outline-danger { + color: #a33b3b; + border-color: #d9a0a0; + background: #fff; +} +.meetlet-modal__foot .btn-outline-danger:hover, +.meetlet-modal__foot .btn-outline-danger:focus { + color: #fff; + background: #c0392b; + border-color: #c0392b; +} +html[dir="rtl"] .meetlet-modal--confirm .meetlet-modal__title, +html[dir="rtl"] .meetlet-modal--confirm .meetlet-modal__body, +html[dir="rtl"] .meetlet-modal--confirm .meetlet-modal__lead { + text-align: right; +} +/* Marker class on html; lock scroll on body only — overflow+height on html resets scroll position. */ +html.meetlet-modal-open body { + overflow: hidden; + touch-action: none; + overscroll-behavior: none; +} + +@media (max-width: 900px) { + .meetlet-hero { grid-template-columns: 1fr; } +} + +.meetlet-meetingpicker-wrap { + margin: 0 0 1rem; +} +.meetlet-meetingpicker { + max-width: 28rem; +} +.meetlet-meetingpicker__search { + width: 100%; + margin-bottom: .35rem; +} +.meetlet-meetingpicker__hint { + min-height: 1.1rem; + font-size: .82rem; + color: #4a6b5f; +} + +.meetlet-combobox { + position: relative; + max-width: 32rem; + z-index: 1; +} +.meetlet-combobox.is-open { + z-index: 40; +} +#fitem_id_existingscoid_ui, +.fitem:has([data-meeting-picker]) { + overflow: visible; +} +.fitem:has([data-meeting-picker].is-open) { + z-index: 20; + position: relative; +} +.meetlet-combobox__toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + width: 100%; + min-height: 2.4rem; + padding: .45rem .7rem; + border: 1px solid #9bb8ad; + border-radius: 8px; + background: #fff; + color: #12352a; + text-align: start; + cursor: pointer; +} +.meetlet-combobox.is-readonly .meetlet-combobox__toggle, +.meetlet-combobox__toggle:disabled { + opacity: .75; + cursor: default; + background: #f3f7f5; +} +.meetlet-combobox__value { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.meetlet-combobox__caret { + opacity: .65; +} +.meetlet-combobox__panel { + position: absolute; + inset-inline: 0; + top: calc(100% + 4px); + z-index: 30; + background: #fff; + border: 1px solid #9bb8ad; + border-radius: 10px; + box-shadow: 0 10px 28px rgba(16, 40, 32, .16); + padding: .55rem; +} +.meetlet-combobox__search { + width: 100%; + margin: 0 0 .45rem; + padding: .4rem .55rem; + border: 1px solid #c5d8cf; + border-radius: 8px; +} +.meetlet-combobox__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 14rem; + overflow: auto; +} +.meetlet-combobox__option { + display: block; + width: 100%; + border: 0; + background: transparent; + text-align: start; + padding: .45rem .5rem; + border-radius: 6px; + color: #12352a; + cursor: pointer; +} +.meetlet-combobox__option:hover, +.meetlet-combobox__option:focus-visible { + background: #e8f7f1; + outline: none; +} +.meetlet-combobox__option.is-selected { + background: #d4f0e5; + font-weight: 700; +} +.meetlet-combobox__hint { + min-height: 1rem; + margin-top: .35rem; + font-size: .8rem; + color: #4a6b5f; +} + +/* Switch toggle (admin settings + activity form + access matrix). */ +.meetlet-switch { + display: inline-flex; + align-items: center; + gap: .4rem; + cursor: pointer; + margin: 0; + padding: 0; + vertical-align: middle; + position: relative; + line-height: 0; +} +.meetlet-switch__input { + position: absolute !important; + opacity: 0 !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + pointer-events: none; +} +.meetlet-switch__track { + --meetlet-switch-pad: 3px; + --meetlet-switch-thumb: 16px; + position: relative; + width: 40px; + height: 22px; + flex: 0 0 40px; + border-radius: 999px; + background: #c5d5ce; + box-shadow: inset 0 0 0 1px rgba(0, 38, 26, .08); + transition: background .18s ease; + overflow: hidden; + box-sizing: border-box; + direction: ltr; /* Keep thumb travel physical LTR; icons stay readable. */ +} +.meetlet-switch__thumb { + position: absolute; + top: var(--meetlet-switch-pad); + left: var(--meetlet-switch-pad); + width: var(--meetlet-switch-thumb); + height: var(--meetlet-switch-thumb); + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 38, 26, .2); + transition: left .18s ease; + box-sizing: border-box; +} +.meetlet-switch__thumb::before { + content: ""; + position: absolute; + inset: 3px; + background: #4a6b5f; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='M2.1 2.1a.9.9 0 0 1 1.27 0L6 4.73 8.63 2.1A.9.9 0 1 1 9.9 3.37L7.27 6 9.9 8.63A.9.9 0 1 1 8.63 9.9L6 7.27 3.37 9.9A.9.9 0 1 1 2.1 8.63L4.73 6 2.1 3.37a.9.9 0 0 1 0-1.27z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='M2.1 2.1a.9.9 0 0 1 1.27 0L6 4.73 8.63 2.1A.9.9 0 1 1 9.9 3.37L7.27 6 9.9 8.63A.9.9 0 1 1 8.63 9.9L6 7.27 3.37 9.9A.9.9 0 1 1 2.1 8.63L4.73 6 2.1 3.37a.9.9 0 0 1 0-1.27z'/%3E%3C/svg%3E") center / contain no-repeat; +} +.meetlet-switch__input:checked + .meetlet-switch__track { + background: #52cca3; +} +.meetlet-switch__input:checked + .meetlet-switch__track .meetlet-switch__thumb { + /* Stay inside the track: pad + thumb + pad = full width. */ + left: calc(100% - var(--meetlet-switch-thumb) - var(--meetlet-switch-pad)); + transform: none; +} +.meetlet-switch__input:checked + .meetlet-switch__track .meetlet-switch__thumb::before { + background: #00261a; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='M10.2 3.1a.85.85 0 0 1 0 1.2L5.35 9.15a.85.85 0 0 1-1.2 0L1.8 6.8A.85.85 0 0 1 3 5.6l1.75 1.75 4.25-4.25a.85.85 0 0 1 1.2 0z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='M10.2 3.1a.85.85 0 0 1 0 1.2L5.35 9.15a.85.85 0 0 1-1.2 0L1.8 6.8A.85.85 0 0 1 3 5.6l1.75 1.75 4.25-4.25a.85.85 0 0 1 1.2 0z'/%3E%3C/svg%3E") center / contain no-repeat; +} +.meetlet-switch__input:focus-visible + .meetlet-switch__track { + outline: 2px solid #52cca3; + outline-offset: 2px; +} +.meetlet-switch--compact .meetlet-switch__track { + --meetlet-switch-pad: 2px; + --meetlet-switch-thumb: 12px; + width: 30px; + height: 16px; + flex-basis: 30px; +} +.meetlet-switch--compact .meetlet-switch__thumb::before { + inset: 2px; +} +.meetlet-switch--compact .meetlet-switch__input:checked + .meetlet-switch__track .meetlet-switch__thumb { + left: calc(100% - var(--meetlet-switch-thumb) - var(--meetlet-switch-pad)); + transform: none; +} +.meetlet-access-cell .meetlet-switch { + justify-content: center; +} +.path-admin #adminsettings .form-item:has(.meetlet-switch) { + align-items: flex-start; +} +.path-admin #adminsettings .form-item:has(.meetlet-switch) > .form-setting { + display: flex; + flex-direction: column; + /* Toward the label column: left in LTR, right in RTL. */ + align-items: start; +} +.path-admin #adminsettings .form-item:has(.meetlet-switch) > .form-label, +.path-admin #adminsettings .form-item:has(.meetlet-switch) > .form-setting { + padding-top: .4rem; +} +.path-admin #adminsettings .form-item:has(.meetlet-switch) .form-label > label { + margin: 0; + padding: 0; + line-height: 22px; + display: flex; + align-items: center; + min-height: 22px; +} +.path-admin #adminsettings .form-item:has(.meetlet-switch) .form-check, +.path-admin #adminsettings .form-item:has(.meetlet-switch) .checkbox, +.path-admin #adminsettings .form-item:has(.meetlet-switch) .form-checkbox { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: .45rem; + margin: 0; + padding: 0; + min-height: 22px; + line-height: 22px; + width: auto; + max-width: 100%; +} +html[dir="rtl"] .path-admin #adminsettings .form-item:has(.meetlet-switch) .form-check, +html[dir="rtl"] .path-admin #adminsettings .form-item:has(.meetlet-switch) .checkbox, +html[dir="rtl"] .path-admin #adminsettings .form-item:has(.meetlet-switch) .form-checkbox { + /* Keep switch on the label side (visual right), same as pre-compact layout. */ + flex-direction: row-reverse; + justify-content: flex-end; + text-align: right; +} + +/* Site admin: Moodle role → Adobe Connect role mapping (chip radios). */ +.meetlet-rolemapping-table .meetlet-rolemapping-cell { + white-space: normal; + vertical-align: middle; +} +.meetlet-rolechip-group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: .35rem; + padding: .2rem; + border-radius: 999px; + background: #eef5f1; + box-shadow: inset 0 0 0 1px rgba(0, 38, 26, .08); +} +.meetlet-rolechip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + padding: .4rem .9rem; + border-radius: 999px; + cursor: pointer; + user-select: none; + color: #3d5c50; + background: transparent; + font-weight: 600; + font-size: .875rem; + line-height: 1.2; + transition: background .15s ease, color .15s ease, box-shadow .15s ease, transform .12s ease; +} +.meetlet-rolechip__input { + position: absolute !important; + opacity: 0 !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + pointer-events: none; +} +.meetlet-rolechip__text { + position: relative; + z-index: 1; +} +.meetlet-rolechip:hover { + color: #00261a; + background: rgba(255, 255, 255, .65); +} +.meetlet-rolechip:has(.meetlet-rolechip__input:checked) { + color: #00261a; + background: #fff; + box-shadow: + 0 1px 2px rgba(0, 38, 26, .12), + 0 0 0 1px rgba(82, 204, 163, .45); +} +.meetlet-rolechip:has(.meetlet-rolechip__input:checked)[data-role-value="host"] { + background: linear-gradient(180deg, #e8fff6 0%, #d4f7e9 100%); + box-shadow: + 0 1px 2px rgba(0, 38, 26, .14), + 0 0 0 1px #52cca3; +} +.meetlet-rolechip:has(.meetlet-rolechip__input:checked)[data-role-value="presenter"] { + background: linear-gradient(180deg, #fff8e8 0%, #ffefc8 100%); + box-shadow: + 0 1px 2px rgba(0, 38, 26, .12), + 0 0 0 1px #e0b44a; +} +.meetlet-rolechip:has(.meetlet-rolechip__input:focus-visible) { + outline: 2px solid #52cca3; + outline-offset: 2px; +} +.meetlet-rolechip:active { + transform: scale(.98); +} +.meetlet-settingchip-group { + width: auto; + max-width: 100%; + gap: .28rem; + padding: .22rem; + border-radius: 999px; + background: #eef5f1; + box-shadow: inset 0 0 0 1px rgba(0, 38, 26, .08); +} +/* Activity form: compact two-option source chips (e.g. role mapping mode). */ +.meetlet-settingchip-group--binary { + display: inline-flex; + flex-wrap: nowrap; + width: auto; +} +.meetlet-settingchip-group--binary .meetlet-settingchip { + flex: 0 0 auto; + min-width: 5.5rem; + padding-inline: .85rem; +} +.mform .fitem:has(.meetlet-settingchip-group--binary) .felement { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .35rem; +} +.mform .fitem:has(.meetlet-settingchip-group--binary) .meetlet-settinghint { + flex: 1 1 12rem; + margin-top: 0; +} +.meetlet-settingchip { + min-width: 0; + flex: 0 1 auto; + align-items: center; + text-align: center; + border-radius: 999px; + padding: .42rem .78rem; + white-space: nowrap; +} +.meetlet-settingchip__content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .38rem; + min-width: 0; +} +.meetlet-settingchip__meta { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: .08rem .42rem; + border-radius: 999px; + background: rgba(0, 38, 26, .08); + color: #4a6b5f; + font-size: .73rem; + font-weight: 650; + line-height: 1.35; + white-space: nowrap; +} +.meetlet-settingchip:has(.meetlet-settingchip__input:checked) .meetlet-settingchip__meta { + background: rgba(0, 38, 26, .06); + color: #334942; +} +.meetlet-settinghint { + margin-top: .45rem; + font-size: .84rem; + line-height: 1.55; + color: #4a6b5f; +} +.mform .fitem:has(.meetlet-settingchip-group) .felement { + max-width: 100%; +} +.mform .fitem.is-disabled, +.mform .fitem .is-disabled { + opacity: .58; +} +@media (max-width: 720px) { + .meetlet-rolechip-group { + border-radius: 14px; + width: 100%; + } + .meetlet-rolechip { + flex: 1 1 auto; + text-align: center; + } + .meetlet-settingchip-group { + border-radius: 14px; + padding: .28rem; + gap: .3rem; + } + .meetlet-settingchip-group--binary { + flex-wrap: wrap; + width: 100%; + } + .meetlet-settingchip-group--binary .meetlet-settingchip { + flex: 1 1 calc(50% - .3rem); + } + .mform .fitem:has(.meetlet-settingchip-group--binary) .meetlet-settinghint { + flex-basis: 100%; + margin-top: .25rem; + } + .meetlet-settingchip { + flex: 1 1 calc(50% - .3rem); + padding-inline: .65rem; + } + .meetlet-settingchip__content { + flex-wrap: wrap; + gap: .22rem; + } +} + +/* Cron & video-delivery dashboard (plugin settings) */ +.path-admin #adminsettings .form-item:has(#meetlet-cronmon) { + display: block; + padding: 0; + border: 0; + background: transparent; +} +.path-admin #adminsettings .form-item:has(#meetlet-cronmon) > .form-label, +.path-admin #adminsettings .form-item:has(#meetlet-cronmon) .form-description, +.path-admin #adminsettings .form-item:has(#meetlet-cronmon) .form-defaultinfo, +.path-admin #adminsettings .form-item:has(#meetlet-cronmon) .form-setting > .form-shortname { + display: none !important; +} +.path-admin #adminsettings .form-item:has(#meetlet-cronmon) > .form-setting { + max-width: none; + width: 100%; + float: none; +} + +.meetlet-cronmon { + --ml-green: #52cca3; + --ml-green-deep: #00261a; + --ml-muted: #4a6b5f; + --ml-line: #cfe8de; + color: var(--ml-green-deep); + margin: 0 0 1.5rem; +} +.meetlet-cronmon.is-refreshing { + opacity: .82; + transition: opacity .25s ease; +} +.meetlet-cronmon__hero { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + align-items: center; + justify-content: space-between; + padding: 1.15rem 1.25rem; + margin: 0 0 1rem; + border-radius: 12px; + background: #fafbfc; + color: var(--ml-green-deep); + border: 1px solid #e2e8f0; + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); +} +.meetlet-cronmon__copy { + min-width: 12rem; + flex: 1 1 16rem; +} +.meetlet-cronmon__copy h3 { + margin: .15rem 0 .35rem; + font-size: 1.25rem; + font-weight: 700; + color: var(--ml-green-deep); +} +.meetlet-cronmon__intro { + margin: 0; + color: var(--ml-muted); + max-width: none; + line-height: 1.55; + white-space: nowrap; +} +.meetlet-cronmon__live { + display: inline-flex; + align-items: center; + gap: .4rem; + margin: 0 0 .2rem; + padding: .12rem .65rem; + border-radius: 999px; + font-size: .72rem; + font-weight: 800; + letter-spacing: .06em; + text-transform: uppercase; + background: #e6faf3; + color: #0d9f6e; + border: 1px solid #b8e8d6; +} +.meetlet-cronmon__live::before { + content: ''; + width: .55rem; + height: .55rem; + border-radius: 50%; + background: #0d9f6e; + box-shadow: 0 0 0 0 rgba(13, 159, 110, .55); + animation: meetlet-cronmon-pulse 1.6s ease-out infinite; +} +@keyframes meetlet-cronmon-pulse { + 0% { box-shadow: 0 0 0 0 rgba(13, 159, 110, .45); } + 70% { box-shadow: 0 0 0 10px rgba(13, 159, 110, 0); } + 100% { box-shadow: 0 0 0 0 rgba(13, 159, 110, 0); } +} +.meetlet-cronmon__timers { + display: flex; + gap: 1rem; + flex: 0 0 auto; +} +.meetlet-cronmon__timer { + position: relative; + width: 6.4rem; + text-align: center; +} +.meetlet-cronmon__ring { + width: 6.4rem; + height: 6.4rem; + transform: rotate(-90deg); + display: block; +} +.meetlet-cronmon__ring-track, +.meetlet-cronmon__ring-prog { + fill: none; + stroke-width: 6; +} +.meetlet-cronmon__ring-track { stroke: #e2e8f0; } +.meetlet-cronmon__ring-prog { + stroke: #52cca3; + stroke-linecap: round; + stroke-dasharray: 263.89; + transition: stroke .2s ease; +} +.meetlet-cronmon__timer.is-low .meetlet-cronmon__ring-prog { stroke: #d97706; } +.meetlet-cronmon__timer.is-due .meetlet-cronmon__ring-prog { + stroke: #0d9f6e; + animation: meetlet-cronmon-glow 1.1s ease-in-out infinite; +} +@keyframes meetlet-cronmon-glow { + 0%, 100% { opacity: 1; } + 50% { opacity: .35; } +} +.meetlet-cronmon__tick { + position: absolute; + inset: 1.15rem 0 1.7rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.05rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: var(--ml-green-deep); + pointer-events: none; +} +.meetlet-cronmon__timerlabel { + display: block; + margin-top: .15rem; + font-size: .72rem; + font-weight: 650; + color: var(--ml-muted); + line-height: 1.3; +} +.meetlet-cronmon__cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .75rem; + margin: 0 0 1rem; +} +.meetlet-cronmon__card { + background: #fff; + border: 1px solid var(--ml-line); + border-radius: 14px; + padding: .9rem 1rem .75rem; + min-height: 7.5rem; +} +.meetlet-cronmon__card h4 { + margin: 0 0 .45rem; + font-size: .95rem; + font-weight: 800; +} +.meetlet-cronmon__card p { + margin: 0 0 .3rem; + font-size: .85rem; + line-height: 1.45; + color: #334942; + word-break: break-word; +} +.meetlet-cronmon__muted { color: var(--ml-muted) !important; font-size: .8rem !important; } +.meetlet-cronmon__card--ok { border-color: #9fdcc4; background: linear-gradient(180deg, #f3fbf8 0%, #fff 70%); } +.meetlet-cronmon__card--warn { border-color: #e6c56a; background: linear-gradient(180deg, #fff8e6 0%, #fff 70%); } +.meetlet-cronmon__card--bad { border-color: #ead3d0; background: linear-gradient(180deg, #fff8f7 0%, #fff 70%); } +.meetlet-cronmon__counts { + display: flex; + flex-wrap: wrap; + gap: .45rem; + margin: 0 0 .9rem; +} +.meetlet-cronmon__chip { + display: inline-flex; + align-items: center; + gap: .3rem; + padding: .28rem .7rem; + border-radius: 999px; + font-size: .78rem; + font-weight: 650; + background: #e6faf3; + color: #00261a; + border: 1px solid #cfe8de; +} +.meetlet-cronmon__chip b { font-weight: 800; } +.meetlet-cronmon__chip--queued { background: #e8f4ff; border-color: #c5ddf5; } +.meetlet-cronmon__chip--running { background: #e6faf3; border-color: #9fdcc4; } +.meetlet-cronmon__chip--waiting { background: #fff4d6; border-color: #e6c56a; } +.meetlet-cronmon__pill--waiting { background: #ffe7a3; } +.meetlet-cronmon__chip--failed { background: #fff1f0; border-color: #ead3d0; } +.meetlet-cronmon__chip--import { background: #eef2ff; border-color: #c5ccef; } +.meetlet-cronmon__pill--import { background: #4c5fd7; color: #fff; } +.meetlet-cronmon__chip--ready { background: #d9f7ea; border-color: #52cca3; } +.meetlet-cronmon__videos-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: .5rem .85rem; + margin: 0 0 .6rem; +} +.meetlet-cronmon__videos-head h4 { + margin: 0; + font-size: 1.02rem; + font-weight: 800; +} +.meetlet-cronmon__refreshbtn { + margin-inline-start: auto; + position: relative; +} +.meetlet-cronmon__refreshbtn.is-busy { + pointer-events: none; +} +.meetlet-cronmon__refreshbtn.is-busy svg, +.meetlet-cronmon__refreshbtn.is-busy .meetlet-btn-with-icon__label { + opacity: 0.35; +} +.meetlet-cronmon__refreshbtn.is-busy::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid rgba(82, 204, 163, 0.25); + border-top-color: var(--ml-green-deep); + animation: meetlet-spin .8s linear infinite; +} +.meetlet-cronmon__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .45rem .7rem; + margin: 0 0 .75rem; + padding: .7rem .85rem; + border: 1px solid #e7f3ee; + border-radius: 12px; + background: linear-gradient(180deg, #f9fdfb 0%, #fff 100%); +} +.meetlet-cronmon__columns-label { + color: #3d5c52; + font-size: .8rem; + font-weight: 750; + white-space: nowrap; +} +.meetlet-cronmon__columnchips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: .35rem; +} +.meetlet-cronmon__columnchip { + border: 1px solid #cfe8de; + border-radius: 999px; + background: #fff; + color: #3d5c52; + padding: .26rem .7rem; + font-size: .76rem; + font-weight: 700; + line-height: 1.25; + cursor: pointer; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} +.meetlet-cronmon__columnchip:hover, +.meetlet-cronmon__columnchip:focus-visible { + background: #edf8f4; + border-color: #9fdcc4; + color: #00261a; + outline: none; +} +.meetlet-cronmon__columnchip.is-active { + background: #e6faf3; + border-color: #52cca3; + color: #00261a; +} +.meetlet-cronmon__columnchip[disabled] { + opacity: .72; + cursor: default; +} +.meetlet-cronmon__tablewrap { + overflow: auto; + border: 1px solid var(--ml-line); + border-radius: 14px; + background: #fff; +} +.meetlet-cronmon__table { + width: 100%; + margin: 0; + border-collapse: collapse; +} +.meetlet-cronmon__table th, +.meetlet-cronmon__table td { + padding: .65rem .8rem; + text-align: start; + border-bottom: 1px solid #e7f3ee; + vertical-align: top; + font-size: .85rem; +} +.meetlet-cronmon__table th { + background: #f3fbf8; + font-weight: 750; + color: #00261a; + white-space: nowrap; +} +.meetlet-cronmon__table tr:last-child td { border-bottom: 0; } +.meetlet-cronmon.is-awaiting .meetlet-cronmon__cards { + min-height: 7rem; +} +.meetlet-cronmon__loading { + grid-column: 1 / -1; + padding: 1.4rem 1rem; + text-align: center; + color: var(--ml-muted); + font-weight: 600; +} +.meetlet-cronmon__name { font-weight: 650; } +.meetlet-cronmon__link { + color: inherit; + font-weight: 650; + text-decoration: none; +} +.meetlet-cronmon__link:hover, +.meetlet-cronmon__link:focus-visible { + color: #0a5c45; + text-decoration: underline; +} +.meetlet-cronmon__sco { color: var(--ml-muted); font-weight: 500; } +.meetlet-cronmon__sco-id, +.meetlet-cronmon__sco-head { + direction: ltr; + unicode-bidi: isolate; +} +.meetlet-cronmon__sco-id { + font-variant-numeric: tabular-nums; + color: #3d5c52; + white-space: nowrap; +} +.meetlet-cronmon__sco-head[data-tip] { + cursor: help; +} +/* Ops cell: no box chrome — only icon buttons keep their own borders. */ +.meetlet-cronmon__table td.meetlet-cronmon__dl, +.meetlet-cronmon__dl { + text-align: end; + white-space: nowrap; + vertical-align: middle; + border-left: 0 !important; + border-right: 0 !important; + border-top: 0 !important; + background: transparent !important; + box-shadow: none !important; + outline: none !important; +} +.meetlet-cronmon__dl .meetlet-iconactions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: .35rem; + border: 0 !important; + background: transparent !important; + box-shadow: none !important; + outline: none !important; + padding: 0; + margin: 0; +} +.meetlet-cronmon__dl .meetlet-iconbtn { + vertical-align: middle; +} +.meetlet-cronmon__detail { color: var(--ml-muted); max-width: 18rem; } +.meetlet-cronmon__when { white-space: nowrap; color: #3d5c52; } +.meetlet-cronmon__empty { + text-align: center; + color: var(--ml-muted); + padding: 1.4rem !important; +} +.meetlet-cronmon__pill { + display: inline-flex; + padding: .15rem .55rem; + border-radius: 999px; + font-size: .75rem; + font-weight: 750; + background: #e6faf3; + color: #00261a; + white-space: nowrap; +} +.meetlet-cronmon__pill--queued { background: #dceeff; } +.meetlet-cronmon__pill--running { background: #52cca3; color: #00261a; } +.meetlet-cronmon__pill--retry { background: #ffe7a3; } +.meetlet-cronmon__pill--failed { background: #f8d0cc; color: #7a1f1a; } +.meetlet-cronmon__pill--ready { background: #0d9f6e; color: #fff; } +.meetlet-cronmon__pill--pending { background: #e7eeeb; } +@media (max-width: 900px) { + .meetlet-cronmon__cards { grid-template-columns: 1fr; } + .meetlet-cronmon__hero { align-items: flex-start; } + .meetlet-cronmon__intro { white-space: normal; } + .meetlet-cronmon__toolbar { + align-items: flex-start; + } +} + +html[dir="rtl"] .path-admin #adminsettings #id_s_meetletacbfree_agent_dispatch, +html[dir="rtl"] .path-admin #adminsettings select[name="s_meetletacbfree_agent_dispatch"] { + direction: rtl; + text-align: right; +} +html[dir="rtl"] .path-admin #adminsettings #id_s_meetletacbfree_agent_dispatch option, +html[dir="rtl"] .path-admin #adminsettings select[name="s_meetletacbfree_agent_dispatch"] option { + direction: rtl; + text-align: right; +} +html[dir="rtl"] .path-admin .meetlet-hourpicker, +html[dir="rtl"] .path-admin .meetlet-hourpresets { + direction: rtl; +} + +.meetlet-gate-msg { + white-space: pre-wrap; + line-height: 1.65; +} +.meetlet-err { + display: grid; + gap: .75rem; + color: #00261a; +} +.meetlet-err__lead { + margin: 0; + font-size: 1rem; + font-weight: 650; + line-height: 1.65; +} +.meetlet-err__codebox { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem .75rem; + padding: .65rem .85rem; + border-radius: 12px; + border: 1px dashed #9fdcc4; + background: #f3fbf8; +} +.meetlet-err__codelabel { + font-size: .8rem; + font-weight: 700; + color: #4a6b5f; +} +.meetlet-err__codebox code { + font-size: 1.02rem; + font-weight: 800; + letter-spacing: .04em; + padding: .2rem .55rem; + border-radius: 8px; + background: #00261a; + color: #52cca3; + direction: ltr; + unicode-bidi: isolate; + cursor: pointer; +} +.meetlet-err__codebox code.is-copied { + background: #0d9f6e; + color: #fff; +} +.meetlet-err__facts { + display: grid; + gap: .45rem; + margin: 0; +} +.meetlet-err__fact { + display: grid; + grid-template-columns: minmax(7rem, 11rem) 1fr; + gap: .4rem 1rem; + padding: .45rem .6rem; + border-radius: 10px; + background: #f7fcfa; + border: 1px solid #e7f3ee; +} +.meetlet-err__fact dt { + margin: 0; + font-size: .78rem; + font-weight: 750; + color: #4a6b5f; +} +.meetlet-err__fact dd { + margin: 0; +} +.meetlet-err__fact code { + font-size: .86rem; + padding: .05rem .35rem; + border-radius: 5px; + background: #e6faf3; + direction: ltr; + unicode-bidi: isolate; + word-break: break-all; +} +.meetlet-err__fix { + padding: .75rem .9rem .55rem; + border-radius: 12px; + border: 1px solid #cfe8de; + background: #fff; + line-height: 1.65; +} +.meetlet-err__fix ol, +.meetlet-err__fix ul { + margin: 0; + padding-inline-start: 1.2rem; +} +.meetlet-err__fix li { margin: 0 0 .4rem; } +.meetlet-err__fix code { + font-size: .86em; + padding: .05rem .3rem; + border-radius: 4px; + background: #e6faf3; +} +.meetlet-err__prelabel { + margin: 0; + font-size: .8rem; + font-weight: 750; + color: #4a6b5f; +} +.meetlet-err__xml { + margin: 0; + padding: .75rem .9rem; + overflow-x: auto; + border-radius: 10px; + border: 1px solid #cfe8de; + background: #0a3d2e; + color: #e6faf3; + font-size: .8rem; + line-height: 1.45; + direction: ltr; + text-align: left; + white-space: pre; + max-height: 14rem; +} +.meetlet-err__guide { + margin: 0; + font-size: .85rem; + color: #4a6b5f; + line-height: 1.55; +} +@media (max-width: 640px) { + .meetlet-err__fact { + grid-template-columns: 1fr; + } +} + +/* Course page: keep branded monologo colours (Moove / filtericon fallback). */ +.activityiconcontainer.communication.isbranded .activityicon, +.activityiconcontainer.communication .activityicon.nofilter, +.activityiconcontainer .activityicon[src*="mod/meetletacbfree"] { + filter: none !important; +} diff --git a/templates/participant_form.mustache b/templates/participant_form.mustache new file mode 100644 index 0000000..0ac1512 --- /dev/null +++ b/templates/participant_form.mustache @@ -0,0 +1,19 @@ +
+ {{#selectionType}} + {{> mod_meetletacbfree/participant_single_select}} + {{/selectionType}} + {{#selectionParticipant}} + {{> mod_meetletacbfree/participant_single_select}} + {{/selectionParticipant}} + {{#action}} + + {{/action}} + + +
+
diff --git a/templates/participant_form_add.mustache b/templates/participant_form_add.mustache new file mode 100644 index 0000000..a9b7ed0 --- /dev/null +++ b/templates/participant_form_add.mustache @@ -0,0 +1,17 @@ + + {{participanttype}} + {{participantvalue}} + {{#str}}participant_as, meetletacbfree{{/str}} + + + + + {{#canRemove}} +
{{#str}}participant_remove, meetletacbfree{{/str}} + {{/canRemove}} + + diff --git a/templates/participant_single_select.mustache b/templates/participant_single_select.mustache new file mode 100644 index 0000000..033c487 --- /dev/null +++ b/templates/participant_single_select.mustache @@ -0,0 +1,22 @@ +
+ {{#label}} + + {{/label}} + +
diff --git a/templates/view_page.mustache b/templates/view_page.mustache new file mode 100644 index 0000000..0921e15 --- /dev/null +++ b/templates/view_page.mustache @@ -0,0 +1,83 @@ +
+
+
+ +
+

{{name}}

+
{{subtitle}}
+
+
+
+
+ {{#licenseusable}} + + + 15 + + {{/licenseusable}} + {{statuslabel}} +
+ {{#starttime}}
{{#str}} starttime, meetletacbfree {{/str}}: {{starttime}}
{{/starttime}} + {{#hasend}}
{{#str}} endtime, meetletacbfree {{/str}}: {{endtime}}
{{/hasend}} +
+ {{#canjoin}} +
+ + + +
+ {{/canjoin}} +
+ + + + {{^configured}} +
{{notconfiguredmsg}}
+ {{/configured}} + + + + + +
+ {{#hasintro}} +
{{{intro}}}
+ {{/hasintro}} + {{^hasintro}} +
{{emptydetails}}
+ {{/hasintro}} +
+
diff --git a/version.php b/version.php new file mode 100644 index 0000000..9c52bc2 --- /dev/null +++ b/version.php @@ -0,0 +1,32 @@ +. + +/** + * Plugin version metadata. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'mod_meetletacbfree'; +$plugin->version = 2026090153; +$plugin->requires = 2024100700; // Moodle 4.5+. +$plugin->supported = [405, 502]; // 4.5 LTS through 5.2. +$plugin->maturity = MATURITY_STABLE; +$plugin->release = '1.1.3'; diff --git a/view.php b/view.php new file mode 100644 index 0000000..bf11d91 --- /dev/null +++ b/view.php @@ -0,0 +1,99 @@ +. + +/** + * Activity view page. + * + * @package mod_meetletacbfree + * @copyright 2026 MeetLet + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../../config.php'); + +use mod_meetletacbfree\local\config; +use mod_meetletacbfree\local\featureaccess; +use mod_meetletacbfree\local\instance; +use mod_meetletacbfree\local\meetingstatus; +use mod_meetletacbfree\output\view_page; +use mod_meetletacbfree\registration\manager as registration_manager; + +$id = required_param('id', PARAM_INT); +$groupid = optional_param('group', 0, PARAM_INT); + +$cm = get_coursemodule_from_id('meetletacbfree', $id, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); +$meetlet = $DB->get_record('meetletacbfree', ['id' => $cm->instance], '*', MUST_EXIST); + +require_login($course, true, $cm); +$context = context_module::instance($cm->id); +require_capability('mod/meetletacbfree:view', $context); +\mod_meetletacbfree\registration\manager::require_registered(); + +$PAGE->set_url('/mod/meetletacbfree/view.php', ['id' => $cm->id]); +$PAGE->set_title(format_string($meetlet->name)); +$PAGE->set_heading(format_string($course->fullname)); +$PAGE->set_context($context); +$PAGE->activityheader->disable(); +$PAGE->requires->css(new moodle_url('/mod/meetletacbfree/styles.css', ['v' => '2026082250'])); +$PAGE->requires->js(new moodle_url('/mod/meetletacbfree/javascript/meetlet_modal_scroll.js', ['v' => '2026082250'])); +$PAGE->requires->js(new moodle_url('/mod/meetletacbfree/javascript/view.js', ['v' => '2026082250'])); + +if ($cm->groupmode) { + $groupid = groups_get_activity_group($cm, true) ?: $groupid; +} + +$inst = instance::get_from_cmid($cm->id, (int)$groupid); +// First paint: local schedule only — no Adobe API. Browser polls status after load. +$lifecycle = meetingstatus::resolve_local($inst, (int)$USER->id); +$featurejoin = featureaccess::allowed(featureaccess::JOIN, $context, $meetlet); + +$event = \mod_meetletacbfree\event\course_module_viewed::create([ + 'objectid' => $meetlet->id, + 'context' => $context, +]); +$event->add_record_snapshot('course', $course); +$event->add_record_snapshot('meetletacbfree', $meetlet); +$event->trigger(); + +$renderer = $PAGE->get_renderer('mod_meetletacbfree'); +$page = new view_page($inst, config::is_configured(), $lifecycle, true); + +echo $OUTPUT->header(); +if ($cm->groupmode) { + groups_print_activity_menu($cm, $PAGE->url); +} +echo html_writer::div('', '', [ + 'data-meetlet-config' => json_encode([ + 'cmid' => (int)$cm->id, + 'groupid' => (int)$groupid, + 'licenseUsable' => true, + 'canjoin' => $featurejoin, + 'joinable' => $featurejoin && !empty($lifecycle['joinable']), + 'status' => (string)$lifecycle['status'], + 'statusMessage' => (string)$lifecycle['message'], + 'statusPollSeconds' => 15, + 'strJoinWaiting' => get_string('joinblocked_waitinghost', 'meetletacbfree'), + 'strJoiningTitle' => get_string('joiningtitle', 'meetletacbfree'), + 'strJoiningText' => get_string('joining', 'meetletacbfree'), + 'strRecordingTitle' => get_string('preparingrecordingtitle', 'meetletacbfree'), + 'strRecordingText' => get_string('preparingrecording', 'meetletacbfree'), + 'canrecordings' => featureaccess::allowed(featureaccess::RECORDINGS, $context, $meetlet), + ], JSON_UNESCAPED_UNICODE), + 'hidden' => 'hidden', +]); +echo $renderer->render($page); +echo $OUTPUT->footer();