From 8ac5301dd701f9888ecb11786ff89fa0da996d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Daoust?= Date: Sat, 21 Oct 2023 18:00:55 +0200 Subject: [PATCH] Copy generic tools over from annual repos and adjust README (#69) Move generic tools produced for TPAC 2023 to a non-dated space, to be maintained here and referenced from annual repos from now on. For tools commit history, see https://github.com/w3c/tpac2023-breakouts/ --- .gitignore | 2 + LICENSE | 692 +++++++++++++++++ README.md | 40 +- package-lock.json | 1208 ++++++++++++++++++++++++++++++ package.json | 50 ++ tools/add-minutes.mjs | 111 +++ tools/create-recording-pages.mjs | 157 ++++ tools/init-repo-labels.mjs | 156 ++++ tools/init-room-zoom.mjs | 38 + tools/lib/calendar.mjs | 340 +++++++++ tools/lib/chairs.mjs | 120 +++ tools/lib/envkeys.mjs | 38 + tools/lib/graphql.mjs | 43 ++ tools/lib/project.mjs | 433 +++++++++++ tools/lib/session.mjs | 337 +++++++++ tools/lib/todostrings.mjs | 1 + tools/lib/validate.mjs | 286 +++++++ tools/lib/w3caccount.mjs | 47 ++ tools/lib/webvtt2html.mjs | 121 +++ tools/list-chairs.mjs | 119 +++ tools/minutes-to-w3c.mjs | 89 +++ tools/setup-irc.mjs | 441 +++++++++++ tools/suggest-grid.mjs | 771 +++++++++++++++++++ tools/update-calendar.mjs | 109 +++ tools/upload-grid.mjs | 93 +++ tools/validate-grid.mjs | 139 ++++ tools/validate-session.mjs | 197 +++++ 27 files changed, 6171 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tools/add-minutes.mjs create mode 100644 tools/create-recording-pages.mjs create mode 100644 tools/init-repo-labels.mjs create mode 100644 tools/init-room-zoom.mjs create mode 100644 tools/lib/calendar.mjs create mode 100644 tools/lib/chairs.mjs create mode 100644 tools/lib/envkeys.mjs create mode 100644 tools/lib/graphql.mjs create mode 100644 tools/lib/project.mjs create mode 100644 tools/lib/session.mjs create mode 100644 tools/lib/todostrings.mjs create mode 100644 tools/lib/validate.mjs create mode 100644 tools/lib/w3caccount.mjs create mode 100644 tools/lib/webvtt2html.mjs create mode 100644 tools/list-chairs.mjs create mode 100644 tools/minutes-to-w3c.mjs create mode 100644 tools/setup-irc.mjs create mode 100644 tools/suggest-grid.mjs create mode 100644 tools/update-calendar.mjs create mode 100644 tools/upload-grid.mjs create mode 100644 tools/validate-grid.mjs create mode 100644 tools/validate-session.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c764a0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +node_modules \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..48b53af --- /dev/null +++ b/LICENSE @@ -0,0 +1,692 @@ +Applicable licenses + +All tools in this package and associated documentation files (the "Software") are licensed under the terms of the MIT License (copied below), except for the `setup-irc` tool, licensed under the terms of the GPL-3.0 license (copied below). + +----- +MIT License + +Copyright (c) 2023 World Wide Web Consortium + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +----- + +----- + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. +----- \ No newline at end of file diff --git a/README.md b/README.md index 4d94f9a..3979448 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,45 @@ -# About this Repo +# TPAC breakout sessions management -This repo was originally used to help organize tooling for [TPAC 2019 breakout session management](https://w3c.github.io/tpac-breakouts/). +## About this Repository -Tooling has evolved since then, but we are still using this repo as the home for documentation about tooling to help manage TPAC breakouts, including: +This repository contains: +1. a set of generic tools used to organize breakout sessions during W3C TPAC events, +2. documentation about tooling for breakout sessions planners, +3. good practices for breakout session chairs, and +4. TPAC breakout session policies. + +This repository **does not** contain breakout session proposals. To see breakout session proposals, please check dedicated repositories per TPAC, such as [TPAC 2023 Breakouts](https://github.com/w3c/tpac2023-breakouts/blob/main/README.md). + +## Documentation + +This repository includes documentation for TPAC participants: * [Good Practices for Session Chairs](https://github.com/w3c/tpac-breakouts/wiki/Policies) * [TPAC Breakout Policies](https://github.com/w3c/tpac-breakouts/wiki/Policies) -Starting in 2023 we anticipate the creation of one repo par TPAC. Please see: +There is also documentation for [TPAC meeting planners](https://github.com/w3c/tpac-breakouts/wiki/For-TPAC-Meeting-Planners). + +## Tools + +This repository includes tools to: -* [TPAC 2024 Breakouts](https://github.com/w3c/tpac2024-breakouts/blob/main/README.md) -* [TPAC 2023 Breakouts](https://github.com/w3c/tpac2023-breakouts/blob/main/README.md) +* Validate a breakout session proposal, chair info and schedule data. +* Suggest, save and restore a schedule that minimizes conflicts. +* Initialize IRC channels with bots and agenda. +* Update the TPAC breakout sessions calendar. +* Update session data with links to IRC-based minutes when available. +* Create recording pages with transcripts. +* Gather a list of session chairs. + +The tools are specific to TPAC events. Some of them require W3C team privileges. ## About the Issues List -Please use the issues list of this repo for general questions or suggestions about TPAC breakout management. +Please use the issues list of this repo for general questions or suggestions about TPAC breakout management and tools. Please **do not** use the issues list of this repo to propose TPAC breakout sessions. + +## Historical note + +This repository was originally used to help organize tooling for [TPAC 2019 breakout session management](https://w3c.github.io/tpac-breakouts/). + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d32a274 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1208 @@ +{ + "name": "tpac-breakouts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tpac-breakouts", + "version": "1.0.0", + "license": "SEE LICENSE IN 'LICENSE' file", + "dependencies": { + "irc": "^0.5.2", + "puppeteer": "^21.4.0", + "seedrandom": "^3.0.5", + "webvtt-parser": "^2.2.0", + "yaml": "^2.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.8.0.tgz", + "integrity": "sha512-TkRHIV6k2D8OlUe8RtG+5jgOF/H98Myx0M6AOafC8DdNVOFiBSFa5cpRDtpm8LXOa9sVwe0+e6Q3FC56X/DZfg==", + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.1", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=16.3.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + }, + "node_modules/@types/node": { + "version": "20.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", + "integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", + "optional": true, + "dependencies": { + "undici-types": "~5.25.1" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.2.tgz", + "integrity": "sha512-Km7XAtUIduROw7QPgvcft0lIupeG8a8rdKL8RiSyKvlE7dYY31fEn41HVuQsRFDuROA8tA4K2UVL+WdfFmErBA==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/basic-ftp": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.3.tgz", + "integrity": "sha512-QHX8HLlncOLpy54mh+k/sWIFd0ThmRqwe9ZjELybGZK+tZ8rUb9VO0saKJUROTbE+KhzDUT7xziGpGrW8Kmd+g==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chromium-bidi": { + "version": "0.4.32", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.32.tgz", + "integrity": "sha512-RJnw0PW3sNdx1WclINVfVVx8JUH+tWTHZNpnEzlcM+Qgvf40dUH34U7gJq+cc/0LE+rbPxeT6ldqWrCbUf4jeg==", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "9.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.1.tgz", + "integrity": "sha512-MZd3VlchQkp8rdend6vrx7MmVDJzSNTBvghvKjirLkD+WTChA3KUf0jkE68Q4UyctNqI11zZO9/x2Yx+ub5Cvg==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1191157", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1191157.tgz", + "integrity": "sha512-Fu2mUhX7zkzLHMJZk5wQTiHdl1eJrhK0GypUoSzogUt51MmYEv/46pCz4PtGGFlr0f2ZyYDzzx5CPtbEkuvcTA==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.2.tgz", + "integrity": "sha512-5KLucCJobh8vBY1K07EFV4+cPZH3mrV9YeAruUseCQKHB58SGjjT2l9/eA9LD082IiuMjSlFJEcdJ27TXvbZNw==", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.0", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/iconv/-/iconv-2.2.3.tgz", + "integrity": "sha512-evIiYeKdt5nEGYKNkQcGPQy781sYgbBKi3gEkt1s4CwteCdOHSjGGRyyp6lP8inYFZwvzG3lgjXEvGUC8nqQ5A==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.3.5" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", + "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==" + }, + "node_modules/irc": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/irc/-/irc-0.5.2.tgz", + "integrity": "sha512-KnrvkV05Y71SWmRWHtnlWEIH7LA/YeDul6l7tncCGLNEw4B6Obtmkatb3ACnSLj0kOJ6UBiuhss9e+eRG3zlxw==", + "dependencies": { + "irc-colors": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "optionalDependencies": { + "iconv": "~2.2.1", + "node-icu-charset-detector": "~0.2.0" + } + }, + "node_modules/irc-colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/irc-colors/-/irc-colors-1.5.0.tgz", + "integrity": "sha512-HtszKchBQTcqw1DC09uD7i7vvMayHGM1OCo6AHt5pkgZEyo99ClhHTMJdf+Ezc9ovuNNxcH89QfyclGthjZJOw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/nan": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", + "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", + "optional": true + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-icu-charset-detector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz", + "integrity": "sha512-DYOFJ3NfKdxEi9hPbmoCss6WydGhJsxpSleUlZfAWEbZt3AU7JuxailgA9tnqQdsHiujfUY9VtDfWD9m0+ThtQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.3.3" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz", + "integrity": "sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "pac-resolver": "^7.0.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.0.tgz", + "integrity": "sha512-Fd9lT9vJbHYRACT8OhCbZBbxr6KRSawSovFpy8nDGshaK99S/EBhVIHp9+crhxrsZOuvLpgL1n23iyPg6Rl2hg==", + "dependencies": { + "degenerator": "^5.0.0", + "ip": "^1.1.8", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.1.tgz", + "integrity": "sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-21.4.0.tgz", + "integrity": "sha512-KkiDe39NJxlw7fyiN6fieM9SVsewzt037nUZRoffNuFtYdAl5rRLVtleBuVZ5i1swK/R4CmA6Pbka/ytpFCu4Q==", + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "1.8.0", + "cosmiconfig": "8.3.6", + "puppeteer-core": "21.4.0" + }, + "engines": { + "node": ">=16.3.0" + } + }, + "node_modules/puppeteer-core": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-21.4.0.tgz", + "integrity": "sha512-ONYjYgHItm6i9WdJf+MnRTRPB4HegwPbPfi1jjNN0LCW3rnNich/5hXgZFcn2oWvgFc8DWLDIcwly7C8z+EvIw==", + "dependencies": { + "@puppeteer/browsers": "1.8.0", + "chromium-bidi": "0.4.32", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1191157", + "ws": "8.14.2" + }, + "engines": { + "node": ">=16.3.0" + } + }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz", + "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks/node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", + "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "node_modules/tar-stream": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "optional": true + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz", + "integrity": "sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/webvtt-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webvtt-parser/-/webvtt-parser-2.2.0.tgz", + "integrity": "sha512-FzmaED+jZyt8SCJPTKbSsimrrnQU8ELlViE1wuF3x1pgiQUM8Llj5XWj2j/s6Tlk71ucPfGSMFqZWBtKn/0uEA==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz", + "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a71c89f --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "tpac-breakouts", + "version": "0.9.0", + "description": "A set of tools to organize breakouts during W3C TPAC event. The package should only be useful for that purpose!", + "license": "SEE LICENSE IN 'LICENSE' file", + "author": { + "name": "tidoust", + "email": "fd@w3.org" + }, + "contributors": [ + { + "name": "ianbjacobs", + "email": "ij@w3.org" + } + ], + "repository": { + "type": "git", + "url": "https://github.com/w3c/tpac-breakouts.git" + }, + "bugs": { + "url": "https://github.com/w3c/tpac-breakouts/issues" + }, + "files": [ + "tools/" + ], + "bin": { + "add-minutes": "./tools/add-minutes.mjs", + "create-recording-pages": "./tools/create-recording-pages.mjs", + "init-repo-labels": "./tools/init-repo-labels.mjs", + "init-room-zoom": "./tools/init-room-zoom.mjs", + "list-chairs": "./tools/list-chairs.mjs", + "minutes-to-w3c": "./tools/minutes-to-w3c.mjs", + "setup-irc": "./tools/setup-irc.mjs", + "suggest-grid": "./tools/suggest-grid.mjs", + "update-calendar": "./tools/update-calendar.mjs", + "upload-grid": "./tools/upload-grid.mjs", + "validate-grid": "./tools/validate-grid.mjs", + "validate-session": "./tools/validate-session.mjs" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "irc": "^0.5.2", + "puppeteer": "^21.4.0", + "seedrandom": "^3.0.5", + "webvtt-parser": "^2.2.0", + "yaml": "^2.3.3" + } +} diff --git a/tools/add-minutes.mjs b/tools/add-minutes.mjs new file mode 100644 index 0000000..0499f5b --- /dev/null +++ b/tools/add-minutes.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * This tool adds a link to W3C IRC minutes to one (or all) sessions + * + * To run the tool: + * + * node tools/add-minutes.mjs [sessionNumber] + * + * where [sessionNumber] is the number of the issue to process (e.g. 15). + * Leave empty to add minute links to all sessions. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import { updateSessionDescription } from './lib/session.mjs'; +import { todoStrings } from './lib/todostrings.mjs'; + + +async function main(number) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + let sessions = project.sessions.filter(s => s.slot && s.room && + (!number || s.number === number)); + sessions.sort((s1, s2) => s1.number - s2.number); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} not found (or did not take place) in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to a slot and room: ${sessions.map(s => s.number).join(', ')}`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} contains errors that need fixing`); + } + else if (sessions[0].description.materials.minutes && + !todoStrings.includes(session.description.materials.minutes)) { + console.log(`- session already has a link to minutes`); + return; + } + } + else { + sessions = sessions.filter(s => + !s.description.materials.minutes || + todoStrings.includes(s.description.materials.minutes)); + if (sessions.length === 0) { + console.log(`- no valid session that needs minutes among them`); + } + else { + console.log(`- found ${sessions.length} valid sessions that need minutes among them: ${sessions.map(s => s.number).join(', ')}`); + } + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + // TODO: date is in the timezone of the TPAC even but actual dated URL + // is on Boston time. No big deal for TPAC meetings in US / Europe, but + // problematic when TPAC is in Asia. + const date = project.metadata.date; + const year = date.substring(0, 4); + const month = date.substring(5, 7); + const day = date.substring(8, 10); + + console.log(); + console.log('Link to minutes...'); + for (const session of sessions) { + const url = `https://www.w3.org/${year}/${month}/${day}-${session.description.shortname.substring(1)}-minutes.html`; + const response = await fetch(url); + if ((response.status !== 200) && (response.status !== 401)) { + console.log(`- no minutes found for session ${session.number}: ${url} yields a ${response.status}`); + } + else { + console.log(`- link session ${session.number} to minutes at ${url}`); + session.description.materials.minutes = url; + await updateSessionDescription(session); + } + } + console.log('Link to minutes... done'); +} + + +// Read session number from command-line +if (process.argv[2] && !process.argv[2].match(/^(\d+|all)$/)) { + console.log('First parameter should be a session number or "all"'); + process.exit(1); +} +const sessionNumber = process.argv[2]?.match(/^\d+$/) ? parseInt(process.argv[2], 10) : undefined; + +main(sessionNumber) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/create-recording-pages.mjs b/tools/create-recording-pages.mjs new file mode 100644 index 0000000..647b8de --- /dev/null +++ b/tools/create-recording-pages.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * This tool is only useful once recordings of breakout sessions have been + * uploaded to Cloudflare. It create HTML recording pages for each of these + * recordings that contain the video and an HTML rendition of the captions as + * a transcript. + * + * To run the tool: + * + * node tools/create-recording-pages.mjs + * + * Pre-requisites: + * 1. Recordings must have been uploaded to Cloudflare with a name that starts + * with a well-known prefix. + * 2. The well-known prefix must appear in a RECORDING_PREFIX env variable. + * 3. Cloudflare account info must appear in CLOUDFLARE_ACCOUNT and + * CLOUDFLARE_TOKEN env variables. + * 4. The RECORDING_FOLDER env variable must target the local folder to use to + * save recordings pages + * 5. The RECORDING_FOLDER folder must contain a "recording-template.html" page + * that contains the template to use for each recording page, see for example: + * https://www.w3.org/2023/09/breakouts/recording-template.html + * + * The tool assumes that the recordings are named prefix-xx.mp4, where xx is + * the breakout session number. It creates "recording-xx.html" pages in the + * recording folder. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { convert } from './lib/webvtt2html.mjs'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs'; +import { validateSession } from './lib/validate.mjs'; +import { todoStrings } from './lib/todostrings.mjs'; + +async function listRecordings(accountId, authToken, recordingPrefix) { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/stream?search=${recordingPrefix}`, + { + headers: { + 'Authorization': `Bearer ${authToken}` + } + } + ); + const json = await response.json(); + const recordings = json.result + .filter(v => v.meta.name.startsWith(recordingPrefix)) + .map(v => Object.assign({ + sessionId: v.meta.name.match(/-(\d+)\.mp4$/)[1], + name: v.meta.name, + title: v.meta.name, + videoId: v.uid, + preview: v.preview, + embedUrl: v.preview.replace(/watch$/, 'iframe'), + captions: v.preview.replace(/watch$/, 'captions/en') + })) + .sort((v1, v2) => v1.name.localeCompare(v2.name)); + return recordings; +} + +async function createRecordingPage(recording, recordingFolder) { + let template = await fs.readFile(path.join(recordingFolder, 'recording-template.html'), 'utf8'); + + recording.transcript = await convert(recording.captions, { clean: true }); + + // Replace content that needs to be serialized as JSON + for (const property of Object.keys(recording)) { + const regexp = new RegExp(`\{\{\{\{${property}\}\}\}\}`, 'g'); + template = template.replace(regexp, JSON.stringify(recording[property], null, 2)); + } + + // Replace content that needs to be escaped for use in HTML attributes + for (const property of Object.keys(recording)) { + const regexp = new RegExp(`\{\{\{${property}\}\}\}`, 'g'); + template = template.replace(regexp, + ('' + recording[property] || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''')); + } + + // Replace raw text content + for (const property of Object.keys(recording)) { + const regexp = new RegExp(`\{\{${property}\}\}`, 'g'); + template = template.replace(regexp, recording[property]); + } + + // Write resulting recording page + await fs.writeFile(path.join(recordingFolder, `recording-${recording.sessionId}.html`), template, 'utf8'); +} + +async function main() { + // First, retrieve known information about the project + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + console.log(`- ${project.sessions.length} sessions`); + console.log(`- ${project.rooms.length} rooms`); + console.log(`- ${project.slots.length} slots`); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`); + + console.log(); + console.log('List recordings...'); + const CLOUDFLARE_ACCOUNT = await getEnvKey('CLOUDFLARE_ACCOUNT'); + const CLOUDFLARE_TOKEN = await getEnvKey('CLOUDFLARE_TOKEN'); + const RECORDING_PREFIX = await getEnvKey('RECORDING_PREFIX'); + const RECORDING_FOLDER = await getEnvKey('RECORDING_FOLDER');; + const recordings = await listRecordings(CLOUDFLARE_ACCOUNT, CLOUDFLARE_TOKEN, RECORDING_PREFIX); + console.log(`- found ${recordings.length} recordings`); + console.log('List recordings... done'); + + console.log(); + console.log('Create recording pages...'); + for (const recording of recordings) { + const session = project.sessions.find(s => s.number === parseInt(recording.sessionId, 10)); + console.log(`- create page for ${recording.sessionId} - ${session.title}`); + await validateSession(session.number, project); + const desc = session.description; + recording.title = session.title; + recording.githubIssue = `https://github.com/${session.repository}/issues/${session.number}`; + const links = [ + { + title: 'Session proposal on GitHub', + url: recording.githubIssue + } + ]; + if (desc.materials.slides && !todoStrings.includes(desc.materials.slides.toUpperCase())) { + links.push({ + title: 'Slides', + url: desc.materials.slides + }); + } + if (desc.materials.minutes && !todoStrings.includes(desc.materials.minutes.toUpperCase())) { + links.push({ + title: 'Session minutes', + url: desc.materials.minutes + }); + } + recording.links = links + .map(l => `
  • ${l.title}
  • `) + .join('\n'); + await createRecordingPage(recording, RECORDING_FOLDER); + } + console.log('Create recording pages... done'); +} + +main().then(_ => process.exit(0)); \ No newline at end of file diff --git a/tools/init-repo-labels.mjs b/tools/init-repo-labels.mjs new file mode 100644 index 0000000..0990b99 --- /dev/null +++ b/tools/init-repo-labels.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * This tool adjusts the list of labels defined in the given repository so + * that it may be used as a breakout sessions repository + * + * To run the tool: + * + * node tools/init-repo-labels.mjs [repo owner] [repo name] + * + * The tool adds labels needed by the session validation logic. It also gets + * rid of labels that should be useless (but note it preserves "track: xxx" + * labels), and updates labels that don't have the right description or color. + * + * Essentially, this tool should be run once when the annual repository is + * created, and each time changes are made to the list of labels below. + */ + +import { sendGraphQLRequest } from './lib/graphql.mjs'; + +const labels = [ + { + "name": "session", + "description": "Breakout session proposal", + "color": "C2E0C6" + } +]; + +async function createRepoLabels(owner, repo) { + console.log('Retrieve repository information...'); + const res = await sendGraphQLRequest(`query { + repository(owner: "${owner}", name: "${repo}") { + id + labels(first: 100) { + nodes { + id + name + description + color + } + } + } + }`); + const repositoryId = res.data.repository.id; + const repositoryLabels = res.data.repository.labels.nodes + .sort((l1, l2) => l1.name.localeCompare(l2.name)); + console.log(`- repository id: ${repositoryId}`); + console.log(`- repository labels:\n ${repositoryLabels.map(l => l.name).join('\n ')}`); + console.log('Retrieve repository information... done'); + + // Mutation commands on labels are in preview as of 2023-06-10: + // https://docs.github.com/en/graphql/overview/schema-previews#labels-preview + const labelsPreviewHeader = 'application/vnd.github.bane-preview+json'; + + console.log(); + console.log('Add labels as needed...'); + const labelsToAdd = labels + .filter(label => !repositoryLabels.find(l => l.name === label.name)); + for (const label of labelsToAdd) { + console.log(`- add ${label.name}`); + const res = await sendGraphQLRequest(`mutation { + createLabel(input: { + repositoryId: "${repositoryId}", + name: "${label.name}", + color: "${label.color}", + description: "${label.description}", + clientMutationId: "mutatis mutandis" + }) { + label { + id + } + } + }`, labelsPreviewHeader); + if (!res?.data?.createLabel?.label?.id) { + console.log(JSON.stringify(res, null, 2)); + throw new Error(`GraphQL error, could not create label ${label.name}`); + } + } + console.log('Add labels as needed... done'); + + console.log(); + console.log('Delete labels as needed...'); + const labelsToDelete = repositoryLabels + .filter(label => + !labels.find(l => l.name === label.name) && + !label.name.startsWith('track: ')); + for (const label of labelsToDelete) { + console.log(`- delete ${label.name}`); + const res = await sendGraphQLRequest(`mutation { + deleteLabel(input: { + id: "${label.id}", + clientMutationId: "mutatis mutandis" + }) { + clientMutationId + } + }`, labelsPreviewHeader); + if (!res?.data?.deleteLabel?.clientMutationId) { + console.log(JSON.stringify(res, null, 2)); + throw new Error(`GraphQL error, could not delete label ${label.name}`); + } + } + console.log('Delete labels as needed... done'); + + console.log(); + console.log('Update labels as needed...'); + const labelsToUpdate = repositoryLabels + .filter(label => labels.find(l => l.name === label.name)) + .filter(label => { + const refLabel = labels.find(l => l.name === label.name); + return (refLabel.description !== label.description) || + (refLabel.color !== label.color); + }); + for (const label of labelsToUpdate) { + console.log(`- update ${label.name}`); + const res = await sendGraphQLRequest(`mutation { + updateLabel(input: { + id: "${label.id}", + name: "${label.name}", + color: "${label.color}", + description: "${label.description}", + clientMutationId: "mutatis mutandis" + }) { + label { + id + } + } + }`, labelsPreviewHeader); + if (!res?.data?.updateLabel?.label?.id) { + console.log(JSON.stringify(res, null, 2)); + throw new Error(`GraphQL error, could not update label ${label.name}`); + } + } + console.log('Update labels as needed... done'); +} + +// Read session number from command-line +if (!process.argv[2]) { + console.log('Command needs to receive a repo owner as first parameter'); + process.exit(1); +} +if (!process.argv[3] && !process.argv[2].includes('/')) { + console.log('Command needs to receive a repo name as second parameter'); + process.exit(1); +} + +const owner = process.argv[2].includes('/') ? + process.argv[2].split('/')[0] : + process.argv[2]; +const repo = + process.argv[3] ?? + process.argv[2].split('/')[1]; + +createRepoLabels(owner, repo) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/init-room-zoom.mjs b/tools/init-room-zoom.mjs new file mode 100644 index 0000000..cb9783c --- /dev/null +++ b/tools/init-room-zoom.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * This tool returns the right structure for the ROOM_ZOOM variable that stores + * mappings between available rooms and Zoom coordinates. The structure then + * needs to be updated with the right Zoom coordinates, and stored locally in + * `config.json` or in the GitHub repository as a ROOM_ZOOM variable. + * + * To run the tool: + * + * node tools/init-room-zoom.mjs + * + * Essentially, this tool should be run once when the annual repository is + * created and the list of rooms known. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs'; + +async function run() { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + + const rooms = {}; + for (const room of project.rooms) { + rooms[room.label] = '@@'; + } + console.log(JSON.stringify(rooms, null, 2)); +} + +run() + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/lib/calendar.mjs b/tools/lib/calendar.mjs new file mode 100644 index 0000000..f31600a --- /dev/null +++ b/tools/lib/calendar.mjs @@ -0,0 +1,340 @@ +import { validateSession } from './validate.mjs'; +import { updateSessionDescription } from './session.mjs'; +import { todoStrings } from './todostrings.mjs'; + + +async function fetchChairName({ chair, browser, login, password }) { + console.log(`- fetch chair name for ${chair.login}`); + const page = await browser.newPage(); + const url = `https://www.w3.org/users/${chair.w3cId}/` + try { + await page.goto(url); + await authenticate(page, login, password, url); + chair.name = await page.evaluate(() => { + const el = document.querySelector('main article h1'); + return el.textContent.trim(); + }); + } + finally { + await page.close(); + } +} + +/** + * Helper function to format calendar entry description from the session's info + */ +function formatAgenda(session) { + const issueUrl = `https://github.com/${session.repository}/issues/${session.number}`; + const materials = Object.entries(session.description.materials || []) + .filter(([key, value]) => (key !== 'agenda') && (key !== 'calendar')) + .filter(([key, value]) => !todoStrings.includes(value)) + .map(([key, value]) => `- [${key}](${value})`); + materials.push(`- [Session proposal on GitHub](${issueUrl})`); + + const tracks = session.labels + .filter(label => label.startsWith('track: ')) + .map(label => '- ' + label.substring('track: '.length)); + tracks.sort(); + const tracksStr = tracks.length > 0 ? ` +**Track(s):** +${tracks.join('\n')}` : + ''; + const attendanceStr = session.description.attendance === 'restricted' ? ` +**Attendance:** +This session is restricted to TPAC registrants.` : + ''; + + return `**Chairs:** +${session.chairs.map(chair => chair.name ?? '@' + chair.login).join(', ')} + +**Description:** +${session.description.description} + +**Goal(s):** +${session.description.goal} +${attendanceStr} + +**Materials:** +${materials.join('\n')} +${tracksStr}`; +} + + +/** + * Retrieve the Zoom meeting link. Zoom info may be a string or an object + * with a `link` property. + */ +function getZoomLink(zoomInfo) { + if (!zoomInfo) { + return ''; + } + const link = typeof zoomInfo === 'string' ? zoomInfo : zoomInfo.link; + if (!link || todoStrings.includes(link)) { + return ''; + } + else { + return link; + } +} + + +/** + * Retrieve instructions to join the meeting through Zoom, if possible. + */ +function getZoomInstructions(zoomInfo) { + if (!zoomInfo || typeof zoomInfo === 'string') { + return ''; + } + const link = getZoomLink(zoomInfo); + const id = zoomInfo.id; + const passcode = zoomInfo.passcode; + if (!id) { + return ''; + } + if (!link.includes('/' + id.replace(/\s/g, ''))) { + throw new Error(`Inconsistent info in ROOM_ZOOM: meeting ID "${id}" could not be found in meeting link "${link}"`); + } + + return `Join the Zoom meeting through: +${link} + +Or join from your phone using one of the local phone numbers at: +https://w3c.zoom.us/u/kb8tBvhWMN + +Meeting ID: ${id} +${passcode ? 'Passcode: ' + passcode : ''} +`; +} + + +/** + * Login to W3C server. + * + * The function throws if login fails. + */ +export async function authenticate(page, login, password, redirectUrl) { + const url = await page.evaluate(() => window.location.href); + if (!url.endsWith('/login')) { + return; + } + + const usernameInput = await page.waitForSelector('input#username'); + await usernameInput.type(login); + + const passwordInput = await page.waitForSelector('input#password'); + await passwordInput.type(password); + + const submitButton = await page.waitForSelector('button[type=submit]'); + await submitButton.click(); + + await page.waitForNavigation(); + const newUrl = await page.evaluate(() => window.location.href); + if (newUrl !== redirectUrl) { + throw new Error('Could not login. Invalid credentials?'); + } +} + + +/** + * Make sure that the calendar entry loaded in the given browser's page links + * back to the given session. + * + * The function throws if that's not the case. + */ +async function assessCalendarEntry(page, session) { + const issueUrl = `https://github.com/${session.repository}/issues/${session.number}`; + await page.evaluate(`window.tpac_breakouts_issueurl = "${issueUrl}";`); + const desc = await page.$eval('textarea#event_agenda', el => el.value); + if (!desc) { + throw new Error('No detailed agenda in calendar entry'); + } + if (!desc.includes(`- [Session proposal on GitHub](${issueUrl}`)) { + throw new Error('Calendar entry does not link back to GitHub issue'); + } +} + + +/** + * Fill/Update calendar entry loaded in the given browser's page with the + * session's info. + * + * The function returns the URL of the calendar entry, once created/updated. + */ +async function fillCalendarEntry({ page, session, project, status, zoom }) { + async function selectEl(selector) { + const el = await page.waitForSelector(selector); + if (!el) { + throw new Error(`No element in page that matches "${selector}"`); + } + return el; + } + async function fillTextInput(selector, value) { + const el = await selectEl(selector); + + // Clear input (select all and backspace!) + // Note this should use platform-specific commands in theory + // ... but that would not work on Mac in any case, see: + // https://github.com/puppeteer/puppeteer/issues/1313 + await el.click({ clickCount: 1 }); + await page.keyboard.down('ControlLeft'); + await page.keyboard.press('KeyA'); + await page.keyboard.up('ControlLeft'); + await el.press('Backspace'); + + if (value) { + await el.type(value); + } + } + async function clickOnElement(selector) { + const el = await selectEl(selector); + await el.click(); + } + async function chooseOption(selector, value) { + const el = await selectEl(selector); + await el.select(value); + } + + await fillTextInput('input#event_title', session.title); + + // Note statuses are different when calendar entry has already been flagged as + // "tentative" or "confirmed" ("draft" no longer exists in particular). + status = status ?? 'draft'; + await page.$eval(`input[name="event[status]"][value=${status}]`, el => el.click()); + await fillTextInput('textarea#event_description', session.description.description); + + const room = project.rooms.find(room => room.name === session.room); + const roomLocation = (room?.label ?? '') + (room?.location ? ' - ' + room.location : ''); + await fillTextInput('input#event_location', roomLocation ?? ''); + + // All events are visible to everyone + await clickOnElement('input#event_visibility_0'); + + await page.evaluate(`window.tpac_breakouts_date = "${project.metadata.date}";`); + await page.$eval('input#event_start_date', el => el.value = window.tpac_breakouts_date); + await page.$eval('input#event_start_date', el => el.value = window.tpac_breakouts_date); + + const slot = project.slots.find(s => s.name === session.slot); + await chooseOption('select#event_start_time_hour', `${parseInt(slot.start.split(':')[0], 10)}`); + await chooseOption('select#event_start_time_minute', `${parseInt(slot.start.split(':')[1], 10)}`); + await chooseOption('select#event_end_time_hour', `${parseInt(slot.end.split(':')[0], 10)}`); + await chooseOption('select#event_end_time_minute', `${parseInt(slot.end.split(':')[1], 10)}`); + + await chooseOption('select#event_timezone', project.metadata.timezone); + + // Add chairs as individual attendees + // Note: the select field is hidden so attendees will only appear once + // calendar entry has been submitted. + const chairs = session.chairs.filter(chair => chair.w3cId && chair.w3cId !== -1); + if (chairs.length > 0) { + await page.evaluate(`window.tpac_breakouts_chairs = ${JSON.stringify(chairs, null, 2)};`); + await page.$eval('select#event_individuals', el => el.innerHTML += + window.tpac_breakouts_chairs + .filter(chair => !el.querySelector(`option[selected][value="${chair.w3cId}"]`)) + .map(chair => ``) + .join('\n') + ); + } + + // Show joining information to "Holders of a W3C account", unless session is restricted + // to TPAC registrants + await clickOnElement('input#event_joinVisibility_' + (session.description.attendance === 'restricted' ? '2' : '1')); + + if (getZoomLink(zoom)) { + await fillTextInput('input#event_joinLink', getZoomLink(zoom)); + await fillTextInput('textarea#event_joiningInstructions', getZoomInstructions(zoom)); + } + else { + // No Zoom info? Let's preserve what the calendar entry may already contain. + } + + await fillTextInput('input#event_chat', + `https://irc.w3.org/?channels=${encodeURIComponent(session.description.shortname)}`); + const agendaUrl = todoStrings.includes(session.description.materials.agenda) ? + undefined : session.description.materials.agenda; + await fillTextInput('input#event_agendaUrl', agendaUrl); + + await fillTextInput('textarea#event_agenda', formatAgenda(session)); + + const minutesUrl = todoStrings.includes(session.description.materials.minutes) ? + undefined : session.description.materials.minutes; + await fillTextInput('input#event_minutesUrl', minutesUrl); + + // Big meeting is "TPAC 2023", not the actual option value + await page.evaluate(`window.tpac_breakouts_meeting = "${project.metadata.meeting}";`); + await page.$$eval('select#event_big_meeting option', options => options.forEach(el => + el.selected = el.innerText.startsWith(window.tpac_breakouts_meeting))); + await chooseOption('select#event_category', 'breakout-sessions'); + + // Click on "Create/Update but don't send notifications" button + // and return URL of the calendar entry + await clickOnElement(status === 'draft' ? + 'button#event_submit' : + 'button#event_no_notif'); + await page.waitForNavigation(); + const calendarUrl = await page.evaluate(() => window.location.href); + if (calendarUrl.endsWith('/new') || calendarUrl.endsWith('/edit/')) { + throw new Error('Calendar entry submission failed'); + } + return calendarUrl; +} + + +/** + * Create/Update calendar entry that matches given session + */ +export async function convertSessionToCalendarEntry( + { browser, session, project, calendarServer, login, password, status, zoom }) { + // First, retrieve known information about the project and the session + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + if (sessionErrors.length > 0) { + throw new Error(`Session ${session.number} contains errors that need fixing`); + } + if (!session.slot) { + // TODO: if calendar URL is set, delete calendar entry + return; + } + + for (const chair of session.chairs) { + if (chair.name === chair.login && chair.w3cId) { + await fetchChairName({ chair, browser, login, password }); + } + } + + const calendarUrl = session.description.materials.calendar ?? undefined; + const pageUrl = calendarUrl ? + `${calendarUrl.replace(/www\.w3\.org/, calendarServer)}edit/` : + `https://${calendarServer}/events/meetings/new/`; + + console.log(`- load calendar page: ${pageUrl}`); + const page = await browser.newPage(); + + try { + await page.goto(pageUrl); + await authenticate(page, login, password, pageUrl); + + if (calendarUrl) { + console.log('- make sure existing calendar entry is linked to the session'); + await assessCalendarEntry(page, session); + } + + console.log('- fill calendar entry'); + const newCalendarUrl = await fillCalendarEntry({ + page, session, project, status, zoom + }); + console.log(`- calendar entry created/updated: ${newCalendarUrl}`); + + // Update session's materials with calendar URL if needed + if (newCalendarUrl && !calendarUrl) { + console.log(`- add calendar URL to session description`); + if (!session.description.materials) { + session.description.materials = {}; + } + session.description.materials.calendar = newCalendarUrl; + await updateSessionDescription(session); + } + } + finally { + await page.close(); + } +} diff --git a/tools/lib/chairs.mjs b/tools/lib/chairs.mjs new file mode 100644 index 0000000..90bd63f --- /dev/null +++ b/tools/lib/chairs.mjs @@ -0,0 +1,120 @@ +import { sendGraphQLRequest } from './graphql.mjs'; +import { fetchW3CAccount } from './w3caccount.mjs'; + +/** + * Retrieve information about session chairs in an array + * + * The session chairs include the session issue author and the additional + * chairs listed as GitHub identities in the issue's body. + * + * Returned array contains, for each chair, an object with the user's: + * - GitHub login + * - GitHub avatar URL + * - GitHub databaseId + * - W3C account ID + * - W3C account name + * - W3C account email + * + * The object may only contain the GitHub login or the W3C account name. + */ +export async function fetchSessionChairs(session, chairs2W3CID) { + const lcChairs2W3CID = {}; + for (const name of Object.keys(chairs2W3CID ?? {})) { + lcChairs2W3CID[name.toLowerCase()] = chairs2W3CID[name]; + } + const chairs = []; + if (session.author) { + if (!session.description.chairs || + !session.description.chairs.find(c => c.name?.toLowerCase() === 'author-')) { + const w3cAccount = await fetchW3CAccount(session.author.databaseId); + const chair = { + databaseId: session.author.databaseId, + avatarUrl: session.author.avatarUrl, + login: session.author.login + }; + if (w3cAccount) { + chair.w3cId = w3cAccount.w3cId; + chair.name = w3cAccount.name; + chair.email = w3cAccount.email; + } + else if (lcChairs2W3CID[session.author.login.toLowerCase()]) { + chair.w3cId = lcChairs2W3CID[session.author.login.toLowerCase()]; + chair.name = session.author.login; + } + chairs.push(chair); + } + } + if (session.description.chairs) { + for (const chairDesc of session.description.chairs) { + if (chairDesc.name?.toLowerCase() === 'author-') { + continue; + } + let chair = Object.assign({}, chairDesc); + if (chair.login) { + const githubAccount = await sendGraphQLRequest(`query { + user(login: "${chair.login}") { + databaseId + login + avatarUrl + } + }`); + if (githubAccount.data.user) { + chair.databaseId = githubAccount.data.user.databaseId; + chair.avatarUrl = githubAccount.data.user.avatarUrl; + const w3cAccount = await fetchW3CAccount(chair.databaseId); + if (w3cAccount) { + chair.w3cId = w3cAccount.w3cId; + chair.name = w3cAccount.name; + chair.email = w3cAccount.email; + } + else if (lcChairs2W3CID[chair.login.toLowerCase()]) { + chair.w3cId = lcChairs2W3CID[chair.login.toLowerCase()]; + chair.name = chair.login; + } + } + } + else if (lcChairs2W3CID[chair.name.toLowerCase()]) { + chair.w3cId = lcChairs2W3CID[chair.name.toLowerCase()]; + } + chairs.push(chair); + } + } + return chairs; +} + + +/** + * Validate the given list of session chairs, where each chair is represented + * with an object that follows the same format as that returned by the + * `fetchSessionChairs` function. + * + * The function returns a list of errors (each error is a string), or an empty + * array when the list looks fine. The function throws if the list is invalid, + * in other words if it contains objects that don't have a `login` property. + */ +export function validateSessionChairs(chairs) { + if (chairs.length === 0) { + return ['Issue author is not a session chair, no other chair specified']; + } + return chairs + .map(chair => { + if (chair.login) { + if (!chair.databaseId) { + return `No GitHub account associated with "@${chair.login}"`; + } + if (!chair.w3cId) { + return `No W3C account linked to the "@${chair.login}" GitHub account`; + } + } + else if (chair.name) { + if (!chair.w3cId) { + return `No W3C account linked to "${chair.name}"`; + } + } + else { + throw new Error('Invalid chair object received in the list to validate'); + } + return null; + }) + .filter(error => !!error); +} \ No newline at end of file diff --git a/tools/lib/envkeys.mjs b/tools/lib/envkeys.mjs new file mode 100644 index 0000000..c0afe7d --- /dev/null +++ b/tools/lib/envkeys.mjs @@ -0,0 +1,38 @@ +import path from 'path'; + +let config = null; + +/** + * Retrieve the requested variable from the environment or from the + * `config.json` file in the current working directory if it exists. + * + * Function throws if the environment key is missing, unless a default + * value was provided. + */ +export async function getEnvKey(key, defaultValue, json) { + if (Object.hasOwn(process.env, key)) { + return json ? JSON.parse(process.env[key]) : process.env[key]; + } + try { + if (!config) { + const configFileUrl = 'file:///' + + path.join(process.cwd(), 'config.json').replace(/\\/g, '/'); + const { default: env } = await import( + configFileUrl, + { assert: { type: 'json' } } + ); + config = env; + } + } + catch { + } + finally { + if (config && Object.hasOwn(config, key)) { + return config[key]; + } + else if (defaultValue !== undefined) { + return defaultValue; + } + throw new Error(`No ${key} found in environment of config file.`); + } +} \ No newline at end of file diff --git a/tools/lib/graphql.mjs b/tools/lib/graphql.mjs new file mode 100644 index 0000000..3788d4f --- /dev/null +++ b/tools/lib/graphql.mjs @@ -0,0 +1,43 @@ +import { getEnvKey } from './envkeys.mjs'; + +/** + * Internal memory cache to avoid sending the same request more than once + * (same author may be associated with multiple sessions!) + */ +const cache = {}; + + +/** + * Wrapper function to send an GraphQL request to the GitHub GraphQL endpoint, + * authenticating using either a token read from the environment (typically + * useful when code is run within a GitHub job) or from a `config.json` file in + * the root folder of the repository (typically useful for local runs). + * + * Function throws if the personal access token is missing. + */ +export async function sendGraphQLRequest(query, acceptHeader = '') { + if (cache[query]) { + return Object.assign({}, cache[query]); + } + const GRAPHQL_TOKEN = await getEnvKey('GRAPHQL_TOKEN'); + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `bearer ${GRAPHQL_TOKEN}`, + 'Accept': acceptHeader ?? undefined + }, + body: JSON.stringify({ query }, null, 2) + }); + if (res.status !== 200) { + if (res.status >= 500) { + throw new Error(`GraphQL server error, ${res.status} status received`); + } + if (res.status === 403) { + throw new Error(`GraphQL server reports that the API key is invalid, ${res.status} status received`); + } + throw new Error(`GraphQL server returned an unexpected HTTP status ${res.status}`); + } + cache[query] = await res.json(); + return cache[query]; +} diff --git a/tools/lib/project.mjs b/tools/lib/project.mjs new file mode 100644 index 0000000..c5be4ae --- /dev/null +++ b/tools/lib/project.mjs @@ -0,0 +1,433 @@ +import { sendGraphQLRequest } from './graphql.mjs'; + +/** + * Retrieve available project data. + * + * This includes: + * - the list of rooms and their capacity + * - the list of slots and their duration + * - the detailed list of breakout sessions associated with the project + * - the room and slot that may already have been associated with each session + * + * Returned object should look like: + * { + * "title": "TPAC xxxx breakout sessions", + * "url": "https://github.com/organization/w3c/projects/xx", + * "id": "xxxxxxx", + * "roomsFieldId": "xxxxxxx", + * "rooms": [ + * { "id": "xxxxxxx", "name": "Salon Ecija (30)", "label": "Salon Ecija", "capacity": 30 }, + * ... + * ], + * "slotsFieldId": "xxxxxxx", + * "slots": [ + * { "id": "xxxxxxx", "name": "9:30 - 10:30", "start": "9:30", "end": "10:30", "duration": 60 }, + * ... + * ], + * "severityFieldIds": { + * "Check": "xxxxxxx", + * "Warning": "xxxxxxx", + * "Error": "xxxxxxx", + * "Note": "xxxxxxx" + * }, + * "sessions": [ + * { + * "repository": "w3c/tpacxxxx-breakouts", + * "number": xx, + * "title": "Session title", + * "body": "Session body, markdown", + * "labels": [ "session", ... ], + * "author": { + * "databaseId": 1122927, + * "login": "tidoust", + * "avatarUrl": "https://avatars.githubusercontent.com/u/1122927?v=4" + * }, + * "createdAt": "2023-05-10T12:55:17Z", + * "updatedAt": "2023-05-10T13:12:11Z", + * "lastEditedAt": "2023-05-10T13:12:11Z", + * "room": "Salon Ecija (30)", + * "slot": "9:30 - 10:30" + * }, + * ... + * ], + * "labels": [ + * { + * "id": "xxxxxxx", + * "name": "error: format" + * }, + * ... + * ] + * } + */ +export async function fetchProject(login, id) { + // Login is an organization name... or starts with "user/" to designate + // a user project. + const tokens = login.split('/'); + const type = (tokens.length === 2) && tokens[0] === 'user' ? + 'user' : + 'organization'; + login = (tokens.length === 2) ? tokens[1] : login; + + // Retrieve information about the list of rooms + const roomsResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + id + url + title + shortDescription + field(name: "Room") { + ... on ProjectV2SingleSelectField { + id + name + options { + ... on ProjectV2SingleSelectFieldOption { + id + name + } + } + } + } + } + } + }`); + const project = roomsResponse.data[type].projectV2 + const rooms = project.field; + + // Similar request to list time slots + const slotsResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "Slot") { + ... on ProjectV2SingleSelectField { + id + name + options { + ... on ProjectV2SingleSelectFieldOption { + id + name + } + } + } + } + } + } + }`); + const slots = slotsResponse.data[type].projectV2.field; + + // Similar requests to get the ids of the custom fields used for validation + const severityFieldIds = {}; + for (const severity of ['Error', 'Warning', 'Check', 'Note']) { + const response = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "${severity}") { + ... on ProjectV2FieldCommon { + id + name + } + } + } + } + }`); + severityFieldIds[severity] = response.data[type].projectV2.field.id; + } + + // Another request to retrieve the list of sessions associated with the project. + const sessionsResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}") { + projectV2(number: ${id}) { + items(first: 100) { + nodes { + id + content { + ... on Issue { + id + repository { + owner { + login + } + name + nameWithOwner + } + number + state + title + body + labels(first: 20) { + nodes { + name + } + } + author { + ... on User { + databaseId + } + login + avatarUrl + } + createdAt + updatedAt + lastEditedAt + } + } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2FieldCommon { + name + } + } + } + } + } + } + } + } + } + }`); + const sessions = sessionsResponse.data[type].projectV2.items.nodes; + + const repository = sessions[0].content.repository; + const labelsResponse = await sendGraphQLRequest(`query { + repository(owner: "${repository.owner.login}", name: "${repository.name}") { + labels(first: 50) { + nodes { + id + name + } + } + } + }`); + const labels = labelsResponse.data.repository.labels.nodes; + + // Let's combine and flatten the information a bit + return { + // Project's title and URL are more for internal reporting purpose. + title: project.title, + url: project.url, + id: project.id, + + // Project's description should help us extract additional metadata: + // - the date of the breakout sessions + // - the timezone to use to interpret time slots + // - the "big meeting" value to associate calendar entries to TPAC + metadata: parseProjectDescription(project.shortDescription), + + // List of rooms. For each of them, we return the exact name of the option + // for the "Room" custom field in the project (which should include the + // room's capacity), the actual name of the room without the capacity, and + // the room's capacity in number of seats. + roomsFieldId: rooms.id, + rooms: rooms.options.map(room => { + const match = + room.name.match(/(.*) \((\d+)\s*(?:\-\s*([^\)]+))?\)$/) ?? + [room.name, room.name, '30', undefined]; + return { + id: room.id, + name: match[0], + label: match[1], + location: match[3] ?? '', + capacity: parseInt(match[2], 10) + }; + }), + + // IDs of custom fields used to store validation problems + severityFieldIds: severityFieldIds, + + // List of slots. For each of them, we return the exact name of the option + // for the "Slot" custom field in the project, the start and end times and + // the duration in minutes. + slotsFieldId: slots.id, + slots: slots.options.map(slot => { + const times = slot.name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/) ?? + [null, '00', '00', '01', '00']; + return { + id: slot.id, + name: slot.name, + start: `${times[1]}:${times[2]}`, + end: `${times[3]}:${times[4]}`, + duration: + (parseInt(times[3], 10) * 60 + parseInt(times[4], 10)) - + (parseInt(times[1], 10) * 60 + parseInt(times[2], 10)) + }; + }), + + // List of open sessions linked to the project (in other words, all of the + // issues that have been associated with the project). For each session, we + // return detailed information, including its title, full body, author, + // labels, and the room and slot that may already have been assigned. + sessions: sessions + .filter(session => session.content.state === 'OPEN') + .map(session => { + return { + projectItemId: session.id, + id: session.content.id, + repository: session.content.repository.nameWithOwner, + number: session.content.number, + title: session.content.title, + body: session.content.body, + labels: session.content.labels.nodes.map(label => label.name), + author: { + databaseId: session.content.author.databaseId, + login: session.content.author.login, + avatarUrl: session.content.author.avatarUrl + }, + createdAt: session.content.createdAt, + updatedAt: session.content.updatedAt, + lastEditedAt: session.content.lastEditedAt, + room: session.fieldValues.nodes + .find(value => value.field?.name === 'Room')?.name, + slot: session.fieldValues.nodes + .find(value => value.field?.name === 'Slot')?.name, + validation: { + check: session.fieldValues.nodes.find(value => value.field?.name === 'Check')?.text, + warning: session.fieldValues.nodes.find(value => value.field?.name === 'Warning')?.text, + error: session.fieldValues.nodes.find(value => value.field?.name === 'Error')?.text, + note: session.fieldValues.nodes.find(value => value.field?.name === 'Note')?.text + } + }; + }), + + // Labels defined in the associated repository + // (note all sessions should belong to the same repository!) + labels: labels + }; +} + + +/** + * Helper function to parse a project description and extract additional + * metadata about breakout sessions: date, timezone, big meeting id + * + * Description needs to be a comma-separated list of parameters. Example: + * "meeting: tpac2023, day: 2023-09-13, timezone: Europe/Madrid" + */ +function parseProjectDescription(desc) { + const metadata = {}; + if (desc) { + desc.split(/,/) + .map(param => param.trim()) + .map(param => param.split(/:/).map(val => val.trim())) + .map(param => metadata[param[0]] = param[1]); + } + return metadata; +} + +/** + * Record the slot and room assignment for the provided session + */ +export async function assignSessionsToSlotAndRoom(session, project) { + const slot = project.slots.find(slot => session.slot === slot.name); + const resSlot = await sendGraphQLRequest(`mutation { + updateProjectV2ItemFieldValue(input: { + clientMutationId: "mutatis mutandis", + fieldId: "${project.slotsFieldId}", + itemId: "${session.projectItemId}", + projectId: "${project.id}", + value: { + singleSelectOptionId: "${slot.id}" + } + }) { + clientMutationId + } + }`); + if (!resSlot?.data?.updateProjectV2ItemFieldValue?.clientMutationId) { + console.log(JSON.stringify(resSlot, null, 2)); + throw new Error(`GraphQL error, could not assign session #${session.number} to slot ${session.slot}`); + } + + const room = project.rooms.find(room => session.room === room.name); + const resRoom = await sendGraphQLRequest(`mutation { + updateProjectV2ItemFieldValue(input: { + clientMutationId: "mutatis mutandis", + fieldId: "${project.roomsFieldId}", + itemId: "${session.projectItemId}", + projectId: "${project.id}", + value: { + singleSelectOptionId: "${room.id}" + } + }) { + clientMutationId + } + }`); + if (!resRoom?.data?.updateProjectV2ItemFieldValue?.clientMutationId) { + console.log(JSON.stringify(resRoom, null, 2)); + throw new Error(`GraphQL error, could not assign session #${session.number} to room ${session.room}`); + } +} + + +/** + * Record session validation problems + */ +export async function saveSessionValidationResult(session, project) { + for (const severity of ['Check', 'Warning', 'Error']) { + const fieldId = project.severityFieldIds[severity]; + const value = session.validation[severity.toLowerCase()] ?? ''; + const response = await sendGraphQLRequest(`mutation { + updateProjectV2ItemFieldValue(input: { + clientMutationId: "mutatis mutandis", + fieldId: "${fieldId}", + itemId: "${session.projectItemId}", + projectId: "${project.id}", + value: { + text: "${value}" + } + }) { + clientMutationId + } + }`); + if (!response?.data?.updateProjectV2ItemFieldValue?.clientMutationId) { + console.log(JSON.stringify(response, null, 2)); + throw new Error(`GraphQL error, could not record "${severity}" for session #${session.number}`); + } + } +} + + +/** + * Validate that we have the information we need about the project. + */ +export function validateProject(project) { + const errors = []; + + if (!project.metadata) { + errors.push('The short description is missing. It should set the meeting, date, and timezone.'); + } + else { + if (!project.metadata.meeting) { + errors.push('The "meeting" info in the short description is missing. Should be something like "meeting: TPAC 2023"'); + } + if (!project.metadata.date) { + errors.push('The "date" info in the short description is missing. Should be something like "date: 2023-09-13"'); + } + else if (!project.metadata.date.match(/^\d{4}-\d{2}-\d{2}$/)) { + errors.push('The "date" info in the short description must follow the YYYY-MM-DD format'); + } + if (!project.metadata.timezone) { + errors.push('The "timezone" info in the short description is missing. Should be something like "timezone: Europe/Madrid"'); + } + } + + for (const slot of project.slots) { + if (!slot.name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/)) { + errors.push(`Invalid slot name "${slot.name}". Format should be "HH:mm - HH:mm"`); + } + if (slot.duration !== 30 && slot.duration !== 60) { + errors.push(`Unexpected slot duration ${slot.duration}. Duration should be 30 or 60 minutes.`); + } + } + + return errors; +} \ No newline at end of file diff --git a/tools/lib/session.mjs b/tools/lib/session.mjs new file mode 100644 index 0000000..a8f6005 --- /dev/null +++ b/tools/lib/session.mjs @@ -0,0 +1,337 @@ +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import * as YAML from 'yaml'; +import { fileURLToPath } from 'url'; +import { sendGraphQLRequest } from './graphql.mjs'; +import { todoStrings } from './todostrings.mjs'; +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + + +/** + * The list of sections that may be found in a session body and, for each of + * them, a `validate` function to validate the format of the section and a + * `parse` function to return interpreted values. + * + * The list needs to be populated once and for all through a call to the async + * `initSectionHandlers` function, which reads section info from the + * `session.yml` file. + */ +let sectionHandlers = null; + + +/** + * The issue template asks proposers to use comma- or space-separated lists, or + * impose the list format. In practice, regardless of what we ask for or try to + * impose, proposers often mix that with other markdown constructs for lists. + * This function tries to handle all possibilities. + * + * The `spaceSeparator` option tells the function to split tokens on spaces. + * The `prefix` option is only useful when `spaceSeparator` is set. It tells + * the function to only split tokens on spaces provided that the value starts + * with the provided prefix. This is useful to parse a list that, e.g., mixes + * GitHub identities and actual names: + * - @tidoust @ianbjacobs + * - John Doe + */ +function parseList(value, { spaceSeparator = false, prefix = null }) { + return (value || '') + .split(/[\n,]/) + .map(token => token.trim()) + .map(token => token.replace(/^(?:[-\+\*]|\d+[\.\)])\s*(.*)$/, '$1')) + .map(token => token.trim()) + .filter(token => !!token) + .map(token => { + if (spaceSeparator) { + if (!prefix || token.startsWith(prefix)) { + return token.split(/\s+/); + } + } + return token; + }) + .flat(); +} + + +/** + * Populate the list of section handlers from the info in `session.yml`. + * + * The function needs to be called once before `parseSessionBody` or + * `validateSessionBody` may be called (function returns immediately on + * further calls). + */ +export async function initSectionHandlers() { + if (sectionHandlers) { + return; + } + const yamlTemplate = await readFile( + path.join(__dirname, '..', '..', '.github', 'ISSUE_TEMPLATE', 'session.yml'), + 'utf8'); + const template = YAML.parse(yamlTemplate); + sectionHandlers = template.body + .filter(section => !!section.id) + .map(section => { + const handler = { + id: section.id, + title: section.attributes.label.replace(/ \(Optional\)$/, ''), + required: !!section.validations?.required, + validate: value => true, + parse: value => value, + serialize: value => value + }; + if (section.type === 'dropdown') { + handler.options = section.attributes.options.map(o => o.toLowerCase()); + handler.validate = value => handler.options.includes(value.toLowerCase()); + } + else if (section.type === 'input') { + handler.validate = value => !value.match(/\n/) + } + return handler; + }) + .map(handler => { + // Add custom validation constraints and parse/serialize logic + // Ideally, this logic would be encoded in session.yml but GitHub rejects + // additional properties in issue template files. + switch (handler.id) { + + case 'description': + // TODO: validate that markdown remains simple enough + break; + + case 'goal': + // Relax, people may use markdown after all + // TODO: validate that markdown remains simple enough + handler.validate = value => true; + break; + + case 'chairs': + // List of GitHub identities... or of actual names + // Space-separated values are possible when there are only GitHub + // identities. Otherwise, CSV, line-separated or markdown lists. + handler.parse = value => parseList(value, { spaceSeparator: true, prefix: '@' }) + .map(nick => { + if (nick.startsWith('@')) { + return { login: nick.substring(1) }; + } + else { + return { name: nick }; + } + }); + handler.validate = value => { + const chairs = parseList(value, { spaceSeparator: true, prefix: '@' }); + return chairs.every(nick => nick.match(/^(@[A-Za-z0-9][A-Za-z0-9\-]+|[^@]+)$/)); + } + handler.serialize = value => value + .map(nick => nick.login ? `@${nick.login}` : nick.name) + .join(', '); + break; + + case 'shortname': + handler.validate = value => value.match(/^#?[A-Za-z0-9\-_]+$/); + break; + + case 'attendance': + handler.parse = value => value.toLowerCase() === 'restricted to tpac registrants' ? + 'restricted' : 'public'; + handler.serialize = value => value === 'restricted' ? + 'Restricted to TPAC registrants' : 'Anyone may attend (Default)'; + break; + + case 'duration': + handler.parse = value => value.toLowerCase() === '30 minutes' ? 30 : 60; + handler.serialize = value => value === 30 ? '30 minutes' : '60 minutes (Default)'; + break; + + case 'conflicts': + // List of GitHub issues + handler.parse = value => parseList(value, { spaceSeparator: true, prefix: '#' }) + .map(issue => parseInt(issue.substring(1), 10)); + handler.validate = value => { + const conflictingSessions = parseList(value, { spaceSeparator: true, prefix: '#' }); + return conflictingSessions.every(issue => issue.match(/^#\d+$/)); + }; + handler.serialize = value => value.map(issue => `#${issue}`).join(', '); + break; + + case 'capacity': + handler.parse = value => { + switch (value.toLowerCase()) { + case 'don\'t know': return 0; + case 'don\'t know (default)': return 0; + case 'fewer than 20 people': return 15; + case '20-45 people': return 30; + case 'more than 45 people': return 50; + }; + }; + handler.serialize = value => { + switch (value) { + case 0: return 'Don\'t know (Default)'; + case 15: return 'Fewer than 20 people'; + case 30: return '20-45 people'; + case 50: return 'More than 45 people'; + } + } + break; + + case 'materials': + const capitalize = str => str.slice(0, 1).toUpperCase() + str.slice(1); + handler.parse = value => { + const materials = {}; + parseList(value, { spaceSeparator: false }) + .map(line => + line.match(/^\[(.+)\]\((.*)\)$/i) ?? + line.match(/^([^:]+):\s*(.*)$/i)) + .forEach(match => materials[match[1].toLowerCase()] = match[2]); + return materials; + }; + handler.validate = value => { + const matches = parseList(value, { spaceSeparator: false }) + .map(line => + line.match(/^\[(.+)\]\((.*)\)$/i) || + line.match(/^([^:]+):\s*(.*)$/i)); + return matches.every(match => { + if (!match) { + return false; + } + if (!todoStrings.includes(match[2].toUpperCase())) { + try { + new URL(match[2]); + return true; + } + catch (err) { + return false; + } + } + return true; + }); + } + handler.serialize = value => Object.entries(value) + .map(([key, url]) => todoStrings.includes(url) ? + `- ${capitalize(key)}: ${url}` : + `- [${capitalize(key)}](${url})`) + .join('\n'); + break; + } + + return handler; + }); +} + + +/** + * Helper function to split a session issue body (in markdown) into sections + */ +function splitIntoSections(body) { + return body.split(/^### /m) + .filter(section => !!section) + .map(section => section.split(/\r?\n/)) + .map(section => { + let value = section.slice(1).join('\n\n').trim(); + if (value.replace(/^_(.*)_$/, '$1') === 'No response') { + value = null; + } + return { + title: section[0].replace(/ \(Optional\)$/, ''), + value + }; + }); +} + + +/** + * Validate the session issue body and return a list of errors (or an empty + * array if all is fine) + */ +export function validateSessionBody(body) { + if (!sectionHandlers) { + throw new Error('Need to call `initSectionHandlers` first!'); + } + const sections = splitIntoSections(body); + const errors = sections + .map(section => { + const sectionHandler = sectionHandlers.find(handler => + handler.title === section.title); + if (!sectionHandler) { + return `Unexpected section "${section.title}"`; + } + if (!section.value && sectionHandler.required) { + return `Unexpected empty section "${section.title}"`; + } + if (section.value && !sectionHandler.validate(section.value)) { + return `Invalid content in section "${section.title}"`; + } + return null; + }) + .filter(error => !!error); + + // Also report required sections that are missing + for (const handler of sectionHandlers) { + if (handler.required && !sections.find(s => s.title === handler.title)) { + errors.push(`Missing required section "${handler.title}"`); + } + } + + return errors; +} + + +/** + * Parse the session issue body and return a structured object with values that + * describes the session. + */ +export function parseSessionBody(body) { + if (!sectionHandlers) { + throw new Error('Need to call `initSectionHandlers` first!'); + } + const session = {}; + splitIntoSections(body) + .map(section => { + const sectionHandler = sectionHandlers.find(handler => + handler.title === section.title); + return { + id: sectionHandler.id, + value: section.value || section.value === 0 ? + sectionHandler.parse(section.value) : + null + }; + }) + .forEach(input => session[input.id] = input.value); + return session; +} + + +/** + * Serialize a session description into an issue body + */ +export function serializeSessionDescription(description) { + if (!sectionHandlers) { + throw new Error('Need to call `initSectionHandlers` first!'); + } + return sectionHandlers + .map(handler => `### ${handler.title}${handler.required ? '' : ' (Optional)'} + +${(description[handler.id] || description[handler.id] === 0) ? + handler.serialize(description[handler.id]) : '_No response_' }`) + .join('\n\n'); +} + + +/** + * Update session description + */ +export async function updateSessionDescription(session) { + const body = serializeSessionDescription(session.description); + const res = await sendGraphQLRequest(`mutation { + updateIssue(input: { + id: "${session.id}", + body: "${body.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}" + }) { + issue { + id + } + } + }`); + if (!res?.data?.updateIssue?.issue?.id) { + console.log(JSON.stringify(res, null, 2)); + throw new Error(`GraphQL error, could not update issue body`); + } +} \ No newline at end of file diff --git a/tools/lib/todostrings.mjs b/tools/lib/todostrings.mjs new file mode 100644 index 0000000..369f64a --- /dev/null +++ b/tools/lib/todostrings.mjs @@ -0,0 +1 @@ +export const todoStrings = ['', '@', '@@', '@@@', 'TBD', 'TODO']; \ No newline at end of file diff --git a/tools/lib/validate.mjs b/tools/lib/validate.mjs new file mode 100644 index 0000000..558e222 --- /dev/null +++ b/tools/lib/validate.mjs @@ -0,0 +1,286 @@ +import { fetchProject, validateProject } from './project.mjs'; +import { initSectionHandlers, validateSessionBody, parseSessionBody } from './session.mjs'; +import { fetchSessionChairs, validateSessionChairs } from './chairs.mjs'; +import { todoStrings } from './todostrings.mjs'; + + +/** + * Validate the entire grid. + * + * The function returns a list of errors by type. Each error links to the + * session that may need some care. + */ +export async function validateGrid(project) { + const projectErrors = validateProject(project); + if (projectErrors.length > 0) { + throw new Error(`Project "${project.title}" is invalid: +${projectErrors.map(error => '- ' + error).join('\n')}`); + } + + let errors = []; + for (const session of project.sessions) { + const sessionErrors = await validateSession(session.number, project); + errors = errors.concat(sessionErrors); + } + return errors; +} + + +/** + * Validate a session. + * + * The function returns a list of errors by type (i.e., by GitHub "label"). + * Errors in the list may be real errors or warnings. + */ +export async function validateSession(sessionNumber, project) { + const projectErrors = validateProject(project); + if (projectErrors.length > 0) { + throw new Error(`Project "${project.title}" is invalid: +${projectErrors.map(error => '- ' + error).join('\n')}`); + } + + // Look for session in the list of issues in the project + const session = project.sessions.find(s => s.number === sessionNumber); + if (!session) { + throw new Error(`Session #${sessionNumber} is not in project "${project.title}"`); + } + + // List of validation issues found, grouped by type (i.e. by label). + let errors = []; + + // Validate and parse the session body, unless that was already done + if (!session.description) { + await initSectionHandlers(); + const formatErrors = validateSessionBody(session.body); + if (formatErrors.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'error', + type: 'format', + messages: formatErrors + }); + // Cannot validate the rest for now if body cannot be parsed + return errors; + } + session.description = parseSessionBody(session.body); + } + + // Retrieve information about chairs, unless that was already done + if (!session.chairs) { + session.chairs = await fetchSessionChairs(session, project.chairsToW3CID); + } + const chairsErrors = validateSessionChairs(session.chairs); + if (chairsErrors.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'error', + type: 'chairs', + messages: chairsErrors + }); + } + + // Make sure sessions identified as conflicting actually exist + let hasConflictErrors = false; + if (session.description.conflicts) { + const conflictErrors = session.description.conflicts + .map(number => { + if (number === sessionNumber) { + return `Session cannot conflict with itself`; + } + const conflictingSession = project.sessions.find(s => s.number === number); + if (!conflictingSession) { + return `Conflicting session ${number} is not in the project`; + } + return null; + }) + .filter(error => !!error); + hasConflictErrors = conflictErrors.length > 0; + if (hasConflictErrors) { + errors.push({ + session: sessionNumber, + severity: 'error', + type: 'conflict', + messages: conflictErrors + }); + } + } + + // Make sure there is no session scheduled at the same time in the same room + const scheduled = session.room && session.slot; + if (scheduled) { + const schedulingErrors = project.sessions + .filter(s => s !== session && s.room && s.slot) + .filter(s => s.room === session.room && s.slot === session.slot) + .map(s => `Session scheduled in same room (${s.room}) and same slot (${s.slot}) as session "${s.title}" (${s.number})`); + if (schedulingErrors.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'error', + type: 'scheduling', + messages: schedulingErrors + }); + } + } + + // Check assigned room matches requested capacity + if (session.room && session.description.capacity) { + const room = project.rooms.find(s => s.name === session.room); + if (room.capacity < session.description.capacity) { + errors.push({ + session: sessionNumber, + severity: 'warning', + type: 'capacity', + messages: ['Room capacity is lower than requested capacity'] + }); + } + } + + // Check absence of conflict with sessions with same chair(s) + if (session.slot) { + const chairConflictErrors = project.sessions + .filter(s => s !== session && s.slot === session.slot) + .filter(s => { + try { + const sdesc = parseSessionBody(s.body); + const sAuthorExcluded = sdesc.chairs + .find(c => c.name?.toLowerCase() === 'author-'); + if (!sAuthorExcluded && session.chairs.find(c => c.login === s.author.login)) { + return true; + } + const inboth = sdesc.chairs.find(chair => session.chairs.find(c => + (c.login && c.login.toLowerCase() === chair.login?.toLowerCase()) || + (c.name && c.name.toLowerCase() === chair.name?.toLowerCase()))); + return !!inboth; + } + catch { + return false; + } + }) + .map(s => `Same slot as session "${s.title}" (#${s.number}), which share a common chair`); + if (chairConflictErrors.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'error', + type: 'chair conflict', + messages: chairConflictErrors + }); + } + } + + // Check assigned slot is different from conflicting sessions + // (skipped if the list of conflicting sessions is invalid) + if (!hasConflictErrors && session.slot && session.description.conflicts) { + const conflictWarnings = session.description.conflicts + .map(number => { + const conflictingSession = project.sessions.find(s => s.number === number); + if (conflictingSession.slot === session.slot) { + return `Same slot "${session.slot}" as conflicting session "${conflictingSession.title}" (#${conflictingSession.number})`; + } + return null; + }) + .filter(warning => !!warning); + if (conflictWarnings.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'warning', + type: 'conflict', + messages: conflictWarnings + }); + } + } + + // Check absence of conflict with sessions in the same track(s) + if (session.slot) { + const tracks = session.labels.filter(label => label.startsWith('track: ')); + let tracksWarnings = []; + for (const track of tracks) { + const sessionsInSameTrack = project.sessions.filter(s => s !== session && s.labels.includes(track)); + const trackWarnings = sessionsInSameTrack + .map(other => { + if (other.slot === session.slot) { + return `Same slot "${session.slot}" as session in same track "${track}": "${other.title}" (#${other.number})`; + } + return null; + }) + .filter(warning => !!warning); + tracksWarnings = tracksWarnings.concat(trackWarnings); + } + if (tracksWarnings.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'warning', + type: 'track', + messages: tracksWarnings + }); + } + } + + // No two sessions can use the same IRC channel during the same slot + if (session.description.shortname) { + const ircConflicts = project.sessions + .filter(s => s.number !== session.number && s.slot === session.slot) + .filter(s => { + try { + const desc = parseSessionBody(s.body); + return desc.shortname === session.shortname; + } + catch { + return false; + } + }); + if (ircConflicts.length > 0) { + errors.push({ + session: sessionNumber, + severity: 'error', + type: 'irc', + messages: ircConflicts.map(s => `Same IRC channel "${s.description.shortname}" as session #${s.number} ${s.title}`) + }); + } + } + + // Check presence of comments + if (session.description.comments) { + errors.push({ + session: sessionNumber, + severity: 'check', + type: 'instructions', + messages: ['Session contains instructions for meeting planners'] + }); + } + + function isMaterialMissing(name) { + return !session.description.materials[name] || + todoStrings.includes(session.description.materials[name].toUpperCase()); + } + + // If breakout session took place more than 2 days ago, + // time to add a link to the minutes + const twoDaysInMs = 48 * 60 * 60 * 1000; + const atLeastTwoDaysOld = ( + (new Date()).getTime() - + (new Date(project.metadata.date)).getTime() + ) > twoDaysInMs; + if (scheduled && isMaterialMissing('minutes') && atLeastTwoDaysOld) { + errors.push({ + session: sessionNumber, + severity: 'warning', + type: 'minutes', + messages: ['Session needs a link to the minutes'] + }); + } + + // Minutes should ideally be stored on www.w3.org + if (!isMaterialMissing('minutes')) { + const minutesUrl = session.description.materials.minutes; + if (!minutesUrl.match(/\/(www|lists)\.w3\.org\//)) { + errors.push({ + session: sessionNumber, + severity: 'warning', + type: 'minutes origin', + messages: ['Minutes not stored on w3.org'] + }); + } + } + + return errors; +} \ No newline at end of file diff --git a/tools/lib/w3caccount.mjs b/tools/lib/w3caccount.mjs new file mode 100644 index 0000000..a053b81 --- /dev/null +++ b/tools/lib/w3caccount.mjs @@ -0,0 +1,47 @@ +import { getEnvKey } from './envkeys.mjs'; + + +/** + * Internal memory cache to avoid sending the same request more than once + * (same author may be associated with multiple sessions!) + */ +const cache = {}; + + +/** + * Return the W3C account linked to the requested person, identified by their + * GitHub identity. + * + * Note: the function takes a `databaseId` identifier (returned by GitHub) + * because users may update their `login` on GitHub at any time. + */ +export async function fetchW3CAccount(databaseId) { + // Only fetch accounts once + if (cache[databaseId]) { + return Object.assign({}, cache[databaseId]); + } + + const res = await fetch( + `https://api.w3.org/users/connected/github/${databaseId}` + ); + + if (res.status !== 200) { + if (res.status >= 500) { + throw new Error(`W3C API server error, ${res.status} status received`); + } + if (res.status === 404) { + return null; + } + throw new Error(`W3C API server returned an unexpected HTTP status ${res.status}`); + } + + const json = await res.json(); + const user = { + githubId: databaseId, + w3cId: json.id, + name: json.name, + email: json.email + }; + cache[databaseId] = user; + return user; +} diff --git a/tools/lib/webvtt2html.mjs b/tools/lib/webvtt2html.mjs new file mode 100644 index 0000000..750b31c --- /dev/null +++ b/tools/lib/webvtt2html.mjs @@ -0,0 +1,121 @@ +import webvttParser from 'webvtt-parser'; + +const parser = new webvttParser.WebVTTParser(); + +export async function convert(vttUrl, options) { + options = options || {}; + + function cleanSentence(sentence) { + if (options.clean) { + sentence = sentence.replace(/^slide [a-z0-9]*\.?/i, ''); + sentence = sentence.replace(/^next slide\.?/i, ''); + sentence = sentence.replace(/^next page\.?/i, ''); + sentence = sentence.replace(/^moving to next slide\.?/i, ''); + sentence = sentence.replace(/^moving to next page\.?/i, ''); + sentence = sentence.replace(/, you know, ?/g, ' '); + } + return sentence; + } + + const response = await fetch(vttUrl); + const vtt = await response.text(); + + let cues; + try { + ({cues} = parser.parse(vtt)); + } catch (e) { + console.error(`Could not parse ${vttUrl} as WebVTT: ` + e); + process.exit(1); + } + + cues.forEach(c => c.text = c.text + .replace(/]*>/, '') + .replace(/<\/v>/, '') + .replace('"','')); + if (options.clean) { + cues.forEach(c => c.text = c.text.replace(/^slide [0-9]+$/i, '')); + } + + const divs = [{ + slide: "1", + paragraphs: [] + }]; + let p = ''; + cues.forEach(c => { + if (c.id.startsWith("slide-")) { + if (cleanSentence(p)) { + divs[divs.length-1].paragraphs.push(cleanSentence(p)); + } + divs.push({ + slide: c.id.substring("slide-".length), + paragraphs: [] + }); + p = ''; + } else if (c.id.endsWith("-p")) { + if (cleanSentence(p)) { + divs[divs.length-1].paragraphs.push(cleanSentence(p)); + p = c.text; + } + p = ''; + } else if (c.text.match(/:/)) { + if (cleanSentence(p)) { + divs[divs.length-1].paragraphs.push(cleanSentence(p)); + p = c.text; + } + p = ''; + } + p += (p ? ' ' : '') + c.text; + }); + + // Output final sentence + if (cleanSentence(p)) { + divs[divs.length-1].paragraphs.push(cleanSentence(p)); + } + + let content = ''; + let pid = 1; + if (options.splitPerSlide) { + for (let i = 0 ; i < divs.length; i++) { + if (options.slideset) { + content += `
    `; + content += `Slide ${divs[i].slide} of ${divs.length}\n`; + } + content += (options.markupStart || `
    `) + "\n"; + + for (const p of divs[i].paragraphs) { + const match = p.match(/^(.*):\s*(.*)$/); + if (match) { + content += `

    ${match[1]}: ${match[2]}

    \n`; + } + else { + content += `

    ${p}

    \n`; + } + pid += 1; + } + content += (options.markupEnd || '
    ') + "\n\n"; + if (options.slideset) { + content += `
    `; + } + } + } else { + let last = ''; + content += '

    '; + for (const p of divs.map(d => d.paragraphs).flat().flat()) { + const match = p.match(/^(.*):\s*(.*)$/); + if (match) { + if (last && match[1] === last) { + content += `
    \n … ${match[2]}`; + } + else { + content += `

    \n

    ${match[1]}: ${match[2]}`; + } + last = match[1]; + } + else { + content += `

    \n ${p}`; + } + } + } + + return content; +} diff --git a/tools/list-chairs.mjs b/tools/list-chairs.mjs new file mode 100644 index 0000000..d48b374 --- /dev/null +++ b/tools/list-chairs.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * This tool reports information about breakout session chairs. + * + * To run the tool: + * + * node tools/list-chairs.mjs + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateGrid } from './lib/validate.mjs'; +import { authenticate } from './lib/calendar.mjs'; +import puppeteer from 'puppeteer'; + +async function main() { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + const W3C_LOGIN = await getEnvKey('W3C_LOGIN'); + const W3C_PASSWORD = await getEnvKey('W3C_PASSWORD'); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + const errors = await validateGrid(project) + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`); + + const sessions = project.sessions.filter(session => session.chairs); + sessions.sort((s1, s2) => s1.number - s2.number); + + const chairs = sessions + .map(session => session.chairs) + .flat() + .filter((chair, index, list) => list.findIndex(c => + c.name === chair.name || c.login === chair.login || c.w3cId === chair.w3cId) === index); + + function formatChair(chair) { + const parts = []; + if (chair.name && chair.email) { + parts.push(`${chair.name} <${chair.email}>`); + } + else if (chair.name) { + parts.push(`${chair.name}`); + } + if (chair.login) { + parts.push(`https://github.com/${chair.login}`); + } + if (chair.w3cId) { + parts.push(`https://www.w3.org/users/${chair.w3cId}`); + } + return parts.join(' '); + } + + if (W3C_LOGIN && W3C_PASSWORD) { + console.log(); + console.log('Retrieving chair emails...'); + const browser = await puppeteer.launch({ headless: true }); + try { + for (const chair of chairs) { + if (!chair.w3cId) { + continue; + } + const page = await browser.newPage(); + const url = `https://www.w3.org/users/${chair.w3cId}/`; + try { + await page.goto(url); + await authenticate(page, W3C_LOGIN, W3C_PASSWORD, url); + chair.email = await page.evaluate(() => { + const el = document.querySelector('.card--user a[href^=mailto]'); + return el.textContent.trim(); + }); + } + finally { + page.close(); + } + } + } + finally { + browser.close(); + } + console.log('Retrieving chair emails... done'); + } + + console.log(); + console.log('All chairs'); + console.log('----------'); + for (const chair of chairs) { + console.log(formatChair(chair)); + } + + console.log(); + console.log('All emails'); + console.log('----------'); + const emails = chairs + .filter(chair => chair.email) + .map(chair => `${chair.name} <${chair.email}>`) + console.log(emails.join(', ')); + + console.log(); + console.log('Per session'); + console.log('-----------'); + for (const session of sessions) { + console.log(`#${session.number} - ${session.title}`); + for (const chair of session.chairs) { + console.log(formatChair(chair)); + } + console.log(); + } +} + +main() + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/minutes-to-w3c.mjs b/tools/minutes-to-w3c.mjs new file mode 100644 index 0000000..f46cfab --- /dev/null +++ b/tools/minutes-to-w3c.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * @@ + * + * To run the tool: + * + * node tools/minutes-to-w3c.mjs [sessionNumber] + * + * where [sessionNumber] is the number of the issue to process (e.g. 15). + * Leave empty to add minute links to all sessions. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import puppeteer from 'puppeteer'; + +async function main(number) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + let sessions = project.sessions.filter(s => s.slot && s.room && + (!number || s.number === number)); + sessions.sort((s1, s2) => s1.number - s2.number); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} not found (or did not take place) in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to a slot and room`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + return session; + })); + sessions = sessions.filter(s => !!s); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} contains errors that need fixing`); + } + else if (sessions[0].description.materials.minutes) { + console.log("Session " + number + ": " + sessions[0].description.materials.minutes); + return; + } + } + else { + for (const session of sessions.filter(s => s.description.materials.minutes)) { + const url = session.description.materials.minutes; + if (url.match(/w3\.org|\@\@/)) { + console.log("Skipping " + session.number + ": " + url); + } else if (url.match(/docs\.google\.com/)) { + console.log(session.number + ": " + session.description.materials.minutes); + (async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto(url); + await page.pdf({ + path: session.number + '-minutes.pdf', + }); + await browser.close(); + })(); + } else { + console.log("Manually get: " + session.number + ": " + session.description.materials.minutes); + } + } + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); +} + + +// Read session number from command-line +if (process.argv[2] && !process.argv[2].match(/^(\d+|all)$/)) { + console.log('First parameter should be a session number or "all"'); + process.exit(1); +} +const sessionNumber = process.argv[2]?.match(/^\d+$/) ? parseInt(process.argv[2], 10) : undefined; + +main(sessionNumber) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); diff --git a/tools/setup-irc.mjs b/tools/setup-irc.mjs new file mode 100644 index 0000000..c5cd24e --- /dev/null +++ b/tools/setup-irc.mjs @@ -0,0 +1,441 @@ +#!/usr/bin/env node +/** + * This tool initializes IRC channels that will be used for breakout sessions. + * + * To run the tool: + * + * node tools/setup-irc.mjs [sessionNumber or "all"] [commands] [dismiss] + * + * where [sessionNumber or "all"] is the session issue number or "all" to + * initialize IRC channels for all valid sessions. + * + * Set [commands] to "commands" to only output the IRC commands to run without + * actually running them. + * + * Set [dismiss] to "dismiss" to make bots draft minutes and leave the channel. + * + * The tool runs IRC commands one after the other to avoid getting kicked out + * of the IRC server. It allows checks that IRC bots return the appropriate + * responses. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import { todoStrings } from './lib/todostrings.mjs'; +import irc from 'irc'; + +const botName = 'tpac-breakout-bot'; +const timeout = 60 * 1000; + +/** + * Helper function to generate a shortname from the session's title + */ +function getChannel(session) { + return session.description.shortname; +} + + +/** + * Helper function to make the code wait for a specific IRC command from the + * IRC server, typically to check that a command we sent was properly executed. + * + * Note the function will timeout after some time. The timeout is meant to + * avoid getting stuck in an infinite loop when a bot becomes unresponsive. + */ +const pendingIRCMessage = { + what: {}, + promise: null, + resolve: null +}; +async function waitForIRCMessage(what) { + pendingIRCMessage.what = what; + pendingIRCMessage.promise = new Promise((resolve, reject) => { + pendingIRCMessage.resolve = resolve; + }); + const timeoutPromise = new Promise((resolve, reject) => { + setTimeout(reject, timeout, 'timeout'); + }); + return Promise.race([pendingIRCMessage.promise, timeoutPromise]); +} + +/** + * Main function + */ +async function main({ number, onlyCommands, dismissBots } = {}) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + let sessions = project.sessions.filter(s => s.slot && + (!number || s.number === number)); + sessions.sort((s1, s2) => s1.number - s2.number); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} not found in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + else if (!sessions[0].slot) { + throw new Error(`Session ${number} not assigned to a slot in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to slots: ${sessions.map(s => s.number).join(', ')}`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} contains errors that need fixing`); + } + } + else { + console.log(`- found ${sessions.length} valid sessions among them: ${sessions.map(s => s.number).join(', ')}`); + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + console.log('Compute IRC channels...'); + const channels = {}; + for (const session of sessions) { + const channel = getChannel(session); + if (!channels[channel]) { + channels[channel] = []; + } + channels[channel].push(session); + channels[channel].sort((s1, s2) => { + const slot1 = project.slots.findIndex(slot => slot.name === s1.slot); + const slot2 = project.slots.findIndex(slot => slot.name === s2.slot); + return slot1 - slot2; + }); + } + sessions = Object.values(channels).map(sessions => sessions[0]); + console.log(`- found ${Object.keys(channels).length} different IRC channels`); + console.log('Compute IRC channels... done'); + + console.log(); + console.log('Connect to W3C IRC server...'); + const bot = onlyCommands ? + undefined : + new irc.Client('irc.w3.org', botName, { + channels: [] + }); + + const connection = { + established: null, + resolve: null, + reject: null + }; + connection.established = new Promise((resolve, reject) => { + connection.resolve = resolve; + connection.reject = reject; + }); + if (bot) { + bot.addListener('registered', msg => { + console.log(`- registered message: ${msg.command}`); + connection.resolve(); + }); + } + else { + console.log(`- commands only, no connection needed`); + connection.resolve(); + } + await connection.established; + console.log('Connect to W3C IRC server... done'); + + if (bot) { + // Only useful when debugging the code + /*bot.addListener('raw', msg => { + console.log(JSON.stringify({ + nick: msg.nick, + command: msg.command, + commandType: msg.commandType, + raw: msg.rawCommand, + args: msg.args + }, null, 2)); + });*/ + + // Listen to the JOIN messages that tell us when our bot or the bots we've + // invited have joined the IRC channel. + bot.addListener('join', (channel, nick, message) => { + if (pendingIRCMessage.what.command === 'join' && + pendingIRCMessage.what.channel === channel && + pendingIRCMessage.what.nick === nick) { + pendingIRCMessage.resolve(); + } + }); + + // Listen to the list of users in the channels we joined + bot.addListener('names', (channel, nicks) => { + if (pendingIRCMessage.what.command === 'names' && + pendingIRCMessage.what.channel === channel) { + pendingIRCMessage.resolve(Object.keys(nicks)); + } + }); + + // Listen to the MESSAGE messages that contain bot replies to our commands. + bot.addListener('message', (nick, channel, text, message) => { + if (pendingIRCMessage.what.command === 'message' && + (pendingIRCMessage.what.channel === channel || channel === botName) && + pendingIRCMessage.what.nick === nick && + text.startsWith(pendingIRCMessage.what.message)) { + pendingIRCMessage.resolve(); + } + }); + + // Listen to the TOPIC message that should tell us that we managed to set + // the topic as planned. + bot.addListener('topic', (channel, topic, nick, message) => { + if (pendingIRCMessage.what.command === 'topic' && + pendingIRCMessage.what.channel === channel && + pendingIRCMessage.what.nick === nick) { + pendingIRCMessage.resolve(); + } + }); + + // Listen to PART messages to tell when our bot or other bots leave the + // channel. + bot.addListener('part', (channel, nick) => { + if (pendingIRCMessage.what.command === 'part' && + pendingIRCMessage.what.channel === channel && + pendingIRCMessage.what.nick === nick) { + pendingIRCMessage.resolve(); + } + }); + + // Errors are returned when a bot gets invited to a channel where it + // already is, and when disconnecting from the server. Both cases are fine, + // let's trap them. + bot.addListener('error', err => { + if (err.command === 'err_useronchannel' && + pendingIRCMessage.what.command === 'join' && + pendingIRCMessage.what.channel === err.args[2] && + pendingIRCMessage.what.nick === err.args[1]) { + pendingIRCMessage.resolve(); + } + else if (err.command === 'ERROR' && + err.args[0] === '"node-irc says goodbye"') { + console.log('- disconnected from IRC server'); + } + else { + throw err; + } + }); + } + + function joinChannel(session) { + const channel = getChannel(session); + console.log(`/join ${channel}`); + if (!onlyCommands) { + bot.join(channel); + return waitForIRCMessage({ command: 'names', channel, nick: botName }); + } + } + + function inviteBot(session, name) { + const channel = getChannel(session); + console.log(`/invite ${name} ${channel}`); + if (!onlyCommands) { + bot.send('INVITE', name, channel); + return waitForIRCMessage({ command: 'join', channel, nick: name }); + } + } + + function leaveChannel(session) { + const channel = getChannel(session); + if (!onlyCommands) { + bot.part(channel); + return waitForIRCMessage({ command: 'part', channel, nick: botName }); + } + } + + function setTopic(session) { + const channel = getChannel(session); + const room = project.rooms.find(r => r.name === session.room); + const roomLabel = room ? `- ${room.label} ` : ''; + const topic = `TPAC breakout: ${session.title} ${roomLabel}- ${session.slot}`; + console.log(`/topic ${channel} ${topic}`); + if (!onlyCommands) { + bot.send('TOPIC', channel, topic); + return waitForIRCMessage({ command: 'topic', channel, nick: botName }); + } + } + + async function setupRRSAgent(session) { + const channel = getChannel(session); + await say(channel, { + to: 'RRSAgent', + message: `do not leave`, + reply: `ok, ${botName}; I will stay here even if the channel goes idle` + }); + + await say(channel, { + to: 'RRSAgent', + message: `make logs ${session.description.attendance === 'restricted' ? 'member' : 'public'}`, + reply: `I have made the request, ${botName}` + }); + + await say(channel, `Meeting: ${session.title}`); + await say(channel, `Chair: ${session.chairs.map(c => c.name).join(', ')}`); + if (session.description.materials.agenda && + !todoStrings.includes(session.description.materials.agenda)) { + await say(channel, `Agenda: ${session.description.materials.agenda}`); + } + else { + await say(channel, `Agenda: https://github.com/${session.repository}/issues/${session.number}`); + } + if (session.description.materials.slides && + !todoStrings.includes(session.description.materials.slides)) { + await say(channel, `Slideset: ${session.description.materials.slides}`); + } + } + + async function setupZakim(session) { + const channel = getChannel(session); + await say(channel, { + to: 'Zakim', + message: 'clear agenda', + reply: 'agenda cleared' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Pick a scribe', + reply: 'agendum 1 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Reminders: code of conduct, health policies, recorded session policy', + reply: 'agendum 2 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Goal of this session', + reply: 'agendum 3 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Discussion', + reply: 'agendum 4 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Next steps / where discussion continues', + reply: 'agendum 5 added' + }); + } + + async function draftMinutes(session, channelUsers) { + const channel = getChannel(session); + if (channelUsers.includes('Zakim')) { + await say(channel, `Zakim, bye`); + } + if (channelUsers.includes('RRSAgent')) { + // Should have been already done in theory, but worth re-doing just in + // case, especially since RRSAgent won't leave a channel until some + // access level has been specified. + await say(channel, { + to: 'RRSAgent', + message: `make logs ${session.description.attendance === 'restricted' ? 'member' : 'public'}`, + reply: `I have made the request, ${botName}` + }); + await say(channel, { + to: 'RRSAgent', + message: 'draft minutes', + reply: 'I have made the request to generate' + }); + await say(channel, `RRSAgent, bye`); + } + } + + // Helper function to send a message to a channel. The function waits for a + // reply if one is expected. + function say(channel, msg) { + const message = msg?.to ? + `${msg.to}, ${msg.message}` : + (msg?.message ? msg.message : msg); + console.log(`/msg ${channel} ${message}`); + if (!onlyCommands) { + bot.say(channel, message); + if (msg?.reply) { + return waitForIRCMessage({ + command: 'message', channel, + nick: msg.to, message: msg.reply + }); + } + } + } + + const errors = []; + for (const session of sessions) { + console.log(); + console.log(`session ${session.number}`); + console.log('-----'); + try { + const channelUsers = await joinChannel(session); + if (dismissBots) { + await draftMinutes(session, channelUsers); + } + else { + await setTopic(session); + if (!channelUsers.includes('RRSAgent')) { + await inviteBot(session, 'RRSAgent'); + } + await setupRRSAgent(session); + if (!channelUsers.includes('Zakim')) { + await inviteBot(session, 'Zakim'); + } + await setupZakim(session); + await leaveChannel(session); + } + } + catch (err) { + errors.push(`- ${session.number}: ${err.message}`); + console.log(`- An error occurred: ${err.message}`); + } + console.log('-----'); + } + + if (!onlyCommands) { + return new Promise((resolve, reject) => { + console.log('Disconnect from IRC server...'); + bot.disconnect(_ => { + console.log('Disconnect from IRC server... done'); + if (errors.length > 0) { + reject(new Error(errors.join('\n'))); + } + else { + resolve(); + } + }); + }); + } +} + +// Read session number from command-line +if (!process.argv[2] || !process.argv[2].match(/^(\d+|all)$/)) { + console.log('Command needs to receive a session number (e.g., 15) or "all" as first parameter'); + process.exit(1); +} +const number = process.argv[2] === 'all' ? undefined : parseInt(process.argv[2], 10); + +// Command only? +const onlyCommands = process.argv[3] === 'commands'; +const dismissBots = process.argv[4] === 'dismiss'; + +main({ number, onlyCommands, dismissBots }) + .then(_ => process.exit(0)) + .catch(err => { + console.error(`Something went wrong:\n${err.message}`); + process.exit(1); + }); \ No newline at end of file diff --git a/tools/suggest-grid.mjs b/tools/suggest-grid.mjs new file mode 100644 index 0000000..fed85c9 --- /dev/null +++ b/tools/suggest-grid.mjs @@ -0,0 +1,771 @@ +#!/usr/bin/env node +/** + * This tool suggests a grid that could perhaps work given known constraints. + * + * To run the tool: + * + * node tools/suggest-grid.mjs [preservelist or all or none] [exceptlist or none] [apply] [seed] + * + * where [preservelist or all] is a comma-separated (no spaces) list of session + * numbers whose assigned slots and rooms must be preserved. Or "all" to + * preserve all slots and rooms that have already been assigned. Or "none" not + * to preserve anything. + * + * [exceptlist or none] only makes sense when the preserve list is "all" and + * allows to specify a comma-separated (no spaces) list of session numbers whose + * assigned slots and rooms are to be discarded. Or "none" to say "no exception, + * preserve info in all sessions". + * + * [apply] is "apply" if you want to apply the suggested grid on GitHub, or + * a link to a changes file if you want to test changes to the suggested grid + * before it gets validated and saved as an HTML page. The changes file must be + * a file where each row starts with a session number, followed by a space, + * followed by either a slot start time or a slot number or a room name. If slot + * was specified, it may be followed by another space, followed by a room name. + * (Room name cannot be specified before the slot). + * + * + * [seed] is the seed string to shuffle the array of sessions. + * + * Assumptions: + * - All rooms are of equal quality + * - Some slots may be seen as preferable + * + * Goals: + * - Where possible, sessions that belong to the same track should take place + * in the same room. Because a session may belong to two tracks, this is not + * an absolute goal. + * - Schedule sessions back-to-back to avoid gaps. + * - Favor minimizing travels over using different rooms. + * - Session issue number should not influence slot and room (early proponents + * should not be favored or disfavored). + * - Minimize the number of rooms used in parallel. + * - Only one session labeled for a given track at the same time. + * - Only one session with a given chair at the same time. + * - No identified conflicting sessions at the same time. + * - Meet duration preference. + * - Meet capacity preference. + * + * The tool schedules as many sessions as possible, skipping over sessions that + * it cannot schedule due to a confict that it cannot resolve. + */ + +import { readFile } from 'fs/promises'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject, assignSessionsToSlotAndRoom } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import { validateGrid } from './lib/validate.mjs'; +import seedrandom from 'seedrandom'; + +const schedulingErrors = [ + 'error: chair conflict', + 'error: scheduling', + 'error: irc', + 'warning: capacity', + 'warning: conflict', + 'warning: duration', + 'warning: track' +]; + +/** + * Helper function to shuffle an array + */ +function shuffle(array, seed) { + const randomGenerator = seedrandom(seed); + for (let i = array.length - 1; i > 0; i--) { + let j = Math.floor(randomGenerator.quick() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} + +/** + * Helper function to generate a random seed + */ +function makeseed() { + const chars = 'abcdefghijklmnopqrstuvwxyz'; + return [1, 2, 3, 4, 5] + .map(_ => chars.charAt(Math.floor(Math.random() * chars.length))) + .join(''); +} + +async function main({ preserve, except, changesFile, apply, seed }) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.warn(); + console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + console.warn(`- found ${project.sessions.length} sessions`); + let sessions = await Promise.all(project.sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(err => + err.severity === 'error' && + err.type !== 'chair conflict' && + err.type !== 'scheduling'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + sessions.sort((s1, s2) => s1.number - s2.number); + console.warn(`- found ${sessions.length} valid sessions among them: ${sessions.map(s => s.number).join(', ')}`); + shuffle(sessions, seed); + console.warn(`- shuffled sessions with seed "${seed}" to: ${sessions.map(s => s.number).join(', ')}`); + console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + // Consider that default capacity is "average number of people" to avoid assigning + // sessions to too small rooms + for (const session of sessions) { + if (session.description.capacity === 0) { + session.description.capacity = 24; + } + } + + const rooms = project.rooms; + const slots = project.slots; + + seed = seed ?? makeseed(); + + // Load changes to apply locally if so requested + let changes = []; + if (changesFile) { + try { + changes = (await readFile(changesFile, 'utf8')) + .split('\n') + .map(line => line.trim()) + .filter(line => line.length && !line.startsWith(';')) + .map(line => { + const change = { + number: null, + slot: null, + room: null + }; + + // Line needs to start with session number + let match = line.match(/^(\d+)(.*)$/); + change.number = parseInt(match[1], 10); + + // Rest may either be a slot (possibly followed by a room) or a room + const rest = match[2].trim(); + match = rest.match(/^(?:(\d{1,2}:\d{1,2})|(\d+))(.*)$/); + if (match) { + // A slot was specified + change.slot = match[1] ? + slots.find(s => s.name.startsWith(match[1])).name : + slots[parseInt(match[2], 10)-1].name; + change.room = match[3].trim() ? + rooms.find(r => r.name.startsWith(match[3].trim())).name : + null; + } + else { + // No slot was specified, there should be a room + change.room = rest.trim() ? + rooms.find(r => r.name.startsWith(rest.trim())).name : + null; + } + return change; + }) + .filter(change => change.slot || change.room) + console.warn(changes); + } + catch (err) { + // Not a changes file! + throw err; + } + } + + // Save initial grid algorithm settings as CLI params + const cli = {}; + if (preserve === 'all') { + cli.preserve = 'all'; + } + else if (!preserve || preserve.length === 0) { + cli.preserve = 'none'; + } + else { + cli.preserve = preserve.join(','); + } + if (!except) { + cli.except = 'none'; + } + else if (except.length > 0) { + cli.except = except.join(','); + } + else { + cli.except = 'none'; + } + cli.seed = seed; + cli.apply = apply; + cli.cmd = `node tools/suggest-grid.mjs ${cli.preserve} ${cli.except} ${apply} ${cli.seed}`; + + if (preserve === 'all') { + preserve = sessions.filter(s => s.slot || s.room).map(s => s.number); + } + if (except) { + preserve = preserve.filter(s => !except.includes(s.number)); + } + if (!preserve) { + preserve = []; + } + for (const session of sessions) { + if (!preserve.includes(session.number)) { + session.slot = undefined; + session.room = undefined; + } + } + + // Initialize the list of tracks + const tracks = new Set(); + for (const session of sessions) { + session.tracks = session.labels + .filter(label => label.startsWith('track: ')) + .map(label => label.substring('track: '.length)) + .map(track => { + tracks.add(track); + return track; + }); + } + tracks.add(''); + + // Initalize the views by slot and by room + for (const slot of slots) { + slot.pos = slots.indexOf(slot); + slot.sessions = sessions.filter(s => s.slot === slot.name); + } + for (const room of rooms) { + room.pos = rooms.indexOf(room); + room.sessions = sessions.filter(s => s.room === room.name); + } + + // Return next session to process (and flag it as processed) + function selectNextSession(track) { + const session = sessions.find(s => !s.processed && + (track === '' || s.tracks.includes(track))); + if (session) { + session.processed = true; + } + return session; + } + + function chooseTrackRoom(track) { + if (!track) { + // No specific room by default for sessions in the main track + return null; + } + const trackSessions = sessions.filter(s => s.tracks.includes(track)); + + // Find the session in the track that requires the largest room + const largestSession = trackSessions.reduce( + (smax, scurr) => (scurr.description.capacity > smax.description.capacity) ? scurr : smax, + trackSessions[0] + ); + + const slotsTaken = room => room.sessions.reduce( + (total, curr) => curr.track === track ? total : total + 1, + 0); + const byAvailability = (r1, r2) => slotsTaken(r1) - slotsTaken(r2); + const meetCapacity = room => room.capacity >= largestSession.description.capacity; + const meetSameRoom = room => slotsTaken(room) + trackSessions.length <= slots.length; + const meetAll = room => meetCapacity(room) && meetSameRoom(room); + + const requestedRoomsSet = new Set(); + trackSessions + .filter(s => s.room) + .forEach(s => requestedRoomsSet.add(s.room)); + const requestedRooms = [...requestedRoomsSet] + .map(name => rooms.find(room => room.name === name)); + const allRooms = [] + .concat(requestedRooms.sort(byAvailability)) + .concat(rooms.filter(room => !requestedRooms.includes(room)).sort(byAvailability)) + const room = + allRooms.find(meetAll) ?? + allRooms.find(meetCapacity) ?? + allRooms.find(meetSameRoom) ?? + allRooms[0]; + return room; + } + + + function setRoomAndSlot(session, { + trackRoom, strictDuration, meetDuration, meetCapacity, meetConflicts + }) { + const byCapacity = (r1, r2) => r1.capacity - r2.capacity; + const byCapacityDesc = (r1, r2) => r2.capacity - r1.capacity; + + // List possible rooms: + // - If we explicitly set a room already, that's the only possibility. + // - Otherwise, if the default track room constraint is set, that's + // the only possible choice. + // - Otherwise, all rooms that have enough capacity are possible, + // or all rooms if capacity constraint has been relaxed already. + const possibleRooms = []; + if (session.room) { + // Keep room already assigned + possibleRooms.push(rooms.find(room => room.name === session.room)); + } + else if (trackRoom) { + // Need to assign the session to the track room + possibleRooms.push(trackRoom); + } + else { + // All rooms that have enough capacity are candidate rooms + possibleRooms.push(...rooms + .filter(room => room.capacity >= session.description.capacity) + .sort(byCapacity)); + if (!meetCapacity) { + possibleRooms.push(...rooms + .filter(room => room.capacity < session.description.capacity) + .sort(byCapacityDesc)); + } + } + + if (possibleRooms.length === 0) { + return false; + } + + for (const room of possibleRooms) { + // List possible slots in the current room: + // - If we explicitly set a slot already, that's the only possibility, + // provided the slot is available in that room! + // - Otherwise, all the slots that are still available in the room are + // possible. + // If we're dealing with a real track, we'll consider possible slots in + // order. If we're dealing with a session that is not in a track, + // possible slots are ordered so that less used ones get considered first + // (to avoid gaps). + const possibleSlots = []; + if (session.slot) { + const slot = slots.find(slot => slot.name === session.slot); + if (!room.sessions.find(s => s !== session && s.slot === session.slot)) { + possibleSlots.push(slot); + } + } + else { + possibleSlots.push(...slots + .filter(slot => !room.sessions.find(session => session.slot === slot.name))); + if (!trackRoom) { + // When not considering a specific track, fill slots in turn, + // starting with least busy ones + possibleSlots.sort((s1, s2) => { + const s1len = s1.sessions.length; + const s2len = s2.sessions.length; + if (s1len === s2len) { + return s1.pos - s2.pos; + } + else { + return s1len - s2len; + } + }); + } + } + + // A non-conflicting slot in the list of possible slots is one that does + // not lead to a situation where: + // - Two sessions in the same track are scheduled at the same time. + // - Two sessions chaired by the same person happen at the same time. + // - Conflicting sessions are scheduled at the same time. + // - Session is scheduled in a slot that does not meet the duration + // requirement. + // ... Unless these constraints have been relaxed! + function nonConflictingSlot(slot) { + const potentialConflicts = sessions.filter(s => + s !== session && s.slot === slot.name); + // There must be no session in the same track at that time + const trackConflict = potentialConflicts.find(s => + s.tracks.find(track => session.tracks.includes(track))); + if (trackConflict && meetConflicts.includes('track')) { + return false; + } + + // There must be no session chaired by the same chair at that time + const chairConflict = potentialConflicts.find(s => + s.chairs.find(c1 => session.chairs.find(c2 => + (c1.login && c1.login === c2.login) || + (c1.name && c1.name === c2.name))) + ); + if (chairConflict) { + return false; + } + + // There must be no conflicting sessions at the same time. + if (meetConflicts.includes('session')) { + const sessionConflict = potentialConflicts.find(s => + session.description.conflicts?.includes(s.number) || + s.description.conflicts?.includes(session.number)); + if (sessionConflict) { + return false; + } + } + + // Meet duration preference unless we don't care + if (meetDuration) { + if ((strictDuration && slot.duration !== session.description.duration) || + (!strictDuration && slot.duration < session.description.duration)) { + return false; + } + } + + return true; + } + + // Search for a suitable slot for the current room in the list. If one is + // found, we're done, otherwise move on to next possible room... or + // surrender for this set of constraints. + const slot = possibleSlots.find(nonConflictingSlot); + if (slot) { + if (!session.room) { + session.room = room.name; + session.updated = true; + room.sessions.push(session); + } + if (!session.slot) { + session.slot = slot.name; + session.updated = true; + slot.sessions.push(session); + } + return true; + } + } + + return false; + } + + // Proceed on a track-by-track basis, and look at sessions in each track in + // turn. + for (const track of tracks) { + // Choose a default track room that has enough capacity and enough + // available slots to fit all session tracks, if possible, starting with + // rooms that have a maximum number of available slots. Relax capacity and + // slot number constraints if there is no ideal candidate room. In + // practice, unless we're running short on rooms, this should select a room + // that is still unused for the track. + const trackRoom = chooseTrackRoom(track); + if (track) { + console.warn(`Schedule sessions in track "${track}" favoring room "${trackRoom.name}"...`); + } + else { + console.warn(`Schedule sessions in main track...`); + } + + // Process each session in the track in turn, unless it has already been + // processed (this may happen when the session belongs to two tracks). + let session = selectNextSession(track); + while (session) { + // Attempt to assign a room and slot that meets all constraints. + // If that fails, relax constraints one by one and start over. + // Scheduling may fail if there's no way to avoid a conflict and if + // that conflict cannot be relaxed (e.g., same person cannot chair two + // sessions at the same time). + const constraints = { + trackRoom, + strictDuration: true, + meetDuration: true, + meetCapacity: true, + meetConflicts: ['session', 'track'] + }; + while (!setRoomAndSlot(session, constraints)) { + if (constraints.strictDuration) { + console.warn(`- relax duration comparison for #${session.number}`); + constraints.strictDuration = false; + } + else if (constraints.trackRoom) { + console.warn(`- relax track constraint for #${session.number}`); + constraints.trackRoom = null; + } + else if (constraints.meetDuration) { + console.warn(`- forget duration constraint for #${session.number}`); + constraints.meetDuration = false; + } + else if (constraints.meetCapacity) { + console.warn(`- forget capacity constraint for #${session.number}`); + constraints.meetCapacity = false; + } + else if (constraints.meetConflicts.length === 2) { + console.warn(`- forget session conflicts for #${session.number}`); + constraints.meetConflicts = ['track']; + } + else if (constraints.meetConflicts[0] === 'track') { + console.warn(`- forget track conflicts for #${session.number}`); + constraints.meetConflicts = ['session']; + } + else if (constraints.meetConflicts.length > 0) { + console.warn(`- forget all conflicts for #${session.number}`); + constraints.meetConflicts = []; + } + else { + console.warn(`- could not find a room and slot for #${session.number}`); + break; + } + } + if (session.room && session.slot) { + console.warn(`- assigned #${session.number} to room ${session.room} and slot ${session.slot}`); + } + session = selectNextSession(track); + } + if (track) { + console.warn(`Schedule sessions in track "${track}" favoring room "${trackRoom.name}"... done`); + } + else { + console.warn(`Schedule sessions in main track... done`); + } + } + + sessions.sort((s1, s2) => s1.number - s2.number); + + for (const session of sessions) { + if (!session.slot || !session.room) { + const tracks = session.tracks.length ? ' - ' + session.tracks.join(', ') : ''; + console.warn(`- [WARNING] #${session.number} could not be scheduled${tracks}`); + } + } + + if (changes.length > 0) { + console.warn(); + console.warn(`Apply local changes...`); + for (const change of changes) { + const session = sessions.find(s => s.number === change.number); + if (change.room && change.room !== session.room) { + console.warn(`- move #${change.number} to room ${change.room}`); + session.room = change.room; + session.updated = true; + } + if (change.slot && change.slot !== session.slot) { + console.warn(`- move #${change.number} to slot ${change.slot}`); + session.slot = change.slot; + session.updated = true; + } + } + console.warn(`Apply local changes... done`); + } + + console.warn(); + console.warn(`Validate grid...`); + const errors = (await validateGrid(project)) + .filter(error => schedulingErrors.includes(`${error.severity}: ${error.type}`)); + if (errors.length) { + for (const error of errors) { + console.warn(`- [${error.severity}: ${error.type}] #${error.session}: ${error.messages.join(', ')}`); + } + } + else { + console.warn(`- looks good!`); + } + console.warn(`Validate grid... done`); + + function logIndent(tab, str) { + let spaces = ''; + while (tab > 0) { + spaces += ' '; + tab -= 1; + } + console.log(spaces + str); + } + + console.warn(); + logIndent(0, ` + + + TPAC schedule + + + + + + `); + for (const room of rooms) { + logIndent(4, ''); + } + logIndent(3, ''); + // Build individual rows + const tablerows = []; + for (const slot of slots) { + const tablerow = [slot.name]; + for (const room of rooms) { + const session = sessions.filter(s => s.slot === slot.name && s.room === room.name).pop(); + tablerow.push(session); + } + tablerows.push(tablerow); + } + // Format rows (after header row) + for (const row of tablerows) { + // Format the row header (the time slot) + logIndent(3, ''); + logIndent(4, ''); + // Format rest of row + for (let i = 1; i'); + } else { + // Warn if session capacity estimate exceeds room capacity + const sloterrors = []; + if (session.description.capacity > rooms[i-1].capacity) { + sloterrors.push('capacity-error'); + } + if (trackdups.length && trackdups.some(r => session.tracks.includes(r))) { + sloterrors.push('track-error'); + } + if (sloterrors.length) { + logIndent(4, ''); + } + } + logIndent(3, ''); + } + logIndent(2, '
    ' + room.name + '
    '); + logIndent(5, row[0]); + + // Warn of any conflicting chairs in this slot (in first column) + let allchairnames = row.filter((s,i) => i > 0).filter((s) => typeof(s) === 'object').map((s) => s.chairs).flat(1).map(c => c.name); + let duplicates = allchairnames.filter((e, i, a) => a.indexOf(e) !== i); + if (duplicates.length) { + logIndent(5, '

    Chair conflicts: ' + duplicates.join(', ') + '

    '); + } + + // Warn if two sessions from the same track are scheduled in this slot + const alltracks = row.filter((s, i) => i > 0 && !!s).map(s => s.tracks).flat(1); + const trackdups = [...new Set(alltracks.filter((e, i, a) => a.indexOf(e) !== i))]; + if (trackdups.length) { + logIndent(5, '

    Same track: ' + trackdups.join(', ') + '

    '); + } + logIndent(4, '
    '); + } else { + logIndent(4, ''); + } + const url= 'https://github.com/' + session.repository + '/issues/' + session.number; + // Format session number (with link to GitHub) and name + logIndent(5, `#${session.number}: ${session.title}`); + + // Format chairs + logIndent(5, '

    '); + logIndent(6, '' + session.chairs.map(x => x.name).join(',
    ') + '
    '); + logIndent(5, '

    '); + + // Add tracks if needed + if (session.tracks?.length > 0) { + for (const track of session.tracks) { + logIndent(5, `

    ${track}

    `); + } + } + + // List session conflicts to avoid and highlight where there is a conflict. + if (Array.isArray(session.description.conflicts)) { + const confs = []; + for (const conflict of session.description.conflicts) { + for (const v of row) { + if (!!v && v.number === conflict) { + confs.push(conflict); + } + } + } + if (confs.length) { + logIndent(5, '

    Conflicts with: ' + confs.map(s => '#' + s + '').join(', ') + '

    '); + } + // This version prints all conflict info if we want that + // logIndent(5, '

    Conflicts: ' + session.description.conflicts.map(s => confs.includes(s) ? '' + s + '' : s).join(', ') + '

    '); + } + if (sloterrors.includes('capacity-error')) { + logIndent(5, '

    Capacity: ' + session.description.capacity + '

    '); + } + logIndent(4, '
    '); + + // If any sessions have not been assigned to a room, warn us. + const unscheduled = sessions.filter(s => !s.slot || !s.room); + if (unscheduled.length) { + logIndent(2, '

    Unscheduled sessions

    '); + logIndent(2, '

    ' + unscheduled.map(s => '#' + s.number).join(', ') + '

    '); + } + + const preserveInPractice = (preserve !== 'all' && preserve.length > 0) ? + ' (in practice: ' + preserve.sort((n1, n2) => n1 - n2).join(',') + ')' : + ''; + logIndent(2, '

    Generation parameters

    '); + logIndent(2, `
      +
    • preserve: ${cli.preserve}${preserveInPractice}
    • +
    • except: ${cli.except}
    • +
    • seed: ${cli.seed}
    • +
    • apply: ${cli.apply}
    • +
    +

    Command-line command:

    +
    ${cli.cmd}
    `); + logIndent(2, '

    Data for Saving/Restoring Schedule

    '); + logIndent(2, '
    ');
    +  console.log(JSON.stringify(sessions.map(s=> ({ number: s.number, room: s.room, slot: s.slot})), null, 2));
    +  logIndent(2, '
    '); + logIndent(1, ''); + logIndent(0, ''); + + console.warn(); + console.warn('To re-generate the grid, run:'); + console.warn(cli.cmd); + + if (apply) { + console.warn(); + const sessionsToUpdate = sessions.filter(s => s.updated); + for (const session of sessionsToUpdate) { + console.warn(`- updating #${session.number}...`); + await assignSessionsToSlotAndRoom(session, project); + console.warn(`- updating #${session.number}... done`); + } + } +} + + +// Read preserve list from command-line +let preserve; +if (process.argv[2]) { + if (!process.argv[2].match(/^all|none|\d+(,\d+)*$/)) { + console.warn('Command needs to receive a list of issue numbers as first parameter or "all"'); + process.exit(1); + } + if (process.argv[2] === 'all') { + preserve = 'all'; + } + else if (process.argv[2] === 'none') { + preserve = []; + } + else { + preserve = process.argv[2].map(n => parseInt(n, 10)); + } +} + +// Read except list +let except; +if (process.argv[3]) { + if (!process.argv[3].match(/^none|\d+(,\d+)*$/)) { + console.warn('Command needs to receive a list of issue numbers as second parameter or "none"'); + process.exit(1); + } + except = process.argv[3] === 'none' ? + undefined : + process.argv[3].split(',').map(n => parseInt(n, 10)); +} + +const apply = process.argv[4] === 'apply'; +const changesFile = apply ? undefined : (process.argv[4] ?? undefined); +const seed = process.argv[5] ?? undefined; + +main({ preserve, except, changesFile, apply, seed }) + .catch(err => { + console.warn(`Something went wrong: ${err.message}`); + throw err; + }); diff --git a/tools/update-calendar.mjs b/tools/update-calendar.mjs new file mode 100644 index 0000000..6b48f57 --- /dev/null +++ b/tools/update-calendar.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import puppeteer from 'puppeteer'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs'; +import { convertSessionToCalendarEntry } from './lib/calendar.mjs'; +import { validateSession } from './lib/validate.mjs'; + +async function main(sessionNumber, status) { + console.log(`Retrieve environment variables...`); + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + console.log(`- PROJECT_OWNER: ${PROJECT_OWNER}`); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + console.log(`- PROJECT_NUMBER: ${PROJECT_NUMBER}`); + const CALENDAR_SERVER = await getEnvKey('CALENDAR_SERVER', 'www.w3.org'); + console.log(`- CALENDAR_SERVER: ${CALENDAR_SERVER}`); + const W3C_LOGIN = await getEnvKey('W3C_LOGIN'); + console.log(`- W3C_LOGIN: ${W3C_LOGIN}`); + const W3C_PASSWORD = await getEnvKey('W3C_PASSWORD'); + console.log(`- W3C_PASSWORD: ***`); + const ROOM_ZOOM = await getEnvKey('ROOM_ZOOM', {}, true); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(`Retrieve environment variables... done`); + + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + let sessions = sessionNumber ? + project.sessions.filter(s => s.number === sessionNumber) : + project.sessions.filter(s => s.slot); + sessions.sort((s1, s2) => s1.number - s2.number); + if (sessionNumber) { + if (sessions.length === 0) { + throw new Error(`Session ${sessionNumber} not found in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + else if (!sessions[0].slot) { + throw new Error(`Session ${sessionNumber} not assigned to a slot in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to slots: ${sessions.map(s => s.number).join(', ')}`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + if (sessionNumber) { + if (sessions.length === 0) { + throw new Error(`Session ${sessionNumber} contains errors that need fixing`); + } + } + else { + console.log(`- found ${sessions.length} valid sessions among them: ${sessions.map(s => s.number).join(', ')}`); + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + console.log(); + console.log('Launch Puppeteer...'); + const browser = await puppeteer.launch({ headless: true }); + console.log('Launch Puppeteer... done'); + + try { + for (const session of sessions) { + console.log(); + console.log(`Convert session ${session.number} to calendar entry...`); + const room = project.rooms.find(r => r.name === session.room); + const zoom = ROOM_ZOOM[room?.label] ? ROOM_ZOOM[room.label] : undefined; + await convertSessionToCalendarEntry({ + browser, session, project, status, zoom, + calendarServer: CALENDAR_SERVER, + login: W3C_LOGIN, + password: W3C_PASSWORD + }); + console.log(`Convert session ${session.number} to calendar entry... done`); + } + } + finally { + await browser.close(); + } +} + + +// Read session number from command-line +const allSessions = process.argv[2]; +if (!allSessions || !allSessions.match(/^\d+$|^all$/)) { + console.log('Command needs to receive a session number, or "all", as first parameter'); + process.exit(1); +} +const sessionNumber = allSessions === 'all' ? undefined : parseInt(allSessions, 10); + +const status = process.argv[3] ?? 'draft'; +if (!['draft', 'tentative', 'confirmed'].includes(status)) { + console.log('Command needs to receive a valid entry status "draft", "tentative" or "confirmed" as second parameter'); + process.exit(1); +} + +main(sessionNumber, status) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/upload-grid.mjs b/tools/upload-grid.mjs new file mode 100644 index 0000000..18bb7a2 --- /dev/null +++ b/tools/upload-grid.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import fs from 'fs'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject, assignSessionsToSlotAndRoom } from './lib/project.mjs' + +function readconfig(filename) { + if (filename) { + let content = fs.readFileSync(filename).toString(); + // Don't want room names with in them! + let regexp = /
    (.*)<\/pre>/s;
    +    let data = content.match(regexp)[1];
    +    return(JSON.parse(data));
    +  }
    +}
    +
    +async function main({ filename, apply }) {
    +  const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER');
    +  const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER');
    +  console.warn();
    +  console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`);
    +  const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER);
    +  if (!project) {
    +    throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`);
    +  }
    +  console.warn(`- found ${project.sessions.length} sessions`);
    +  let sessions = await Promise.all(project.sessions);
    +  sessions = sessions.filter(s => !!s);
    +  console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`);
    +
    +  console.warn(`Extract grid from HTML page...`);
    +  const rooms = project.rooms;
    +  const slots = project.slots;
    +  const configs = readconfig(filename);
    +  console.warn(`Extract grid from HTML page... done`);
    +
    +  console.warn(`Assign sessions to rooms and slots...`);
    +  const updated = [];
    +  for (const config of configs) {
    +    if (!sessions.map(s => s.number === config.number)) {
    +      throw new Error('Unknown session ' + config.number);
    +    }
    +    if (!slots.map(s => s.name === config.slot)) {
    +      throw new Error('Unknown slot ' + config.slot + ' in ' + config.number);
    +    }
    +    if (!rooms.map(s => s.name === config.room)) {
    +      throw new Error('Unknown room ' + config.room + ' in ' + config.number);
    +    }
    +    let session = sessions.find(s => s.number === config.number);
    +    if (session.room !== config.room || session.slot !== config.slot) {
    +      session.room = config.room;
    +      session.slot = config.slot;
    +      updated.push(session);
    +    }
    +  }
    +
    +  if (apply) {
    +    for (const session of updated) {
    +      console.warn(`- updating #${session.number}...`);
    +      await assignSessionsToSlotAndRoom(session, project);
    +      console.warn(`- updating #${session.number}... done`);
    +    }
    +    console.warn(updated.length ?
    +      `- ${updated.length} sessions updated` :
    +      '- no session to update');
    +  }
    +  else {
    +    console.warn(updated.length ?
    +      `- ${updated.length} sessions would be updated: ${updated.map(s => s.number).join(', ')}` :
    +      '- no session would be updated');
    +  }
    +  console.warn(`Assign sessions to rooms and slots... done`);
    +}
    +
    +
    +// filename is an HTML file generated by suggest-grid.mjs that
    +// contains the raw session data
    +let filename
    +if (!process.argv[2]) {
    +    console.warn('Missing first param: HTML file with grid and raw data');
    +    } else {
    +    filename  = process.argv[2];
    +   }
    +
    +let apply;
    +if (process.argv[3]) {
    +    apply = process.argv[3];   
    +}
    +
    +main({ filename, apply })
    +  .catch(err => {
    +    console.warn(`Something went wrong: ${err.message}`);
    +    throw err;
    +  });
    diff --git a/tools/validate-grid.mjs b/tools/validate-grid.mjs
    new file mode 100644
    index 0000000..31ad493
    --- /dev/null
    +++ b/tools/validate-grid.mjs
    @@ -0,0 +1,139 @@
    +#!/usr/bin/env node
    +/**
    + * This tool validates the grid and sets a few validation results accordingly.
    + * Unless user requests full re-validation of the sessions, validation results
    + * managed by the tool are those related to scheduling problems (in other words,
    + * problems that may arise when an admin chooses a room and slot).
    + *
    + * To run the tool:
    + *
    + *  node tools/validate-grid.mjs [validation]
    + *
    + * where [validation] is either "scheduling" (default) to validate only
    + * scheduling conflicts or "everything" to re-validate all sessions.
    + */
    +
    +import { getEnvKey } from './lib/envkeys.mjs';
    +import { fetchProject, saveSessionValidationResult } from './lib/project.mjs'
    +import { validateGrid } from './lib/validate.mjs';
    +import { sendGraphQLRequest } from './lib/graphql.mjs';
    +
    +const schedulingErrors = [
    +  'error: chair conflict',
    +  'error: scheduling',
    +  'error: irc',
    +  'warning: capacity',
    +  'warning: conflict',
    +  'warning: duration',
    +  'warning: track'
    +];
    +
    +async function main(validation) {
    +  // First, retrieve known information about the project and the session
    +  const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER');
    +  const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER');
    +  const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true);
    +  console.log();
    +  console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`);
    +  const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER);
    +  if (!project) {
    +    throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`);
    +  }
    +  project.chairsToW3CID = CHAIR_W3CID;
    +  console.log(`- ${project.sessions.length} sessions`);
    +  console.log(`- ${project.rooms.length} rooms`);
    +  console.log(`- ${project.slots.length} slots`);
    +  console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`);
    +
    +  console.log();
    +  console.log(`Validate grid...`);
    +  const errors = (await validateGrid(project))
    +    .filter(error => validation === 'everything' || schedulingErrors.includes(`${error.severity}: ${error.type}`));
    +  console.log(`- ${errors.length} problems found`);
    +  console.log(`Validate grid... done`);
    +
    +  // Time to record session validation issues
    +  const sessions = [... new Set(errors.map(error => error.session))]
    +    .map(number => project.sessions.find(s => s.number === number));
    +  for (const session of sessions) {
    +    console.log();
    +    console.log(`Save validation results for session ${session.number}...`);
    +    for (const severity of ['Error', 'Warning', 'Check']) {
    +      let results = errors
    +        .filter(error => error.session === session.number && error.severity === severity.toLowerCase())
    +        .map(error => error.type);
    +      if (severity === 'Check' &&
    +          session.validation.check?.includes('irc channel') &&
    +          !results.includes('irc channel')) {
    +        // Need to keep the 'irc channel' value until an admin removes it
    +        results.push('irc channel');
    +      }
    +      else if (severity === 'Warning' && session.validation.note) {
    +        results = results.filter(warning => {
    +          const keep =
    +            !session.validation.note.includes(`-warning:${warning}`) &&
    +            !session.validation.note.includes(`-warn:${warning}`) &&
    +            !session.validation.note.includes(`-w:${warning}`);
    +          if (!keep) {
    +            console.log(`- drop warning:${warning} per note`);
    +          }
    +          return keep;
    +        });
    +      }
    +      if (validation !== 'everything' && session.validation[severity.toLowerCase()]) {
    +        // Need to preserve previous results that touched on other aspects
    +        const previousResults = session.validation[severity.toLowerCase()]
    +          .split(',')
    +          .map(value => value.trim());
    +        for (const result of previousResults) {
    +          if (!schedulingErrors.includes(`${severity.toLowerCase()}: ${result}`)) {
    +            results.push(result);
    +          }
    +        }
    +      }
    +      results = results.sort();
    +      session.validation[severity.toLowerCase()] = results.join(', ');
    +    }
    +    await saveSessionValidationResult(session, project);
    +    console.log(`Save validation results for session ${session.number}... done`);
    +  }
    +
    +  if (validation !== 'everything') {
    +    const resetSessions = project.sessions.filter(session =>
    +      !sessions.find(s => s.number === session.number));
    +    for (const session of resetSessions) {
    +      let updated = false;
    +      for (const severity of ['Error', 'Warning', 'Check']) {
    +        if (!session.validation[severity.toLowerCase()]) {
    +          continue;
    +        }
    +        let results = [];
    +        const previousResults = session.validation[severity.toLowerCase()]
    +          .split(',')
    +          .map(value => value.trim());
    +        for (const result of previousResults) {
    +          if (!schedulingErrors.includes(`${severity.toLowerCase()}: ${result}`)) {
    +            results.push(result);
    +          }
    +        }
    +        if (results.length !== previousResults.length) {
    +          results = results.sort();
    +          session.validation[severity.toLowerCase()] = results.join(', ');
    +          updated = true;
    +        }
    +      }
    +      if (updated) {
    +        console.log(`Save validation results for session ${session.number}...`);
    +        await saveSessionValidationResult(session, project);
    +        console.log(`Save validation results for session ${session.number}... done`);
    +      }
    +    }
    +  }
    +}
    +
    +
    +main(process.argv[2] ?? 'scheduling')
    +  .catch(err => {
    +    console.log(`Something went wrong: ${err.message}`);
    +    throw err;
    +  });
    \ No newline at end of file
    diff --git a/tools/validate-session.mjs b/tools/validate-session.mjs
    new file mode 100644
    index 0000000..6a19f7f
    --- /dev/null
    +++ b/tools/validate-session.mjs
    @@ -0,0 +1,197 @@
    +#!/usr/bin/env node
    +/**
    + * This tool validates a session issue and manages validation results in the
    + * project accordingly.
    + *
    + * To run the tool:
    + *
    + *  node tools/validate-session.mjs [sessionNumber] [changes]
    + *
    + * where [sessionNumber] is the number of the issue to validate (e.g. 15)
    + * and [changes] is the filename of a JSON file that describes changes made to
    + * the body of the issue (e.g. changes.json).
    + *
    + * The JSON file should look like:
    + * {
    + *   "body": {
    + *     "from": "[previous version]"
    + *   }
    + * }
    + *
    + * The JSON file typically matches github.event.issue.changes in a GitHub job.
    + */
    +
    +import { getEnvKey } from './lib/envkeys.mjs';
    +import { fetchProject, saveSessionValidationResult } from './lib/project.mjs'
    +import { validateSession } from './lib/validate.mjs';
    +import { parseSessionBody, updateSessionDescription } from './lib/session.mjs';
    +import { sendGraphQLRequest } from './lib/graphql.mjs';
    +
    +/**
    + * Helper function to generate a shortname from the session's title
    + */
    +function generateShortname(session) {
    +  return '#' + session.title
    +    .toLowerCase()
    +    .replace(/\([^\)]\)/g, '')
    +    .replace(/[^a-z0-0\-\s]/g, '')
    +    .replace(/\s+/g, '-');
    +}
    +
    +async function main(sessionNumber, changesFile) {
    +  // First, retrieve known information about the project and the session
    +  const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER');
    +  const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER');
    +  const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true);
    +  console.log();
    +  console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`);
    +  const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER);
    +  const session = project.sessions.find(s => s.number === sessionNumber);
    +  if (!project) {
    +    throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`);
    +  }
    +  if (!session) {
    +    throw new Error(`Session ${sessionNumber} not found in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`);
    +  }
    +  console.log(`- ${project.sessions.length} sessions`);
    +  console.log(`- ${project.rooms.length} rooms`);
    +  console.log(`- ${project.slots.length} slots`);
    +  project.chairsToW3CID = CHAIR_W3CID;
    +  console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`);
    +
    +  console.log();
    +  console.log(`Validate session...`);
    +  let report = await validateSession(sessionNumber, project, changes);
    +  for (const error of report) {
    +    console.log(`- ${error.severity}:${error.type}: ${error.messages.join(', ')}`);
    +  }
    +  console.log(`Validate session... done`);
    +
    +  const checkComments = report.find(error =>
    +    error.severity === 'check' && error.type === 'instructions');
    +  if (checkComments &&
    +      !session.validation.check?.includes('instructions') &&
    +      changesFile) {
    +    // The session contains comments and does not have a "check: instructions"
    +    // flag. That said, an admin may already have validated these comments
    +    // (and removed the flag). We should only add it back if the comments
    +    // section changed.
    +    console.log();
    +    console.log(`Assess need to add "check: instructions" flag...`);
    +
    +    // Read JSON file that describes changes if one was given
    +    // (needs to contain a dump of `github.event.changes` when run in a job)
    +    const { default: changes } = await import(
    +      ['..', changesFile].join('/'),
    +      { assert: { type: 'json' } }
    +    );
    +    if (!changes.body?.from) {
    +      console.log(`- no previous version of session body, add flag`);
    +    }
    +    else {
    +      console.log(`- previous version of session body found`);
    +      try {
    +        const previousDescription = parseSessionBody(changes.body.from);
    +        const newDescription = parseSessionBody(session.body);
    +        if (newDescription.comments === previousDescription.comments) {
    +          console.log(`- no change in comments section, no need to add flag`);
    +          report = report.filter(error =>
    +            !(error.severity === 'check' && error.type === 'instructions'));
    +        }
    +        else {
    +          console.log(`- comments section changed, add flag`);
    +        }
    +      }
    +      catch {
    +        // Previous version could not be parsed. Well, too bad, let's add
    +        // the "check: comments" flag then.
    +        // TODO: consider doing something smarter as broken format errors
    +        // will typically arise when author adds links to agenda/minutes.
    +        console.log(`- previous version of session body could not be parsed, add flag`);
    +      }
    +    }
    +    console.log(`Assess need to add "check: instructions" flag... done`);
    +  }
    +
    +  // No IRC channel provided, one will be created, let's add a
    +  // "check: irc channel" flag
    +  if (!report.find(err => err.severity === 'error' && err.type === 'format') &&
    +      !session.description.shortname) {
    +    report.push({
    +      session: sessionNumber,
    +      severity: 'check',
    +      type: 'irc channel',
    +      messages: ['IRC channel was generated from the title']
    +    });
    +  }
    +
    +  // Time to record session validation issues
    +  console.log();
    +  console.log(`Save session validation results...`);
    +  for (const severity of ['Error', 'Warning', 'Check']) {
    +    let results = report
    +      .filter(error => error.severity === severity.toLowerCase())
    +      .map(error => error.type)
    +      .sort();
    +    if (severity === 'Check' &&
    +        session.validation.check?.includes('irc channel') &&
    +        !results.includes('irc channel')) {
    +      // Need to keep the 'irc channel' value until an admin removes it
    +      results.push('irc channel');
    +      results = results.sort();
    +    }
    +    else if (severity === 'Warning' && session.validation.note) {
    +      results = results.filter(warning => {
    +        const keep =
    +          !session.validation.note.includes(`-warning:${warning}`) &&
    +          !session.validation.note.includes(`-warn:${warning}`) &&
    +          !session.validation.note.includes(`-w:${warning}`);
    +        if (!keep) {
    +          console.log(`- drop warning:${warning} per note`);
    +        }
    +        return keep;
    +      });
    +    }
    +    session.validation[severity.toLowerCase()] = results.join(', ');
    +  }
    +  await saveSessionValidationResult(session, project);
    +  console.log(`Save session validation results... done`);
    +
    +  // Prefix IRC channel with '#' if not already done
    +  if (!report.find(err => err.severity === 'error' && err.type === 'format') &&
    +      session.description.shortname &&
    +      !session.description.shortname.startsWith('#')) {
    +    console.log();
    +    console.log(`Add '#' prefix to IRC channel...`);
    +    session.description.shortname = '#' + session.description.shortname;
    +    await updateSessionDescription(session);
    +    console.log(`Add '#' prefix to IRC channel... done`);
    +  }
    +
    +  // Or generate IRC channel if it was not provided.
    +  if (!report.find(err => err.severity === 'error' && err.type === 'format') &&
    +      !session.description.shortname) {
    +    console.log();
    +    console.log(`Generate IRC channel...`);
    +    session.description.shortname = generateShortname(session);
    +    await updateSessionDescription(session);
    +    console.log(`Generate IRC channel... done`);
    +  }
    +}
    +
    +
    +// Read session number from command-line
    +if (!process.argv[2] || !process.argv[2].match(/^\d+$/)) {
    +  console.log('Command needs to receive a session number as first parameter');
    +  process.exit(1);
    +}
    +const sessionNumber = parseInt(process.argv[2], 10);
    +
    +// Read change filename from command-line if specified
    +const changes = process.argv[3];
    +
    +main(sessionNumber, changes)
    +  .catch(err => {
    +    console.log(`Something went wrong: ${err.message}`);
    +    throw err;
    +  });
    \ No newline at end of file