Adobe Connect Bridge free edition (meetletacbfree) v1.1.3

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 23:59:56 +03:30
commit 91334a46b5
93 changed files with 21023 additions and 0 deletions
+47
View File
@@ -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
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <http://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<http://www.gnu.org/licenses/>.
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
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+317
View File
@@ -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).
+41
View File
@@ -0,0 +1,41 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
+83
View File
@@ -0,0 +1,83 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Ajax endpoint: activate / recheck / deactivate MeetLet site registration.
*
* @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\registration\error_mapper;
use mod_meetletacbfree\registration\manager;
use mod_meetletacbfree\registration\registration_exception;
require_login();
require_capability('moodle/site:config', context_system::instance());
require_sesskey();
$action = required_param('action', PARAM_ALPHA);
$apiurl = optional_param('registration_api_url', '', PARAM_URL);
header('Content-Type: application/json; charset=utf-8');
try {
if ($apiurl !== '') {
set_config(manager::CONFIG_API_URL, rtrim($apiurl, '/'), 'meetletacbfree');
}
if ($action === 'activate') {
manager::activate();
echo json_encode([
'success' => 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(),
]);
}
+69
View File
@@ -0,0 +1,69 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Reset site access settings (role mapping or feature matrix) to built-in defaults.
*
* @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\featureaccess;
use mod_meetletacbfree\local\roles;
require_login();
require_capability('moodle/site:config', context_system::instance());
require_sesskey();
\mod_meetletacbfree\registration\manager::require_registered();
$section = required_param('section', PARAM_ALPHA);
header('Content-Type: application/json; charset=utf-8');
try {
if ($section === 'rolemapping') {
$rules = roles::builtin_site_defaults();
$json = json_encode($rules, JSON_UNESCAPED_UNICODE);
set_config('default_participants', $json, 'meetletacbfree');
echo json_encode([
'success' => 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);
}
@@ -0,0 +1,41 @@
<?php
// This file is part of Moodle - http://moodle.org/
defined('MOODLE_INTERNAL') || die();
require_once($CFG->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;
}
}
@@ -0,0 +1,38 @@
<?php
// This file is part of Moodle - http://moodle.org/
defined('MOODLE_INTERNAL') || die();
/**
* Backup structure.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class backup_meetletacbfree_activity_structure_step extends backup_activity_structure_step {
protected function define_structure() {
$userinfo = $this->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);
}
}
@@ -0,0 +1,48 @@
<?php
// This file is part of Moodle - http://moodle.org/
defined('MOODLE_INTERNAL') || die();
require_once($CFG->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'),
];
}
}
@@ -0,0 +1,104 @@
<?php
// This file is part of Moodle - http://moodle.org/
defined('MOODLE_INTERNAL') || die();
/**
* Restore structure.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class restore_meetletacbfree_activity_structure_step extends restore_activity_structure_step {
protected function define_structure() {
$paths = [];
// Current format.
$paths[] = new restore_path_element('meetletacbfree', '/activity/meetletacbfree');
$paths[] = new restore_path_element('meetletacbfree_meeting',
'/activity/meetletacbfree/meetings/meeting');
$paths[] = new restore_path_element('meetletacbfree_long', '/activity/meetletadobeconnectbridge');
$paths[] = new restore_path_element('meetletacbfree_long_meeting',
'/activity/meetletadobeconnectbridge/meetings/meeting');
// Legacy format from pre-rename backups.
$paths[] = new restore_path_element('meetlet_legacy', '/activity/meetlet');
$paths[] = new restore_path_element('meetlet_legacy_meeting', '/activity/meetlet/meetings/meeting');
return $this->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);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Reset-to-default controls for access settings (role mapping + feature matrix).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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<string,string>
*/
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',
]
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Admin setting: site-wide role × feature access matrix.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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
);
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Admin UI for voluntary MeetLet site registration (Marketplace free edition).
*
* @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\icons;
use mod_meetletacbfree\registration\manager;
defined('MOODLE_INTERNAL') || die();
/**
* Transparent online activation panel — no license key in this edition.
*/
class setting_registration extends admin_setting {
public function __construct() {
parent::__construct(
'meetletacbfree/registration_panel',
'',
'',
''
);
$this->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',
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg>'
. \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',
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M12 2a10 10 0 1 0 10 10h-2a8 8 0 1 1-8-8V2zm1 5v5.6l3.2 1.9-.8 1.4L11 13.2V7h2z"/></svg>'
. \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',
]
);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Admin setting: default Moodle role → Adobe Connect role mapping.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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();
}
}
+398
View File
@@ -0,0 +1,398 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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, '<?xml');
if ($xmlstart === false) {
$xmlstart = strpos($raw, '<results');
}
$code = '';
$sub = '';
$sx = @simplexml_load_string(($xmlstart === false) ? $raw : substr($raw, $xmlstart));
if ($sx !== false) {
$code = (string)($sx->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, '<?xml');
if ($xmlstart === false) {
$xmlstart = strpos($raw, '<results');
}
$body = ($xmlstart === false) ? $raw : substr($raw, $xmlstart);
if ($body === '' || $body === false) {
throw new moodle_exception('erroremptyxml', 'meetletacbfree');
}
$sx = @simplexml_load_string($body);
if ($sx === false) {
throw new moodle_exception('erroremptyxml', 'meetletacbfree');
}
return $sx;
}
/**
* Whether last/raw response status is ok.
*
* @param string|null $raw
* @return bool
*/
public function response_ok(?string $raw = null): bool {
$raw = $raw ?? $this->lastresponse;
$xmlstart = strpos($raw, '<?xml');
if ($xmlstart === false) {
$xmlstart = strpos($raw, '<results');
}
$body = ($xmlstart === false) ? $raw : substr($raw, $xmlstart);
$sx = @simplexml_load_string($body);
if ($sx === false) {
return false;
}
return (string)$sx->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 '';
}
}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
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;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
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]);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Ajax: SSO URL to view uploaded meeting content in Adobe Connect.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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());
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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());
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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'),
]);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Ajax: SSO URL to play or edit a recording in Adobe Connect.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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());
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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];
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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'),
]);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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'),
])),
]);
}
}
+655
View File
@@ -0,0 +1,655 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<string,string> 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<int,string>
*/
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<string,int>
*/
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<string,mixed> $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<string,string|int|bool> $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() !== '';
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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,
];
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Meeting content download helpers (Adobe Connect proxy).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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();
}
}
+544
View File
@@ -0,0 +1,544 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<int,stdClass>|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<int,stdClass> 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<string,array<string,int>> 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<string,array<string,int>>
*/
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<string,array<string,int>>
*/
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<string,array<string,int>>
*/
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<string,array<string,int>>
*/
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<string,array<string,int>>
*/
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<string,string> feature => label
*/
public static function feature_labels(): array {
$out = [];
foreach (self::features() as $f) {
$out[$f] = self::feature_label($f);
}
return $out;
}
/**
* @return array<string,string> 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<string,array<string,int>>
*/
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);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>',
'zip' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm1 7V3.5L19.5 9H15zM9 4h2v2H9V4zm0 3h2v2H9V7zm0 3h2v2H9v-2zm1 3h1v4H8v-1h2v-3z"/></svg>',
'offline' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7V3zm1 4v5l4 2-.8 1.6L11 12.2V7h2z"/></svg>',
'mp4' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 4v9.2l3.2-3.2 1.4 1.4L12 16.4 7.4 11.8l1.4-1.4L11 13.2V4h1z"/><rect x="5" y="18" width="14" height="2" rx="1" fill="currentColor"/></svg>',
'import' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
'request' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 6.25C4 4.45 5.45 3 7.25 3h6.5C15.55 3 17 4.45 17 6.25V8.1l4.15-2.3A1.25 1.25 0 0 1 23 6.9v10.2c0 .95-1.03 1.55-1.85 1.1L17 15.9v1.85C17 19.55 15.55 21 13.75 21h-6.5C5.45 21 4 19.55 4 17.75V6.25z"/></svg>',
'edit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.34a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.82z"/></svg>',
'adobeedit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4h7v2H6v12h12v-5h2v7H4V4zm9 0h7v7h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H13V4z"/></svg>',
'users' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>',
'csv' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 13h2.5l1.25 3.5L13 13h2v6h-1.5v-3.4L12.2 19h-.9L10 15.6V19H8.5v-6H8zm7.5 0H18c.8 0 1.5.7 1.5 1.5v3c0 .8-.7 1.5-1.5 1.5h-2.5v-6zm1.5 4.5h1v-3h-1v3z"/></svg>',
'refresh' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
'update' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
'download' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
'details' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2 5 5h-4a1 1 0 0 1-1-1V4zM8 13h8v2H8v-2zm0 4h8v2H8v-2z"/></svg>',
'attendance' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-8 14-4-4 1.41-1.41L11 14.17l6.59-6.59L19 9l-8 8z"/></svg>',
'recordings' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg>',
'content' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>',
'sessions' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H5V8h14v11zM7 10h5v5H7v-5z"/></svg>',
];
return $svgs[$name] ?? $svgs['play'];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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');
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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]
);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Recording list sync (community edition — no offline MP4 / agent).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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;
}
}
+486
View File
@@ -0,0 +1,486 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<string,string>
*/
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<string,string> 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<string,string> $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);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<int, array{0:string,1:string}> $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']
);
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Adobe Connect SSO helpers for the current Moodle user.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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];
}
}
+397
View File
@@ -0,0 +1,397 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<string, string> 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<string, string> $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<string, array{label:string,titlekey:string,fixkey:string}>
*/
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, '<?xml');
if ($start === false) {
$start = strpos($raw, '<results');
}
$chunk = $start === false ? $raw : substr($raw, $start);
if (preg_match('/<results\b[\s\S]*?<\/results>/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<string,string>,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<string,string>,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<string,string>,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'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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));
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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,
];
}
}
+373
View File
@@ -0,0 +1,373 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy API for mod_meetletacbfree.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* HTTP client for MeetLet registration API.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Talks to MeetLet /api/v1 licensing endpoints (free-tier registration).
*/
class api_client {
/** @var string */
protected $baseurl;
public function __construct(?string $baseurl = null) {
$url = $baseurl ?? manager::api_base_url();
$this->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;
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Ed25519 + canonical JSON helpers (compatible with @meetlet/crypto).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Crypto primitives for MeetLet registration protocol.
*/
class crypto {
/** MeetLet platform public key (SPKI PEM) — verify runtime authorizations only. */
public const PLATFORM_PUBLIC_KEY_PEM = <<<'PEM'
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAYyZTlJQzdGHyZ2aAZ45kJIOx4Rq1Opyy6OPpgRFoEtU=
-----END PUBLIC KEY-----
PEM;
/**
* Canonical JSON: sorted object keys, no whitespace.
*
* @param mixed $value
*/
public static function canonicalize($value): string {
return json_encode(self::sort_value($value), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* @param mixed $value
* @return mixed
*/
protected static function sort_value($value) {
if ($value instanceof \stdClass) {
$asarray = get_object_vars($value);
if ($asarray === []) {
return new \stdClass();
}
ksort($asarray);
$out = new \stdClass();
foreach ($asarray as $k => $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));
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Map registration API / local error codes → product status and UX behaviour.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Branch on stable error.code only (never on message text).
*/
class error_mapper {
public const STATUS_ACTIVE = 'active';
public const STATUS_GRACE = 'grace';
public const STATUS_OFFLINE_GRACE_EXPIRED = 'offline_grace_expired';
public const STATUS_INVALID_KEY = 'invalid_key';
public const STATUS_EXPIRED = 'expired';
public const STATUS_SUSPENDED = 'suspended';
public const STATUS_REVOKED = 'revoked';
public const STATUS_DOMAIN_MISMATCH = 'domain_mismatch';
public const STATUS_ACTIVATION_LIMIT = 'activation_limit';
public const STATUS_NETWORK_ERROR = 'network_error';
public const STATUS_MISCONFIGURED = 'misconfigured';
public const STATUS_UNKNOWN = 'unknown';
public const STATUS_INACTIVE = 'inactive';
public const LOCAL_NETWORK_ERROR = 'NETWORK_ERROR';
public const LOCAL_INVALID_AUTHORIZATION_SIGNATURE = 'INVALID_AUTHORIZATION_SIGNATURE';
/**
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array}
*/
public static function map(string $code, ?array $details = null, bool $hasgraceauth = false): array {
$code = strtoupper(trim($code));
if ($code === '') {
$code = 'UNKNOWN';
}
switch ($code) {
case 'INVALID_LICENSE':
case 'INSTALLATION_NOT_FOUND':
return self::result($code, self::STATUS_INVALID_KEY, true, 'registration_err_invalid_key', $details);
case 'LICENSE_EXPIRED':
return self::result($code, self::STATUS_EXPIRED, true, 'registration_err_expired', $details);
case 'LICENSE_REVOKED':
case 'INSTALLATION_REVOKED':
return self::result($code, self::STATUS_REVOKED, true, 'registration_err_revoked', $details);
case 'LICENSE_SUSPENDED':
case 'CUSTOMER_SUSPENDED':
case 'PRODUCT_SUSPENDED':
return self::result($code, self::STATUS_SUSPENDED, true, 'registration_err_suspended', $details);
case 'PRODUCT_NOT_FOUND':
case 'PRODUCT_NOT_ENTITLED':
case 'PRODUCT_MISMATCH':
return self::result($code, self::STATUS_MISCONFIGURED, true, 'registration_err_product_mismatch', $details);
case 'DOMAIN_NOT_ALLOWED':
return self::result($code, self::STATUS_DOMAIN_MISMATCH, true, 'registration_err_domain', $details);
case 'ACTIVATION_LIMIT_REACHED':
case 'ACTIVATION_LIMIT':
return self::result($code, self::STATUS_ACTIVATION_LIMIT, true, 'registration_err_activation_limit', $details);
case 'INVALID_REQUEST_SIGNATURE':
case 'INVALID_SIGNATURE':
case 'TIMESTAMP_INVALID':
case 'VALIDATION_ERROR':
case 'INVALID_REQUEST':
return self::result($code, self::STATUS_MISCONFIGURED, false, 'registration_err_misconfigured', $details);
case self::LOCAL_INVALID_AUTHORIZATION_SIGNATURE:
return self::result($code, self::STATUS_MISCONFIGURED, true, 'registration_err_bad_signature', $details);
case 'REPLAY_DETECTED':
case 'RATE_LIMITED':
case 'TEMPORARY_LICENSE_SERVICE_UNAVAILABLE':
case self::LOCAL_NETWORK_ERROR:
if ($hasgraceauth) {
return self::result($code, self::STATUS_ACTIVE, false, '', $details);
}
return self::result($code, self::STATUS_NETWORK_ERROR, false, 'registration_err_network', $details);
default:
return self::result($code, self::STATUS_UNKNOWN, false, 'registration_err_unknown', $details);
}
}
/**
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array,request_id:?string,message:string}
*/
public static function from_exception(registration_exception $e, bool $hasgraceauth = false): array {
$mapped = self::map($e->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,
];
}
}
+616
View File
@@ -0,0 +1,616 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Free online activation for MeetLet Marketplace edition.
*
* Hard-locks all plugin features until the administrator completes voluntary
* online activation. After activation, every Marketplace feature unlocks.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Activate / validate / deactivate free-tier site registration and gate features.
*/
class manager {
/** Free product slug on the MeetLet license platform. */
public const PRODUCT_SLUG = 'free-adobe-connect-bridge';
public const CONFIG_API_URL = 'registration_api_url';
public const DEFAULT_API_URL = 'https://license.webatsign.com';
public static function api_base_url(): string {
$url = trim((string)get_config('meetletacbfree', self::CONFIG_API_URL));
$url = rtrim($url, '/');
return $url !== '' ? $url : self::DEFAULT_API_URL;
}
/**
* Whether the site administrator has completed free-tier registration.
*/
public static function is_registered(): bool {
$row = store::get_record();
if (!$row || ($row->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<string,mixed>
*/
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';
}
}
@@ -0,0 +1,88 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Structured registration API / local client exception.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Carries stable error.code from the platform envelope (or a local SDK code).
*/
class registration_exception extends \moodle_exception {
/** @var string */
protected $apicode;
/** @var string */
protected $apimessage;
/** @var array|null */
protected $details;
/** @var string|null */
protected $requestid;
public function __construct(string $code, string $message = '', ?array $details = null, ?string $requestid = null) {
$code = strtoupper(trim($code));
if ($code === '') {
$code = 'UNKNOWN';
}
$this->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);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Persist MeetLet installation identity + cached runtime authorization.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Single-row registration store in meetletacbfree_license.
*/
class store {
public const TABLE = 'meetletacbfree_license';
/**
* @return \stdClass|null
*/
public static function get_record() {
global $DB;
if (!$DB->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,
]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Scheduled task: optional MeetLet site registration heartbeat.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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();
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Scheduled task: sync Adobe Connect recordings into Moodle.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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());
}
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
// This file is part of Moodle - http://moodle.org/
require_once('../../config.php');
use mod_meetletacbfree\api\client;
use mod_meetletacbfree\api\service;
use mod_meetletacbfree\local\config;
require_login();
require_capability('moodle/site:config', context_system::instance());
\mod_meetletacbfree\registration\manager::require_registered();
global $OUTPUT, $PAGE;
$PAGE->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();
+98
View File
@@ -0,0 +1,98 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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',
],
];
+52
View File
@@ -0,0 +1,52 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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,
],
];
+37
View File
@@ -0,0 +1,37 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Post-install: migrate legacy mod_meetletacbmdlrules data when present.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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);
}
+118
View File
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/meetletacbfree/db" VERSION="2026081912" COMMENT="XMLDB for mod_meetletacbfree"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="meetletacbfree" COMMENT="MeetLet Adobe Connect Bridge activities">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="course" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="intro" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="templatescoid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="meeturl" TYPE="char" LENGTH="60" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="starttime" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="endtime" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Optional; 0 means no end"/>
<FIELD NAME="meetingpublic" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="participants" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="JSON Moodle-role to Adobe-role mapping"/>
<FIELD NAME="grade" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="autooffline" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Auto schedule MP4 after end"/>
<FIELD NAME="showzip" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="showofflineplayer" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="showrecordingstab" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="showcontenttab" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="showmp4" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="showmakeoffline" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="linkmode" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="0=create new, 1=link existing"/>
<FIELD NAME="existingscoid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="meetinglang" TYPE="char" LENGTH="20" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="waitforhost" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="featureaccess" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="JSON role×feature matrix override"/>
<FIELD NAME="settingoverrides" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="JSON three-state activity overrides for site-backed settings"/>
<FIELD NAME="participantsmode" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="0=inherit site role mapping, 1=manual activity mapping"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="course" UNIQUE="false" FIELDS="course"/>
</INDEXES>
</TABLE>
<TABLE NAME="meetletacbfree_meetings" COMMENT="Adobe meeting sco mapping per group">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="instanceid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="meetingscoid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="groupid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="urlpath" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="islive" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Last seen Adobe room occupancy"/>
<FIELD NAME="liveat" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Unix time room was last seen occupied"/>
<FIELD NAME="closedat" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Unix time occupancy dropped to empty"/>
<FIELD NAME="sweepuntil" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Keep auto-queuing recordings until this time"/>
<FIELD NAME="sweptat" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Last auto-queue sweep"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="fk_instance" TYPE="foreign" FIELDS="instanceid" REFTABLE="meetletacbfree" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="instanceid_idx" UNIQUE="false" FIELDS="instanceid"/>
<INDEX NAME="groupid_idx" UNIQUE="false" FIELDS="groupid"/>
</INDEXES>
</TABLE>
<TABLE NAME="meetletacbfree_recordings" COMMENT="Cached recording / offline status">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="instanceid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="meetingscoid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="recordingscoid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="urlpath" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="mp4scoid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="mp4url" TYPE="char" LENGTH="512" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="zipurl" TYPE="char" LENGTH="512" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="jobstatus" TYPE="char" LENGTH="32" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="jobmessage" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Last Agent hand-off error for cron retry"/>
<FIELD NAME="agentjobid" TYPE="char" LENGTH="64" NOTNULL="false" SEQUENCE="false" COMMENT="MeetLet render agent job id"/>
<FIELD NAME="duration" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timestart" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timeend" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="fk_instance" TYPE="foreign" FIELDS="instanceid" REFTABLE="meetletacbfree" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="instance_rec" UNIQUE="true" FIELDS="instanceid, recordingscoid"/>
<INDEX NAME="meeting_idx" UNIQUE="false" FIELDS="meetingscoid"/>
</INDEXES>
</TABLE>
<TABLE NAME="meetletacbfree_license" COMMENT="Optional MeetLet site registration identity and auth cache">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="installation_id" TYPE="char" LENGTH="128" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="private_key_pem" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="public_key_pem" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="auth_json" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="license_uuid" TYPE="char" LENGTH="64" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="status" TYPE="char" LENGTH="32" NOTNULL="true" DEFAULT="inactive" SEQUENCE="false"/>
<FIELD NAME="last_error" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="last_validated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="installation_uniq" UNIQUE="true" FIELDS="installation_id"/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>
+84
View File
@@ -0,0 +1,84 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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',
],
];
+46
View File
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Scheduled tasks for mod_meetletacbfree.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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' => '*',
],
];
+1063
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 <info@meetlet.net>
* @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']);
+55
View File
@@ -0,0 +1,55 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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();
+54
View File
@@ -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();
}
})();
+110
View File
@@ -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));
+480
View File
@@ -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();
});
+420
View File
@@ -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) +
'&section=' + 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();
}
})();
+307
View File
@@ -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 = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
+ '<path fill="currentColor" d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-4-4zm-5 16a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm3-10H5V5h10v4z"/>'
+ '</svg>';
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
+ '<span class="meetlet-btn-with-icon__label">'
+ (btn.value || btn.getAttribute('value') || 'Save changes')
+ '</span>';
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;
}
});
});
});
})();
+334
View File
@@ -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();
}
})();
+146
View File
@@ -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();
}
})();
+1029
View File
File diff suppressed because it is too large Load Diff
+570
View File
@@ -0,0 +1,570 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* English language strings for mod_meetletacbfree.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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'] = '<ol><li>Confirm <code>HTTP_AUTH_HEADER</code> in <code>custom.ini</code> matches the plugin setting.</li><li>Add this Moodle server’s public IP to <code>HTTP_AUTH_TRUSTED_HOSTS</code> (keep <code>127.0.0.1</code>).</li><li>Enable <code>HeaderAuthenticationFilter</code> in <code>web.xml</code> and do not ignore <code>/api/</code>.</li><li>Restart the <code>ConnectPro</code> service after changing trusted hosts.</li></ol>';
$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 <code>ConnectPro</code> service after each change.';
$string['serverguide_match_title'] = 'Fields that must match the server';
$string['serverguide_match_intro'] = 'Set these on the <strong>Server settings</strong> 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'] = '<p>Path: <code>C:\\Connect\\11.2.0\\custom.ini</code> (the version folder name may stay the same after a CPS patch).</p><ul><li><code>HTTP_AUTH_HEADER</code> = same as the plugin “HTTP authentication header”</li><li><code>HTTP_AUTH_TRUSTED_HOSTS</code> = <code>127.0.0.1</code> plus Moodle egress IP(s)</li><li><code>ADMIN_HOST</code> = meeting domain · for HTTPS: <code>ADMIN_PROTOCOL=https://</code> and <code>SSL_ONLY=yes</code></li></ul>';
$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'] = '<p>Path: <code>C:\\Connect\\11.2.0\\appserv\\web\\WEB-INF\\web.xml</code></p><ul><li>Uncomment the <code>HeaderAuthenticationFilter</code> <code>&lt;filter&gt;</code> and <code>&lt;filter-mapping&gt;</code> blocks.</li><li>Do not list <code>/api/</code> in this filter’s <code>ignore-pattern</code> values. The plugin sends the user’s SSO login request to Adobe’s XML API URL; if <code>/api/</code> is ignored, SSO fails.</li><li>Keep the <code>NtlmAuthenticationFilter</code> 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.</li></ul>';
$string['serverguide_webxml_sample'] = '<!-- Keep HeaderAuthenticationFilter enabled for Moodle SSO.
Do NOT add /api/ to ignore-pattern values. -->
<filter>
<filter-name>HeaderAuthenticationFilter</filter-name>
<filter-class>com.macromedia.airspeed.servlet.filter.HeaderAuthenticationFilter</filter-class>
<init-param>
<param-name>ignore-pattern-0</param-name>
<param-value>/common/</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-1</param-name>
<param-value>/servlet/gateway/</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-2</param-name>
<param-value>/servlet/mirror</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-3</param-name>
<param-value>/servlet/testbuilder</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-4</param-name>
<param-value>/main</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>HeaderAuthenticationFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Leave NtlmAuthenticationFilter commented (Windows/AD login).
Example of what must stay commented unless you use NTLM on purpose:
<filter>
<filter-name>NtlmAuthenticationFilter</filter-name>
...
</filter>
-->';
$string['serverguide_serverxml_title'] = 'server.xml — SSL / HTTPS connectors';
$string['serverguide_serverxml_body'] = '<p>Path: <code>C:\\Connect\\11.2.0\\appserv\\conf\\server.xml</code></p><p>In the stock install file, <strong>two blocks</strong> marked <code>Uncomment for SSL support</code> are commented — uncomment both:</p><ol><li><code>&lt;Executor name=&quot;httpsThreadPool&quot; ...&gt;</code></li><li><code>&lt;Connector port=&quot;8443&quot; ...&gt;</code> (often with <code>SSLEnabled=&quot;false&quot;</code> when stunnel terminates TLS on 443)</li></ol><p>On the port <code>80</code> Connector keep <code>redirectPort=&quot;443&quot;</code>.</p>';
$string['serverguide_serverxml_sample'] = '<!-- 1) Uncomment for SSL support -->
<Executor name="httpsThreadPool"
namePrefix="https-8443-"
maxThreads="350"
minSpareThreads="25"/>
<!-- On the port 80 Connector, keep: -->
redirectPort="443"
<!-- 2) Uncomment for SSL support -->
<Connector port="8443" protocol="HTTP/1.1"
compression="on"
executor="httpsThreadPool"
enableLookups="false"
acceptCount="250"
connectionTimeout="20000"
SSLEnabled="false"
scheme="https"
secure="true"
proxyPort="443"
URIEncoding="utf-8"
relaxedQueryChars="{}|&lt;&gt;&quot;"/>';
$string['serverguide_upgrade_title'] = 'Note after a Connect upgrade / patch: ';
$string['serverguide_upgrade_body'] = '<ul><li>The file <code>appserv\\web\\WEB-INF\\web.xml</code> usually breaks after an upgrade; the updater often re-comments the <code>HeaderAuthenticationFilter</code> block. Uncomment that block again and remove <code>/api/</code> from the <code>ignore-pattern</code> list.</li><li>Open <code>custom.ini</code> and confirm that <code>HTTP_AUTH_HEADER</code> and <code>HTTP_AUTH_TRUSTED_HOSTS</code> are still set correctly.</li><li>Open <code>appserv\\conf\\server.xml</code> and confirm that both the <code>httpsThreadPool</code> block and the port <code>8443</code> Connector are still uncommented.</li><li>After you apply the fixes, restart the Windows <code>ConnectPro</code> service and run the Connection test tab in Moodle again.</li></ul>';
$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'] = '<p>This policy covers data processed by the <strong>mod_meetletacbfree</strong> 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.</p><p>MeetLet develops and maintains this plugin. For questions about this policy, contact <a href="mailto:info@meetlet.net">info@meetlet.net</a>.</p>';
$string['privacy_tab_registration_title'] = '2. Optional plugin activation';
$string['privacy_tab_registration_body'] = '<p>Plugin activation is <strong>voluntary</strong>. No data is sent to MeetLet until a site administrator explicitly clicks <em>Activate plugin</em> on the Plugin activation tab. Until then, the plugin does not contact the MeetLet activation service.</p><p>When an administrator activates the plugin, the following metadata may be transmitted to the MeetLet activation service:</p><ul><li>Public site URL / domain</li><li>A unique installation identifier generated by the plugin</li><li>Moodle platform name and release version</li><li>Plugin component name and release version</li><li>Site or organisation name (from Moodle site settings), if available</li><li>Site administrator or support contact email, if available in Moodle</li></ul><p>No learner personal data, course content, meeting recordings, or Adobe Connect credentials are included in activation requests.</p>';
$string['privacy_tab_purpose_title'] = '3. Purpose of activation data';
$string['privacy_tab_purpose_body'] = '<p>MeetLet uses activation metadata solely to:</p><ul><li>Identify your installation for support and troubleshooting when you contact us</li><li>Deliver security and compatibility updates relevant to your Moodle and plugin versions</li><li>Maintain aggregate, non-identifying statistics about free Marketplace edition adoption</li></ul><p>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.</p>';
$string['privacy_tab_service_title'] = '4. MeetLet activation service';
$string['privacy_tab_service_body'] = '<p>Activation data is sent over HTTPS to the <strong>MeetLet activation service</strong> 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.</p><p>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.</p>';
$string['privacy_tab_adobe_title'] = '5. Adobe Connect single sign-on (SSO)';
$string['privacy_tab_adobe_body'] = '<p>When users open or join a meeting, the plugin exchanges selected Moodle profile fields with the <strong>Adobe Connect server you configure</strong> (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.</p><p>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.</p>';
$string['privacy_tab_local_title'] = '6. Data stored locally in Moodle';
$string['privacy_tab_local_body'] = '<p>The plugin stores the following in your Moodle database:</p><ul><li>Activity settings, meeting mappings, cached recording metadata, and optional per-activity participant rules</li><li>Activation status, installation ID, and cryptographic keys used for activation (when activated)</li></ul>';
$string['privacy_tab_retention_title'] = '7. Data retention';
$string['privacy_tab_retention_body'] = '<p><strong>Local Moodle data</strong> is retained until deleted through normal Moodle course/activity administration or Privacy API deletion requests.</p><p><strong>Activation records</strong> 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 <em>Deactivate plugin</em>, the plugin stops sending heartbeats; MeetLet may retain historical activation logs in anonymised or aggregated form for security and statistics.</p><p>Data on your Adobe Connect server is governed by that server\'s retention policies, not by MeetLet.</p>';
$string['privacy_tab_sharing_title'] = '8. Sharing and sale of data';
$string['privacy_tab_sharing_body'] = '<p>MeetLet does <strong>not sell</strong> 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.</p><p>Adobe Connect SSO data is shared only with the Adobe Connect endpoint configured by your administrator.</p>';
$string['privacy_tab_control_title'] = '9. Administrator choices';
$string['privacy_tab_control_body'] = '<p>Site administrators can:</p><ul><li>Decline plugin activation and still read this policy before deciding</li><li>Deactivate at any time from the Plugin activation tab, which stops further activation traffic</li><li>Configure which Adobe Connect server receives SSO data</li><li>Use Moodle\'s Privacy API tools to export or delete user data held by this plugin</li></ul>';
$string['privacy_tab_contact_title'] = '10. Contact';
$string['privacy_tab_contact_body'] = '<p>For privacy questions, data subject requests relating to MeetLet activation records, or support regarding this plugin, contact MeetLet at <a href="mailto:info@meetlet.net">info@meetlet.net</a>.</p><p>For requests about data held on your Adobe Connect server, contact your Adobe Connect administrator or hosting provider.</p>';
$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.';
+572
View File
@@ -0,0 +1,572 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Persian language strings for mod_meetletacbfree.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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'] = '<ol><li>مطمئن شوید <code>HTTP_AUTH_HEADER</code> در <code>custom.ini</code> با تنظیم پلاگین یکی است.</li><li>IP عمومی همین سرور مودل را به <code>HTTP_AUTH_TRUSTED_HOSTS</code> اضافه کنید (<code>127.0.0.1</code> را نگه دارید).</li><li><code>HeaderAuthenticationFilter</code> را در <code>web.xml</code> فعال کنید و <code>/api/</code> را ignore نکنید.</li><li>پس از تغییر trusted hosts سرویس <code>ConnectPro</code> را ری‌استارت کنید.</li></ol>';
$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 تنظیم شوند. بعد از هر تغییر، سرویس <code>ConnectPro</code> را ری‌استارت کنید.';
$string['serverguide_match_title'] = 'فیلدهایی که باید با سرور یکی باشند';
$string['serverguide_match_intro'] = 'این موارد را در تب <strong>تنظیمات سرور</strong> تنظیم کنید. مقادیر زیر فقط مثال هستند — میزبان 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'] = '<p>مسیر: <code>C:\\Connect\\11.2.0\\custom.ini</code> (نام پوشه نسخه ممکن است بعد از پچ عوض نشود).</p><ul><li><code>HTTP_AUTH_HEADER</code> = همان «هدر احراز هویت HTTP» در پلاگین</li><li><code>HTTP_AUTH_TRUSTED_HOSTS</code> = <code>127.0.0.1</code> و IP خروجی Moodle</li><li><code>ADMIN_HOST</code> = دامنه جلسه · برای HTTPS: <code>ADMIN_PROTOCOL=https://</code> و <code>SSL_ONLY=yes</code></li></ul>';
$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'] = '<p>مسیر: <code>C:\\Connect\\11.2.0\\appserv\\web\\WEB-INF\\web.xml</code></p><ul><li>بلوک <code>HeaderAuthenticationFilter</code> (هم <code>&lt;filter&gt;</code> و هم <code>&lt;filter-mapping&gt;</code>) را از کامنت خارج کنید.</li><li>در <code>ignore-pattern</code>های این فیلتر، مسیر <code>/api/</code> را نگذارید. پلاگین برای ورود یکپارچه کاربر، درخواست را به آدرس XML API ادوبی می‌فرستد؛ اگر <code>/api/</code> ignore شود، SSO کار نمی‌کند.</li><li>بلوک <code>NtlmAuthenticationFilter</code> را کامنت‌شده نگه دارید. آن فیلتر برای ورود با حساب ویندوز/دامنه (Active Directory) است و برای این پلاگین لازم نیست. فقط اگر سازمان شما عمداً NTLM را روی Connect فعال کرده، آن را uncomment کنید؛ در غیر این صورت دست نزنید و HeaderAuthenticationFilter را فعال نگه دارید.</li></ul>';
$string['serverguide_webxml_sample'] = '<!-- Keep HeaderAuthenticationFilter enabled for Moodle SSO.
Do NOT add /api/ to ignore-pattern values. -->
<filter>
<filter-name>HeaderAuthenticationFilter</filter-name>
<filter-class>com.macromedia.airspeed.servlet.filter.HeaderAuthenticationFilter</filter-class>
<init-param>
<param-name>ignore-pattern-0</param-name>
<param-value>/common/</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-1</param-name>
<param-value>/servlet/gateway/</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-2</param-name>
<param-value>/servlet/mirror</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-3</param-name>
<param-value>/servlet/testbuilder</param-value>
</init-param>
<init-param>
<param-name>ignore-pattern-4</param-name>
<param-value>/main</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>HeaderAuthenticationFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Leave NtlmAuthenticationFilter commented (Windows/AD login).
Example of what must stay commented unless you use NTLM on purpose:
<filter>
<filter-name>NtlmAuthenticationFilter</filter-name>
...
</filter>
-->';
$string['serverguide_serverxml_title'] = 'server.xml — کانکتور SSL / HTTPS';
$string['serverguide_serverxml_body'] = '<p>مسیر: <code>C:\\Connect\\11.2.0\\appserv\\conf\\server.xml</code></p><p>در فایل پیش‌فرض نصب، <strong>دو بلوک</strong> با برچسب <code>Uncomment for SSL support</code> کامنت‌اند و هر دو باید uncomment شوند:</p><ol><li><code>&lt;Executor name=&quot;httpsThreadPool&quot; ...&gt;</code></li><li><code>&lt;Connector port=&quot;8443&quot; ...&gt;</code> (معمولاً با <code>SSLEnabled=&quot;false&quot;</code> وقتی stunnel روی ۴۴۳ TLS را تمام می‌کند)</li></ol><p>روی Connector پورت <code>80</code> هم <code>redirectPort=&quot;443&quot;</code> باشد.</p>';
$string['serverguide_serverxml_sample'] = '<!-- 1) Uncomment for SSL support -->
<Executor name="httpsThreadPool"
namePrefix="https-8443-"
maxThreads="350"
minSpareThreads="25"/>
<!-- On the port 80 Connector, keep: -->
redirectPort="443"
<!-- 2) Uncomment for SSL support -->
<Connector port="8443" protocol="HTTP/1.1"
compression="on"
executor="httpsThreadPool"
enableLookups="false"
acceptCount="250"
connectionTimeout="20000"
SSLEnabled="false"
scheme="https"
secure="true"
proxyPort="443"
URIEncoding="utf-8"
relaxedQueryChars="{}|&lt;&gt;&quot;"/>';
$string['serverguide_upgrade_title'] = 'نکته بعد از آپدیت / پچ Connect: ';
$string['serverguide_upgrade_body'] = '<ul><li>فایل <code>appserv\\web\\WEB-INF\\web.xml</code> معمولاً بعد از آپدیت خراب می‌شود؛ آپدیت کننده اغلب بلوک <code>HeaderAuthenticationFilter</code> را دوباره کامنت می‌کند. آن بلوک را دوباره از کامنت خارج کنید و مسیر <code>/api/</code> را از فهرست <code>ignore-pattern</code> حذف کنید.</li><li>فایل <code>custom.ini</code> را باز کنید و مطمئن شوید مقادیر <code>HTTP_AUTH_HEADER</code> و <code>HTTP_AUTH_TRUSTED_HOSTS</code> هنوز درست و کامل هستند.</li><li>فایل <code>appserv\\conf\\server.xml</code> را باز کنید و مطمئن شوید هر دو بلوک <code>httpsThreadPool</code> و Connector پورت <code>8443</code> هنوز از حالت کامنت خارج مانده‌اند.</li><li>بعد از اعمال اصلاحات، سرویس ویندوز <code>ConnectPro</code> را ری‌استارت کنید و در مودل تب «تست اتصال» را دوباره اجرا کنید.</li></ul>';
$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'] = '<p>این سیاست داده‌های پردازش‌شده توسط ماژول فعالیت <strong>mod_meetletacbfree</strong> و تنظیمات سراسری آن را پوشش می‌دهد. جایگزین اعلامیه حریم خصوصی سازمان شما نسبت به فراگیران، شرایط اپراتور سرور Adobe Connect، یا مستندات حریم خصوصی هسته مودل نیست.</p><p>میت‌لت (MeetLet) این افزونه را توسعه و نگهداری می‌کند. برای پرسش درباره این سیاست با <a href="mailto:info@meetlet.net">info@meetlet.net</a> تماس بگیرید.</p>';
$string['privacy_tab_registration_title'] = '۲. فعال‌سازی اختیاری پلاگین';
$string['privacy_tab_registration_body'] = '<p>فعال‌سازی پلاگین <strong>اختیاری</strong> است. تا زمانی که مدیر سایت صریحاً روی <em>فعال‌سازی پلاگین</em> در تب فعال‌سازی پلاگین کلیک نکند، هیچ داده‌ای به میت‌لت ارسال نمی‌شود و افزونه با سرویس فعال‌سازی میت‌لت تماس نمی‌گیرد.</p><p>هنگام فعال‌سازی، متادیتای زیر ممکن است به <strong>سرویس فعال‌سازی میت‌لت</strong> ارسال شود:</p><ul><li>آدرس/دامنه عمومی سایت</li><li>شناسه یکتای نصب تولیدشده توسط افزونه</li><li>نام و نسخه انتشار مودل</li><li>نام کامپوننت و نسخه انتشار افزونه</li><li>نام سایت یا سازمان (از تنظیمات مودل)، در صورت موجود بودن</li><li>ایمیل مدیر یا پشتیبانی سایت، در صورت موجود بودن در مودل</li></ul><p>داده شخصی فراگیران، محتوای درس، ضبط جلسات یا اعتبارنامه Adobe Connect در درخواست‌های فعال‌سازی گنجانده نمی‌شود.</p>';
$string['privacy_tab_purpose_title'] = '۳. هدف از داده‌های فعال‌سازی';
$string['privacy_tab_purpose_body'] = '<p>میت‌لت از متادیتای فعال‌سازی صرفاً برای موارد زیر استفاده می‌کند:</p><ul><li>شناسایی نصب شما برای پشتیبانی و عیب‌یابی هنگام تماس با ما</li><li>ارائه به‌روزرسانی‌های امنیتی و سازگاری متناسب با نسخه مودل و افزونه</li><li>نگهداری آمار تجمیعی و غیرشناسا از پذیرش نسخه رایگان Marketplace</li></ul><p>میت‌لت از اطلاعات فعال‌سازی برای ارسال ایمیل تبلیغاتی یا تماس بازاریابی بدون درخواست شما استفاده نمی‌کند. فعال‌سازی شما را ملزم به خرید محصولات پولی نمی‌کند.</p>';
$string['privacy_tab_service_title'] = '۴. سرویس فعال‌سازی میت‌لت';
$string['privacy_tab_service_body'] = '<p>داده‌های فعال‌سازی از طریق HTTPS به نقطه پایانی <strong>سرویس فعال‌سازی میت‌لت</strong> که در تب فعال‌سازی پلاگین پیکربندی شده ارسال می‌شود (پیش‌فرض تولید: API فعال‌سازی میزبانی‌شده میت‌لت). این سرویس رکوردهای نصب مرتبط با شناسه نصب و دامنه شما را نگه می‌دارد.</p><p>در حالت فعال‌شده، افزونه ممکن است درخواست‌های ضربان دوره‌ای با همان دسته‌های متادیتا برای تأیید وضعیت فعال‌سازی ارسال کند. آدرس دقیق API در تنظیمات افزونه قابل مشاهده است؛ فقط مدیران دارای حق پیکربندی سایت می‌توانند آن را تغییر دهند.</p>';
$string['privacy_tab_adobe_title'] = '۵. ورود یکپارچه Adobe Connect (SSO)';
$string['privacy_tab_adobe_body'] = '<p>هنگام ورود یا پیوستن به جلسه، افزونه فیلدهای انتخاب‌شده پروفایل مودل را با <strong>سرور Adobe Connectی که شما پیکربندی می‌کنید</strong> (میزبان خودتان یا میزبان ارائه‌دهنده) مبادله می‌کند. معمولاً شامل نام، نام خانوادگی، ایمیل و نام کاربری/لاگین مودل است تا Adobe Connect حساب بسازد و دسترسی جلسه بدهد.</p><p>این ارسال به نمونه Adobe Connect شما هدایت می‌شود، نه به سرویس فعال‌سازی میت‌لت. میت‌لت حساب‌های کاربری Adobe Connect را روی سرورهای خود ذخیره نمی‌کند.</p>';
$string['privacy_tab_local_title'] = '۶. داده ذخیره‌شده محلی در مودل';
$string['privacy_tab_local_body'] = '<p>افزونه موارد زیر را در پایگاه داده مودل شما نگه می‌دارد:</p><ul><li>تنظیمات فعالیت، نگاشت جلسات، متادیتای کش‌شده ضبط و قواعد اختیاری شرکت‌کنندگان</li><li>وضعیت فعال‌سازی، شناسه نصب و کلیدهای رمزنگاری فعال‌سازی (پس از فعال‌سازی)</li></ul>';
$string['privacy_tab_retention_title'] = '۷. نگهداری داده';
$string['privacy_tab_retention_body'] = '<p><strong>داده محلی مودل</strong> تا زمان حذف از طریق مدیریت عادی درس/فعالیت یا درخواست‌های حذف Privacy API نگه داشته می‌شود.</p><p><strong>رکوردهای فعال‌سازی</strong> در سرویس فعال‌سازی میت‌لت تا زمانی که پلاگین فعال‌شده بماند و مدت معقول پس از آن برای جلوگیری از سوءاستفاده و پشتیبانی فعال‌سازی مجدد نگه داشته می‌شوند. با <em>غیرفعال‌سازی پلاگین</em>، افزونه ارسال ضربان را متوقف می‌کند؛ میت‌لت ممکن است گزارش‌های تاریخی فعال‌سازی را به‌صورت ناشناس یا تجمیعی برای امنیت و آمار نگه دارد.</p><p>داده روی سرور Adobe Connect شما تابع سیاست نگهداری همان سرور است، نه میت‌لت.</p>';
$string['privacy_tab_sharing_title'] = '۸. اشتراک‌گذاری و فروش داده';
$string['privacy_tab_sharing_body'] = '<p>میت‌لت متادیتای فعال‌سازی یا داده استفاده از افزونه را به اشخاص ثالث <strong>نمی‌فروشد</strong>. داده فقط در حد لازم برای به‌کار انداختن سرویس فعال‌سازی (مثلاً زیرساخت میزبانی) یا در صورت الزام قانون به اشتراک گذاشته می‌شود.</p><p>داده SSO Adobe Connect فقط با نقطه پایانی Adobe Connectی که مدیر شما پیکربندی کرده به اشتراک گذاشته می‌شود.</p>';
$string['privacy_tab_control_title'] = '۹. انتخاب‌های مدیر';
$string['privacy_tab_control_body'] = '<p>مدیران سایت می‌توانند:</p><ul><li>از فعال‌سازی پلاگین خودداری کنند و پیش از تصمیم این سیاست را بخوانند</li><li>هر زمان از تب فعال‌سازی پلاگین غیرفعال کنند تا ترافیک فعال‌سازی متوقف شود</li><li>مشخص کنند کدام سرور Adobe Connect داده SSO را دریافت کند</li><li>از ابزارهای Privacy API مودل برای صادرات یا حذف داده کاربران نگه‌داشته‌شده توسط این افزونه استفاده کنند</li></ul>';
$string['privacy_tab_contact_title'] = '۱۰. تماس';
$string['privacy_tab_contact_body'] = '<p>برای پرسش حریم خصوصی، درخواست‌های مربوط به رکوردهای فعال‌سازی میت‌لت یا پشتیبانی این افزونه با میت‌لت در <a href="mailto:info@meetlet.net">info@meetlet.net</a> تماس بگیرید.</p><p>برای درخواست‌های مربوط به داده روی سرور Adobe Connect خود، با مدیر یا میزبان Adobe Connect تماس بگیرید.</p>';
$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'] = 'نسخه انتشار مودل.';
+584
View File
@@ -0,0 +1,584 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<string,int>
*/
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});
}
}
+464
View File
@@ -0,0 +1,464 @@
<?php
// This file is part of Moodle - http://moodle.org/
defined('MOODLE_INTERNAL') || die();
require_once($CFG->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<int,string> $descriptions
* @param array<int,string>|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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.8 500" enable-background="new 0 0 512.8 500" xml:space="preserve">
<path fill="#00261A" d="M90.8,0H422c50.2,0,90.8,40.7,90.8,90.8v318.4c0,50.2-40.7,90.8-90.8,90.8H90.8C40.7,500,0,459.3,0,409.2
V90.8C0,40.7,40.7,0,90.8,0z"/>
<g id="Group_3">
<rect id="Rectangle_2" x="117.8" y="294.5" fill="#52CCA3" width="94.5" height="94.5"/>
<rect id="Rectangle_3" x="310.7" y="304.2" fill="#52CCA3" width="75.1" height="75.1"/>
<rect id="Rectangle_4" x="310.7" y="120.6" fill="#52CCA3" width="75.1" height="75.1"/>
<rect id="Rectangle_5" x="127.5" y="121" fill="#52CCA3" width="75.1" height="75.1"/>
<path id="Path_3" fill="#CAFCEB" d="M385.9,120.6v75.1l-151.6,76.8l76.5-151.9L385.9,120.6 M395.5,110.9h-90.7l-92.5,183.6
l183.2-92.8L395.5,110.9L395.5,110.9z"/>
<path id="Path_4" fill="#CAFCEB" d="M385.9,304.2v75.1h-75l-75.1-75.1H385.9 M395.6,294.5H212.3l94.5,94.5h88.7L395.6,294.5
L395.6,294.5z"/>
<path id="Path_5" fill="#CAFCEB" d="M202.6,121v150.1L127.5,196V121H202.6 M212.3,111.3h-94.5V200l94.5,94.5L212.3,111.3
L212.3,111.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#00261a;}.cls-2{fill:#52cca3;}.cls-3{fill:#cafceb;}</style></defs><rect class="cls-1" width="256" height="249.6" rx="45.33333"/><g id="Group_3" data-name="Group 3"><rect id="Rectangle_2" data-name="Rectangle 2" class="cls-2" x="58.79681" y="147.02378" width="47.19257" height="47.19257"/><rect id="Rectangle_3" data-name="Rectangle 3" class="cls-2" x="155.12593" y="151.86661" width="37.49226" height="37.49226"/><rect id="Rectangle_4" data-name="Rectangle 4" class="cls-2" x="155.12593" y="60.22647" width="37.49226" height="37.49226"/><rect id="Rectangle_5" data-name="Rectangle 5" class="cls-2" x="63.65429" y="60.39498" width="37.49226" height="37.49226"/><path id="Path_3" data-name="Path 3" class="cls-3" d="M192.61818,60.22647V97.69675l-75.68053,38.34212,38.20782-75.81241h37.463m4.84283-4.84283H152.1709l-46.1815,91.645,91.47162-46.35v-45.295Z"/><path id="Path_4" data-name="Path 4" class="cls-3" d="M192.61818,151.8666v37.50446H155.19187L117.68251,151.8666h74.93567m4.84284-4.84283H105.9894l47.19745,47.19258h44.27663V147.02377Z"/><path id="Path_5" data-name="Path 5" class="cls-3" d="M101.14411,60.40475V135.3282L63.63964,97.81886V60.40475h37.50447m4.84284-4.84284H58.79681V99.83853l47.19258,47.18281V55.54971Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.8 500" enable-background="new 0 0 512.8 500" xml:space="preserve">
<path fill="#00261A" d="M90.8,0H422c50.2,0,90.8,40.7,90.8,90.8v318.4c0,50.2-40.7,90.8-90.8,90.8H90.8C40.7,500,0,459.3,0,409.2
V90.8C0,40.7,40.7,0,90.8,0z"/>
<g id="Group_3">
<rect id="Rectangle_2" x="117.8" y="294.5" fill="#52CCA3" width="94.5" height="94.5"/>
<rect id="Rectangle_3" x="310.7" y="304.2" fill="#52CCA3" width="75.1" height="75.1"/>
<rect id="Rectangle_4" x="310.7" y="120.6" fill="#52CCA3" width="75.1" height="75.1"/>
<rect id="Rectangle_5" x="127.5" y="121" fill="#52CCA3" width="75.1" height="75.1"/>
<path id="Path_3" fill="#CAFCEB" d="M385.9,120.6v75.1l-151.6,76.8l76.5-151.9L385.9,120.6 M395.5,110.9h-90.7l-92.5,183.6
l183.2-92.8L395.5,110.9L395.5,110.9z"/>
<path id="Path_4" fill="#CAFCEB" d="M385.9,304.2v75.1h-75l-75.1-75.1H385.9 M395.6,294.5H212.3l94.5,94.5h88.7L395.6,294.5
L395.6,294.5z"/>
<path id="Path_5" fill="#CAFCEB" d="M202.6,121v150.1L127.5,196V121H202.6 M212.3,111.3h-94.5V200l94.5,94.5L212.3,111.3
L212.3,111.3z"/>
</g>
<!-- Marketplace edition badge: ~28% diameter, bold white M on MeetLet green. -->
<g id="marketplace_badge">
<circle cx="453" cy="464" r="72" fill="#52CCA3" stroke="#FFFFFF" stroke-width="8"/>
<text x="453" y="482" text-anchor="middle" fill="#FFFFFF" font-family="Arial,Helvetica,sans-serif" font-weight="900" font-size="64">M</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.8 500" enable-background="new 0 0 512.8 500" xml:space="preserve">
<path fill="#00261A" d="M90.8,0H422c50.2,0,90.8,40.7,90.8,90.8v318.4c0,50.2-40.7,90.8-90.8,90.8H90.8C40.7,500,0,459.3,0,409.2
V90.8C0,40.7,40.7,0,90.8,0z"/>
<g id="Group_3">
<rect id="Rectangle_2" x="117.8" y="294.5" fill="#52CCA3" width="94.5" height="94.5"/>
<rect id="Rectangle_3" x="310.7" y="304.2" fill="#52CCA3" width="75.1" height="75.1"/>
<rect id="Rectangle_4" x="310.7" y="120.6" fill="#52CCA3" width="75.1" height="75.1"/>
<rect id="Rectangle_5" x="127.5" y="121" fill="#52CCA3" width="75.1" height="75.1"/>
<path id="Path_3" fill="#CAFCEB" d="M385.9,120.6v75.1l-151.6,76.8l76.5-151.9L385.9,120.6 M395.5,110.9h-90.7l-92.5,183.6
l183.2-92.8L395.5,110.9L395.5,110.9z"/>
<path id="Path_4" fill="#CAFCEB" d="M385.9,304.2v75.1h-75l-75.1-75.1H385.9 M395.6,294.5H212.3l94.5,94.5h88.7L395.6,294.5
L395.6,294.5z"/>
<path id="Path_5" fill="#CAFCEB" d="M202.6,121v150.1L127.5,196V121H202.6 M212.3,111.3h-94.5V200l94.5,94.5L212.3,111.3
L212.3,111.3z"/>
</g>
<!-- Marketplace edition badge: ~28% diameter, bold white M on MeetLet green. -->
<g id="marketplace_badge">
<circle cx="453" cy="464" r="72" fill="#52CCA3" stroke="#FFFFFF" stroke-width="8"/>
<text x="453" y="482" text-anchor="middle" fill="#FFFFFF" font-family="Arial,Helvetica,sans-serif" font-weight="900" font-size="64">M</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+967
View File
@@ -0,0 +1,967 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Admin settings for MeetLet Adobe Connect Bridge (tabbed).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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 = '
<link rel="stylesheet" href="' . s($stylesurl->out(false)) . '">
<style>
.path-admin #adminsettings .settingsform > h2 {
display: none;
}
.path-admin .meetlet-settings-hero {
display:flex; gap:1rem; align-items:center;
padding:1.15rem 1.25rem; margin:0 0 1rem;
border-radius:16px;
background:
radial-gradient(700px 180px at 100% 0%, rgba(82,204,163,.22), transparent 55%),
linear-gradient(135deg, #00261a 0%, #0a3d2e 60%, #145c45 100%);
color:#fff;
border:1px solid rgba(82,204,163,.25);
}
.path-admin .meetlet-settings-hero img {
width:56px; height:56px; object-fit:contain;
background:rgba(255,255,255,.95); border-radius:12px; padding:6px;
}
.path-admin .meetlet-settings-hero h3 {
margin:0 0 .2rem; font-size:1.2rem; font-weight:700; color:#fff;
display:flex; flex-wrap:wrap; align-items:center; gap:.45rem .55rem;
}
.path-admin .meetlet-settings-hero .meetlet-settings-version {
display:inline-block;
flex:0 0 auto;
align-self:center;
margin-inline-start:auto;
padding:.12rem .55rem;
font-size:.72rem; font-weight:650; line-height:1.35;
letter-spacing:.02em;
border-radius:999px;
background:rgba(82,204,163,.18);
border:1px solid rgba(82,204,163,.35);
color:#b8f5df;
white-space:nowrap;
direction:ltr;
unicode-bidi:isolate;
}
.path-admin .meetlet-settings-hero p {
margin:0; opacity:.85; color:#e6faf3;
white-space:nowrap;
max-width:none;
}
.path-admin .meetlet-settings-copy {
min-width:0; flex:1 1 auto;
}
@media (max-width: 900px) {
.path-admin .meetlet-settings-hero p {
white-space:normal;
}
}
.path-admin #adminsettings .nav-tabs {
border-bottom:2px solid #cfe8de;
gap:.2rem;
}
.path-admin #adminsettings .nav-tabs .nav-link {
border:0 !important;
border-radius:6px !important;
padding:.45rem .9rem;
color:#00261a;
font-weight:650;
box-shadow:none !important;
outline:none !important;
}
.path-admin #adminsettings .nav-tabs .nav-link:hover {
background:#e6faf3; color:#00261a;
}
.path-admin #adminsettings .nav-tabs .nav-link.active {
background:#52cca3; color:#00261a;
}
.path-admin #adminsettings .nav-tabs .nav-link:focus,
.path-admin #adminsettings .nav-tabs .nav-link:focus-visible,
.path-admin #adminsettings .nav-tabs .nav-link:active {
outline:none !important;
box-shadow:none !important;
border:0 !important;
}
.path-admin .meetlet-feature-group {
margin:1rem 0 .35rem;
padding:1rem 1.1rem .35rem;
border:1px solid #cfe8de;
border-radius:12px;
background:linear-gradient(180deg, #f3fbf8 0%, #ffffff 55%);
}
.path-admin .meetlet-feature-group h4 {
margin:0 0 .25rem; color:#00261a; font-size:1.02rem; font-weight:700;
}
.path-admin .meetlet-feature-group p {
margin:0 0 .5rem; color:#4a6b5f; font-size:.9rem;
}
.path-admin .meetlet-settings-card {
background:#fff; border:1px solid #cfe8de; border-radius:14px;
padding:1rem 1.15rem; margin:.35rem 0 1rem;
}
.meetlet-license-badge {
font-weight: 700;
border-radius: 999px;
padding: .35rem .65rem;
}
.meetlet-license-badge--active {
background: var(--ml-green, #52cca3);
color: var(--ml-green-deep, #00261a);
}
.meetlet-license-badge--inactive {
background: #fff4d6;
color: #5a4700;
}
.path-admin #meetlet-license-modal.meetlet-modal,
.path-admin #meetlet-registration-modal.meetlet-modal,
.path-admin #meetlet-cronmon-modal.meetlet-modal,
.path-admin #meetlet-access-reset-modal.meetlet-modal {
z-index: 20000 !important;
}
.path-admin .meetlet-access-reset-toolbar {
display: flex;
justify-content: flex-end;
margin: 0 0 .65rem;
}
html[dir="rtl"] .path-admin .meetlet-access-reset-toolbar {
justify-content: flex-start;
}
/* Cron videos ops cell: no extra cell/wrapper border (icon buttons keep theirs). */
.path-admin .meetlet-cronmon__table td.meetlet-cronmon__dl {
border-left: 0 !important;
border-right: 0 !important;
border-top: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.path-admin .meetlet-cronmon__dl .meetlet-iconactions {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
.path-admin .meetlet-modal--confirm .meetlet-modal__dialog {
width: min(480px, 100%);
max-height: min(86vh, 640px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.path-admin .meetlet-modal--confirm .meetlet-modal__head {
padding: 1.2rem 1.15rem 1rem;
border-radius: 18px 18px 0 0;
}
.path-admin .meetlet-modal__lead {
margin: 0 0 .85rem;
font-size: 1rem;
font-weight: 650;
line-height: 1.65;
color: #00261a;
}
.path-admin .meetlet-modal__callout {
margin: 0 0 .9rem;
padding: .85rem 1rem .7rem;
border-radius: 12px;
border: 1px solid #e6c56a;
background: #fff8e6;
color: #5a4700;
}
.path-admin .meetlet-modal__callout-title {
margin: 0 0 .4rem;
font-weight: 700;
}
.path-admin .meetlet-modal__limits {
margin: 0 0 .55rem;
padding-inline-start: 1.2rem;
}
.path-admin .meetlet-modal__keep {
margin: 0;
color: #334942;
line-height: 1.65;
}
.path-admin .meetlet-modal__foot {
display: flex;
justify-content: flex-end;
align-items: center;
gap: .6rem;
flex-wrap: wrap;
padding: .9rem 1.15rem 1.05rem;
border-top: 1px solid #cfe8de;
background: linear-gradient(180deg, #f7fcfa 0%, #ffffff 70%);
}
.path-admin .meetlet-modal__foot .btn {
border-radius: 10px;
font-weight: 700;
padding: .5rem 1.15rem;
}
.path-admin .meetlet-modal__foot .btn-success {
background: #52cca3 !important;
border-color: #3db892 !important;
color: #00261a !important;
}
.path-admin .meetlet-modal__foot .btn-outline-danger {
color: #a33b3b !important;
border-color: #d9a0a0 !important;
background: #fff !important;
}
.path-admin .meetlet-modal__foot .btn-outline-danger:hover,
.path-admin .meetlet-modal__foot .btn-outline-danger:focus {
color: #fff !important;
background: #c0392b !important;
border-color: #c0392b !important;
}
.path-admin .meetlet-about {
display:grid; gap:1.1rem;
}
.path-admin .meetlet-about__brand {
display:flex; justify-content:center; align-items:center;
padding:1.35rem 1.2rem;
border-radius:14px;
background:#f0f2f4;
border:1px solid #e2e6ea;
}
.path-admin .meetlet-about__brand img {
max-width:240px; width:100%; height:auto; display:block; margin:0 auto;
}
.path-admin .meetlet-about h4 { margin:0 0 .55rem; color:#00261a; }
.path-admin .meetlet-about p {
margin:0 0 .85rem; color:#334942; line-height:1.75; text-align:justify;
}
.path-admin .meetlet-guide { display:grid; gap:1rem; }
.path-admin .meetlet-guide__heading { margin:0; color:#00261a; font-size:1.15rem; }
.path-admin .meetlet-guide__intro {
margin:0; color:#334942; line-height:1.7;
}
.path-admin .meetlet-guide__callout {
padding:.85rem 1rem; border-radius:10px; line-height:1.65;
border:1px solid #e6c56a; background:#fff8e6; color:#5a4700;
}
.path-admin .meetlet-guide__section {
padding:1rem 1.05rem; border-radius:12px;
border:1px solid #cfe8de; background:#f7fcfa;
}
.path-admin .meetlet-guide__section--current { background:#fff; }
.path-admin .meetlet-guide__section h4 {
margin:0 0 .55rem; color:#00261a; font-size:1.02rem; font-weight:700;
}
.path-admin .meetlet-guide__section h5 {
margin:.85rem 0 .35rem; color:#0a3d2e; font-size:.95rem; font-weight:700;
}
.path-admin .meetlet-guide__body,
.path-admin .meetlet-guide__body p,
.path-admin .meetlet-guide__body li {
color:#334942; line-height:1.7;
}
.path-admin .meetlet-guide__body p { margin:0 0 .55rem; }
.path-admin .meetlet-guide__body ol,
.path-admin .meetlet-guide__body ul { margin:0 0 .35rem; padding-inline-start:1.25rem; }
.path-admin .meetlet-guide__body li { margin:0 0 .35rem; }
.path-admin .meetlet-guide__body code,
.path-admin .meetlet-guide__table code {
font-size:.88em; padding:.05rem .3rem; border-radius:4px;
background:#e6faf3; color:#00261a;
}
.path-admin .meetlet-guide__table {
width:100%; margin:.35rem 0 0; border-collapse:collapse;
}
.path-admin .meetlet-guide__table th,
.path-admin .meetlet-guide__table td {
padding:.45rem .55rem; border:1px solid #cfe8de; vertical-align:top;
text-align:start;
}
.path-admin .meetlet-guide__table thead th,
.path-admin .meetlet-guide__table tbody th {
background:#e6faf3; color:#00261a; font-weight:650;
}
.path-admin .meetlet-guide__code {
margin:.35rem 0 0; padding:.75rem .9rem; overflow-x:auto;
border-radius:10px; border:1px solid #cfe8de;
background:#0a3d2e; color:#e6faf3;
font-size:.82rem; line-height:1.45; direction:ltr; text-align:left;
white-space:pre;
}
.path-admin .meetlet-guide__code-cell code {
font-weight:800; letter-spacing:.02em; direction:ltr; unicode-bidi:isolate;
}
.path-admin .meetlet-guide__table td ol,
.path-admin .meetlet-guide__table td ul {
margin:.15rem 0 0; padding-inline-start:1.15rem;
}
.path-admin .meetlet-guide__table td li { margin:0 0 .25rem; }
.path-admin .meetlet-hourpicker { max-width: 42rem; }
.path-admin .meetlet-hourpicker.is-dimmed,
.path-admin #adminsettings .form-item.is-dimmed {
opacity: .45;
pointer-events: none;
}
.path-admin .meetlet-hourpicker__tz {
margin: 0 0 .55rem; color:#4a6b5f; font-size:.88rem;
}
.path-admin .meetlet-hourpresets {
display:flex; flex-wrap:wrap; gap:.4rem; margin:0 0 .65rem;
}
.path-admin .meetlet-hourpreset {
border:1px solid #9fdcc4; background:#fff; color:#00261a;
border-radius:999px; padding:.28rem .7rem; font-size:.8rem; font-weight:650;
cursor:pointer;
}
.path-admin .meetlet-hourpreset:hover { background:#e6faf3; }
.path-admin .meetlet-hourpreset--ghost { border-color:#cfe8de; color:#4a6b5f; }
.path-admin .meetlet-hourgrid {
display:grid; grid-template-columns:repeat(8, minmax(0,1fr)); gap:.35rem;
}
.path-admin .meetlet-hourchip {
border:1px solid #cfe8de; background:#f7fcfa; color:#00261a;
border-radius:10px; padding:.45rem 0; font-size:.82rem; font-weight:750;
cursor:pointer;
}
.path-admin .meetlet-hourchip.is-on {
background:#52cca3; border-color:#0d9f6e; color:#00261a;
}
.path-admin .meetlet-hourchip.is-now { box-shadow:0 0 0 2px rgba(13,159,110,.35); }
@media (max-width: 720px) {
.path-admin .meetlet-hourgrid { grid-template-columns:repeat(6, minmax(0,1fr)); }
}
.path-admin .meetlet-about__cta-wrap {
display:flex; justify-content:center; margin-top:.35rem; padding-top:.35rem;
}
.path-admin .meetlet-about__cta {
display:inline-flex; align-items:center; justify-content:center;
min-width:220px; padding:.7rem 1.35rem; border-radius:10px;
background:#52cca3; color:#00261a !important;
text-decoration:none !important; font-weight:700;
border:1px solid #3db892;
box-shadow:0 6px 16px rgba(0,38,26,.08);
}
.path-admin .meetlet-about__cta:hover {
background:#6fd9b5; color:#00261a !important;
}
.path-admin .meetlet-settings-btn {
display:inline-flex; align-items:center; justify-content:center; gap:.45rem;
padding:.6rem 1.15rem; border-radius:10px; border:0; cursor:pointer;
background:#52cca3; color:#00261a !important;
text-decoration:none !important; font-weight:700;
}
.path-admin .meetlet-settings-btn:hover { background:#6fd9b5; color:#00261a !important; }
.path-admin .meetlet-settings-btn:disabled { opacity:.65; cursor:wait; }
.path-admin .meetlet-settings-btn svg {
width:1.1rem; height:1.1rem; flex:0 0 auto; display:block;
}
.path-admin .meetlet-license-actions {
display:flex; flex-wrap:wrap; gap:.55rem; align-items:center;
}
.path-admin .meetlet-license-actions .btn {
display:inline-flex; align-items:center; justify-content:center; gap:.45rem;
}
.path-admin .meetlet-license-actions .btn svg {
width:1.05rem; height:1.05rem; flex:0 0 auto; display:block;
}
.path-admin .meetlet-license-btn--free {
background: linear-gradient(180deg, #e6faf3 0%, #d4f5ea 100%) !important;
border: 2px solid #52cca3 !important;
color: #00261a !important;
border-radius: 10px !important;
font-weight: 700 !important;
padding: .6rem 1.15rem !important;
box-shadow: 0 4px 12px rgba(82, 204, 163, 0.18) !important;
}
.path-admin .meetlet-license-btn--free:hover,
.path-admin .meetlet-license-btn--free:focus {
background: linear-gradient(180deg, #6fd9b5 0%, #52cca3 100%) !important;
border-color: #3db892 !important;
color: #00261a !important;
box-shadow: 0 6px 16px rgba(82, 204, 163, 0.28) !important;
}
.path-admin .meetlet-conntest { max-width:none; margin:0; }
.path-admin .meetlet-conntest__actions {
display:flex; flex-wrap:wrap; gap:.75rem; align-items:center;
margin:.85rem 0 1rem;
}
.path-admin .meetlet-conntest__status { color:#4a6b5f; font-size:.9rem; }
.path-admin .meetlet-conntest__progress {
margin:0 0 1rem; padding:.85rem 1rem;
border:1px solid #cfe8de; border-radius:12px; background:#f7fcfa;
}
.path-admin .meetlet-conntest__progress-label {
display:flex; justify-content:space-between; gap:.75rem;
margin:0 0 .45rem; color:#00261a; font-weight:650; font-size:.92rem;
}
.path-admin .meetlet-conntest__progress-track {
height:10px; border-radius:999px; background:#d9eee5; overflow:hidden;
}
.path-admin .meetlet-conntest__progress-fill {
height:100%; width:0; border-radius:999px;
background:linear-gradient(90deg, #2f9f7a, #52cca3);
transition:width .28s ease;
}
.path-admin .meetlet-conntest__num {
min-width:1.7rem; height:1.7rem; border-radius:8px;
display:inline-flex; align-items:center; justify-content:center;
background:#e6faf3; color:#00261a; font-weight:750; font-size:.85rem;
}
.path-admin .meetlet-conntest__card--pending { opacity:.78; background:#fbfefe; }
.path-admin .meetlet-conntest__card--running {
border-color:#e6b422;
box-shadow:0 0 0 2px rgba(230,180,34,.22);
background:#fff8e6;
}
.path-admin .meetlet-conntest__card--running .meetlet-conntest__num {
background:#ffe08a; color:#5c4700;
}
.path-admin .meetlet-conntest__card--ok {
border-color:#9fdcc4; background:#f3fbf8;
}
.path-admin .meetlet-conntest__card--fail {
border-color:#ead3d0; background:#fff8f7;
}
.path-admin .meetlet-conntest__card--skipped { opacity:.55; }
.path-admin .meetlet-dot--pending { background:#9bb7ac; box-shadow:0 0 0 4px rgba(155,183,172,.18); }
.path-admin .meetlet-dot--running {
background:#e6b422; box-shadow:0 0 0 4px rgba(230,180,34,.28);
animation:meetletPulse 1s ease-in-out infinite;
}
.path-admin .meetlet-dot--skipped { background:#b7c4be; box-shadow:none; }
.path-admin .meetlet-pill--pending { background:#e8efec; color:#456257; }
.path-admin .meetlet-pill--running { background:#ffe08a; color:#5c4700; }
.path-admin .meetlet-pill--skipped { background:#eef1f0; color:#667a72; }
@keyframes meetletPulse {
0%, 100% { transform:scale(1); opacity:1; }
50% { transform:scale(1.15); opacity:.75; }
}
.path-admin #adminsettings form.settingsform {
padding-bottom:1rem;
}
/* Single save row: spacing only — no stacked border/gradient bands. */
.path-admin #adminsettings .settingsform > .row:has(.btn-primary),
.path-admin #adminsettings .form-buttons,
.path-admin #adminsettings .btns-group {
margin-top:1.25rem !important;
padding:0;
border:0;
background:transparent;
border-radius:0;
}
.path-admin #adminsettings .meetlet-savebar {
margin-top: 1.25rem !important;
margin-right: 0 !important;
margin-left: 0 !important;
margin-inline: 0 !important;
flex: 0 0 100% !important;
max-width: 100% !important;
width: 100% !important;
padding: .35rem 0 !important;
border: 0 !important;
background: transparent !important;
border-radius: 0 !important;
}
@media (min-width: 576px) {
.path-admin #adminsettings .meetlet-savebar.offset-sm-3,
.path-admin #adminsettings .meetlet-savebar {
margin-right: 0 !important;
margin-left: 0 !important;
}
}
.path-admin #adminsettings .btn-primary,
.path-admin #adminsettings input[type="submit"].btn-primary,
.path-admin #adminsettings button[type="submit"].btn-primary,
.path-admin #adminsettings .meetlet-btn-with-icon.btn-primary {
background:#52cca3 !important;
border-color:#3db892 !important;
color:#00261a !important;
font-weight:700 !important;
border-radius:10px !important;
padding:.55rem 1.35rem !important;
box-shadow:none !important;
}
.path-admin #adminsettings .btn-primary:hover,
.path-admin #adminsettings input[type="submit"].btn-primary:hover,
.path-admin #adminsettings button[type="submit"].btn-primary:hover {
background:#6fd9b5 !important;
border-color:#52cca3 !important;
color:#00261a !important;
}
.path-admin .meetlet-btn-with-icon {
display:inline-flex !important;
align-items:center;
justify-content:center;
gap:.45rem;
}
.path-admin .meetlet-btn-with-icon svg {
width:1.1rem;
height:1.1rem;
flex:0 0 auto;
display:block;
}
.path-admin .meetlet-btn-with-icon__label {
line-height:1.2;
}
.path-admin .meetlet-server-group {
margin:1.1rem 0 .4rem; padding:1rem 1.1rem .2rem;
border:1px solid #cfe8de; border-radius:12px;
background:linear-gradient(180deg, #f3fbf8 0%, #ffffff 60%);
}
.path-admin .meetlet-server-group h4 {
margin:0 0 .25rem; color:#00261a; font-size:1.02rem; font-weight:700;
}
.path-admin .meetlet-server-group p {
margin:0 0 .55rem; color:#4a6b5f; font-size:.9rem; line-height:1.55;
}
/* RTL alignment for Persian/Arabic admin settings */
html[dir="rtl"] .path-admin #adminsettings,
html[dir="rtl"] .path-admin #adminsettings .settingsform,
html[dir="rtl"] .path-admin #adminsettings .tab-content,
html[dir="rtl"] .path-admin #adminsettings .form-item {
direction: rtl;
}
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label,
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label.label,
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label > label,
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label .form-shortname,
html[dir="rtl"] .path-admin #adminsettings .form-description,
html[dir="rtl"] .path-admin #adminsettings .form-defaultinfo,
html[dir="rtl"] .path-admin #adminsettings .form-setting .form-check,
html[dir="rtl"] .path-admin #adminsettings .form-setting .checkbox,
html[dir="rtl"] .path-admin #adminsettings .form-setting label,
html[dir="rtl"] .path-admin #adminsettings h3.main,
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label.text-sm-end,
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label.text-end {
text-align: right !important;
direction: rtl;
}
html[dir="rtl"] .path-admin #adminsettings .meetlet-feature-group,
html[dir="rtl"] .path-admin #adminsettings .meetlet-feature-group h4,
html[dir="rtl"] .path-admin #adminsettings .meetlet-feature-group p,
html[dir="rtl"] .path-admin #adminsettings .meetlet-settings-card,
html[dir="rtl"] .path-admin #adminsettings .meetlet-about,
html[dir="rtl"] .path-admin #adminsettings .meetlet-about p,
html[dir="rtl"] .path-admin #adminsettings .meetlet-about h4,
html[dir="rtl"] .path-admin #adminsettings .meetlet-privacy,
html[dir="rtl"] .path-admin #adminsettings .meetlet-privacy p,
html[dir="rtl"] .path-admin #adminsettings .meetlet-privacy h3,
html[dir="rtl"] .path-admin #adminsettings .meetlet-privacy h4,
html[dir="rtl"] .path-admin #adminsettings .meetlet-conntest,
html[dir="rtl"] .path-admin #adminsettings .meetlet-conntest__intro,
html[dir="rtl"] .path-admin #adminsettings .meetlet-conntest__title,
html[dir="rtl"] .path-admin #adminsettings .meetlet-conntest__status,
html[dir="rtl"] .path-admin #adminsettings .meetlet-conntest__progress-label,
html[dir="rtl"] .path-admin .meetlet-settings-hero,
html[dir="rtl"] .path-admin .meetlet-settings-hero h3,
html[dir="rtl"] .path-admin .meetlet-settings-hero .meetlet-settings-version,
html[dir="rtl"] .path-admin .meetlet-settings-hero p,
html[dir="rtl"] .path-admin .meetlet-settings-copy {
direction: rtl;
text-align: right;
}
html[dir="rtl"] .path-admin .meetlet-settings-hero {
flex-direction: row;
}
html[dir="rtl"] .path-admin #adminsettings .nav-tabs {
direction: rtl;
}
html[dir="rtl"] .path-admin #adminsettings .form-setting input[type="text"],
html[dir="rtl"] .path-admin #adminsettings .form-setting input[type="password"],
html[dir="rtl"] .path-admin #adminsettings .form-setting input[type="url"],
html[dir="rtl"] .path-admin #adminsettings .form-setting input[type="number"],
html[dir="rtl"] .path-admin #adminsettings .form-setting select,
html[dir="rtl"] .path-admin #adminsettings .form-setting textarea {
direction: ltr;
text-align: left;
unicode-bidi: isolate;
}
html[dir="rtl"] .path-admin #adminsettings .form-setting .form-check,
html[dir="rtl"] .path-admin #adminsettings .form-setting .checkbox,
html[dir="rtl"] .path-admin #adminsettings .form-setting .form-checkbox {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
align-items: center;
gap: .5rem;
text-align: right;
}
html[dir="rtl"] .path-admin #adminsettings .form-item .form-label .form-shortname {
direction: ltr;
unicode-bidi: isolate;
text-align: right !important;
display: block;
}
html[dir="rtl"] .path-admin #adminsettings .form-setting .form-checkbox {
direction: rtl;
text-align: right;
}
html[dir="rtl"] .path-admin #adminsettings .form-setting .defaultsnext {
text-align: right;
}
html[dir="rtl"] .path-admin #adminsettings .meetlet-savebar,
html[dir="rtl"] .path-admin #adminsettings .form-buttons {
text-align: right;
}
</style>
<script src="' . s($modalscrolljsurl->out(false)) . '" defer></script>
<script src="' . s($registrationjsurl->out(false)) . '" defer></script>
<script src="' . s($jsurl->out(false)) . '" defer></script>
<script src="' . s($togglejsurl->out(false)) . '" defer></script>
<script src="' . s($matrixjsurl->out(false)) . '" defer></script>
<script src="' . s($accessresetjsurl->out(false)) . '" defer></script>';
$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',
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M8 5v14l11-7z"/></svg>'
. 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);
}
+2202
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<div class="participant-form meetlet-participant-form">
{{#selectionType}}
{{> mod_meetletacbfree/participant_single_select}}
{{/selectionType}}
{{#selectionParticipant}}
{{> mod_meetletacbfree/participant_single_select}}
{{/selectionParticipant}}
{{#action}}
<button type="button" class="btn btn-secondary"
id="{{id}}"
name="meetlet_participant_selection_add"
title={{#quote}}{{tooltip}}{{/quote}}>
{{label}}
</button>
{{/action}}
<table id="participant_list_table" class="table generaltable meetlet-participant-table mt-3">
<tbody></tbody>
</table>
</div>
+17
View File
@@ -0,0 +1,17 @@
<tr id="participant_list_tr_{{selectiontypevalue}}-{{selectionvalue}}">
<td class="participant_list_c0"><b><i>{{participanttype}}</i></b></td>
<td class="participant_list_c1">{{participantvalue}}</td>
<td class="participant_list_c2"><i>{{#str}}participant_as, meetletacbfree{{/str}}</i></td>
<td class="participant_list_c3">
<select class="participant-select form-select" name="select-for-{{selectiontypevalue}}-{{selectionvalue}}">
{{#roles}}
<option value="{{id}}" {{#isselected}}selected{{/isselected}}>{{label}}</option>
{{/roles}}
</select>
</td>
<td class="participant_list_c4">
{{#canRemove}}
<a href="#" class="btn btn-secondary btn-sm remove-button">{{#str}}participant_remove, meetletacbfree{{/str}}</a>
{{/canRemove}}
</td>
</tr>
@@ -0,0 +1,22 @@
<div class="{{classes}} d-inline-block">
{{#label}}
<label for="{{id}}"{{#labelattributes}} {{name}}="{{value}}"{{/labelattributes}}>
{{{label}}}
</label>
{{/label}}
<select {{#attributes}}{{name}}="{{value}}" {{/attributes}} id="{{id}}" class="form-select {{classes}}" name="{{name}}"
{{#title}}title="{{.}}"{{/title}} {{#disabled}}disabled{{/disabled}}>
{{#options}}
{{#optgroup}}
<optgroup label="{{name}}">
{{#options}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{{name}}}</option>
{{/options}}
</optgroup>
{{/optgroup}}
{{^optgroup}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{{name}}}</option>
{{/optgroup}}
{{/options}}
</select>
</div>
+83
View File
@@ -0,0 +1,83 @@
<div class="meetlet-app" data-cmid="{{cmid}}" data-groupid="{{groupid}}">
<section class="meetlet-hero">
<div class="meetlet-hero__brand">
<img class="meetlet-hero__logo" src="{{logourl}}" alt="Adobe Connect">
<div>
<h2 class="meetlet-title">{{name}}</h2>
<div class="meetlet-subtitle">{{subtitle}}</div>
</div>
</div>
<div class="meetlet-hero__meta">
<div class="meetlet-statuswrap" data-meetlet-status-wrap data-status="{{status}}">
{{#licenseusable}}
<span class="meetlet-statusring-wrap"
data-tip="{{#str}} statuscountdown_help, meetletacbfree {{/str}}"
tabindex="0">
<svg class="meetlet-statusring" viewBox="0 0 40 40" aria-hidden="true">
<circle class="meetlet-statusring__track" cx="20" cy="20" r="16"></circle>
<circle class="meetlet-statusring__prog" data-status-ring cx="20" cy="20" r="16"></circle>
</svg>
<span class="meetlet-statuscountdown" data-status-countdown>15</span>
</span>
{{/licenseusable}}
<span class="meetlet-status meetlet-status--{{status}}" data-meetlet-status
{{^joinable}}{{#statusmessage}}data-tip="{{statusmessage}}" tabindex="0"{{/statusmessage}}{{/joinable}}>{{statuslabel}}</span>
</div>
{{#starttime}}<div class="meetlet-meta-line">{{#str}} starttime, meetletacbfree {{/str}}: {{starttime}}</div>{{/starttime}}
{{#hasend}}<div class="meetlet-meta-line">{{#str}} endtime, meetletacbfree {{/str}}: {{endtime}}</div>{{/hasend}}
</div>
{{#canjoin}}
<div class="meetlet-hero__actions">
<span class="meetlet-joinwrap{{^joinable}} is-blocked{{/joinable}}" data-join-wrap>
<button type="button" class="meetlet-btn meetlet-btn--join{{^joinable}} is-disabled{{/joinable}}"
data-action="join"
{{^joinable}}aria-disabled="true"{{/joinable}}>{{joinlabel}}</button>
</span>
</div>
{{/canjoin}}
</section>
<div class="meetlet-joinoverlay" data-join-overlay hidden>
<div class="meetlet-joinoverlay__card">
<img class="meetlet-joinoverlay__logo" src="{{logourl}}" alt="">
<div class="meetlet-joinoverlay__spinner" aria-hidden="true"></div>
<div class="meetlet-joinoverlay__title">{{joiningtitle}}</div>
<div class="meetlet-joinoverlay__text">{{joining}}</div>
</div>
</div>
{{^configured}}
<div class="meetlet-alert">{{notconfiguredmsg}}</div>
{{/configured}}
<template data-meetlet-details-html>
{{#hasintro}}
<div class="meetlet-intro meetlet-intro--plain">{{{intro}}}</div>
{{/hasintro}}
{{^hasintro}}
<div class="meetlet-empty">{{emptydetails}}</div>
{{/hasintro}}
</template>
<nav class="meetlet-tabs" role="tablist">
{{#tabs}}
<button type="button"
class="meetlet-tab{{#active}} is-active{{/active}}"
role="tab"
data-tab="{{id}}"
aria-selected="{{#active}}true{{/active}}{{^active}}false{{/active}}">
<span class="meetlet-tab__icon" aria-hidden="true">{{{icon}}}</span>
<span class="meetlet-tab__label">{{label}}</span>
</button>
{{/tabs}}
</nav>
<div class="meetlet-panel" data-panel>
{{#hasintro}}
<div class="meetlet-intro meetlet-intro--plain">{{{intro}}}</div>
{{/hasintro}}
{{^hasintro}}
<div class="meetlet-empty">{{emptydetails}}</div>
{{/hasintro}}
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Plugin version metadata.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @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';
+99
View File
@@ -0,0 +1,99 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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();