diff options
| author | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-09-24 21:01:06 +0200 |
|---|---|---|
| committer | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-09-25 20:16:02 +0200 |
| commit | 628406f0a50e040d4661629446c620a3a495441f (patch) | |
| tree | e1a2b783296b1779a4040b5a7ea55d72547a37e8 | |
| -rw-r--r-- | LICENSE | 662 | ||||
| -rw-r--r-- | README.md | 56 | ||||
| -rw-r--r-- | codesettings.json | 9 | ||||
| -rw-r--r-- | noctalia.d.luau | 796 | ||||
| -rw-r--r-- | sourcehut-builds/bar.luau | 98 | ||||
| -rw-r--r-- | sourcehut-builds/constants.luau | 49 | ||||
| -rw-r--r-- | sourcehut-builds/date_utils.luau | 16 | ||||
| -rw-r--r-- | sourcehut-builds/empty_state.luau | 9 | ||||
| -rw-r--r-- | sourcehut-builds/error_utils.luau | 8 | ||||
| -rw-r--r-- | sourcehut-builds/panel.luau | 204 | ||||
| -rw-r--r-- | sourcehut-builds/plugin.toml | 79 | ||||
| -rw-r--r-- | sourcehut-builds/poller.luau | 121 | ||||
| -rw-r--r-- | sourcehut-builds/string_utils.luau | 5 | ||||
| -rw-r--r-- | sourcehut-builds/table_utils.luau | 11 | ||||
| -rw-r--r-- | sourcehut-builds/translations/en.json | 59 | ||||
| -rw-r--r-- | sourcehut_builds.d.luau | 47 |
16 files changed, 2229 insertions, 0 deletions
@@ -0,0 +1,662 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + noctalia-plugin-sourcehut-builds, Noctalia plugin to display sourcehut + builds in a panel. + Copyright (C) 2025 Ruben Beltran del Rio + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a44db79 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Sourcehut Builds Noctalia Plugin + +A plugin for noctalia v5 that displays your last few sourcehut builds. + +It will display icons for recent running, failed or successful jobs +(with a configurable window.) + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `rbdr/sourcehut-builds` | +| Plugin API | 30 | +| Entries | Bar widget: `bar`, panel: `panel`, service: `poller` | + +## Usage + +Configure your sourcehut API token in the plugin configuration, then add the +bar widget. + +You can toggle, open or close the panel by targeting `rbdr/sourcehut-builds:panel` + +```sh +noctalia msg panel-toggle rbdr/sourcehut-builds:panel +``` + +And you can open the settings by using the plugin id. + +```sh +noctalia msg settings-open-plugin rbdr/sourcehut-builds +``` + +| Action | Effect | +| --- | --- | +| Left click (bar glyph) | Open/close the panel | +| Left click on a build label | Open the build in the browser | +| Left click on a glyph for a task | Open the build in the browser, scroll the task into view | + +## Settings + +| Setting | What it does | +| --- | --- | +| Sourcehut API token| The token to use to connect to the sourcehut API | +| Build Count | The number of builds to show | +| Cutoff for recent builds | The number of seconds to consider "recent" for the glyph displays | + +## Install + +Checkout this repo somewhere in your computer, then add it as a plugin source +in noctalia settings, then enable the plugin. + +## No LLM Usage + +This project has not used LLMs in any stage of its development and does not +accept contributions where LLMs have been used in any point: whether it's +planning, debugging, testing, implementing or anything else. diff --git a/codesettings.json b/codesettings.json new file mode 100644 index 0000000..c249f67 --- /dev/null +++ b/codesettings.json @@ -0,0 +1,9 @@ +{ + "luau-lsp.platform.type": "standard", + "luau-lsp.sourcemap.enabled": false, + "luau-lsp.types.definitionFiles": { + "noctalia": "noctalia.d.luau", + "sourcehut_builds": "sourcehut_builds.d.luau" + }, + "luau-lsp.ignoreGlobs": ["**/*.d.luau"] +} diff --git a/noctalia.d.luau b/noctalia.d.luau new file mode 100644 index 0000000..5ec34a0 --- /dev/null +++ b/noctalia.d.luau @@ -0,0 +1,796 @@ +--!strict +-- Type definitions for the Noctalia plugin API (plugin_api 32). +-- +-- luau-lsp *definition file*: it declares the host-injected globals (noctalia.*, +-- barWidget.*, shortcut.*, launcher.*, desktopWidget.*, panel.*, ui.*) so authors +-- get autocomplete and typo diagnostics. Annotations are a runtime no-op. +-- See README.md ("Editor setup") for pointing luau-lsp at this file. +-- +-- Prop tables are exhaustive: the host logs and skips any prop not listed here. +-- "API n" marks the plugin_api level a member requires. + +-- ── Shared value shapes ────────────────────────────────────────────────────── + +export type CommandResult = { + exitCode: number, + stdout: string, + stderr: string, + timedOut: boolean, + stdoutTruncated: boolean, + stderrTruncated: boolean, +} + +export type HttpRequest = { + url: string, + method: string?, -- defaults to "GET" + headers: { string }?, -- each entry is a full "Header: value" line + body: string?, + basic_username: string?, + basic_password: string?, + follow_redirects: boolean?, + -- Disables origin certificate and hostname verification; trusted endpoints only. API 7. + allow_insecure_tls: boolean?, +} + +export type HttpResponse = { + ok: boolean, -- transport success (not the HTTP status) + status: number, + body: string, +} + +export type HttpStreamResult = { + ok: boolean, -- transport success (not the HTTP status) + status: number, -- HTTP status code (0 when ok is false) +} + +export type HttpStreamHandle = { + stop: () -> (), -- cancel the stream; idempotent, suppresses onClose +} + +export type Output = { + name: string, + description: string, + width: number, + height: number, + x: number, + y: number, + scale: number, + focused: boolean, +} + +export type WallpaperMask = { + path: string, + wallpaperPath: string, +} + +export type PanelContextMenuAction = { + kind: "item"?, -- may be omitted for action rows + id: string, + label: string, + enabled: boolean?, -- defaults to true +} + +export type PanelContextMenuHeader = { + kind: "header", + label: string, +} + +export type PanelContextMenuSeparator = { + kind: "separator", +} + +export type PanelContextMenuItem = PanelContextMenuAction | PanelContextMenuHeader | PanelContextMenuSeparator + +export type PanelContextMenuRequest = { + items: { PanelContextMenuItem }, + onActivate: string, + context: (string | number | boolean)?, + maxVisible: number?, -- defaults to 12, valid range 1..30 +} + +-- A tooltip row: { key, value } or the positional array form { key, value }. +export type TooltipRow = { key: string?, value: string? } + +export type LauncherResult = { + id: string?, + title: string?, + subtitle: string?, + glyph: string?, + icon: string?, + badge: string?, + category: string?, -- must match a [[launcher_provider.category]] label + presentation: string?, + query: string?, -- on activate, set this provider's query to this sub-query (host adds the prefix) + score: number?, +} + +export type SystemStats = { + sampledAtMs: number?, -- epoch ms of the latest aggregate sample (API 16) + -- Absent sensors are nil rather than 0, so "no probe" is distinguishable from "idle". + cpu: { usagePercent: number, tempC: number?, freqMhz: number?, maxFreqMhz: number? }, + ram: { usagePercent: number, usedMb: number, totalMb: number }, + swap: { usedMb: number, totalMb: number }, + gpu: { tempC: number?, usagePercent: number?, vramUsedBytes: number?, vramTotalBytes: number? }, + net: { + rxBytesPerSec: number, + txBytesPerSec: number, + interfaces: { [string]: { rxBytesPerSec: number, txBytesPerSec: number } }, + }, + loadAvg: { number }, -- 1, 5 and 15 minute averages +} + +export type DiskMount = { + path: string, + source: string, + filesystem: string, +} + +export type DiskStats = { + usagePercent: number, + totalBytes: number, + freeBytes: number, + availableBytes: number, +} + +-- Noctalia provides Luau's built-in `require(path: string): any` for explicit relative `.luau` modules (API 22). + +-- ── noctalia.* - shared across every entry type ────────────────────────────── + +export type NoctaliaState = { + set: (key: string, value: any) -> (), + get: (key: string) -> any, + watch: (key: string, callback: (value: any) -> ()) -> (), +} + +export type NoctaliaJson = { + decode: (str: string) -> (any, string?), -- value, or (nil, err) + encode: (value: any, pretty: boolean?) -> (string?, string?), +} + +export type NoctaliaString = { + trim: (s: string) -> string, + urlEncode: (s: string) -> string, + urlDecode: (s: string) -> string, +} + +-- API 20. Paths resolve like the filesystem APIs. `load` returning true means the +-- request was accepted, not that decoding succeeded; at most eight loads may be +-- pending. Names and pending callbacks are released when this runtime reloads. +export type NoctaliaSound = { + load: (name: string, path: string, onLoaded: (ok: boolean, error: string?) -> ()) -> boolean, + play: (name: string) -> (), +} + +export type Noctalia = { + log: (msg: string) -> (), + + -- Subprocess. A string runs through /bin/sh -c; an argv table (API 24) executes + -- the program directly. With no callback, runAsync is a detached fire-and-forget + -- launch; timeoutMs is clamped to [50, 60000]. + runAsync: (cmdOrArgv: string | { string }, onResult: ((result: CommandResult) -> ())?, timeoutMs: number?) -> boolean, + runStream: (cmd: string, onLine: (line: string) -> ()) -> boolean, + runInTerminal: (cmd: string) -> boolean, + commandExists: (name: string) -> boolean, + -- onResult(true) iff a running process command line matches all needles. + processMatches: (onResult: (matched: boolean) -> (), ...string) -> boolean, + flatpakAppInstalled: (appId: string) -> boolean, + portalAvailable: () -> boolean, + + -- Outputs / display. + focusedOutputName: () -> string?, + outputs: () -> { Output }, + isDarkMode: () -> boolean, + -- Active theme palette color for a role ("primary", "surface", "on_surface", ...) as + -- "#RRGGBB"; nil for unknown role names. API 31. + getColor: (role: string) -> string?, + -- Effective shell config by dotted path ("bar.main.position", "shell.offline_mode"). + -- Array indices are zero-based ("bar.order[0]"); nil when the path matches nothing. API 26. + getSetting: (path: string) -> any, + + -- Resolves an app id (desktop-entry id / StartupWMClass), or a raw icon name when no + -- entry matches, to an icon path for ui.image. nil when nothing resolves. + appIconPath: (appIdOrIconName: string, sizePx: number?) -> string?, + + -- Wallpaper. setWallpaper(path) targets all outputs; setWallpaper(connector, path) targets one. + setWallpaperEnabled: (connector: string, enabled: boolean) -> (), + setWallpaper: (connectorOrPath: string, path: string?) -> (), + wallpaperDirectory: () -> string?, + -- Source-aligned output mask: 0 keeps desktop widgets visible, 255 erases them to + -- reveal the wallpaper. API 25. + wallpaperPath: (connector: string) -> string?, + setWallpaperMask: (connector: string, mask: WallpaperMask?) -> (), + + togglePanel: (panelId: string) -> (), -- "author/plugin:panel" + -- Opens the settings window at this plugin's settings; no-op without settings. API 15. + openSettings: () -> (), + -- The callback receives the canonical #RRGGBB color, or nil when cancelled. + openColorPicker: (initialColor: string, onClose: (color: string?) -> ()) -> boolean, + + notify: (title: string, body: string?) -> (), + notifyError: (title: string, body: string?) -> (), + + copyToClipboard: (text: string, mimeType: string) -> boolean, + clipboardText: () -> string?, -- nil when empty or non-text + getenv: (name: string) -> string?, + expandPath: (path: string) -> string, + formatTime: (pattern: string, unixSeconds: number?, timezone: string?) -> string, + timeFormat: () -> string, -- [shell].time_format, e.g. "{:%H:%M}" (API 19) + dateFormat: () -> string, -- [shell].date_format, e.g. "%A, %x" (API 19) + -- True when `name` is empty (system local) or names a zone in the active database. API 19. + isValidTimezone: (name: string) -> boolean, + nowMs: () -> number, -- the only sub-second clock; formatTime and os.time are whole-second (API 12) + + -- System monitor. nil when [system.monitor] is disabled. The first systemStats call + -- opts this plugin into its optional CPU/GPU probes. API 12. + systemStats: () -> SystemStats?, + -- Per-core usage in /proc/stat order; nil until the first delta lands. Offline cores are + -- absent, so length can change and an index is not a core id. + cpuCores: () -> { number }?, + -- Physical block-device filesystems, deduped by source, sorted by mount path. API 16. + diskMounts: () -> { DiskMount }, + -- statvfs snapshot for an absolute or ~/ path; the path is retained for sampling. API 16. + diskStats: (path: string) -> DiskStats?, + + setUpdateInterval: (ms: number) -> (), -- update() tick rate, clamped to >= 16ms + + -- Filesystem (paths resolve ~ -> $HOME, absolute verbatim, else plugin-relative). + readFile: (path: string) -> (string?, string?), -- contents, or (nil, err) + readFileAsync: (path: string, onResult: (contents: string?, err: string?) -> ()) -> boolean, -- API 23 + writeFile: (path: string, contents: string) -> (boolean, string?), + mkdirAll: (path: string) -> (boolean, string?), -- like mkdir -p; existing dir is success + removeFile: (path: string) -> (boolean, string?), -- files only, refuses directories + renameFile: (from: string, to: string) -> (boolean, string?), + fileExists: (path: string) -> boolean, + fileInfo: (path: string) -> ({ size: number, mtime: number, isDir: boolean }?, string?), + listDir: (path: string) -> ({ string }?, string?), + pluginDir: () -> string?, + -- Per-plugin persistent data dir, created on demand; survives updates, honors + -- NOCTALIA_STATE_HOME. Use for durable data (state is in-memory only). + pluginDataDir: () -> (string?, string?), + + -- Registers a font file so its family works in setFont / a label's fontFamily. + -- Returns the family name, or (nil, err); visible to every surface once loaded. + loadFont: (path: string) -> (string?, string?), + + -- Translation against the plugin's own translations/<lang>.json. + tr: (key: string, subst: { [string]: string | number | boolean }?) -> string, + trp: (key: string, count: number, subst: { [string]: string | number | boolean }?) -> string, + + -- HTTP (honors shell.offline_mode; download dest resolves like readFile). + http: (req: HttpRequest, onResponse: (response: HttpResponse) -> ()) -> boolean, + -- Long-lived stream (e.g. SSE). onLine fires per line (CR trimmed); onClose fires once + -- unless stopped through the handle. Non-2xx bodies stream to onLine and the status + -- arrives in onClose. Cancelled on script reload; nil when it could not start. API 4. + httpStream: ( + req: HttpRequest, + onLine: (line: string) -> (), + onClose: (result: HttpStreamResult) -> () + ) -> HttpStreamHandle?, + download: (url: string, destPath: string, onDone: (success: boolean) -> ()) -> boolean, + + fuzzyScore: (pattern: string, text: string) -> number?, -- nil if no match + + getConfig: (key: string) -> any, -- string | number | boolean | {string} | {[string]: string} | nil + + state: NoctaliaState, + sound: NoctaliaSound, + json: NoctaliaJson, + string: NoctaliaString, +} + +declare noctalia: Noctalia + +-- ── ui.* - declarative control tree (bar widgets, desktop widgets, panels) ─── + +-- One node of a ui.* tree. +export type UiNode = { + type: string, + props: { [string]: any }, + children: { UiNode }, +} + +-- A palette role ("primary", "on_surface"), a role with alpha ("primary/0.6", +-- resolved live against the palette), or a hex value ("#rrggbb" / "#rrggbbaa"). +export type UiColor = string + +-- A callback prop takes the name of a plugin global, or a function (API 9) that is +-- render-scoped: re-rendering replaces it, and an event on a node the current tree no +-- longer contains does nothing. An empty name counts as unset. Every argument arrives +-- as a string, for named handlers and closures alike. +export type UiClickHandler = string | (() -> ()) +-- state is "true" on enter and "false" on leave; key is the node's `key` ("" when unset). +-- Only the innermost hovered node reports, and every "true" is matched by a "false". +export type UiHoverHandler = string | ((state: string, key: string) -> ()) +export type UiChangeHandler = string | ((value: string) -> ()) +export type UiSelectHandler = string | ((index: string, text: string) -> ()) +export type UiScrollHandler = string | ((offset: string, maxOffset: string) -> ()) +-- Pointer position normalized to the graph's own box, "0.0000".."1.0000", 0,0 top-left. +export type UiPointerHandler = string | ((normX: string, normY: string) -> ()) +export type UiDropHandler = string | ((payload: string, value: string) -> ()) + +-- Common to every node. `opacity` is a group opacity: it fades children too, so use a +-- translucent `fill` for a translucent background. `key` gives a child stable identity +-- across renders (keeps input text, hover state, and closures aligned with their row). +export type UiCommonProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, +} + +-- ui.column / ui.row. Children stretch across the cross axis unless `align` says otherwise. +-- onClick makes the whole container a click target and joins the tab order (Enter/Space); +-- a container with only onHover passes clicks through to an enclosing target. +export type UiFlexProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + gap: number?, + padding: number?, + paddingH: number?, + paddingV: number?, + align: ("start" | "center" | "end" | "stretch")?, + justify: ("start" | "center" | "end" | "space_between")?, + fill: UiColor?, + radius: number?, + border: UiColor?, + borderWidth: number?, + minWidth: number?, + minHeight: number?, + onClick: UiClickHandler?, + onHover: UiHoverHandler?, + tooltip: string?, -- shown on hover (API 32); wraps the container in a hover target; cleared when dropped +} + +-- ui.box. Leaf node: it takes no children (use a column/row for content). +export type UiBoxProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + fill: UiColor?, + radius: number?, + border: UiColor?, + borderWidth: number?, + softness: number?, + onClick: UiClickHandler?, + onHover: UiHoverHandler?, + tooltip: string?, -- shown on hover (API 32); wraps the box in a hover target; cleared when dropped +} + +-- ui.label. Unset text props inherit the host defaults (in a bar, the bar's or widget's +-- font_family/font_weight and scale). fontFamily needs noctalia.loadFont first. +-- baseline "pictographic" centers art/icon fonts anchored at the ink top. +export type UiLabelProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + text: string?, + fontSize: number?, + color: UiColor?, + fontWeight: ("thin" | "light" | "normal" | "medium" | "semibold" | "bold" | "heavy")?, + fontFamily: string?, + baseline: ("text" | "textFixedHeight" | "inkCentered" | "pictographic")?, + maxWidth: number?, + maxLines: number?, + textAlign: ("start" | "center" | "end")?, +} + +-- ui.markdown. Read-only block; re-parsed only when text or the surface scale changes. API 21. +export type UiMarkdownProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + text: string?, +} + +-- ui.glyph. `name` is a Tabler/Nerd-Font glyph; `size` is a glyph size in px. +export type UiGlyphProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + name: string?, + size: number?, + color: UiColor?, +} + +-- ui.image. Local files only: download remote previews first, then pass the saved path. +export type UiImageProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + path: string?, -- plugin-relative, ~, or absolute + radius: number?, + fit: ("contain" | "cover" | "stretch")?, + border: UiColor?, + borderWidth: number?, + onClick: UiClickHandler?, + onHover: UiHoverHandler?, + tooltip: string?, -- shown on hover (API 32); wraps the image in a hover target; cleared when dropped +} + +export type UiSeparatorProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + thickness: number?, + color: UiColor?, + spacing: number?, + orientation: ("auto" | "horizontal" | "vertical")?, +} + +-- ui.spacer: flexible filler, sized with flexGrow. +export type UiSpacerProps = UiCommonProps + +export type UiProgressProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + progress: number?, -- 0..1 + fill: UiColor?, + track: UiColor?, + radius: number?, +} + +-- ui.button. Setting only `glyph` clears a retained button's previous text. `tooltip` +-- shows in bar widgets and panels, never on desktop widgets; dropping it clears it. +-- In bar widgets a button hugs its content unless sized with width/height/controlSize. +export type UiButtonProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + text: string?, + glyph: string?, + fontSize: number?, + glyphSize: number?, + variant: ("default" | "primary" | "secondary" | "destructive" | "outline" | "ghost")?, + contentAlign: ("start" | "center" | "end")?, + controlSize: ("sm" | "md" | "lg")?, -- 32 / 38 / 44px tiers; `height` wins when both are set + tooltip: string?, + enabled: boolean?, + selected: boolean?, + onClick: UiClickHandler?, + onRightClick: UiClickHandler?, -- the only place panel.openContextMenu may be called + onHover: UiHoverHandler?, +} + +-- ui.graph. Takes no clicks. The pointer callbacks (API 29) are coalesced on one shared +-- stream, so the newest event wins and a leave never arrives ahead of a position it +-- followed. Every entered graph reports its leave, including when the graph is dropped or +-- rewired - but that teardown leave only reaches a *named* handler, since a closure from +-- the render that dropped the graph is already superseded. +export type UiGraphProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + values: { number }?, -- 0..1, clamped + values2: { number }?, + color: UiColor?, + color2: UiColor?, + lineWidth: number?, + fillOpacity: number?, + onPointerMove: UiPointerHandler?, + onPointerLeave: UiClickHandler?, +} + +-- ui.input (panels only; skipped with a warning in the bar). Uncontrolled: `value` seeds +-- the field once, then the host owns the text - keep a stable `key` so edits survive a +-- re-render, and read them through onChange/onSubmit. `focus` grabs the keyboard when the +-- control is *created*, never on a later render; a fresh `key` focuses again. +-- multiline and password are mutually exclusive; multiline submits on Ctrl+Enter, or on +-- Enter with submitOnEnter (Shift+Enter then inserts the newline). +export type UiInputProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + value: string?, + placeholder: string?, + fontSize: number?, + controlSize: ("sm" | "md" | "lg")?, + password: boolean?, + multiline: boolean?, + submitOnEnter: boolean?, -- API 21 + frameVisible: boolean?, -- false hides the native background/border, keeps editing (API 27) + focus: boolean?, + enabled: boolean?, + onChange: UiChangeHandler?, + onSubmit: UiChangeHandler?, +} + +-- ui.select (panels only; no dropdowns inside a persistent panel). Value-driven: +-- pass selectedIndex on every render and update it from onChange, which receives the +-- selected index and its text. +export type UiSelectProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + options: { string }?, + selectedIndex: number?, + placeholder: string?, + controlSize: ("sm" | "md" | "lg")?, + enabled: boolean?, + onChange: UiSelectHandler?, +} + +-- ui.slider. Value-driven, but `value` is re-applied only while not dragging, so +-- re-rendering mid-drag with a draft value is safe. onChange reports every change +-- (coalesced); onDragEnd fires with no arguments when the interaction ends - pointer +-- release and keyboard adjustment. The wheel does not adjust plugin sliders. +export type UiSliderProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + min: number?, + max: number?, + step: number?, + value: number?, + controlSize: ("sm" | "md" | "lg")?, + enabled: boolean?, + onChange: UiChangeHandler?, + onDragEnd: UiClickHandler?, +} + +-- ui.toggle. Value-driven; onChange receives "true" / "false". +export type UiToggleProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + checked: boolean?, + enabled: boolean?, + onChange: UiChangeHandler?, +} + +-- ui.scroll (panels only; skipped with a warning in the bar). Vertical scrolling +-- container with a column's layout props. stickToBottom, onScroll and +-- scrollToBottomRev are API 21. +export type UiScrollProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + gap: number?, + padding: number?, + paddingH: number?, + paddingV: number?, + align: ("start" | "center" | "end" | "stretch")?, + justify: ("start" | "center" | "end" | "space_between")?, + fill: UiColor?, + radius: number?, + border: UiColor?, + borderWidth: number?, + stickToBottom: boolean?, -- stay pinned to the bottom until the user scrolls away + scrollToBottomRev: number?, -- jumps to the bottom on first sight and on every change + onScroll: UiScrollHandler?, +} + +-- ui.dragSource (panels only, API 5). Marks a subtree draggable: a grip glyph, or a whole +-- row through previewAncestor. dragType and payload are required - a missing, mistyped, +-- empty or over-limit value disables the control for that render. Limits: payload 16 KiB, +-- dragType 256 bytes. +export type UiDragSourceProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + gap: number?, + padding: number?, + paddingH: number?, + paddingV: number?, + align: ("start" | "center" | "end" | "stretch")?, + justify: ("start" | "center" | "end" | "space_between")?, + fill: UiColor?, + radius: number?, + border: UiColor?, + borderWidth: number?, + minWidth: number?, + minHeight: number?, + dragType: string, -- matched against a dropZone's `accepts` + payload: string, -- opaque; first onDrop argument + enabled: boolean?, + tooltip: string?, + previewAncestor: number?, -- integer 0..8 parent levels the ghost shows; 1 previews the row around a grip + liftFromLayout: boolean?, -- remove the previewed row from layout while dragging +} + +-- ui.dropZone (panels only, API 5). Flex container that accepts drops; accepts, value and +-- onDrop are required (`accepts = {}` accepts nothing). Nested zones resolve to the +-- deepest accepting zone, hitSlop zones first, closest wins. The host moves nothing: the +-- callback mutates the plugin's model and re-renders. Limits: value/onDrop/each accepts +-- entry 256 bytes, at most 16 accepts entries. +export type UiDropZoneProps = { + key: string?, + width: number?, + height: number?, + flexGrow: number?, + opacity: number?, + visible: boolean?, + gap: number?, + padding: number?, + paddingH: number?, + paddingV: number?, + align: ("start" | "center" | "end" | "stretch")?, + justify: ("start" | "center" | "end" | "space_between")?, + fill: UiColor?, + radius: number?, + border: UiColor?, + borderWidth: number?, + minWidth: number?, + minHeight: number?, + accepts: { string }, -- drag types + value: string, -- opaque; second onDrop argument + onDrop: UiDropHandler, + direction: ("column" | "row")?, + enabled: boolean?, + expandOnDrag: boolean?, -- a fixed-height zone animates to the dragged row's height + hitSlop: number?, -- extra drag-only hit distance, without changing layout or clicks +} + +-- Only column, row, scroll, dragSource and dropZone host children; the rest are leaves. +declare ui: { + column: (props: UiFlexProps?, children: { UiNode }?) -> UiNode, + row: (props: UiFlexProps?, children: { UiNode }?) -> UiNode, + scroll: (props: UiScrollProps?, children: { UiNode }?) -> UiNode, + dragSource: (props: UiDragSourceProps, children: { UiNode }?) -> UiNode, + dropZone: (props: UiDropZoneProps, children: { UiNode }?) -> UiNode, + box: (props: UiBoxProps?) -> UiNode, + label: (props: UiLabelProps?) -> UiNode, + markdown: (props: UiMarkdownProps?) -> UiNode, + glyph: (props: UiGlyphProps?) -> UiNode, + image: (props: UiImageProps?) -> UiNode, + separator: (props: UiSeparatorProps?) -> UiNode, + spacer: (props: UiSpacerProps?) -> UiNode, + progress: (props: UiProgressProps?) -> UiNode, + button: (props: UiButtonProps?) -> UiNode, + graph: (props: UiGraphProps?) -> UiNode, + input: (props: UiInputProps?) -> UiNode, + select: (props: UiSelectProps?) -> UiNode, + slider: (props: UiSliderProps?) -> UiNode, + toggle: (props: UiToggleProps?) -> UiNode, +} + +-- ── barWidget.* - [[widget]] presentation ──────────────────────────────────── + +declare barWidget: { + setText: (text: string) -> (), + setGlyph: (name: string) -> (), + setImage: (path: string, watch: boolean?, width: number?, height: number?) -> (), + setTooltip: (tooltip: (string | TooltipRow | { TooltipRow })?) -> (), + clearTooltip: () -> (), + -- family: a font family name (load a file with noctalia.loadFont first). + -- baseline: "text" (default) | "textFixedHeight" | "inkCentered" | "pictographic". + setFont: (family: string, baseline: string?) -> (), + setColor: (role: string, mode: string?) -> (), + setGlyphColor: (role: string, mode: string?) -> (), + isVertical: () -> boolean, + -- Connector of the output this widget instance's bar is on; per-instance, unlike + -- noctalia.focusedOutputName(). nil when unknown. + outputName: () -> string?, + setVisible: (visible: boolean) -> (), + -- Declarative alternative to setText/setGlyph: the tree replaces the built-in + -- glyph/text row. ui.input/ui.select/ui.scroll are not supported in the bar. + render: (tree: UiNode) -> (), +} + +-- Gestures are configuration, not code: the user binds them per widget instance in +-- [widget.<id>.actions] (left, right, middle, back, forward, scroll_up, scroll_down, +-- scroll_left, scroll_right), and a binding wins over the matching callback. An action is +-- an IPC command ("media toggle"), `exec <command line>`, or `none`. Manifests declare +-- their own defaults in [widget.actions] (API 14). +-- +-- Middle click is the one to know about: every widget defaults to +-- `middle = "settings-open-widget"`, so onMiddleClick does not fire until the manifest or +-- the user binds `middle = "none"`. Scroll has one extra gate: `enable_scroll = false` +-- turns onScroll off regardless of bindings. + +-- ── shortcut.* - [[shortcut]] quick-toggle tile ────────────────────────────── + +declare shortcut: { + setLabel: (label: string) -> (), + setIcon: (on: string, off: string?) -> (), + setActive: (active: boolean) -> (), + setEnabled: (enabled: boolean) -> (), +} + +-- ── launcher.* - [[launcher_provider]] results ─────────────────────────────── + +declare launcher: { + setResults: (query: string, results: { LauncherResult }) -> (), + setQuery: (text: string) -> (), -- prefix + text (stays in provider); "" resets to root +} + +-- ── desktopWidget.* - [[desktop_widget]] declarative UI ────────────────────── + +declare desktopWidget: { + render: (tree: UiNode) -> (), + setWantsSecondTicks: (wants: boolean) -> (), -- run update() on second boundaries + setNeedsFrameTick: (needs: boolean) -> (), -- deliver onFrameTick(deltaMs) every frame +} + +-- ── panel.* - [[panel]] declarative UI ─────────────────────────────────────── + +declare panel: { + render: (tree: UiNode) -> (), + close: () -> (), + -- Opens a native menu at the originating direct pointer callback; false outside a live + -- one. onActivate receives (actionId, context) in the panel script. API 28. + openContextMenu: (request: PanelContextMenuRequest) -> boolean, + setWantsSecondTicks: (wants: boolean) -> (), + setNeedsFrameTick: (needs: boolean) -> (), -- onFrameTick(deltaMs) while open (API 18) +} + +-- ── Entry-point callbacks ──────────────────────────────────────────────────── +-- +-- Your entry defines the globals the host calls, as plain global functions - the +-- host only calls a callback if the entry defines it: +-- +-- function update() end -- bar/desktop widget, service tick +-- function onIpc(event, payload) end -- any entry (payload: string?) +-- function onClick() / onRightClick() end -- shortcut, bar widget +-- function onMiddleClick() end -- bar widget (see "Gestures" above) +-- function onHover(entered) end -- bar widget pointer enter / leave +-- function onScroll(axis, steps, startsGesture) end -- bar widget scroll; axis is "vertical" | "horizontal", +-- -- steps is whole wheel detents (negative = up / left), +-- -- startsGesture is true only on the first step of a flick +-- function onQuery(text) / onActivate(id) end -- launcher provider +-- function onFrameTick(deltaMs) end -- desktop widget, or open panel (API 18); after +-- -- setNeedsFrameTick(true), frames coalesced +-- function onAudioSpectrum(valuesCsv, stateCsv) end -- audio-reactive bar widget +-- function onOpen(context) / onClose() end -- panel lifecycle +-- function onKey(chord, pressed) end -- panel: a capture_keys chord, verbatim from the +-- -- manifest (API 13) +-- function onConfigChanged() end -- service: settings changed; getConfig() is now new +-- function onEnable() end -- service: plugin explicitly enabled (API 17) +-- function onOutputsChanged() end -- service: output set or geometry changed +-- function onExit(signal, reason) end -- any entry teardown; signal is 0 normally, 2 SIGINT, +-- -- 15 SIGTERM; reason is "reload" | "disable" | +-- -- "uninstall" | "shutdown" (API 17) +-- +-- These are intentionally NOT declared here: declaring them as globals makes +-- luau-lsp treat your definition as overwriting a built-in. diff --git a/sourcehut-builds/bar.luau b/sourcehut-builds/bar.luau new file mode 100644 index 0000000..862287d --- /dev/null +++ b/sourcehut-builds/bar.luau @@ -0,0 +1,98 @@ +--!nonstrict +-- Sourcehut Builds - Bar widget that toggles the build status panel and shows the status of recent tasks. +-- +-- You can configure the glyphs shown for the different statuses, as well as +-- when there are no recent builds. + +local K = require("./constants.luau") + +-------------------------------------------------------------------------------- +-- Constants +-------------------------------------------------------------------------------- +local GLYPHS = { + default = "glyph", + ok = "ok_glyph", + failed = "failed_glyph", + inProgress = "in_progress_glyph", +} +local STYLE = { + color = { + closed = "on_surface", + open = "primary" + } +} + +-------------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------------- +local isPanelOpen: boolean = false +local buildState: SourcehutBuildsState = require("./empty_state.luau") + +-------------------------------------------------------------------------------- +-- Internal Functions +-------------------------------------------------------------------------------- + +------------------------------------------------------------------ Logic Helpers + +local function hasRecentBuilds(): boolean + return buildState.successful > 0 or buildState.failed > 0 or buildState.inProgress > 0 +end + +----------------------------------------------------------------------------- UI + +-- Renders the bar +local function render() + barWidget.setGlyphColor(isPanelOpen and STYLE.color.open or STYLE.color.closed) + + barWidget.setTooltip(noctalia.tr("bar.tooltip")) + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, { + not hasRecentBuilds() and ui.glyph({ name = noctalia.getConfig(GLYPHS.default), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + buildState.successful > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.ok), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + buildState.failed > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.failed), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + buildState.inProgress > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.inProgress), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + })) +end + + +---------------------------------------------------------------- Event Listeners + +local function onPanelStateChanged(value: boolean) + isPanelOpen = value + render() +end + +local function onBuildsStateChanged(value: SourcehutBuildsState) + buildState = value + render() +end + +---------------------------------------------------------------------- Lifecycle + +local function run() + noctalia.setUpdateInterval(K.bar.update_interval) +end + +-------------------------------------------------------------------------------- +-- Listeners +-------------------------------------------------------------------------------- + +noctalia.state.watch(K.events.panel_state_updated, onPanelStateChanged) +noctalia.state.watch(K.events.builds_state_updated, onBuildsStateChanged) + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- +function update() + render() +end + +function onClick() + noctalia.togglePanel(K.panel.id) +end + +function onConfigChanged() +end + +run() diff --git a/sourcehut-builds/constants.luau b/sourcehut-builds/constants.luau new file mode 100644 index 0000000..5133849 --- /dev/null +++ b/sourcehut-builds/constants.luau @@ -0,0 +1,49 @@ +-------------------------------------------------------------------------------- +-- Events +-------------------------------------------------------------------------------- +local CONSTANTS = { + events = { + panel_state_updated = "sourcehut_panel_state_updated", + builds_state_updated = "sourcehut_builds_state_updated", + poller_state_updated = "sourcehut_poller_state_updated" + }, + panel = { + id = "rbdr/sourcehut-builds:panel", + theme = { + fontSize = { + heading = 18 + }, + fontWeight = { + heading = "bold" + } + } + }, + bar = { + update_interval = 2000 + }, + poller = { + state = { + pending = {type= "pending"}, + ok = {type= "ok"} + }, + update_interval = 30000, + errors = { + request_failed = noctalia.tr("error.request_failed"), + bad_response = noctalia.tr("error.bad_response"), + unauthorized = noctalia.tr("error.unauthorized") + }, + cutoff_configuration_key = "recent_cutoff", + build_count_configuration_key = "build_count" + }, + sourcehut = { + builds_url = "https://builds.sr.ht/query", + web_url = "https://builds.sr.ht/", + query = "{\"query\": \"query { me { canonicalName jobs { results { id created updated status note tags tasks { name status } } } } }\"}", + token_configuration_key = "sourcehut_api_token", + errors = { + unauthorized = "ERR_UNAUTHORIZED" + } + } +} + +return CONSTANTS diff --git a/sourcehut-builds/date_utils.luau b/sourcehut-builds/date_utils.luau new file mode 100644 index 0000000..be47b3a --- /dev/null +++ b/sourcehut-builds/date_utils.luau @@ -0,0 +1,16 @@ +return { + parseDate = function(dateString: string): number + local pattern = "(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+).(%d+)" + local runyear, runmonth, runday, + runhour, runminute, runseconds = dateString:match(pattern) + + return os.time({ + year = runyear, + month = runmonth, + day = runday, + hour = runhour, + min = runminute, + sec = runseconds + }) + end +} diff --git a/sourcehut-builds/empty_state.luau b/sourcehut-builds/empty_state.luau new file mode 100644 index 0000000..259c607 --- /dev/null +++ b/sourcehut-builds/empty_state.luau @@ -0,0 +1,9 @@ +local buildState: SourcehutBuildsState = { + successful = 0, + failed = 0, + inProgress = 0, + user = "", + builds = {} +} + +return buildState diff --git a/sourcehut-builds/error_utils.luau b/sourcehut-builds/error_utils.luau new file mode 100644 index 0000000..2969a54 --- /dev/null +++ b/sourcehut-builds/error_utils.luau @@ -0,0 +1,8 @@ +local K = require("./constants.luau") + +return { + isUnauthorized = function(error): boolean + return type(error.extensions) == "table" + and error.extensions.code == K.sourcehut.errors.unauthorized + end +} diff --git a/sourcehut-builds/panel.luau b/sourcehut-builds/panel.luau new file mode 100644 index 0000000..a720721 --- /dev/null +++ b/sourcehut-builds/panel.luau @@ -0,0 +1,204 @@ +--!nonstrict +-- Sourcehut Builds - Panel that shows the +-- +-- You can configure the glyphs shown for the different statuses, as well as +-- when there are no recent builds. + +local EmptyState = require("./empty_state.luau") +local StringUtils = require("./string_utils.luau") +local K = require("./constants.luau") + +-------------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------------- +local isOpen = false +-- I'm being lazy and not cloning this because my assumption is that I'll always +-- replace the object from the network call and not modify it. But I know I will +-- forget and break that assumption, so this comment is left here because +-- whatever bug you're finding with stale state or bad empty state is caused by +-- not cloning empty state across the files. +local buildState: SourcehutBuildsState = EmptyState +local pollerStatus: PollerStatus = K.poller.state.pending + +-------------------------------------------------------------------------------- +-- Internal Functions +-------------------------------------------------------------------------------- + +------------------------------------------------------------ Translation Helpers + +local function emptyStateLabel(): string + if pollerStatus.type == "pending" then + return noctalia.tr("panel.loading.label") + elseif pollerStatus.type == "error" then + return noctalia.tr("panel.error.label", {error = pollerStatus.error}) + else + return noctalia.tr("panel.empty.label") + end +end + +------------------------------------------------------------------------ Actions + +local function openBuild(build: SourcehutBuild, task: SourcehutBuildTask | nil) + local url = K.sourcehut.web_url .. buildState.user .. '/job/' .. build.id + + if task ~= nil then + url = url .. "#task-" .. task.name + end + + noctalia.runAsync("gio open '" .. url .. "' || xdg-open '" .. url .. "'") +end + + +--------------------------------------------------------------------- UI: Glyphs + +local function buildGlyph(status: SourcehutBuildStatus): string + if status == "PENDING" then + return "circle" + elseif status == "RUNNING" then + return "circle-caret-right" + elseif status == "FAILED" then + return "x" + elseif status == "SUCCESS" then + return "check" + elseif status == "TIMEOUT" then + return "clock-x" + elseif status == "CANCELLED" then + return "circle-off" + end + return "question-mark" +end + +local function taskGlyph(status: SourcehutBuildTaskStatus): string + if status == "PENDING" then + return "circle-dotted" + elseif status == "RUNNING" then + return "circle-caret-right" + elseif status == "FAILED" then + return "x" + elseif status == "SUCCESS" then + return "check" + elseif status == "SKIPPED" then + return "circle-dashed-minus" + end + return "question-mark" +end + +----------------------------------------------------------------- UI: Components + +local function taskButton(build: SourcehutBuild, task: SourcehutBuildTask): UiNode + return ui.button({ + glyph = taskGlyph(task.status), + controlSize = "sm", + variant = "ghost", + tooltip = noctalia.tr("panel.task.tooltip", { + task = task.name, + status = StringUtils.capitalize(task.status), + }), + onClick = function() openBuild(build, task) end + }) +end + +local function buildButton(build: SourcehutBuild): UiNode + return ui.button({ + glyph = buildGlyph(build.status), + text = noctalia.tr("panel.build.label", { + user = buildState.user, + repo = build.tags[1], + id = build.id + }), + controlSize = "sm", + variant = "ghost", + tooltip = noctalia.tr("panel.build.tooltip", { + status = StringUtils.capitalize(build.status), + }), + onClick = function() openBuild(build) end + }) +end + +local function buildRow(build: SourcehutBuild): UiNode + local tasks: {[number]: UiNode} = {} + for _k, task in ipairs(build.tasks) do + tasks[#tasks + 1] = taskButton(build, task) + end + return ui.row({align = "start", justify = "space_between", gap = 2, flexGrow = 1}, { + buildButton(build), + ui.row({justify = "end", gap = 0, flexGrow = 0}, tasks) + }) +end + +local function buildRows(builds: {[number]: SourcehutBuild}): UiNode + local build_rows: {[number]: UiNode} = {} + for _, build in ipairs(builds) do + build_rows[#build_rows+1] = buildRow(build) + end + return ui.column({ flexGrow = 1, align = "top", justify = "start" }, build_rows) +end + +-- Renders the panel +local function render() + local body + local header = ui.row({ align = "center", gap = 8 }, { + ui.label({ + text = noctalia.tr("panel.title"), + fontSize = K.panel.theme.fontSize.heading, + fontWeight = K.panel.theme.fontWeight.heading, + flexGrow = 1 + }), + }) + + if #buildState.builds == 0 then + body = ui.column({ flexGrow = 1, align = "center", justify = "center" }, { + ui.label({ text = emptyStateLabel(), color = "on_surface_variant" }), + }) + else + body = buildRows(buildState.builds) + end + + if isOpen then + panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { header, body })) + end +end + +---------------------------------------------------------------- Event Listeners + +local function onPollerStateChanged(value: PollerStatus) + pollerStatus = value + render() +end + +local function onBuildsStateChanged(value: SourcehutBuildsState) + buildState = value + render() +end + +-------------------------------------------------------------------------------- +-- Listeners +-------------------------------------------------------------------------------- + +-- Synchronizes the panel state +noctalia.state.watch(K.events.poller_state_updated, onPollerStateChanged) +noctalia.state.watch(K.events.builds_state_updated, onBuildsStateChanged) + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- + +function onOpen(_context) + isOpen = true + noctalia.state.set(K.events.panel_state_updated, isOpen) + render() +end + +function onClose() + isOpen = false + noctalia.state.set(K.events.panel_state_updated, isOpen) +end + +function onToggle(value: boolean) + isOpen = value + render() +end + +function onConfigChanged() + render() +end diff --git a/sourcehut-builds/plugin.toml b/sourcehut-builds/plugin.toml new file mode 100644 index 0000000..1d443e2 --- /dev/null +++ b/sourcehut-builds/plugin.toml @@ -0,0 +1,79 @@ +id = "rbdr/sourcehut-builds" +name = "Sourcehut Builds" +version = "1.0.0" +plugin_api = 30 +author = "rbdr" +license = "AGPL-3" +dependencies = [] +tags = ["bar", "panel", "development"] +icon = "circle" +description = "A widget that shows your recent sourcehut builds" + +[[setting]] +key = "sourcehut_api_token" +type = "string" +label_key = "settings.sourcehut_api_token.label" +description_key = "settings.sourcehut_api_token.description" +default = "" + +[[setting]] +key = "build_count" +type = "int" +label_key = "settings.build_count.label" +description_key = "settings.build_count.description" +default = 10 +min = 1 +max = 20 + +[[setting]] +key = "recent_cutoff" +type = "int" +label_key = "settings.recent_cutoff.label" +description_key = "settings.recent_cutoff.description" +default = 900 +min = 60 +max = 86400 + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 450 +height = 320 +placement = "attached" +open_near_click = true + +[[service]] +id = "poller" +entry = "poller.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "circle" + + [[widget.setting]] + key = "ok_glyph" + type = "glyph" + label_key = "settings.ok_glyph.label" + description_key = "settings.ok_glyph.description" + default = "check" + + [[widget.setting]] + key = "failed_glyph" + type = "glyph" + label_key = "settings.failed_glyph.label" + description_key = "settings.failed_glyph.description" + default = "x" + + [[widget.setting]] + key = "in_progress_glyph" + type = "glyph" + label_key = "settings.in_progress_glyph.label" + description_key = "settings.in_progress_glyph.description" + default = "circle-caret-right" diff --git a/sourcehut-builds/poller.luau b/sourcehut-builds/poller.luau new file mode 100644 index 0000000..39cfa5b --- /dev/null +++ b/sourcehut-builds/poller.luau @@ -0,0 +1,121 @@ +--!nonstrict +-- Poller for sourcehut, checks the actual poll + +local K = require("./constants.luau") +local ErrorUtils = require("./error_utils.luau") +local DateUtils = require("./date_utils.luau") +local TableUtils = require("./table_utils.luau") + +-------------------------------------------------------------------------------- +-- Internal Functions +-------------------------------------------------------------------------------- + +--------------------------------------------------------------- State Management + +local function notifyStateChange(state: PollerStatus) + noctalia.state.set(K.events.poller_state_updated, state) +end + +local function notifyError(error: string) + notifyStateChange({type = "error", error = error}) +end + +local function notifyBuildsChange(state: SourcehutBuildsState) + noctalia.state.set(K.events.builds_state_updated, state) +end + +------------------------------------------------------------------------ Parsing + +local function parseBuilds(builds: SourcehutBuildResponse) + local successful = 0 + local failed = 0 + local inProgress = 0 + local cutoff = noctalia.getConfig(K.poller.cutoff_configuration_key) + local build_count = noctalia.getConfig(K.poller.build_count_configuration_key) + local now = os.time() + local eligible_builds = TableUtils.trim(builds.data.me.jobs.results, build_count) + for _, job in ipairs(eligible_builds) do + local jobDate = DateUtils.parseDate(job.created) + if now - jobDate > cutoff then + continue + end + if job.status == "SUCCESS" then + successful += 1 + end + if job.status == "FAILED" then + failed += 1 + end + if job.status == "RUNNING" then + inProgress += 1 + end + end + local state = { + successful = successful, + failed = failed, + inProgress = inProgress, + user = builds.data.me.canonicalName, + builds = eligible_builds + } + notifyStateChange(K.poller.state.ok) + notifyBuildsChange(state) +end + +------------------------------------------------------------------------ Network + +local function onBuildsFetched(response: HttpResponse) + if not response.ok then + notifyError(K.poller.errors.request_failed) + return + end + local builds, error = noctalia.json.decode(response.body) + if error or builds == nil then + notifyError(K.poller.errors.bad_response) + return + end + if response.status == 400 then + if type(builds.errors) == "table" then + for _, error_response in ipairs(builds.errors) do + if ErrorUtils.isUnauthorized(error_response) then + notifyError(K.poller.errors.unauthorized) + return + end + end + end + notifyError(K.poller.errors.bad_response) + return + end + parseBuilds(builds) +end + +local function fetchBuilds() + local request: HttpRequest = { + url = K.sourcehut.builds_url, + method = "GET", + body = K.sourcehut.query, + headers = { + "Content-Type: application/json", + "Authorization: Bearer " .. noctalia.getConfig(K.sourcehut.token_configuration_key) + }, + } + noctalia.http(request, onBuildsFetched) +end + +---------------------------------------------------------------------- Lifecycle + +local function run() + noctalia.setUpdateInterval(K.poller.update_interval) + update() +end + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- +function update() + fetchBuilds() +end + +function onConfigChanged() + fetchBuilds() +end + +run() diff --git a/sourcehut-builds/string_utils.luau b/sourcehut-builds/string_utils.luau new file mode 100644 index 0000000..1072bef --- /dev/null +++ b/sourcehut-builds/string_utils.luau @@ -0,0 +1,5 @@ +return { + capitalize = function(str: string): string + return str:lower():gsub("^%l", string.upper) + end +} diff --git a/sourcehut-builds/table_utils.luau b/sourcehut-builds/table_utils.luau new file mode 100644 index 0000000..47feafa --- /dev/null +++ b/sourcehut-builds/table_utils.luau @@ -0,0 +1,11 @@ +return { + trim = function<T>(table: {[number]: T}, count: number): {[number]: T} + local sliced: {[number]: T} = {} + + for i = 1, count or #table, 1 do + sliced[#sliced+1] = table[i] + end + + return sliced + end +} diff --git a/sourcehut-builds/translations/en.json b/sourcehut-builds/translations/en.json new file mode 100644 index 0000000..c56cafa --- /dev/null +++ b/sourcehut-builds/translations/en.json @@ -0,0 +1,59 @@ +{ + "bar": { + "tooltip": "Sourcehut Builds." + }, + "panel": { + "title": "Sourcehut Builds", + "loading": { + "label": "Fetching builds." + }, + "error": { + "label": "Woops: {error}" + }, + "empty": { + "label": "No builds were found." + }, + "build": { + "label": "{user}/{repo} (#{id})", + "tooltip": "{status}" + }, + "task": { + "tooltip": "{task}: {status}" + } + }, + "settings": { + "sourcehut_api_token": { + "label": "Sourcehut API token", + "description": "Your sourcehut API token, with access to read builds" + }, + "build_count": { + "label": "Build Count", + "description": "Number of builds to show." + }, + "recent_cutoff": { + "label": "Cutoff for Recent Builds", + "description": "The amount of time in seconds to consider a build 'recent' for the glyph display." + }, + "glyph": { + "label": "Glyph", + "description": "Glyph to show when there aren't recent builds." + }, + "ok_glyph": { + "label": "OK Glyph", + "description": "Glyph to show when recent builds are OK." + }, + "failed_glyph": { + "label": "Failed Glyph", + "description": "Glyph to show when recent builds have failed" + }, + "in_progress_glyph": { + "label": "In Progress Glyph", + "description": "Glyph to show when a build is in progress" + } + }, + "error": { + "request_failed": "We could not reach sourcehut. Make sure your network is connected and sourcehut is reachable.", + "bad_response": "We weren't able to understand the response. Make sure sourcehut is operating well, or reach out to the maintainer.", + "unauthorized": "Sourcehut did not authorize the request. Make sure you have set a valid API Token." + } +} diff --git a/sourcehut_builds.d.luau b/sourcehut_builds.d.luau new file mode 100644 index 0000000..100e388 --- /dev/null +++ b/sourcehut_builds.d.luau @@ -0,0 +1,47 @@ +export type SourcehutBuildsState = { + successful: number, + failed: number, + inProgress: number, + user: string, + builds: { SourcehutBuild } +} + +export type SourcehutBuild = { + id: number, + created: string, + updated: string, + status: SourcehutBuildStatus, + note: string, + tags: {[number]: string}, + tasks: {[number]: SourcehutBuildTask} +} + +export type SourcehutBuildStatus = "PENDING" | "QUEUED" | "RUNNING" | "FAILED" | "SUCCESS" | "TIMEOUT" | "CANCELLED" + +export type SourcehutBuildTask = { + name: string, + status: SourcehutBuildTaskStatus +} +export type SourcehutBuildTaskStatus = "PENDING" | "RUNNING" | "FAILED" | "SUCCESS" | "SKIPPED" + +type Err<E> = {type: "error", error: string} +type Pending = {type: "pending"} +type Ok = {type: "ok"} +export type PollerStatus = Err<string> | Pending | Ok + +export type SourcehutBuildResponse = { + data: SourcehutBuildData +} + +export type SourcehutBuildData = { + me: SourcehutUser, +} + +export type SourcehutUser = { + canonicalName: string, + jobs: SourcehutUserJobs +} + +export type SourcehutUserJobs = { + results: {[number]: SourcehutBuild}, +} |