commit 06153e387d6b933d70032b860df83a5ed0eb4109 Author: Javier Casares Date: Mon Oct 12 11:55:23 2026 +0000 v1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7aceac --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/admin/class-fontdelbosc-admin.php b/admin/class-fontdelbosc-admin.php new file mode 100644 index 0000000..1578d20 --- /dev/null +++ b/admin/class-fontdelbosc-admin.php @@ -0,0 +1,334 @@ + array( __CLASS__, 'sanitize' ), + 'show_in_rest' => false, + ) + ); + + add_settings_section( 'fdb_section_keys', __( 'API Keys', 'fontdelbosc-eltemps' ), '__return_false', 'fontdelbosc-eltemps' ); + add_settings_section( 'fdb_section_cache', __( 'Cache Settings', 'fontdelbosc-eltemps' ), '__return_false', 'fontdelbosc-eltemps' ); + add_settings_section( 'fdb_section_display', __( 'Display Options', 'fontdelbosc-eltemps' ), '__return_false', 'fontdelbosc-eltemps' ); + + add_settings_field( 'fdb_field_aemet_key', __( 'AEMET API key', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_aemet_key' ), 'fontdelbosc-eltemps', 'fdb_section_keys' ); + add_settings_field( 'fdb_field_meteocat_key', __( 'Meteocat API key', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_meteocat_key' ), 'fontdelbosc-eltemps', 'fdb_section_keys' ); + add_settings_field( 'fdb_field_ttl_current', __( 'Current conditions cache (seconds)', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_ttl_current' ), 'fontdelbosc-eltemps', 'fdb_section_cache' ); + add_settings_field( 'fdb_field_ttl_forecast', __( 'Forecast cache (seconds)', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_ttl_forecast' ), 'fontdelbosc-eltemps', 'fdb_section_cache' ); + add_settings_field( 'fdb_field_ttl_historical', __( 'Historical data cache (seconds)', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_ttl_historical' ), 'fontdelbosc-eltemps', 'fdb_section_cache' ); + add_settings_field( 'fdb_field_attribution', __( 'Display Options', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_attribution' ), 'fontdelbosc-eltemps', 'fdb_section_display' ); + add_settings_field( 'fdb_field_delete', __( 'Uninstall', 'fontdelbosc-eltemps' ), array( __CLASS__, 'field_delete' ), 'fontdelbosc-eltemps', 'fdb_section_display' ); + } + + /** + * Sanitizes the option array on save. + * + * @param mixed $input Raw input. + * @return array + */ + public static function sanitize( mixed $input ): array { + $input = is_array( $input ) ? $input : array(); + $old = (array) get_option( 'fontdelbosc_options', array() ); + + $aemet_key = sanitize_text_field( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $input, 'aemet_api_key' ) ) ?? '' ); + $meteo_key = sanitize_text_field( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $input, 'meteocat_api_key' ) ) ?? '' ); + + return array( + 'aemet_api_key' => '' === $aemet_key ? Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $old, 'aemet_api_key' ) ) ?? '' : $aemet_key, + 'meteocat_api_key' => '' === $meteo_key ? Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $old, 'meteocat_api_key' ) ) ?? '' : $meteo_key, + 'meteocat_station_code' => sanitize_text_field( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $old, 'meteocat_station_code' ) ) ?? '' ), + 'cache_ttl_current' => max( 60, min( 86400, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $input, 'cache_ttl_current' ) ) ?? 600 ) ), + 'cache_ttl_forecast' => max( 60, min( 86400, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $input, 'cache_ttl_forecast' ) ) ?? 3600 ) ), + 'cache_ttl_historical' => max( 60, min( 604800, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $input, 'cache_ttl_historical' ) ) ?? 86400 ) ), + 'display_attribution' => ! empty( Fontdelbosc_Payload::get( $input, 'display_attribution' ) ), + 'delete_data_on_uninstall' => ! empty( Fontdelbosc_Payload::get( $input, 'delete_data_on_uninstall' ) ), + ); + } + + /** + * Renders the AEMET API key field. The stored key is never rendered; + * only its last 4 characters are shown as a hint. + * + * @return void + */ + public static function field_aemet_key(): void { + $key = Fontdelbosc_Plugin::get_options()['aemet_api_key']; + + printf( + '' + ); + + self::key_hint( $key ); + + echo '

opendata.aemet.es

'; + } + + /** + * Renders the Meteocat API key field. The stored key is never rendered; + * only its last 4 characters are shown as a hint. + * + * @return void + */ + public static function field_meteocat_key(): void { + $key = Fontdelbosc_Plugin::get_options()['meteocat_api_key']; + + printf( + '' + ); + + self::key_hint( $key ); + + echo '

apidocs.meteocat.gencat.cat

'; + } + + /** + * Prints the "current key" hint when a key is stored, leaving the field + * itself empty so submitting the form without retyping keeps the key. + * + * @param string $key Stored API key. + * @return void + */ + private static function key_hint( string $key ): void { + if ( '' === $key ) { + return; + } + + printf( + '

%s

', + sprintf( + /* translators: %s: last 4 characters of the stored API key. */ + esc_html__( 'Current key ends in …%s', 'fontdelbosc-eltemps' ), + esc_html( substr( $key, -4 ) ) + ) + ); + } + + /** + * Renders the current conditions TTL field. + * + * @return void + */ + public static function field_ttl_current(): void { + $value = Fontdelbosc_Plugin::get_options()['cache_ttl_current']; + + printf( + '', + esc_attr( (string) $value ) + ); + } + + /** + * Renders the forecast TTL field. + * + * @return void + */ + public static function field_ttl_forecast(): void { + $value = Fontdelbosc_Plugin::get_options()['cache_ttl_forecast']; + + printf( + '', + esc_attr( (string) $value ) + ); + } + + /** + * Renders the historical data TTL field. + * + * @return void + */ + public static function field_ttl_historical(): void { + $value = Fontdelbosc_Plugin::get_options()['cache_ttl_historical']; + + printf( + '', + esc_attr( (string) $value ) + ); + } + + /** + * Renders the attribution toggle. + * + * @return void + */ + public static function field_attribution(): void { + $options = Fontdelbosc_Plugin::get_options(); + + printf( + '', + checked( ! empty( $options['display_attribution'] ), true, false ), + esc_html__( 'Show data source attribution', 'fontdelbosc-eltemps' ) + ); + } + + /** + * Renders the uninstall data-deletion toggle. + * + * @return void + */ + public static function field_delete(): void { + $options = Fontdelbosc_Plugin::get_options(); + + printf( + '', + checked( ! empty( $options['delete_data_on_uninstall'] ), true, false ), + esc_html__( 'Delete all plugin data on uninstall', 'fontdelbosc-eltemps' ) + ); + } + + /** + * Shows an informational notice when no API key is configured and the + * plugin is running on the Open-Meteo fallback. + * + * @return void + */ + public static function maybe_show_notice(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $options = Fontdelbosc_Plugin::get_options(); + + if ( '' !== $options['aemet_api_key'] || '' !== $options['meteocat_api_key'] ) { + return; + } + + $screen = get_current_screen(); + + if ( null === $screen || ! in_array( $screen->base, array( 'plugins', 'dashboard', 'options-general' ), true ) ) { + return; + } + + printf( + '

%s

', + esc_html__( 'No weather API keys are configured. Data is served by Open-Meteo (no key required).', 'fontdelbosc-eltemps' ) + ); + } + + /** + * AJAX handler: tests an API source connectivity. + * + * @return void + */ + public static function ajax_test_api(): void { + check_ajax_referer( 'fontdelbosc_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => 'forbidden' ) ); + } + + $raw = isset( $_POST['source'] ) && is_string( $_POST['source'] ) ? wp_unslash( $_POST['source'] ) : ''; + $source = sanitize_key( $raw ); + $options = Fontdelbosc_Plugin::get_options(); + $ok = false; + + switch ( $source ) { + case 'aemet': + $ok = '' !== $options['aemet_api_key'] && null !== Fontdelbosc_Api_Aemet::get_daily( $options['aemet_api_key'] ); + break; + case 'meteocat': + $ok = '' !== $options['meteocat_api_key'] && null !== Fontdelbosc_Api_Meteocat::get_hourly( $options['meteocat_api_key'] ); + break; + case 'open-meteo': + $ok = null !== Fontdelbosc_Api_OpenMeteo::get_current(); + break; + } + + if ( $ok ) { + wp_send_json_success( array( 'message' => 'ok' ) ); + } + + wp_send_json_error( array( 'message' => 'failed' ) ); + } + + /** + * AJAX handler: finds and stores the XEMA station closest to Font del Bosc. + * + * @return void + */ + public static function ajax_find_station(): void { + check_ajax_referer( 'fontdelbosc_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => 'forbidden' ) ); + } + + $options = Fontdelbosc_Plugin::get_options(); + + if ( '' === $options['meteocat_api_key'] ) { + wp_send_json_error( array( 'message' => 'no-key' ) ); + } + + $station = Fontdelbosc_Api_Meteocat::find_nearest_station( $options['meteocat_api_key'] ); + + if ( null === $station ) { + wp_send_json_error( array( 'message' => 'failed' ) ); + } + + $options['meteocat_station_code'] = $station['codi']; + update_option( 'fontdelbosc_options', $options ); + + wp_send_json_success( $station ); + } +} diff --git a/admin/views/settings-page.php b/admin/views/settings-page.php new file mode 100644 index 0000000..ed2834b --- /dev/null +++ b/admin/views/settings-page.php @@ -0,0 +1,156 @@ + +
+

+ +
+ +
+ +
+

+
    +
  • [fontdelbosc_eltemps] —
  • +
  • [fontdelbosc_eltemps view="hourly" hours="24"] —
  • +
  • [fontdelbosc_eltemps view="daily" days="7"] —
  • +
  • [fontdelbosc_eltemps view="historical" year="2024" month="6"] —
  • +
+

+
+ +
+

+ + + + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..894dcde --- /dev/null +++ b/changelog.txt @@ -0,0 +1,43 @@ +== Changelog == + += 1.0.0 = + +_Release date: 2026-10-12_ + +**Highlights** + +* Weather display for Font del Bosc (Mediona, Alt Penedès) with current conditions, hourly forecast, daily forecast and historical data. + +**Added** + +* Shortcode `[fontdelbosc_eltemps]` with `view` (current, hourly, daily, historical), `hours`, `days`, `year` and `month` attributes. +* Sidebar widget with current conditions or today's forecast. +* Meteocat (municipal forecast, XEMA stations), AEMET OpenData (daily and hourly municipal forecast) and Open-Meteo (current, forecast, ERA5 archive) integrations with automatic fallback chains. +* Transients caching with stale-while-error policy and configurable TTLs. +* Hourly WP-Cron event (fontdelbosc_refresh_weather) that keeps the caches warm; scheduled on activation, cleared on deactivation. +* Settings page (Settings → Font del Bosc Temps) with API keys, cache, attribution, uninstall options, connection tests and nearest XEMA station detection. +* Spanish (es_ES) and Catalan (ca) translations. +* Meteocons icons (MIT), bundled locally. + +**Changed** + +* Nothing; initial release. + +**Fixed** + +* Nothing; initial release. + +**Security** + +* Nonces and capability checks on all admin and AJAX endpoints; sanitization and escaping on all input and output. + +**Compatibility** + +* WordPress: 6.4 - 7.1 +* PHP: 8.2 - 8.5 + +**Tests** + +* PHP Coding Standards: 3.13.6 (WPCS 3.4.1, zero errors) +* PHPStan level max: 2.3.1 (zero errors) +* Plugin Check (PCP): pending (requires WP-CLI) diff --git a/fontdelbosc-eltemps.php b/fontdelbosc-eltemps.php new file mode 100644 index 0000000..eef58aa --- /dev/null +++ b/fontdelbosc-eltemps.php @@ -0,0 +1,43 @@ +|null + */ + public static function get_daily( string $api_key ): ?array { + $payload = self::fetch_data( '/prediccion/especifica/municipio/diaria/' . self::MUNICIPAL_CODE, $api_key ); + $dias = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccion', 'dia' ) ); + $days = array(); + + foreach ( $dias as $day ) { + $date = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $day, 'fecha' ) ); + + if ( null === $date ) { + continue; + } + + $date = substr( $date, 0, 10 ); + $pop = self::period_value( Fontdelbosc_Payload::get( $day, 'probPrecipitacion' ) ); + $velocity = self::period_value( Fontdelbosc_Payload::get( $day, 'viento' ), 'velocidad' ); + + $days[ $date ] = array( + 'date' => $date, + 'condition' => self::sky_condition( Fontdelbosc_Payload::get( $day, 'estadoCielo' ) ), + 'temp_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $day, 'temperatura', 'maxima' ) ), + 'temp_min' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $day, 'temperatura', 'minima' ) ), + 'precipitation' => null, + 'pop' => Fontdelbosc_Payload::int( $pop ), + 'wind_max' => Fontdelbosc_Payload::float( $velocity ), + ); + } + + if ( array() === $days ) { + return null; + } + + ksort( $days ); + + return array( + 'source' => 'aemet', + 'days' => array_values( $days ), + ); + } + + /** + * Returns the normalized hourly forecast, from the current hour onwards. + * + * @param string $api_key AEMET API key. + * @return array|null + */ + public static function get_hourly( string $api_key ): ?array { + $payload = self::fetch_data( '/prediccion/especifica/municipio/horaria/' . self::MUNICIPAL_CODE, $api_key ); + $dias = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccion', 'dia' ) ); + + $temperatures = array(); + $pops = array(); + $cielos = array(); + $winds = array(); + + foreach ( $dias as $day ) { + $date = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $day, 'fecha' ) ); + + if ( null === $date ) { + continue; + } + + $date = substr( $date, 0, 10 ); + + self::collect_entries( Fontdelbosc_Payload::get( $day, 'temperatura' ), $date, 'value', $temperatures ); + self::collect_entries( Fontdelbosc_Payload::get( $day, 'estadoCielo' ), $date, 'value', $cielos ); + self::collect_entries( Fontdelbosc_Payload::get( $day, 'probPrecipitacion' ), $date, 'value', $pops ); + self::collect_entries( Fontdelbosc_Payload::get( $day, 'vientoAndRachaMax' ), $date, 'velocidad', $winds ); + } + + $keys = array_unique( array_merge( array_keys( $temperatures ), array_keys( $cielos ) ) ); + sort( $keys ); + + $now = ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' ); + $hours = array(); + + foreach ( $keys as $key ) { + if ( $key < $now ) { + continue; + } + + $hours[] = array( + 'time' => self::key_timestamp( $key ), + 'hour' => (int) substr( $key, 11, 2 ), + 'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $temperatures, $key ) ), + 'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $pops, $key ) ), + 'condition' => self::condition( Fontdelbosc_Payload::get( $cielos, $key ) ), + 'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $winds, $key ) ), + ); + } + + if ( array() === $hours ) { + return null; + } + + return array( + 'source' => 'aemet', + 'hours' => $hours, + ); + } + + /** + * Collects period-based values into an hour-keyed map. Handles the + * single-hour ("08"), hour-range ("0814") and dashed ("00-24") period + * formats; ranges are applied end-exclusive. + * + * @param mixed $entries Raw element entries. + * @param string $date Day prefix (Y-m-d). + * @param string $value_key Key holding the value. + * @param array $map Output map, modified by reference. + * @return void + */ + private static function collect_entries( mixed $entries, string $date, string $value_key, array &$map ): void { + foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) { + $period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) ); + $range = null === $period ? null : self::expand_period( $period ); + + if ( null === $range ) { + continue; + } + + for ( $hour = $range[0]; $hour < $range[1]; $hour++ ) { + if ( $hour < 0 || $hour > 23 ) { + continue; + } + + $map[ sprintf( '%s %02d:00', $date, $hour ) ] = Fontdelbosc_Payload::get( $entry, $value_key ); + } + } + } + + /** + * Expands an AEMET period string to an end-exclusive hour range. + * + * @param string $period Period ("08", "0814" or "00-24"). + * @return array{0:int,1:int}|null + */ + private static function expand_period( string $period ): ?array { + $period = str_replace( '-', '', $period ); + + if ( 2 === strlen( $period ) ) { + return array( (int) $period, (int) $period + 1 ); + } + + if ( 4 === strlen( $period ) ) { + return array( (int) substr( $period, 0, 2 ), (int) substr( $period, 2, 2 ) ); + } + + return null; + } + + /** + * Returns the "00-24" value of a daily element, falling back to the first entry. + * + * @param mixed $entries Raw element entries. + * @param string $value_key Key holding the value. + * @return mixed + */ + private static function period_value( mixed $entries, string $value_key = 'value' ): mixed { + foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) { + $period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) ); + + if ( '00-24' === $period ) { + return Fontdelbosc_Payload::get( $entry, $value_key ); + } + } + + return Fontdelbosc_Payload::path( $entries, 0, $value_key ); + } + + /** + * Picks the best sky-state code from daily estadoCielo entries: the + * "00-24" period when present, otherwise the first non-empty value. + * Empty values (distant days, night-only periods) are skipped. + * + * @param mixed $entries estadoCielo entries. + * @return string|null + */ + private static function sky_condition( mixed $entries ): ?string { + $fallback = null; + + foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) { + $value = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'value' ) ) ?? ''; + $period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) ); + + if ( '' === $value ) { + continue; + } + + if ( null === $fallback ) { + $fallback = $value; + } + + if ( '00-24' === $period ) { + return self::condition( $value ); + } + } + + return null === $fallback ? null : self::condition( $fallback ); + } + + /** + * Maps an AEMET sky-state code to a condition key. Codes may carry a + * trailing "n" (night variant), which is stripped first. + * + * @param mixed $value AEMET sky-state code. + * @return string|null + */ + private static function condition( mixed $value ): ?string { + $code_str = is_string( $value ) || is_numeric( $value ) ? rtrim( (string) $value, 'n' ) : ''; + + if ( ! is_numeric( $code_str ) ) { + return null; + } + + $code = (int) $code_str; + + if ( 11 === $code ) { + return 'clear'; + } + + if ( 12 === $code ) { + return 'mainly_clear'; + } + + if ( 13 === $code ) { + return 'partly_cloudy'; + } + + if ( 14 === $code || 15 === $code ) { + return 'cloudy'; + } + + if ( 16 === $code || 17 === $code ) { + return 'overcast'; + } + + if ( 18 === $code || 19 === $code ) { + return 'fog'; + } + + if ( $code >= 20 && $code <= 27 ) { + return 'rain_light'; + } + + if ( ( $code >= 28 && $code <= 37 ) || ( $code >= 61 && $code <= 66 ) ) { + return 'thunderstorm'; + } + + if ( $code >= 38 && $code <= 44 ) { + return 'rain'; + } + + if ( ( $code >= 45 && $code <= 47 ) || ( $code >= 70 && $code <= 77 ) ) { + return 'snow'; + } + + if ( $code >= 51 && $code <= 56 ) { + return 'drizzle'; + } + + if ( 81 === $code || 82 === $code ) { + return 'fog'; + } + + return 'cloudy'; + } + + /** + * Performs the AEMET double call and returns the decoded data payload. + * + * @param string $path API path. + * @param string $api_key AEMET API key. + * @return array|null + */ + private static function fetch_data( string $path, string $api_key ): ?array { + if ( '' === $api_key ) { + return null; + } + + $meta = self::request_json( self::BASE_URL . $path . '?api_key=' . rawurlencode( $api_key ), false ); + $data_url = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $meta, 'datos' ) ) ?? ''; + + if ( '' === $data_url ) { + return null; + } + + return self::request_json( $data_url, true ); + } + + /** + * Performs a GET request and decodes the JSON response, with an + * ISO-8859-1 to UTF-8 fallback for AEMET data payloads. Transient + * gateway 5xx errors are retried once. + * + * @param string $url Request URL. + * @param bool $latin1_fallback Whether to retry with converted encoding. + * @return array|null + */ + private static function request_json( string $url, bool $latin1_fallback ): ?array { + $response = self::http_get( $url ); + $code = wp_remote_retrieve_response_code( $response ); + + if ( $code >= 500 && wp_doing_cron() ) { + sleep( 1 ); + + $response = self::http_get( $url ); + $code = wp_remote_retrieve_response_code( $response ); + } + + if ( is_wp_error( $response ) ) { + self::log( 'AEMET request failed: ' . $response->get_error_message() ); + + return null; + } + + if ( 200 !== $code ) { + self::log( 'AEMET returned HTTP ' . $code ); + + return null; + } + + $body = (string) wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( null === $data && $latin1_fallback ) { + $converted = self::to_utf8( $body ); + $data = null === $converted ? null : json_decode( $converted, true ); + } + + return is_array( $data ) ? $data : null; + } + + /** + * Performs the HTTP GET request. + * + * @param string $url Request URL. + * @return array|WP_Error + */ + private static function http_get( string $url ): array|WP_Error { + return wp_remote_get( + $url, + array( + 'timeout' => 5, + 'headers' => array( + 'Accept' => 'application/json', + ), + ) + ); + } + + /** + * Converts an ISO-8859-1 payload to UTF-8 using the first available + * extension (mbstring, then iconv). + * + * @param string $body Raw body. + * @return string|null + */ + private static function to_utf8( string $body ): ?string { + if ( function_exists( 'mb_convert_encoding' ) ) { + return mb_convert_encoding( $body, 'UTF-8', 'ISO-8859-1' ); + } + + if ( function_exists( 'iconv' ) ) { + $converted = iconv( 'ISO-8859-1', 'UTF-8//IGNORE', $body ); + + return false === $converted ? null : $converted; + } + + return null; + } + + /** + * Converts an hour key to a Unix timestamp. + * + * @param string $key Hour key (Y-m-d H:00). + * @return int + */ + private static function key_timestamp( string $key ): int { + try { + return ( new DateTimeImmutable( $key, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp(); + } catch ( Exception ) { + return time(); + } + } + + /** + * Writes a message to the error log with the plugin prefix. + * + * @param string $message Message. + * @return void + */ + private static function log( string $message ): void { + error_log( '[fontdelbosc-eltemps] ' . $message ); + } +} diff --git a/includes/class-fontdelbosc-api-meteocat.php b/includes/class-fontdelbosc-api-meteocat.php new file mode 100644 index 0000000..e868c58 --- /dev/null +++ b/includes/class-fontdelbosc-api-meteocat.php @@ -0,0 +1,398 @@ +|null + */ + public static function get_hourly( string $api_key ): ?array { + $payload = self::request( '/prediccio/v1/municipi/' . self::MUNICIPAL_CODE . '/prediccio', $api_key ); + $series = array(); + + foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccions' ) ) as $prediction ) { + foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $prediction, 'variables' ) ) as $name => $points ) { + foreach ( Fontdelbosc_Payload::arr( $points ) as $point ) { + $iso = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'data' ) ); + $key = null === $iso ? null : self::hour_key( $iso ); + + if ( null !== $key ) { + $series[ (string) $name ][ $key ] = $point; + } + } + } + } + + $keys = array(); + + foreach ( $series as $points ) { + $keys = array_merge( $keys, array_keys( $points ) ); + } + + $keys = array_values( array_unique( $keys ) ); + sort( $keys ); + + $now = self::now_key(); + $hours = array(); + + foreach ( $keys as $key ) { + if ( $key < $now ) { + continue; + } + + $hours[] = array( + 'time' => self::key_timestamp( $key ), + 'hour' => (int) substr( $key, 11, 2 ), + 'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'temperatura', $key, 'valor' ) ), + 'pop' => Fontdelbosc_Payload::int( self::coalesce( Fontdelbosc_Payload::path( $series, 'probabilitatPrecipitacio', $key, 'valor' ), Fontdelbosc_Payload::path( $series, 'probabilitatDePrecipitacio', $key, 'valor' ) ) ), + 'condition' => self::condition( Fontdelbosc_Payload::path( $series, 'cel', $key ) ), + 'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'vent', $key, 'valor' ) ), + ); + } + + if ( array() === $hours ) { + return null; + } + + return array( + 'source' => 'meteocat', + 'hours' => $hours, + ); + } + + /** + * Returns normalized current conditions from the configured XEMA station. + * + * @param string $api_key Meteocat API key. + * @param string $station_code XEMA station code. + * @return array|null + */ + public static function get_current( string $api_key, string $station_code ): ?array { + if ( '' === $station_code ) { + return null; + } + + $temperature = self::station_variable( $api_key, $station_code, 't' ); + $humidity = self::station_variable( $api_key, $station_code, 'hr' ); + $wind = self::station_variable( $api_key, $station_code, 'v' ); + + if ( null === $temperature && null === $humidity && null === $wind ) { + return null; + } + + return array( + 'temperature' => $temperature, + 'apparent' => null, + 'humidity' => $humidity, + 'precipitation' => null, + 'wind_speed' => null === $wind ? null : round( $wind * 3.6, 1 ), + 'wind_direction' => null, + 'condition' => null, + 'source' => 'meteocat', + 'time' => time(), + ); + } + + /** + * Finds the XEMA station closest to Font del Bosc. + * + * @param string $api_key Meteocat API key. + * @return array{codi:string,nom:string,distance_km:float}|null + */ + public static function find_nearest_station( string $api_key ): ?array { + $stations = self::request( '/xema/v1/estacions/metadades', $api_key ); + + if ( null === $stations ) { + return null; + } + + $best = null; + $distance = INF; + + foreach ( Fontdelbosc_Payload::arr( $stations ) as $station ) { + $codi = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'codi' ) ); + $lat = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'latitud' ) ); + $lon = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'longitud' ) ); + + if ( null === $codi || '' === $codi || null === $lat || null === $lon ) { + continue; + } + + $current = self::haversine( FONTDELBOSC_ELTEMPS_LAT, FONTDELBOSC_ELTEMPS_LON, $lat, $lon ); + + if ( $current < $distance ) { + $distance = $current; + $best = array( + 'codi' => $codi, + 'nom' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'nom' ) ) ?? '', + 'distance_km' => round( $current, 1 ), + ); + } + } + + return $best; + } + + /** + * Returns the latest numeric value of a XEMA station variable. + * + * @param string $api_key Meteocat API key. + * @param string $station Station code. + * @param string $variable Variable code (t, hr, v). + * @return float|null + */ + private static function station_variable( string $api_key, string $station, string $variable ): ?float { + $data = self::request( '/xema/v1/estacions/' . rawurlencode( $station ) . '/dades/' . rawurlencode( $variable ), $api_key ); + $measures = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'variables', 0, 'mesures' ) ); + + foreach ( array_reverse( $measures ) as $measure ) { + $value = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $measure, 'valor' ) ); + + if ( null !== $value ) { + return $value; + } + } + + return null; + } + + /** + * Maps a Meteocat "cel" data point to a condition key, preferring the + * descriptive Catalan "estat" text over the numeric symbol. + * + * @param mixed $point Cel data point. + * @return string|null + */ + private static function condition( mixed $point ): ?string { + if ( ! is_array( $point ) ) { + return null; + } + + $estat = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'estat' ) ) ?? ''; + + if ( '' !== $estat ) { + $estat = mb_strtolower( $estat ); + $keywords = array( + 'tempesta' => 'thunderstorm', + 'calabruix' => 'thunderstorm_hail', + 'aiguaneu' => 'snow', + 'neu' => 'snow', + 'plugim' => 'drizzle', + 'ruixat' => 'rain_showers', + 'pluja' => 'rain', + 'boira' => 'fog', + 'parcialment' => 'partly_cloudy', + 'molt ennuvolat' => 'overcast', + 'cobert' => 'overcast', + 'ennuvolat' => 'cloudy', + 'núvol' => 'cloudy', + 'sol' => 'clear', + 'clar' => 'clear', + ); + + foreach ( $keywords as $needle => $condition ) { + if ( str_contains( $estat, $needle ) ) { + return $condition; + } + } + } + + $symbol = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $point, 'simbol' ) ); + + if ( null === $symbol ) { + return null; + } + + $map = array( + 1 => 'clear', + 2 => 'partly_cloudy', + 3 => 'partly_cloudy', + 4 => 'cloudy', + 5 => 'drizzle', + 6 => 'drizzle', + 7 => 'drizzle', + 8 => 'overcast', + 9 => 'rain_light', + 10 => 'rain', + 11 => 'thunderstorm', + 12 => 'thunderstorm', + 13 => 'rain_showers', + 14 => 'rain_showers', + 15 => 'rain_showers', + 16 => 'rain', + ); + + return $map[ $symbol ] ?? 'cloudy'; + } + + /** + * Returns the first non-null value of a pair. + * + * @param mixed $first First value. + * @param mixed $second Second value. + * @return mixed + */ + private static function coalesce( mixed $first, mixed $second ): mixed { + return null !== $first ? $first : $second; + } + + /** + * Converts a Meteocat ISO date-time to an hour key in local time. + * + * @param string $iso ISO date-time string. + * @return string|null Format: Y-m-d H:00. + */ + private static function hour_key( string $iso ): ?string { + try { + return ( new DateTimeImmutable( $iso ) )->setTimezone( new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) )->format( 'Y-m-d H:00' ); + } catch ( Exception ) { + return null; + } + } + + /** + * Converts an hour key to a Unix timestamp. + * + * @param string $key Hour key (Y-m-d H:00). + * @return int + */ + private static function key_timestamp( string $key ): int { + try { + return ( new DateTimeImmutable( $key, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp(); + } catch ( Exception ) { + return time(); + } + } + + /** + * Returns the current hour key used to filter hourly series. + * + * @return string Format: Y-m-d H:00. + */ + private static function now_key(): string { + return ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' ); + } + + /** + * Converts a coordinate value to float, handling decimal commas. + * + * @param mixed $value Raw value. + * @return float|null + */ + private static function to_float( mixed $value ): ?float { + if ( is_int( $value ) || is_float( $value ) ) { + return (float) $value; + } + + if ( is_string( $value ) ) { + $normalized = str_replace( ',', '.', trim( $value ) ); + + return is_numeric( $normalized ) ? (float) $normalized : null; + } + + return null; + } + + /** + * Haversine distance in kilometers between two coordinates. + * + * @param float $lat1 Latitude A. + * @param float $lon1 Longitude A. + * @param float $lat2 Latitude B. + * @param float $lon2 Longitude B. + * @return float + */ + private static function haversine( float $lat1, float $lon1, float $lat2, float $lon2 ): float { + $radians = M_PI / 180; + $radius = 6371; + + $dlat = ( $lat2 - $lat1 ) * $radians; + $dlon = ( $lon2 - $lon1 ) * $radians; + + $a = ( sin( $dlat / 2 ) ** 2 ) + ( cos( $lat1 * $radians ) * cos( $lat2 * $radians ) * ( sin( $dlon / 2 ) ** 2 ) ); + + return 2 * $radius * asin( sqrt( $a ) ); + } + + /** + * Performs an authenticated GET request and decodes the JSON response. + * + * @param string $path API path. + * @param string $api_key Meteocat API key. + * @return array|null + */ + private static function request( string $path, string $api_key ): ?array { + if ( '' === $api_key ) { + return null; + } + + $response = wp_remote_get( + self::BASE_URL . $path, + array( + 'timeout' => 5, + 'headers' => array( + 'X-Api-Key' => $api_key, + 'Accept' => 'application/json', + ), + ) + ); + + if ( is_wp_error( $response ) ) { + self::log( 'Meteocat request failed: ' . $response->get_error_message() ); + + return null; + } + + $code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== $code ) { + self::log( 'Meteocat returned HTTP ' . $code . ' for ' . $path ); + + return null; + } + + $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + + return is_array( $data ) ? $data : null; + } + + /** + * Writes a message to the error log with the plugin prefix. + * + * @param string $message Message. + * @return void + */ + private static function log( string $message ): void { + error_log( '[fontdelbosc-eltemps] ' . $message ); + } +} diff --git a/includes/class-fontdelbosc-api-openmeteo.php b/includes/class-fontdelbosc-api-openmeteo.php new file mode 100644 index 0000000..3003ae2 --- /dev/null +++ b/includes/class-fontdelbosc-api-openmeteo.php @@ -0,0 +1,379 @@ + + */ + private const WMO_MAP = array( + 0 => 'clear', + 1 => 'mainly_clear', + 2 => 'partly_cloudy', + 3 => 'overcast', + 45 => 'fog', + 48 => 'fog', + 51 => 'drizzle_light', + 53 => 'drizzle', + 55 => 'drizzle_heavy', + 61 => 'rain_light', + 63 => 'rain', + 65 => 'rain_heavy', + 71 => 'snow_light', + 73 => 'snow', + 75 => 'snow_heavy', + 80 => 'rain_showers_light', + 81 => 'rain_showers', + 82 => 'rain_showers_heavy', + 95 => 'thunderstorm', + 96 => 'thunderstorm_hail', + 99 => 'thunderstorm_hail', + ); + + /** + * Returns normalized current conditions. + * + * @return array|null + */ + public static function get_current(): ?array { + $url = add_query_arg( + array( + 'latitude' => FONTDELBOSC_ELTEMPS_LAT, + 'longitude' => FONTDELBOSC_ELTEMPS_LON, + 'current' => 'temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m', + 'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE, + 'forecast_days' => 1, + ), + self::FORECAST_URL + ); + $data = self::request( $url ); + $time = Fontdelbosc_Payload::str( Fontdelbosc_Payload::path( $data, 'current', 'time' ) ); + + if ( null === $time ) { + return null; + } + + return array( + 'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'temperature_2m' ) ), + 'apparent' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'apparent_temperature' ) ), + 'humidity' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'current', 'relative_humidity_2m' ) ), + 'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'precipitation' ) ), + 'wind_speed' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'wind_speed_10m' ) ), + 'wind_direction' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'current', 'wind_direction_10m' ) ), + 'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'current', 'weather_code' ) ), + 'source' => 'open-meteo', + 'time' => self::timestamp( $time ), + ); + } + + /** + * Returns the normalized hourly forecast, from the current hour onwards. + * + * @return array|null + */ + public static function get_hourly(): ?array { + $url = add_query_arg( + array( + 'latitude' => FONTDELBOSC_ELTEMPS_LAT, + 'longitude' => FONTDELBOSC_ELTEMPS_LON, + 'hourly' => 'temperature_2m,precipitation_probability,weather_code,wind_speed_10m', + 'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE, + 'forecast_days' => 3, + ), + self::FORECAST_URL + ); + $data = self::request( $url ); + $times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'hourly', 'time' ) ); + $now = self::now_key(); + $hours = array(); + + foreach ( $times as $index => $iso_time ) { + $raw = Fontdelbosc_Payload::str( $iso_time ); + + if ( null === $raw ) { + continue; + } + + $time = str_replace( 'T', ' ', $raw ); + + if ( $time < $now ) { + continue; + } + + $hours[] = array( + 'time' => self::timestamp( $time ), + 'hour' => (int) substr( $time, 11, 2 ), + 'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'hourly', 'temperature_2m', $index ) ), + 'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'hourly', 'precipitation_probability', $index ) ), + 'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'hourly', 'weather_code', $index ) ), + 'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'hourly', 'wind_speed_10m', $index ) ), + ); + } + + if ( array() === $hours ) { + return null; + } + + return array( + 'source' => 'open-meteo', + 'hours' => $hours, + ); + } + + /** + * Returns the normalized daily forecast. + * + * @return array|null + */ + public static function get_daily(): ?array { + $url = add_query_arg( + array( + 'latitude' => FONTDELBOSC_ELTEMPS_LAT, + 'longitude' => FONTDELBOSC_ELTEMPS_LON, + 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max', + 'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE, + 'forecast_days' => 7, + ), + self::FORECAST_URL + ); + $data = self::request( $url ); + $times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'daily', 'time' ) ); + $days = array(); + + foreach ( $times as $index => $date ) { + $date = Fontdelbosc_Payload::str( $date ); + + if ( null === $date ) { + continue; + } + + $days[] = array( + 'date' => $date, + 'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'daily', 'weather_code', $index ) ), + 'temp_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_max', $index ) ), + 'temp_min' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_min', $index ) ), + 'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_sum', $index ) ), + 'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_probability_max', $index ) ), + 'wind_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'wind_speed_10m_max', $index ) ), + ); + } + + if ( array() === $days ) { + return null; + } + + return array( + 'source' => 'open-meteo', + 'days' => $days, + ); + } + + /** + * Returns the normalized daily history for a given month (ERA5). + * + * @param int $year Year (1940 or later). + * @param int $month Month (1-12). + * @return array|null + */ + public static function get_historical( int $year, int $month ): ?array { + try { + $start = new DateTimeImmutable( sprintf( '%04d-%02d-01', $year, $month ), new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ); + } catch ( Exception ) { + return null; + } + + $url = add_query_arg( + array( + 'latitude' => FONTDELBOSC_ELTEMPS_LAT, + 'longitude' => FONTDELBOSC_ELTEMPS_LON, + 'start_date' => $start->format( 'Y-m-d' ), + 'end_date' => $start->modify( 'last day of this month' )->format( 'Y-m-d' ), + 'daily' => 'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max', + 'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE, + ), + self::ARCHIVE_URL + ); + $data = self::request( $url ); + + $times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'daily', 'time' ) ); + $days = array(); + + foreach ( $times as $index => $date ) { + $date = Fontdelbosc_Payload::str( $date ); + $max = Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_max', $index ) ); + $min = Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_min', $index ) ); + + if ( null === $date || ( null === $max && null === $min ) ) { + continue; + } + + $days[] = array( + 'date' => $date, + 'temp_max' => $max, + 'temp_min' => $min, + 'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_sum', $index ) ), + 'wind_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'wind_speed_10m_max', $index ) ), + ); + } + + if ( array() === $days ) { + return null; + } + + return array( + 'source' => 'open-meteo', + 'year' => $year, + 'month' => $month, + 'days' => $days, + 'stats' => self::stats( $days ), + ); + } + + /** + * Computes monthly statistics for a set of normalized history days. + * + * @param array> $days Normalized days. + * @return array + */ + private static function stats( array $days ): array { + $maxes = array(); + $mins = array(); + $precip_sum = 0.0; + $wind_max = null; + + foreach ( $days as $day ) { + $max = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'temp_max' ) ); + $min = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'temp_min' ) ); + $pre = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'precipitation' ) ); + $win = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'wind_max' ) ); + + if ( null !== $max ) { + $maxes[] = $max; + } + + if ( null !== $min ) { + $mins[] = $min; + } + + if ( null !== $pre ) { + $precip_sum += $pre; + } + + if ( null !== $win ) { + $wind_max = max( (float) $wind_max, $win ); + } + } + + return array( + 'avg_max' => array() === $maxes ? null : round( array_sum( $maxes ) / count( $maxes ), 1 ), + 'avg_min' => array() === $mins ? null : round( array_sum( $mins ) / count( $mins ), 1 ), + 'total_precip' => round( $precip_sum, 1 ), + 'max_wind' => $wind_max, + ); + } + + /** + * Maps a WMO code to a condition key. + * + * @param mixed $code WMO weather code. + * @return string|null + */ + private static function condition( mixed $code ): ?string { + if ( ! is_numeric( $code ) ) { + return null; + } + + return self::WMO_MAP[ (int) $code ] ?? 'cloudy'; + } + + /** + * Converts a local (Europe/Madrid) ISO string to a Unix timestamp. + * + * @param string $iso ISO date-time string. + * @return int + */ + private static function timestamp( string $iso ): int { + try { + return ( new DateTimeImmutable( $iso, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp(); + } catch ( Exception ) { + return time(); + } + } + + /** + * Returns the current hour key used to filter hourly series. + * + * @return string Format: Y-m-d H:00. + */ + private static function now_key(): string { + return ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' ); + } + + /** + * Performs a GET request and decodes the JSON response. + * + * @param string $url Request URL. + * @return array|null + */ + private static function request( string $url ): ?array { + $response = wp_remote_get( + $url, + array( + 'timeout' => 5, + 'headers' => array( + 'Accept' => 'application/json', + ), + ) + ); + + if ( is_wp_error( $response ) ) { + self::log( 'Open-Meteo request failed: ' . $response->get_error_message() ); + + return null; + } + + $code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== $code ) { + self::log( 'Open-Meteo returned HTTP ' . $code ); + + return null; + } + + $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + + return is_array( $data ) ? $data : null; + } + + /** + * Writes a message to the error log with the plugin prefix. + * + * @param string $message Message. + * @return void + */ + private static function log( string $message ): void { + error_log( '[fontdelbosc-eltemps] ' . $message ); + } +} diff --git a/includes/class-fontdelbosc-conditions.php b/includes/class-fontdelbosc-conditions.php new file mode 100644 index 0000000..f5c7099 --- /dev/null +++ b/includes/class-fontdelbosc-conditions.php @@ -0,0 +1,148 @@ + + */ + private const ICONS = array( + 'clear' => array( 'clear-day.svg', 'clear-night.svg' ), + 'mainly_clear' => array( 'clear-day.svg', 'clear-night.svg' ), + 'partly_cloudy' => array( 'partly-cloudy-day.svg', 'partly-cloudy-night.svg' ), + 'cloudy' => array( 'cloudy.svg', 'cloudy.svg' ), + 'overcast' => array( 'overcast.svg', 'overcast.svg' ), + 'fog' => array( 'fog-day.svg', 'fog-night.svg' ), + 'drizzle_light' => array( 'drizzle.svg', 'drizzle.svg' ), + 'drizzle' => array( 'drizzle.svg', 'drizzle.svg' ), + 'drizzle_heavy' => array( 'drizzle.svg', 'drizzle.svg' ), + 'rain_light' => array( 'overcast-rain.svg', 'overcast-rain.svg' ), + 'rain' => array( 'rain.svg', 'rain.svg' ), + 'rain_heavy' => array( 'extreme-rain.svg', 'extreme-rain.svg' ), + 'rain_showers_light' => array( 'partly-cloudy-day-rain.svg', 'partly-cloudy-night-rain.svg' ), + 'rain_showers' => array( 'partly-cloudy-day-rain.svg', 'partly-cloudy-night-rain.svg' ), + 'rain_showers_heavy' => array( 'rain.svg', 'rain.svg' ), + 'snow_light' => array( 'snow.svg', 'snow.svg' ), + 'snow' => array( 'snow.svg', 'snow.svg' ), + 'snow_heavy' => array( 'extreme-snow.svg', 'extreme-snow.svg' ), + 'thunderstorm' => array( 'thunderstorms-day-rain.svg', 'thunderstorms-night-rain.svg' ), + 'thunderstorm_hail' => array( 'thunderstorms-day-hail.svg', 'thunderstorms-night-hail.svg' ), + ); + + /** + * Returns the translated label for a condition key. + * + * @param string $key Condition key. + * @return string + */ + public static function label( string $key ): string { + $labels = array( + 'clear' => __( 'Clear sky', 'fontdelbosc-eltemps' ), + 'mainly_clear' => __( 'Clear sky', 'fontdelbosc-eltemps' ), + 'partly_cloudy' => __( 'Partly cloudy', 'fontdelbosc-eltemps' ), + 'cloudy' => __( 'Cloudy', 'fontdelbosc-eltemps' ), + 'overcast' => __( 'Overcast', 'fontdelbosc-eltemps' ), + 'fog' => __( 'Fog', 'fontdelbosc-eltemps' ), + 'drizzle_light' => __( 'Drizzle', 'fontdelbosc-eltemps' ), + 'drizzle' => __( 'Drizzle', 'fontdelbosc-eltemps' ), + 'drizzle_heavy' => __( 'Drizzle', 'fontdelbosc-eltemps' ), + 'rain_light' => __( 'Rain', 'fontdelbosc-eltemps' ), + 'rain' => __( 'Rain', 'fontdelbosc-eltemps' ), + 'rain_heavy' => __( 'Rain', 'fontdelbosc-eltemps' ), + 'rain_showers_light' => __( 'Rain', 'fontdelbosc-eltemps' ), + 'rain_showers' => __( 'Rain', 'fontdelbosc-eltemps' ), + 'rain_showers_heavy' => __( 'Rain', 'fontdelbosc-eltemps' ), + 'snow_light' => __( 'Snow', 'fontdelbosc-eltemps' ), + 'snow' => __( 'Snow', 'fontdelbosc-eltemps' ), + 'snow_heavy' => __( 'Snow', 'fontdelbosc-eltemps' ), + 'thunderstorm' => __( 'Thunderstorm', 'fontdelbosc-eltemps' ), + 'thunderstorm_hail' => __( 'Thunderstorm', 'fontdelbosc-eltemps' ), + ); + + return $labels[ $key ] ?? $labels['cloudy']; + } + + /** + * Returns the bundled Meteocons icon URL for a condition key. + * + * @param string $key Condition key. + * @param bool $night Whether the night variant should be used. + * @return string + */ + public static function icon( string $key, bool $night = false ): string { + $files = self::ICONS[ $key ] ?? self::ICONS['cloudy']; + + return FONTDELBOSC_ELTEMPS_PLUGIN_URL . 'public/icons/flat/' . $files[ $night ? 1 : 0 ]; + } + + /** + * Rough day/night detection for icon selection. + * + * @param int $hour Local hour (0-23). + * @return bool True when night. + */ + public static function is_night( int $hour ): bool { + return $hour < 7 || $hour >= 21; + } + + /** + * Returns the translated weekday name for an ISO-8601 weekday number. + * + * @param int $number Weekday number (1 = Monday, 7 = Sunday). + * @return string + */ + public static function weekday( int $number ): string { + $weekdays = array( + 1 => __( 'Monday', 'fontdelbosc-eltemps' ), + 2 => __( 'Tuesday', 'fontdelbosc-eltemps' ), + 3 => __( 'Wednesday', 'fontdelbosc-eltemps' ), + 4 => __( 'Thursday', 'fontdelbosc-eltemps' ), + 5 => __( 'Friday', 'fontdelbosc-eltemps' ), + 6 => __( 'Saturday', 'fontdelbosc-eltemps' ), + 7 => __( 'Sunday', 'fontdelbosc-eltemps' ), + ); + + return $weekdays[ $number ] ?? ''; + } + + /** + * Returns the translated month name for a month number. + * + * @param int $number Month number (1-12). + * @return string + */ + public static function month( int $number ): string { + $months = array( + 1 => __( 'January', 'fontdelbosc-eltemps' ), + 2 => __( 'February', 'fontdelbosc-eltemps' ), + 3 => __( 'March', 'fontdelbosc-eltemps' ), + 4 => __( 'April', 'fontdelbosc-eltemps' ), + 5 => __( 'May', 'fontdelbosc-eltemps' ), + 6 => __( 'June', 'fontdelbosc-eltemps' ), + 7 => __( 'July', 'fontdelbosc-eltemps' ), + 8 => __( 'August', 'fontdelbosc-eltemps' ), + 9 => __( 'September', 'fontdelbosc-eltemps' ), + 10 => __( 'October', 'fontdelbosc-eltemps' ), + 11 => __( 'November', 'fontdelbosc-eltemps' ), + 12 => __( 'December', 'fontdelbosc-eltemps' ), + ); + + return $months[ $number ] ?? ''; + } +} diff --git a/includes/class-fontdelbosc-data-manager.php b/includes/class-fontdelbosc-data-manager.php new file mode 100644 index 0000000..f081887 --- /dev/null +++ b/includes/class-fontdelbosc-data-manager.php @@ -0,0 +1,260 @@ +modify( 'first day of previous month' ); + + self::get_historical( (int) $previous->format( 'Y' ), (int) $previous->format( 'n' ) ); + } + + /** + * Returns normalized current conditions. + * + * Chain: Meteocat XEMA station (when key and station are configured) + * → Open-Meteo current. + * + * @return array|null + */ + public static function get_current(): ?array { + $options = Fontdelbosc_Plugin::get_options(); + $data = self::cached( + 'fontdelbosc_current', + $options['cache_ttl_current'], + static function () use ( $options ): ?array { + if ( '' !== $options['meteocat_api_key'] && '' !== $options['meteocat_station_code'] ) { + $current = Fontdelbosc_Api_Meteocat::get_current( $options['meteocat_api_key'], $options['meteocat_station_code'] ); + + if ( null !== $current ) { + $hourly = Fontdelbosc_Data_Manager::get_hourly( 1 ); + $firsthour = Fontdelbosc_Payload::path( $hourly, 'hours', 0 ); + + if ( is_array( $firsthour ) && null !== Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $firsthour, 'condition' ) ) ) { + $current['condition'] = Fontdelbosc_Payload::get( $firsthour, 'condition' ); + } + + return $current; + } + } + + return Fontdelbosc_Api_OpenMeteo::get_current(); + } + ); + + if ( ! is_array( $data ) ) { + return null; + } + + return array( + 'temperature' => Fontdelbosc_Payload::get( $data, 'temperature' ), + 'apparent' => Fontdelbosc_Payload::get( $data, 'apparent' ), + 'humidity' => Fontdelbosc_Payload::get( $data, 'humidity' ), + 'precipitation' => Fontdelbosc_Payload::get( $data, 'precipitation' ), + 'wind_speed' => Fontdelbosc_Payload::get( $data, 'wind_speed' ), + 'wind_direction' => Fontdelbosc_Payload::get( $data, 'wind_direction' ), + 'condition' => Fontdelbosc_Payload::get( $data, 'condition' ), + 'source' => Fontdelbosc_Payload::get( $data, 'source' ), + 'time' => Fontdelbosc_Payload::get( $data, 'time' ), + ); + } + + /** + * Returns the hourly forecast sliced to the requested number of hours. + * + * Chain: Meteocat municipal → AEMET hourly → Open-Meteo hourly. + * + * @param int $hours Number of hours (1-48). + * @return array|null + */ + public static function get_hourly( int $hours = 24 ): ?array { + $options = Fontdelbosc_Plugin::get_options(); + $data = self::cached( + 'fontdelbosc_forecast_hourly', + $options['cache_ttl_forecast'], + static function () use ( $options ): ?array { + if ( '' !== $options['meteocat_api_key'] ) { + $data = Fontdelbosc_Api_Meteocat::get_hourly( $options['meteocat_api_key'] ); + + if ( null !== $data ) { + return $data; + } + } + + if ( '' !== $options['aemet_api_key'] ) { + $data = Fontdelbosc_Api_Aemet::get_hourly( $options['aemet_api_key'] ); + + if ( null !== $data ) { + return $data; + } + } + + return Fontdelbosc_Api_OpenMeteo::get_hourly(); + } + ); + + $entries = array_values( array_filter( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'hours' ) ), 'is_array' ) ); + $source = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $data, 'source' ) ); + + if ( '' === $source || array() === $entries ) { + return null; + } + + return array( + 'source' => $source, + 'hours' => array_slice( $entries, 0, max( 1, min( 48, $hours ) ) ), + ); + } + + /** + * Returns the daily forecast sliced to the requested number of days. + * + * Chain: AEMET daily → Open-Meteo daily. + * + * @param int $days Number of days (1-7). + * @return array|null + */ + public static function get_daily( int $days = 7 ): ?array { + $options = Fontdelbosc_Plugin::get_options(); + $data = self::cached( + 'fontdelbosc_forecast_daily', + $options['cache_ttl_forecast'], + static function () use ( $options ): ?array { + if ( '' !== $options['aemet_api_key'] ) { + $data = Fontdelbosc_Api_Aemet::get_daily( $options['aemet_api_key'] ); + + if ( null !== $data ) { + return $data; + } + } + + return Fontdelbosc_Api_OpenMeteo::get_daily(); + } + ); + + $entries = array_values( array_filter( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'days' ) ), 'is_array' ) ); + $source = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $data, 'source' ) ); + + if ( '' === $source || array() === $entries ) { + return null; + } + + return array( + 'source' => $source, + 'days' => array_slice( $entries, 0, max( 1, min( 7, $days ) ) ), + ); + } + + /** + * Returns the daily history for a month (Open-Meteo ERA5 archive). + * + * @param int $year Year (1940 onwards). + * @param int $month Month (1-12). + * @return array|null + */ + public static function get_historical( int $year, int $month ): ?array { + $year = max( 1940, min( (int) gmdate( 'Y' ), $year ) ); + $month = max( 1, min( 12, $month ) ); + + $options = Fontdelbosc_Plugin::get_options(); + $data = self::cached( + 'fontdelbosc_historical_' . $year . '_' . $month, + $options['cache_ttl_historical'], + static function () use ( $year, $month ): ?array { + return Fontdelbosc_Api_OpenMeteo::get_historical( $year, $month ); + } + ); + + $entries = array_values( array_filter( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'days' ) ), 'is_array' ) ); + $source = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $data, 'source' ) ); + + if ( '' === $source || array() === $entries ) { + return null; + } + + return array( + 'source' => $source, + 'year' => $year, + 'month' => $month, + 'days' => $entries, + 'stats' => Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'stats' ) ), + ); + } + + /** + * Returns the license attribution line for a data source slug. + * + * @param string $source Source slug (meteocat, aemet, open-meteo). + * @return string + */ + public static function attribution( string $source ): string { + $map = array( + 'meteocat' => '© Servei Meteorològic de Catalunya. Generalitat de Catalunya', + 'aemet' => '© AEMET. Gobierno de España', + 'open-meteo' => 'Weather data by Open-Meteo.com (CC BY 4.0)', + ); + + return $map[ $source ] ?? ''; + } + + /** + * Cache helper implementing stale-while-error. Fresh data is served + * until its TTL expires; on fetch failure the last cached payload is + * served for up to one extra day. + * + * @param string $key Transient key. + * @param int $ttl Time-to-live in seconds. + * @param callable $fetch Fetch callback returning array|null. + * @return mixed + */ + private static function cached( string $key, int $ttl, callable $fetch ): mixed { + $cache = get_transient( $key ); + + if ( is_array( $cache ) && (int) Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $cache, 'expires' ) ?? 0 ) > time() ) { + return Fontdelbosc_Payload::get( $cache, 'data' ); + } + + $data = $fetch(); + + if ( null !== $data ) { + set_transient( + $key, + array( + 'expires' => time() + $ttl, + 'data' => $data, + ), + $ttl + DAY_IN_SECONDS + ); + + return $data; + } + + if ( is_array( $cache ) ) { + return Fontdelbosc_Payload::get( $cache, 'data' ); + } + + return null; + } +} diff --git a/includes/class-fontdelbosc-payload.php b/includes/class-fontdelbosc-payload.php new file mode 100644 index 0000000..3f7c0b0 --- /dev/null +++ b/includes/class-fontdelbosc-payload.php @@ -0,0 +1,88 @@ + + */ + public static function arr( mixed $value ): array { + return is_array( $value ) ? $value : array(); + } + + /** + * Returns the value as a string, or null. + * + * @param mixed $value Raw value. + * @return string|null + */ + public static function str( mixed $value ): ?string { + return is_string( $value ) ? $value : null; + } + + /** + * Returns the value as an integer, or null. + * + * @param mixed $value Raw value. + * @return int|null + */ + public static function int( mixed $value ): ?int { + return is_numeric( $value ) ? (int) round( (float) $value ) : null; + } + + /** + * Returns the value as a float, or null. + * + * @param mixed $value Raw value. + * @return float|null + */ + public static function float( mixed $value ): ?float { + return is_numeric( $value ) ? (float) $value : null; + } +} diff --git a/includes/class-fontdelbosc-plugin.php b/includes/class-fontdelbosc-plugin.php new file mode 100644 index 0000000..6e27f85 --- /dev/null +++ b/includes/class-fontdelbosc-plugin.php @@ -0,0 +1,168 @@ + Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $stored, 'aemet_api_key' ) ) ?? '', + 'meteocat_api_key' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $stored, 'meteocat_api_key' ) ) ?? '', + 'meteocat_station_code' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $stored, 'meteocat_station_code' ) ) ?? '', + 'cache_ttl_current' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $stored, 'cache_ttl_current' ) ) ?? 600, + 'cache_ttl_forecast' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $stored, 'cache_ttl_forecast' ) ) ?? 3600, + 'cache_ttl_historical' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $stored, 'cache_ttl_historical' ) ) ?? 86400, + 'display_attribution' => array_key_exists( 'display_attribution', $stored ) ? ! empty( $stored['display_attribution'] ) : true, + 'delete_data_on_uninstall' => ! empty( $stored['delete_data_on_uninstall'] ), + ); + } + + return self::$options; + } +} diff --git a/includes/class-fontdelbosc-shortcode.php b/includes/class-fontdelbosc-shortcode.php new file mode 100644 index 0000000..5960d70 --- /dev/null +++ b/includes/class-fontdelbosc-shortcode.php @@ -0,0 +1,145 @@ + $atts Shortcode attributes. + * @param string|null $content Enclosed content (unused). + * @param string $tag Shortcode tag (unused). + * @return string + */ + public static function render( $atts = array(), ?string $content = null, string $tag = '' ): string { + $atts = shortcode_atts( + array( + 'view' => 'current', + 'hours' => 24, + 'days' => 7, + 'year' => 0, + 'month' => 0, + ), + $atts, + 'fontdelbosc_eltemps' + ); + + $view = sanitize_key( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $atts, 'view' ) ) ?? 'current' ); + + if ( ! in_array( $view, array( 'current', 'hourly', 'daily', 'historical' ), true ) ) { + $view = 'current'; + } + + return self::render_view( + $view, + array( + 'hours' => max( 1, min( 48, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'hours' ) ) ?? 24 ) ), + 'days' => max( 1, min( 7, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'days' ) ) ?? 7 ) ), + 'year' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'year' ) ) ?? 0, + 'month' => max( 0, min( 12, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'month' ) ) ?? 0 ) ), + ) + ); + } + + /** + * Renders a weather view. Shared by shortcode and widget. + * + * @param string $view View name. + * @param array $args Optional view arguments. + * @return string + */ + public static function render_view( string $view, array $args = array() ): string { + $args = wp_parse_args( + $args, + array( + 'hours' => 24, + 'days' => 7, + 'year' => 0, + 'month' => 0, + ) + ); + + $template = 'current.php'; + $data = null; + $now = new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ); + + switch ( $view ) { + case 'hourly': + $template = 'forecast-hourly.php'; + $data = Fontdelbosc_Data_Manager::get_hourly( Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'hours' ) ) ?? 24 ); + break; + case 'daily': + $template = 'forecast-daily.php'; + $data = Fontdelbosc_Data_Manager::get_daily( Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'days' ) ) ?? 7 ); + break; + case 'historical': + $template = 'historical.php'; + $year = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'year' ) ) ?? 0; + $month = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'month' ) ) ?? 0; + + if ( $year > 0 || $month > 0 ) { + $data = Fontdelbosc_Data_Manager::get_historical( + $year > 0 ? $year : (int) $now->format( 'Y' ), + $month > 0 ? $month : (int) $now->format( 'n' ) + ); + } else { + $previous = $now->modify( 'first day of previous month' ); + + $data = Fontdelbosc_Data_Manager::get_historical( + (int) $previous->format( 'Y' ), + (int) $previous->format( 'n' ) + ); + } + break; + default: + $data = Fontdelbosc_Data_Manager::get_current(); + } + + if ( null === $data ) { + return '

' . esc_html__( 'Weather data unavailable.', 'fontdelbosc-eltemps' ) . '

'; + } + + wp_enqueue_style( 'fdb-weather' ); + + return self::load_template( $template, $data ); + } + + /** + * Buffers and returns a template. + * + * @param string $template Template file name. + * @param array $data Normalized view data. + * @return string + */ + private static function load_template( string $template, array $data ): string { + ob_start(); + include FONTDELBOSC_ELTEMPS_PLUGIN_DIR . 'templates/' . $template; + + return (string) ob_get_clean(); + } +} diff --git a/includes/class-fontdelbosc-widget.php b/includes/class-fontdelbosc-widget.php new file mode 100644 index 0000000..4ad708d --- /dev/null +++ b/includes/class-fontdelbosc-widget.php @@ -0,0 +1,99 @@ + __( 'Current weather and forecast for Font del Bosc (Mediona).', 'fontdelbosc-eltemps' ), + ) + ); + } + + /** + * Renders the widget output. + * + * @param array $args Widget arguments. + * @param array $instance Saved widget values. + * @return void + */ + public function widget( $args, $instance ): void { + $title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'title' ) ) ?? ''; + $title = '' !== $title ? Fontdelbosc_Payload::str( apply_filters( 'widget_title', $title, $instance, $this->id_base ) ) ?? $title : ''; + $view = 'today' === Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'view' ) ) ? 'today' : 'current'; + + $before_widget = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'before_widget' ) ) ?? ''; + $after_widget = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'after_widget' ) ) ?? ''; + $before_title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'before_title' ) ) ?? ''; + $after_title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'after_title' ) ) ?? ''; + + echo wp_kses_post( $before_widget ); + + if ( $title ) { + echo wp_kses_post( $before_title . esc_html( $title ) . $after_title ); + } + + if ( 'today' === $view ) { + echo wp_kses_post( Fontdelbosc_Shortcode::render_view( 'daily', array( 'days' => 1 ) ) ); + } else { + echo wp_kses_post( Fontdelbosc_Shortcode::render_view( 'current' ) ); + } + + echo wp_kses_post( $after_widget ); + } + + /** + * Renders the widget settings form. + * + * @param array $instance Saved widget values. + * @return string|null + */ + public function form( $instance ): ?string { + $title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'title' ) ) ?? ''; + $view = 'today' === Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'view' ) ) ? 'today' : 'current'; + ?> +

+ + +

+

+ + +

+ $new_instance New values. + * @param array $old_instance Old values. + * @return array + */ + public function update( $new_instance, $old_instance ): array { + return array( + 'title' => sanitize_text_field( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $new_instance, 'title' ) ) ?? '' ), + 'view' => 'today' === Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $new_instance, 'view' ) ) ? 'today' : 'current', + ); + } +} diff --git a/languages/fontdelbosc-eltemps-ca.mo b/languages/fontdelbosc-eltemps-ca.mo new file mode 100644 index 0000000..5a8be33 Binary files /dev/null and b/languages/fontdelbosc-eltemps-ca.mo differ diff --git a/languages/fontdelbosc-eltemps-ca.po b/languages/fontdelbosc-eltemps-ca.po new file mode 100644 index 0000000..058223d --- /dev/null +++ b/languages/fontdelbosc-eltemps-ca.po @@ -0,0 +1,243 @@ +# Catalan (ca) translation for Font del Bosc — El Temps. +# Copyright (C) 2026 ROBOTSTXT +# This file is distributed under the GPL-3.0-or-later license. +msgid "" +msgstr "" +"Project-Id-Version: Font del Bosc — El Temps 1.0.0\n" +"Report-Msgid-Bugs-To: https://github.com/robotstxt/fontdelbosc-eltemps/issues\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: ca\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"PO-Revision-Date: 2026-10-12 12:00+0200\n" +"Last-Translator: ROBOTSTXT\n" +"Language-Team: ROBOTSTXT\n" + +msgid "Clear sky" +msgstr "Cel clar" + +msgid "Partly cloudy" +msgstr "Parcialment ennuvolat" + +msgid "Cloudy" +msgstr "Ennuvolat" + +msgid "Overcast" +msgstr "Molt ennuvolat" + +msgid "Drizzle" +msgstr "Plugim" + +msgid "Rain" +msgstr "Pluja" + +msgid "Snow" +msgstr "Neu" + +msgid "Thunderstorm" +msgstr "Tempesta" + +msgid "Fog" +msgstr "Boira" + +msgid "Wind" +msgstr "Vent" + +msgid "Humidity" +msgstr "Humitat" + +msgid "Precipitation" +msgstr "Precipitació" + +msgid "Temperature" +msgstr "Temperatura" + +msgid "Feels like" +msgstr "Sensació tèrmica" + +msgid "Max" +msgstr "Màx." + +msgid "Min" +msgstr "Mín." + +msgid "UV Index" +msgstr "Índex UV" + +msgid "Today" +msgstr "Avui" + +msgid "Tomorrow" +msgstr "Demà" + +msgid "Forecast" +msgstr "Predicció" + +msgid "Current conditions" +msgstr "Condicions actuals" + +msgid "Historical data" +msgstr "Dades històriques" + +msgid "Loading weather data…" +msgstr "Carregant dades del temps…" + +msgid "Weather data unavailable." +msgstr "Dades del temps no disponibles." + +msgid "Source: %s" +msgstr "Font: %s" + +msgid "Monday" +msgstr "Dilluns" + +msgid "Tuesday" +msgstr "Dimarts" + +msgid "Wednesday" +msgstr "Dimecres" + +msgid "Thursday" +msgstr "Dijous" + +msgid "Friday" +msgstr "Divendres" + +msgid "Saturday" +msgstr "Dissabte" + +msgid "Sunday" +msgstr "Diumenge" + +msgid "January" +msgstr "Gener" + +msgid "February" +msgstr "Febrer" + +msgid "March" +msgstr "Març" + +msgid "April" +msgstr "Abril" + +msgid "May" +msgstr "Maig" + +msgid "June" +msgstr "Juny" + +msgid "July" +msgstr "Juliol" + +msgid "August" +msgstr "Agost" + +msgid "September" +msgstr "Setembre" + +msgid "October" +msgstr "Octubre" + +msgid "November" +msgstr "Novembre" + +msgid "December" +msgstr "Desembre" + +msgid "Average max." +msgstr "Màx. mitjana" + +msgid "Average min." +msgstr "Mín. mitjana" + +msgid "Total precipitation" +msgstr "Precipitació total" + +msgid "Current weather and forecast for Font del Bosc (Mediona)." +msgstr "El temps actual i la predicció per a Font del Bosc (Mediona)." + +msgid "Title" +msgstr "Títol" + +msgid "View" +msgstr "Vista" + +msgid "Today's forecast" +msgstr "Predicció d'avui" + +msgid "API Keys" +msgstr "Claus de l'API" + +msgid "Cache Settings" +msgstr "Ajustos de la memòria cau" + +msgid "Display Options" +msgstr "Opcions de visualització" + +msgid "Status" +msgstr "Estat" + +msgid "AEMET API key" +msgstr "Clau de l'API de l'AEMET" + +msgid "Meteocat API key" +msgstr "Clau de l'API de Meteocat" + +msgid "Current conditions cache (seconds)" +msgstr "Memòria cau de les condicions actuals (segons)" + +msgid "Forecast cache (seconds)" +msgstr "Memòria cau de la predicció (segons)" + +msgid "Historical data cache (seconds)" +msgstr "Memòria cau de les dades històriques (segons)" + +msgid "Show data source attribution" +msgstr "Mostra l'atribució de la font de dades" + +msgid "Uninstall" +msgstr "Desinstal·lació" + +msgid "Delete all plugin data on uninstall" +msgstr "Suprimeix totes les dades del complement en desinstal·lar-lo" + +msgid "Test connection" +msgstr "Prova la connexió" + +msgid "Connection successful." +msgstr "La connexió és correcta." + +msgid "Connection failed." +msgstr "No s'ha pogut establir la connexió." + +msgid "Find nearest station" +msgstr "Cerca l'estació més propera" + +msgid "Nearest station: %s" +msgstr "Estació més propera: %s" + +msgid "No weather API keys are configured. Data is served by Open-Meteo (no key required)." +msgstr "No hi ha cap clau d'API meteorològica configurada. Les dades les serveix Open-Meteo (no en necessita cap)." + +msgid "Usage" +msgstr "Ús" + +msgid "Current conditions (default view)." +msgstr "Condicions actuals (vista per defecte)." + +msgid "Hourly forecast for the following hours. Attribute hours: 1-48 (24 by default)." +msgstr "Predicció horària de les properes hores. Atribut hours: 1-48 (24 per defecte)." + +msgid "Daily forecast. Attribute days: 1-7 (7 by default)." +msgstr "Predicció diària. Atribut days: 1-7 (7 per defecte)." + +msgid "Historical data for one month. Attributes year and month; by default the last completed month." +msgstr "Dades històriques d'un mes. Atributs year i month; per defecte, l'últim mes completat." + +msgid "In the block editor, add a Shortcode block and paste the code. This version ships no dedicated blocks; the shortcode works anywhere shortcodes are supported, including the Shortcode block and text widgets." +msgstr "En l'editor de blocs, afegiu un bloc de codi curt i enganxeu-hi el codi. Aquesta versió no inclou blocs propis; el shortcode funciona a qualsevol lloc que admeti codis curts, incloent-hi el bloc de codi curt i els ginys de text." + +msgid "Current key ends in …%s" +msgstr "La clau actual acaba en …%s" diff --git a/languages/fontdelbosc-eltemps-es_ES.mo b/languages/fontdelbosc-eltemps-es_ES.mo new file mode 100644 index 0000000..73677ae Binary files /dev/null and b/languages/fontdelbosc-eltemps-es_ES.mo differ diff --git a/languages/fontdelbosc-eltemps-es_ES.po b/languages/fontdelbosc-eltemps-es_ES.po new file mode 100644 index 0000000..d7a1538 --- /dev/null +++ b/languages/fontdelbosc-eltemps-es_ES.po @@ -0,0 +1,243 @@ +# Spanish (es_ES) translation for Font del Bosc — El Temps. +# Copyright (C) 2026 ROBOTSTXT +# This file is distributed under the GPL-3.0-or-later license. +msgid "" +msgstr "" +"Project-Id-Version: Font del Bosc — El Temps 1.0.0\n" +"Report-Msgid-Bugs-To: https://github.com/robotstxt/fontdelbosc-eltemps/issues\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: es_ES\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"PO-Revision-Date: 2026-10-12 12:00+0200\n" +"Last-Translator: ROBOTSTXT\n" +"Language-Team: ROBOTSTXT\n" + +msgid "Clear sky" +msgstr "Cielo despejado" + +msgid "Partly cloudy" +msgstr "Parcialmente nublado" + +msgid "Cloudy" +msgstr "Nublado" + +msgid "Overcast" +msgstr "Cubierto" + +msgid "Drizzle" +msgstr "Llovizna" + +msgid "Rain" +msgstr "Lluvia" + +msgid "Snow" +msgstr "Nieve" + +msgid "Thunderstorm" +msgstr "Tormenta" + +msgid "Fog" +msgstr "Niebla" + +msgid "Wind" +msgstr "Viento" + +msgid "Humidity" +msgstr "Humedad" + +msgid "Precipitation" +msgstr "Precipitación" + +msgid "Temperature" +msgstr "Temperatura" + +msgid "Feels like" +msgstr "Sensación térmica" + +msgid "Max" +msgstr "Máx." + +msgid "Min" +msgstr "Mín." + +msgid "UV Index" +msgstr "Índice UV" + +msgid "Today" +msgstr "Hoy" + +msgid "Tomorrow" +msgstr "Mañana" + +msgid "Forecast" +msgstr "Previsión" + +msgid "Current conditions" +msgstr "Condiciones actuales" + +msgid "Historical data" +msgstr "Datos históricos" + +msgid "Loading weather data…" +msgstr "Cargando datos del tiempo…" + +msgid "Weather data unavailable." +msgstr "Datos del tiempo no disponibles." + +msgid "Source: %s" +msgstr "Fuente: %s" + +msgid "Monday" +msgstr "Lunes" + +msgid "Tuesday" +msgstr "Martes" + +msgid "Wednesday" +msgstr "Miércoles" + +msgid "Thursday" +msgstr "Jueves" + +msgid "Friday" +msgstr "Viernes" + +msgid "Saturday" +msgstr "Sábado" + +msgid "Sunday" +msgstr "Domingo" + +msgid "January" +msgstr "Enero" + +msgid "February" +msgstr "Febrero" + +msgid "March" +msgstr "Marzo" + +msgid "April" +msgstr "Abril" + +msgid "May" +msgstr "Mayo" + +msgid "June" +msgstr "Junio" + +msgid "July" +msgstr "Julio" + +msgid "August" +msgstr "Agosto" + +msgid "September" +msgstr "Septiembre" + +msgid "October" +msgstr "Octubre" + +msgid "November" +msgstr "Noviembre" + +msgid "December" +msgstr "Diciembre" + +msgid "Average max." +msgstr "Máx. media" + +msgid "Average min." +msgstr "Mín. media" + +msgid "Total precipitation" +msgstr "Precipitación total" + +msgid "Current weather and forecast for Font del Bosc (Mediona)." +msgstr "El tiempo actual y la previsión para Font del Bosc (Mediona)." + +msgid "Title" +msgstr "Título" + +msgid "View" +msgstr "Vista" + +msgid "Today's forecast" +msgstr "Previsión de hoy" + +msgid "API Keys" +msgstr "Claves de API" + +msgid "Cache Settings" +msgstr "Ajustes de caché" + +msgid "Display Options" +msgstr "Opciones de visualización" + +msgid "Status" +msgstr "Estado" + +msgid "AEMET API key" +msgstr "Clave de API de AEMET" + +msgid "Meteocat API key" +msgstr "Clave de API de Meteocat" + +msgid "Current conditions cache (seconds)" +msgstr "Caché de condiciones actuales (segundos)" + +msgid "Forecast cache (seconds)" +msgstr "Caché de previsión (segundos)" + +msgid "Historical data cache (seconds)" +msgstr "Caché de datos históricos (segundos)" + +msgid "Show data source attribution" +msgstr "Mostrar la atribución de la fuente de datos" + +msgid "Uninstall" +msgstr "Desinstalación" + +msgid "Delete all plugin data on uninstall" +msgstr "Eliminar todos los datos del plugin al desinstalarlo" + +msgid "Test connection" +msgstr "Probar conexión" + +msgid "Connection successful." +msgstr "Conexión correcta." + +msgid "Connection failed." +msgstr "No se ha podido establecer la conexión." + +msgid "Find nearest station" +msgstr "Buscar la estación más cercana" + +msgid "Nearest station: %s" +msgstr "Estación más cercana: %s" + +msgid "No weather API keys are configured. Data is served by Open-Meteo (no key required)." +msgstr "No hay claves de API meteorológicas configuradas. Los datos los sirve Open-Meteo (no requiere clave)." + +msgid "Usage" +msgstr "Uso" + +msgid "Current conditions (default view)." +msgstr "Condiciones actuales (vista predeterminada)." + +msgid "Hourly forecast for the following hours. Attribute hours: 1-48 (24 by default)." +msgstr "Previsión horaria de las próximas horas. Atributo hours: 1-48 (24 por defecto)." + +msgid "Daily forecast. Attribute days: 1-7 (7 by default)." +msgstr "Previsión diaria. Atributo days: 1-7 (7 por defecto)." + +msgid "Historical data for one month. Attributes year and month; by default the last completed month." +msgstr "Datos históricos de un mes. Atributos year y month; por defecto, el último mes completado." + +msgid "In the block editor, add a Shortcode block and paste the code. This version ships no dedicated blocks; the shortcode works anywhere shortcodes are supported, including the Shortcode block and text widgets." +msgstr "En el editor de bloques, añade un bloque de Código corto y pega el código. Esta versión no incluye bloques propios; el shortcode funciona en cualquier lugar que admita códigos cortos, incluidos el bloque de Código corto y los widgets de texto." + +msgid "Current key ends in …%s" +msgstr "La clave actual termina en …%s" diff --git a/public/css/fdb-weather.css b/public/css/fdb-weather.css new file mode 100644 index 0000000..a039a2a --- /dev/null +++ b/public/css/fdb-weather.css @@ -0,0 +1,256 @@ +/** + * Font del Bosc — El Temps + * Stylesheet for the weather views. BEM naming, prefixed .fdb-. + */ + +.fdb-weather { + --fdb-bg: #ffffff; + --fdb-fg: #1c2430; + --fdb-muted: #5a6572; + --fdb-accent: #0b6bcb; + --fdb-warm: #d97706; + --fdb-cold: #0284c7; + --fdb-border: #e2e8f0; + --fdb-bar-max: #f59e0b; + --fdb-bar-min: #38bdf8; + + box-sizing: border-box; + max-width: 100%; + padding: 1rem; + border: 1px solid var(--fdb-border); + border-radius: 12px; + background: var(--fdb-bg); + color: var(--fdb-fg); + font-size: 0.95rem; + line-height: 1.4; +} + +.fdb-weather *, +.fdb-weather *::before, +.fdb-weather *::after { + box-sizing: border-box; +} + +@media (prefers-color-scheme: dark) { + .fdb-weather { + --fdb-bg: #141a22; + --fdb-fg: #e5eaf0; + --fdb-muted: #97a3b0; + --fdb-border: #2a3440; + } +} + +.fdb-weather__title { + margin: 0 0 0.75rem; + font-size: 1.05rem; + font-weight: 600; +} + +.fdb-icon { + display: block; + width: 48px; + height: 48px; +} + +.fdb-icon--lg { + width: 96px; + height: 96px; +} + +.fdb-attribution { + margin: 0.75rem 0 0; + font-size: 0.7rem; + color: var(--fdb-muted); +} + +.fdb-unavailable { + margin: 0; + color: var(--fdb-muted); +} + +/* Current conditions */ + +.fdb-current__main { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.fdb-current__temp { + font-size: 2.6rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.fdb-current__label { + margin: 0.25rem 0 0.5rem; + color: var(--fdb-muted); +} + +.fdb-current__meta { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin: 0; + padding: 0; + list-style: none; + color: var(--fdb-muted); +} + +/* Hourly forecast */ + +.fdb-hourly__scroll { + display: flex; + gap: 0.25rem; + overflow-x: auto; + padding-bottom: 0.5rem; + -webkit-overflow-scrolling: touch; +} + +.fdb-hourly__item { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: center; + gap: 0.25rem; + min-width: 64px; + padding: 0.5rem 0.25rem; + border-radius: 8px; + text-align: center; +} + +.fdb-hourly__time { + font-size: 0.75rem; + color: var(--fdb-muted); +} + +.fdb-hourly__temp { + font-size: 1.05rem; + font-weight: 700; +} + +.fdb-hourly__pop { + font-size: 0.75rem; + color: var(--fdb-cold); +} + +.fdb-hourly__wind { + font-size: 0.7rem; + color: var(--fdb-muted); +} + +.fdb-hourly__icon-placeholder { + display: block; + width: 48px; + height: 48px; +} + +/* Daily forecast */ + +.fdb-daily__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); + gap: 0.5rem; +} + +.fdb-daily__day { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.25rem; + border: 1px solid var(--fdb-border); + border-radius: 8px; + text-align: center; +} + +.fdb-daily__name { + font-size: 0.8rem; + font-weight: 600; +} + +.fdb-daily__date { + font-size: 0.7rem; + color: var(--fdb-muted); +} + +.fdb-daily__temps { + display: flex; + gap: 0.5rem; + margin: 0; +} + +.fdb-daily__max { + color: var(--fdb-warm); + font-weight: 700; +} + +.fdb-daily__min { + color: var(--fdb-cold); + font-weight: 700; +} + +.fdb-daily__pop, +.fdb-daily__wind { + margin: 0; + font-size: 0.75rem; + color: var(--fdb-muted); +} + +/* Historical chart */ + +.fdb-historical__stats { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.25rem; + margin: 0 0 1rem; + padding: 0; + list-style: none; + color: var(--fdb-muted); + font-size: 0.85rem; +} + +.fdb-historical__chart { + display: flex; + gap: 2px; + height: 180px; + align-items: flex-end; +} + +.fdb-historical__col { + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: 0; + height: 100%; +} + +.fdb-historical__bars { + display: flex; + gap: 1px; + align-items: flex-end; + height: calc(100% - 1.25rem); +} + +.fdb-historical__bar { + flex: 1 1 0; + min-height: 2px; + border-radius: 2px 2px 0 0; +} + +.fdb-historical__bar--max { + background: var(--fdb-bar-max); +} + +.fdb-historical__bar--min { + background: var(--fdb-bar-min); +} + +.fdb-historical__day { + height: 1.25rem; + font-size: 0.65rem; + color: var(--fdb-muted); + text-align: center; + overflow: hidden; + white-space: nowrap; +} diff --git a/public/icons/LICENSE-meteocons.txt b/public/icons/LICENSE-meteocons.txt new file mode 100644 index 0000000..1588fe9 --- /dev/null +++ b/public/icons/LICENSE-meteocons.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present Bas Milius + +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. diff --git a/public/icons/flat/clear-day.svg b/public/icons/flat/clear-day.svg new file mode 100644 index 0000000..9f914c1 --- /dev/null +++ b/public/icons/flat/clear-day.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/clear-night.svg b/public/icons/flat/clear-night.svg new file mode 100644 index 0000000..93c3d23 --- /dev/null +++ b/public/icons/flat/clear-night.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/cloudy.svg b/public/icons/flat/cloudy.svg new file mode 100644 index 0000000..512e9f6 --- /dev/null +++ b/public/icons/flat/cloudy.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/drizzle.svg b/public/icons/flat/drizzle.svg new file mode 100644 index 0000000..c70ee81 --- /dev/null +++ b/public/icons/flat/drizzle.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/extreme-rain.svg b/public/icons/flat/extreme-rain.svg new file mode 100644 index 0000000..ca7d200 --- /dev/null +++ b/public/icons/flat/extreme-rain.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/extreme-snow.svg b/public/icons/flat/extreme-snow.svg new file mode 100644 index 0000000..8d5b338 --- /dev/null +++ b/public/icons/flat/extreme-snow.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/fog-day.svg b/public/icons/flat/fog-day.svg new file mode 100644 index 0000000..3fa071e --- /dev/null +++ b/public/icons/flat/fog-day.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/fog-night.svg b/public/icons/flat/fog-night.svg new file mode 100644 index 0000000..b2edb3a --- /dev/null +++ b/public/icons/flat/fog-night.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/overcast-rain.svg b/public/icons/flat/overcast-rain.svg new file mode 100644 index 0000000..3c5c5bd --- /dev/null +++ b/public/icons/flat/overcast-rain.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/overcast.svg b/public/icons/flat/overcast.svg new file mode 100644 index 0000000..a9071ad --- /dev/null +++ b/public/icons/flat/overcast.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/partly-cloudy-day-rain.svg b/public/icons/flat/partly-cloudy-day-rain.svg new file mode 100644 index 0000000..97777ca --- /dev/null +++ b/public/icons/flat/partly-cloudy-day-rain.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/partly-cloudy-day.svg b/public/icons/flat/partly-cloudy-day.svg new file mode 100644 index 0000000..db7ca27 --- /dev/null +++ b/public/icons/flat/partly-cloudy-day.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/partly-cloudy-night-rain.svg b/public/icons/flat/partly-cloudy-night-rain.svg new file mode 100644 index 0000000..25b1cfe --- /dev/null +++ b/public/icons/flat/partly-cloudy-night-rain.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/partly-cloudy-night.svg b/public/icons/flat/partly-cloudy-night.svg new file mode 100644 index 0000000..ce6f523 --- /dev/null +++ b/public/icons/flat/partly-cloudy-night.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/rain.svg b/public/icons/flat/rain.svg new file mode 100644 index 0000000..c20c9f1 --- /dev/null +++ b/public/icons/flat/rain.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/snow.svg b/public/icons/flat/snow.svg new file mode 100644 index 0000000..2a715c6 --- /dev/null +++ b/public/icons/flat/snow.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/thunderstorms-day-hail.svg b/public/icons/flat/thunderstorms-day-hail.svg new file mode 100644 index 0000000..04adf83 --- /dev/null +++ b/public/icons/flat/thunderstorms-day-hail.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/thunderstorms-day-rain.svg b/public/icons/flat/thunderstorms-day-rain.svg new file mode 100644 index 0000000..cf65527 --- /dev/null +++ b/public/icons/flat/thunderstorms-day-rain.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/thunderstorms-night-hail.svg b/public/icons/flat/thunderstorms-night-hail.svg new file mode 100644 index 0000000..55934fd --- /dev/null +++ b/public/icons/flat/thunderstorms-night-hail.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/flat/thunderstorms-night-rain.svg b/public/icons/flat/thunderstorms-night-rain.svg new file mode 100644 index 0000000..ccb8122 --- /dev/null +++ b/public/icons/flat/thunderstorms-night-rain.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..b1a5e62 --- /dev/null +++ b/readme.txt @@ -0,0 +1,96 @@ +=== Font del Bosc — El Temps === +Contributors: robotstxt +Tags: weather, forecast, meteocat, aemet, open-meteo +Requires at least: 6.4 +Tested up to: 7.1 +Stable tag: 1.0.0 +Requires PHP: 8.2 +Version: 1.0.0 +License: GPL-3.0-or-later +License URI: https://www.gnu.org/licenses/gpl-3.0.txt + +Weather for Font del Bosc (Mediona, Alt Penedès): current conditions, hourly and daily forecast, and historical data. + +== Description == + +Displays the weather for the Font del Bosc urbanization (Mediona, Sant Joan de Mediona, Alt Penedès, Barcelona). The location is fixed to the Font del Bosc spring coordinates; there is no location picker. + +Data sources, with automatic fallback: + +* Meteocat (Servei Meteorològic de Catalunya): hourly municipal forecast and XEMA station observations. Requires a free API key. +* AEMET OpenData: daily municipal forecast. Requires a free API key. +* Open-Meteo: current conditions, forecast and historical data (ERA5). No API key required; used automatically when no other key is configured. + +All requests are cached through the WordPress Transients API, with a stale-while-error policy so cached data is served when a source temporarily fails. + +Icons by Meteocons (MIT license), bundled with the plugin. + +== Using the plugin == + += Shortcode = + +`[fontdelbosc_eltemps]` renders the current conditions. Attributes: + +* `view`: `current` (default), `hourly`, `daily` or `historical`. +* `hours`: hours to show in the hourly view (1-48, default 24). +* `days`: days to show in the daily view (1-7, default 7). +* `year` / `month`: month shown in the historical view (defaults to the last completed month). + +Examples: + +`[fontdelbosc_eltemps view="hourly" hours="24"]` +`[fontdelbosc_eltemps view="daily" days="5"]` +`[fontdelbosc_eltemps view="historical" year="2024" month="6"]` + += Widget = + +A sidebar widget with an editable title offers the current conditions or today's forecast. + += Settings = + +Settings → Font del Bosc Temps: AEMET and Meteocat API keys, cache TTLs, attribution toggle, data deletion on uninstall, and connection tests. When a Meteocat key is set, the nearest XEMA station to Font del Bosc can be detected automatically. + +== Installation == + += Automatic download = + +Visit the plugin section in your WordPress, search for "Font del Bosc — El Temps"; download and install the plugin. + += Manual download = + +Extract the contents of the ZIP and upload the contents to the `/wp-content/plugins/fontdelbosc-eltemps/` directory. Once uploaded, it will appear in your plugin list. + +== Frequently Asked Questions == + += Do I need API keys? = + +No. Without keys the plugin uses Open-Meteo for everything. Adding an AEMET key improves the daily forecast, and adding a Meteocat key improves the hourly forecast and enables station observations. + += Where do the historical data come from? = + +The Open-Meteo ERA5 archive (from 1940), which is available without a key. + +== Compatibility == + +* WordPress: 6.4 - 7.1 +* PHP: 8.2 - 8.5 + +== Changelog == + += 1.0.0 = + +* Initial release: current conditions, hourly forecast, daily forecast, historical month view, widget, shortcode, caching and source fallback. + += Previous versions = + +If you want to see the full changelog, visit the [changelog.txt](https://github.com/robotstxt/fontdelbosc-eltemps/raw/main/changelog.txt) file. + +== Compliance == + +This plugin adheres to the following security measures and review protocols for each version: + +* [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/) +* [WordPress Plugin Security](https://developer.wordpress.org/plugins/wordpress-org/plugin-security/) +* [WordPress APIs Security](https://developer.wordpress.org/apis/security/) +* [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards) +* [Plugin Check (PCP)](https://wordpress.org/plugins/plugin-check/) diff --git a/templates/current.php b/templates/current.php new file mode 100644 index 0000000..2b2174a --- /dev/null +++ b/templates/current.php @@ -0,0 +1,47 @@ + $data Normalized current conditions. + * + * @package FontdelboscEltemps + */ + +defined( 'ABSPATH' ) || exit; + +$fontdelbosc_night = Fontdelbosc_Conditions::is_night( (int) wp_date( 'G', (int) ( $data['time'] ?? time() ) ) ); +$fontdelbosc_show_attribution = ! empty( Fontdelbosc_Plugin::get_options()['display_attribution'] ); +?> +
+
+ + + + + °C + +
+ + +

+ + +
    + +
  • : °C
  • + + +
  • : %
  • + + +
  • : km/h
  • + + +
  • : mm
  • + +
+ + +

+ +
diff --git a/templates/forecast-daily.php b/templates/forecast-daily.php new file mode 100644 index 0000000..9266979 --- /dev/null +++ b/templates/forecast-daily.php @@ -0,0 +1,68 @@ + $data Normalized daily forecast. + * + * @package FontdelboscEltemps + */ + +defined( 'ABSPATH' ) || exit; + +$fontdelbosc_show_attribution = ! empty( Fontdelbosc_Plugin::get_options()['display_attribution'] ); +?> +
+

+ +
+ $fontdelbosc_day ) : ?> + getTimestamp() : time(); + $fontdelbosc_weekday = (int) wp_date( 'N', $fontdelbosc_timestamp ); + + if ( 0 === $fontdelbosc_index ) { + $fontdelbosc_label = __( 'Today', 'fontdelbosc-eltemps' ); + } elseif ( 1 === $fontdelbosc_index ) { + $fontdelbosc_label = __( 'Tomorrow', 'fontdelbosc-eltemps' ); + } else { + $fontdelbosc_label = Fontdelbosc_Conditions::weekday( $fontdelbosc_weekday ); + } + ?> +
+ + + + + + +

+ + ° + + + ° + +

+ + +

%

+ +

mm

+ + +

km/h

+ +
+ +
+ + +

+ +
diff --git a/templates/forecast-hourly.php b/templates/forecast-hourly.php new file mode 100644 index 0000000..24b9489 --- /dev/null +++ b/templates/forecast-hourly.php @@ -0,0 +1,43 @@ + $data Normalized hourly forecast. + * + * @package FontdelboscEltemps + */ + +defined( 'ABSPATH' ) || exit; + +$fontdelbosc_show_attribution = ! empty( Fontdelbosc_Plugin::get_options()['display_attribution'] ); +?> +
+

+ +
+ + +
+ + + + + + + + ° + + + % + + + km/h + +
+ +
+ + +

+ +
diff --git a/templates/historical.php b/templates/historical.php new file mode 100644 index 0000000..afdc0d8 --- /dev/null +++ b/templates/historical.php @@ -0,0 +1,86 @@ + $data Normalized monthly history. + * + * @package FontdelboscEltemps + */ + +defined( 'ABSPATH' ) || exit; + +$fontdelbosc_show_attribution = ! empty( Fontdelbosc_Plugin::get_options()['display_attribution'] ); +$fontdelbosc_maxes = array(); +$fontdelbosc_mins = array(); + +foreach ( $data['days'] as $fontdelbosc_day ) { + if ( is_numeric( $fontdelbosc_day['temp_max'] ?? null ) ) { + $fontdelbosc_maxes[] = (float) $fontdelbosc_day['temp_max']; + } + + if ( is_numeric( $fontdelbosc_day['temp_min'] ?? null ) ) { + $fontdelbosc_mins[] = (float) $fontdelbosc_day['temp_min']; + } +} + +$fontdelbosc_scale_min = array() === $fontdelbosc_mins ? 0 : floor( min( $fontdelbosc_mins ) ); +$fontdelbosc_scale_max = array() === $fontdelbosc_maxes ? 1 : ceil( max( $fontdelbosc_maxes ) ); + +if ( $fontdelbosc_scale_max <= $fontdelbosc_scale_min ) { + $fontdelbosc_scale_max = $fontdelbosc_scale_min + 1; +} + +$fontdelbosc_range = $fontdelbosc_scale_max - $fontdelbosc_scale_min; +?> +
+

—

+ +
    + +
  • °C
  • + + +
  • °C
  • + +
  • mm
  • +
+ + + + +

+ +
diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..a885e3c --- /dev/null +++ b/uninstall.php @@ -0,0 +1,18 @@ +