├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PERFORMANCE.md ├── README.md ├── benchmark-apcu.php ├── composer.json ├── phpunit.xml.dist ├── psalm-baseline.xml ├── psalm.xml ├── src ├── Cache │ ├── ApcuCache.php │ ├── ApcuCacheFactory.php │ ├── CacheFactoryInterface.php │ ├── CacheInterface.php │ ├── GetAllInterface.php │ └── InMemoryCache.php ├── CacheException.php ├── Loader.php ├── MoParser.php ├── ReaderException.php ├── StringReader.php ├── Translator.php └── functions.php └── tests ├── Cache ├── ApcuCacheFactoryTest.php ├── ApcuCacheTest.php ├── ApcuDisabledTest.php └── InMemoryCacheTest.php ├── FunctionsTest.php ├── LoaderTest.php ├── MoFilesTest.php ├── PluralFormulaTest.php ├── PluralTest.php ├── StringReaderTest.php ├── TranslatorTest.php └── data ├── big.mo ├── error ├── big.mo ├── dos.mo ├── empty.mo ├── fpd.mo ├── fpdle.mo └── magic.mo ├── invalid-formula.mo ├── lessplurals.mo ├── little.mo ├── locale ├── be │ └── LC_MESSAGES │ │ └── phpmyadmin.mo ├── be@latin │ └── LC_MESSAGES │ │ └── phpmyadmin.mo └── cs │ └── LC_MESSAGES │ └── phpmyadmin.mo ├── noheader.mo ├── not-translated ├── fpd1.mo └── invalid-equation.mo └── plurals.mo /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [5.4.0] - 2025-03-16 4 | 5 | * Drop support for PHP 7.2, PHP 7.3, PHP 7.4, PHP 8.0 and PHP 8.1. 6 | 7 | ## [Unreleased] 8 | 9 | * Bump PHP minimum version to 7.2 10 | * Add support for Symfony 7 11 | 12 | ## [5.3.1] - 2023-08-23 13 | 14 | * Add function guards to the global functions (#44) 15 | 16 | ## [5.3.0] - 2022-04-26 17 | 18 | * Add support for Symfony 6 19 | * Split out `.mo` parsing to separate `MoParser` class 20 | * Added `CacheInterface` so alternate cache implementations are pluggable 21 | * Added `ApcuCache` implementation to leverage shared in-memory translation cache 22 | 23 | ## [5.2.0] - 2021-02-05 24 | 25 | * Fix "Translator::selectString() must be of the type integer, boolean returned" (#37) 26 | * Fix "TypeError: StringReader::readintarray() ($count) must be of type int, float given" failing tests on ARM (#36) 27 | * Add support for getting and setting all translations (#30) 28 | 29 | ## [5.1.0] - 2020-11-15 30 | 31 | * Allow PHPUnit 9 (#35) 32 | * Fix some typos 33 | * Sync config files 34 | * Allow PHP 8.0 35 | 36 | ## [5.0.0] - 2020-02-28 37 | 38 | * Drop support for PHP 5.3, PHP 5.4, PHP 5.5, PHP 5.6, PHP 7.0 and HHVM 39 | * Enabled strict mode on PHP files 40 | * Add support for Symfony 5 (#34) 41 | * Add support for phpunit 8 42 | * Rename CHANGES.md to CHANGELOG.md and follow a standard format 43 | 44 | ## [4.0] - 2018-02-12 45 | 46 | * The library no longer changes system locales. 47 | 48 | ## [3.4] - 2017-12-15 49 | 50 | * Added Translator::setTranslation method. 51 | 52 | ## [3.3] - 2017-06-01 53 | 54 | * Add support for switching locales for Loader instance. 55 | 56 | ## [3.2] - 2017-05-23 57 | 58 | * Various fixes when handling corrupted mo files. 59 | 60 | ## [3.1] - 2017-05-15 61 | 62 | * Documentation improvements. 63 | 64 | ## [3.0] - 2017-01-23 65 | 66 | * All classes moved to the PhpMyAdmin namespace. 67 | 68 | ## [2.2] - 2017-01-07 69 | 70 | * Coding style cleanup. 71 | * Avoid installing tests using composer. 72 | 73 | ## [2.1] - 2016-12-21 74 | 75 | * Various code cleanups. 76 | * Added support for PHP 5.3. 77 | 78 | ## [2.0] - 2016-10-13 79 | 80 | * Consistently use camelCase in API. 81 | * No more relies on using eval(). 82 | * Depends on symfony/expression-language for calculations. 83 | 84 | ## [1.2] - 2016-09-22 85 | 86 | * Stricter validation of plural expression. 87 | 88 | ## [1.1] - 2016-08-29 89 | 90 | * Improved handling of corrupted mo files. 91 | * Minor performance improvements. 92 | * Stricter validation of plural expression. 93 | 94 | ## [1.0] - 2016-04-27 95 | 96 | * Documentation improvements. 97 | * Testsuite improvements. 98 | 99 | ## [0.4] - 2016-03-02 100 | 101 | * Fixed test failures with hhvm due to broken putenv. 102 | 103 | ## [0.3] - 2016-03-01 104 | 105 | * Added Loader::detectlocale method. 106 | 107 | ## [0.2] - 2016-02-24 108 | 109 | * Marked PHP 5.4 and 5.5 as supported. 110 | 111 | ## [0.1] - 2016-02-23 112 | 113 | * Initial release. 114 | 115 | [5.4.0]: https://github.com/phpmyadmin/motranslator/compare/5.3.1...5.4.0 116 | [5.3.1]: https://github.com/phpmyadmin/motranslator/compare/5.3.0...5.3.1 117 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at info@phpmyadmin.net. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to motranslator 2 | 3 | ## Reporting issues 4 | 5 | Our issue tracker is hosted at GitHub: 6 | 7 | https://github.com/phpmyadmin/motranslator/issues 8 | 9 | Please search for existing issues before reporting new ones. 10 | 11 | ## Working with Git checkout 12 | 13 | The dependencies are managed by Composer, to get them all installed (or update 14 | on consequent runs) do: 15 | 16 | ``` 17 | composer update 18 | ``` 19 | 20 | ## Submitting patches 21 | 22 | Please submit your patches using GitHub pull requests, this allows us to review 23 | them and to run automated tests on the code. 24 | 25 | ## Coding standards 26 | 27 | We do follow PSR-1 and PSR-2 coding standards. 28 | 29 | You can use phpcbf to fix the code to match our expectations: 30 | 31 | ``` 32 | composer run phpcbf 33 | ``` 34 | 35 | ## Testsuite 36 | 37 | Our code comes with quite comprehensive testsuite, it is automatically executed 38 | on every commit and pull request, you can also run it locally: 39 | 40 | ``` 41 | composer run phpunit 42 | ``` 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /PERFORMANCE.md: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | This library was tweaked for best performance for single use - translating 4 | application with many strings using mo file. Current benchmarks show it's about 5 | four times faster than original php-gettext. 6 | 7 | There are two benchmark scripts in the code: 8 | 9 | * ``benchmark-context.php`` - benchmarks context usage 10 | * ``benchmark-plural.php`` - benchmarks plural evaluation 11 | * ``benchmark.php`` - benchmarks file parsing 12 | * ``benchmark-apcu.php`` - benchmarks file parsing with APCu cache enabled 13 | 14 | ## Performance measurements 15 | 16 | The performance improvements based on individual changes in the code: 17 | 18 | | Stage | Seconds | 19 | | -------------- | --------------- | 20 | | Original code | 4.7929680347443 | 21 | | Remove nocache | 4.6308250427246 | 22 | | Direct endian | 4.5883052349091 | 23 | | Remove attribs | 4.5297479629517 | 24 | | String reader | 1.8148958683014 | 25 | | No offset | 1.2436759471893 | 26 | | Less attribs | 1.1722540855408 | 27 | | Remove shift | 1.0970499515533 | 28 | | Magic order | 1.0868430137634 | 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # motranslator 2 | 3 | Translation API for PHP using Gettext MO files. 4 | 5 | [![Test-suite](https://github.com/phpmyadmin/motranslator/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/phpmyadmin/motranslator/actions/workflows/tests.yml?query=branch%3Amaster) 6 | [![codecov.io](https://codecov.io/github/phpmyadmin/motranslator/coverage.svg?branch=master)](https://codecov.io/github/phpmyadmin/motranslator?branch=master) 7 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/phpmyadmin/motranslator/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/phpmyadmin/motranslator/?branch=master) 8 | [![Packagist](https://img.shields.io/packagist/dt/phpmyadmin/motranslator.svg)](https://packagist.org/packages/phpmyadmin/motranslator) 9 | 10 | ## Features 11 | 12 | * All strings are stored in memory for fast lookup 13 | * Fast loading of MO files 14 | * Low level API for reading MO files 15 | * Emulation of Gettext API 16 | * No use of `eval()` for plural equation 17 | 18 | ## Limitations 19 | 20 | * Default `InMemoryCache` not suitable for huge MO files which you don't want to store in memory 21 | * Input and output encoding has to match (preferably UTF-8) 22 | 23 | ## Installation 24 | 25 | Please use [Composer][1] to install: 26 | 27 | ```sh 28 | composer require phpmyadmin/motranslator 29 | ``` 30 | 31 | ## Documentation 32 | 33 | The API documentation is available at . 34 | 35 | ## Object API usage 36 | 37 | ```php 38 | // Create loader object 39 | $loader = new PhpMyAdmin\MoTranslator\Loader(); 40 | 41 | // Set locale 42 | $loader->setlocale('cs'); 43 | 44 | // Set default text domain 45 | $loader->textdomain('domain'); 46 | 47 | // Set path where to look for a domain 48 | $loader->bindtextdomain('domain', __DIR__ . '/data/locale/'); 49 | 50 | // Get translator 51 | $translator = $loader->getTranslator(); 52 | 53 | // Now you can use Translator API (see below) 54 | ``` 55 | 56 | ## Low level API usage 57 | 58 | ```php 59 | // Directly load the mo file 60 | // You can use null to not load a file and the use a setter to set the translations 61 | $cache = new PhpMyAdmin\MoTranslator\Cache\InMemoryCache(new PhpMyAdmin\MoTranslator\MoParser('./path/to/file.mo')); 62 | $translator = new PhpMyAdmin\MoTranslator\Translator($cache); 63 | 64 | // Now you can use Translator API (see below) 65 | ``` 66 | 67 | ## Translator API usage 68 | 69 | ```php 70 | // Translate string 71 | echo $translator->gettext('String'); 72 | 73 | // Translate plural string 74 | echo $translator->ngettext('String', 'Plural string', $count); 75 | 76 | // Translate string with context 77 | echo $translator->pgettext('Context', 'String'); 78 | 79 | // Translate plural string with context 80 | echo $translator->npgettext('Context', 'String', 'Plural string', $count); 81 | 82 | // Get the translations 83 | echo $translator->getTranslations(); 84 | 85 | // All getters and setters below are more to be used if you are using a manual loading mode 86 | // Example: $translator = new PhpMyAdmin\MoTranslator\Translator(null); 87 | 88 | // Set a translation 89 | echo $translator->setTranslation('Test', 'Translation for "Test" key'); 90 | 91 | // Set translations 92 | echo $translator->setTranslations([ 93 | 'Test' => 'Translation for "Test" key', 94 | 'Test 2' => 'Translation for "Test 2" key', 95 | ]); 96 | 97 | // Use the translation 98 | echo $translator->gettext('Test 2'); // -> Translation for "Test 2" key 99 | ``` 100 | 101 | ## Gettext compatibility usage 102 | 103 | ```php 104 | // Load compatibility layer 105 | PhpMyAdmin\MoTranslator\Loader::loadFunctions(); 106 | 107 | // Configure 108 | _setlocale(LC_MESSAGES, 'cs'); 109 | _textdomain('phpmyadmin'); 110 | _bindtextdomain('phpmyadmin', __DIR__ . '/data/locale/'); 111 | _bind_textdomain_codeset('phpmyadmin', 'UTF-8'); 112 | 113 | // Use functions 114 | echo _gettext('Type'); 115 | echo __('Type'); 116 | 117 | // It also support other Gettext functions 118 | _dnpgettext($domain, $msgctxt, $msgid, $msgidPlural, $number); 119 | _dngettext($domain, $msgid, $msgidPlural, $number); 120 | _npgettext($msgctxt, $msgid, $msgidPlural, $number); 121 | _ngettext($msgid, $msgidPlural, $number); 122 | _dpgettext($domain, $msgctxt, $msgid); 123 | _dgettext($domain, $msgid); 124 | _pgettext($msgctxt, $msgid); 125 | ``` 126 | 127 | ## Using APCu-backed cache 128 | 129 | If you have the [APCu][5] extension installed you can use it for storing the translation cache. The `.mo` file 130 | will then only be loaded once and all processes will share the same cache, reducing memory usage and resulting in 131 | performance comparable to the native `gettext` extension. 132 | 133 | If you are using `Loader`, pass it an `ApcuCacheFactory` _before_ getting the translator instance: 134 | 135 | ```php 136 | PhpMyAdmin\MoTranslator\Loader::setCacheFactory( 137 | new PhpMyAdmin\MoTranslator\Cache\ApcuCacheFactory() 138 | ); 139 | $loader = new PhpMyAdmin\MoTranslator\Loader(); 140 | 141 | // Proceed as before 142 | ``` 143 | 144 | If you are using the low level API, instantiate the `ApcuCache` directly: 145 | 146 | ```php 147 | $cache = new PhpMyAdmin\MoTranslator\Cache\ApcuCache( 148 | new PhpMyAdmin\MoTranslator\MoParser('./path/to/file.mo'), 149 | 'de_DE', // the locale 150 | 'phpmyadmin' // the domain 151 | ); 152 | $translator = new PhpMyAdmin\MoTranslator\Translator($cache); 153 | 154 | // Proceed as before 155 | ``` 156 | 157 | By default, APCu will cache the translations until next server restart and prefix the cache entries with `mo_` to 158 | avoid clashes with other cache entries. You can control this behaviour by passing `$ttl` and `$prefix` arguments, either 159 | to the `ApcuCacheFactory` or when instantiating `ApcuCache`: 160 | 161 | ```php 162 | PhpMyAdmin\MoTranslator\Loader::setCacheFactory( 163 | new PhpMyAdmin\MoTranslator\Cache\ApcuCacheFactory( 164 | 3600, // cache for 1 hour 165 | true, // reload on cache miss 166 | 'custom_' // custom prefix for cache entries 167 | ) 168 | ); 169 | $loader = new PhpMyAdmin\MoTranslator\Loader(); 170 | 171 | // or... 172 | 173 | $cache = new PhpMyAdmin\MoTranslator\Cache\ApcuCache( 174 | new PhpMyAdmin\MoTranslator\MoParser('./path/to/file.mo'), 175 | 'de_DE', 176 | 'phpmyadmin', 177 | 3600, // cache for 1 hour 178 | true, // reload on cache miss 179 | 'custom_' // custom prefix for cache entries 180 | ); 181 | $translator = new PhpMyAdmin\MoTranslator\Translator($cache); 182 | ``` 183 | 184 | If you receive updated translation files you can load them without restarting the server using the low-level API: 185 | 186 | ```php 187 | $parser = new PhpMyAdmin\MoTranslator\MoParser('./path/to/file.mo'); 188 | $cache = new PhpMyAdmin\MoTranslator\Cache\ApcuCache($parser, 'de_DE', 'phpmyadmin'); 189 | $parser->parseIntoCache($cache); 190 | ``` 191 | 192 | You should ensure APCu has enough memory to store all your translations, along with any other entries you use it 193 | for. If an entry is evicted from cache, the `.mo` file will be re-parsed, impacting performance. See the 194 | `apc.shm_size` and `apc.shm_segments` [documentation][6] and monitor cache usage when first rolling out. 195 | 196 | If your `.mo` files are missing lots of translations, the first time a missing entry is requested the `.mo` file 197 | will be re-parsed. Again, this will impact performance until all the missing entries are hit once. You can turn off this 198 | behaviour by setting the `$reloadOnMiss` argument to `false`. If you do this it is _critical_ that APCu has enough 199 | memory, or users will see untranslated text when entries are evicted. 200 | 201 | ## History 202 | 203 | This library is based on [php-gettext][2]. It adds some performance 204 | improvements and ability to install using [Composer][1]. 205 | 206 | ## Motivation 207 | 208 | Motivation for this library includes: 209 | 210 | * The [php-gettext][2] library is not maintained anymore 211 | * It doesn't work with recent PHP version (phpMyAdmin has patched version) 212 | * It relies on `eval()` function for plural equations what can have severe security implications, see [CVE-2016-6175][4] 213 | * It's not possible to install it using [Composer][1] 214 | * There was place for performance improvements in the library 215 | 216 | ### Why not to use native gettext in PHP? 217 | 218 | We've tried that, but it's not a viable solution: 219 | 220 | * You can not use locales not known to system, what is something you can not 221 | control from web application. This gets even more tricky with minimalist 222 | virtualisation containers. 223 | * Changing the MO file usually leads to PHP segmentation fault. It (or rather 224 | Gettext library) caches headers of MO file and if it's content is changed 225 | (for example new version is uploaded to server) it tries to access new data 226 | with old references. This is bug known for ages: 227 | https://bugs.php.net/bug.php?id=45943 228 | 229 | ### Why use Gettext and not JSON, YAML or whatever? 230 | 231 | We want translators to be able to use their favorite tools and we want us to be 232 | able to use wide range of tools available with Gettext as well such as 233 | [web based translation using Weblate][3]. Using custom format usually adds 234 | another barrier for translators and we want to make it easy for them to 235 | contribute. 236 | 237 | [1]:https://getcomposer.org/ 238 | [2]:https://launchpad.net/php-gettext 239 | [3]:https://weblate.org/ 240 | [4]: https://www.cve.org/CVERecord?id=CVE-2016-6175 241 | [5]:https://www.php.net/manual/en/book.apcu.php 242 | [6]:https://www.php.net/manual/en/apcu.configuration.php 243 | -------------------------------------------------------------------------------- /benchmark-apcu.php: -------------------------------------------------------------------------------- 1 | './tests/data/big.mo', 13 | 'little' => './tests/data/little.mo', 14 | ]; 15 | 16 | $start = microtime(true); 17 | 18 | for ($i = 0; $i < 2000; ++$i) { 19 | foreach ($files as $domain => $filename) { 20 | $translator = new Translator(new ApcuCache(new MoParser($filename), 'foo', $domain)); 21 | $translator->gettext('Column'); 22 | } 23 | } 24 | 25 | $end = microtime(true); 26 | 27 | $diff = $end - $start; 28 | 29 | echo 'Execution took ' . $diff . ' seconds' . "\n"; 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpmyadmin/motranslator", 3 | "description": "Translation API for PHP using Gettext MO files", 4 | "license": "GPL-2.0-or-later", 5 | "keywords": ["gettext", "mo", "translator", "i18n"], 6 | "homepage": "https://github.com/phpmyadmin/motranslator", 7 | "authors": [ 8 | { 9 | "name": "The phpMyAdmin Team", 10 | "email": "developers@phpmyadmin.net", 11 | "homepage": "https://www.phpmyadmin.net/team/" 12 | } 13 | ], 14 | "support": { 15 | "issues": "https://github.com/phpmyadmin/motranslator/issues", 16 | "source": "https://github.com/phpmyadmin/motranslator" 17 | }, 18 | "scripts": { 19 | "phpcbf": "@php phpcbf", 20 | "phpcs": "@php phpcs", 21 | "phpstan": "@php phpstan", 22 | "phpunit": "@php phpunit", 23 | "test": [ 24 | "@phpcs", 25 | "@phpstan", 26 | "@phpunit" 27 | ] 28 | }, 29 | "require": { 30 | "php": "^8.2", 31 | "symfony/expression-language": "^6.4 || ^7.0" 32 | }, 33 | "require-dev": { 34 | "phpmyadmin/coding-standard": "^4.0", 35 | "phpstan/extension-installer": "^1.4", 36 | "phpstan/phpstan": "^2.1", 37 | "phpstan/phpstan-deprecation-rules": "^2.0", 38 | "phpstan/phpstan-phpunit": "^2.0", 39 | "phpstan/phpstan-strict-rules": "^2.0", 40 | "phpunit/phpunit": "^11.5 || ^12.0", 41 | "psalm/plugin-phpunit": "^0.19.2", 42 | "vimeo/psalm": "^6.8" 43 | }, 44 | "suggest": { 45 | "ext-apcu": "Needed for APCu-backed translation cache" 46 | }, 47 | "autoload": { 48 | "psr-4": { 49 | "PhpMyAdmin\\MoTranslator\\": "src" 50 | }, 51 | "files": ["src/functions.php"] 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "PhpMyAdmin\\MoTranslator\\Tests\\": "tests" 56 | } 57 | }, 58 | "config": { 59 | "allow-plugins": { 60 | "dealerdirect/phpcodesniffer-composer-installer": true, 61 | "phpstan/extension-installer": true 62 | }, 63 | "discard-changes": true, 64 | "sort-packages": true 65 | }, 66 | "extra": { 67 | "branch-alias": { 68 | "dev-master": "6.0-dev" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | src/ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | pluralCount]]> 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Cache/ApcuCache.php: -------------------------------------------------------------------------------- 1 | ensureTranslationsLoaded(); 40 | } 41 | 42 | public function get(string $msgid): string 43 | { 44 | $msgstr = apcu_fetch($this->getKey($msgid), $success); 45 | if ($success && is_string($msgstr)) { 46 | return $msgstr; 47 | } 48 | 49 | if (! $this->reloadOnMiss) { 50 | return $msgid; 51 | } 52 | 53 | return $this->reloadOnMiss($msgid); 54 | } 55 | 56 | private function reloadOnMiss(string $msgid): string 57 | { 58 | // store original if translation is not present 59 | $cached = apcu_entry($this->getKey($msgid), static function () use ($msgid) { 60 | return $msgid; 61 | }, $this->ttl); 62 | // if another process has updated cache, return early 63 | if ($cached !== $msgid && is_string($cached)) { 64 | return $cached; 65 | } 66 | 67 | // reload .mo file, in case entry has been evicted 68 | $this->parser->parseIntoCache($this); 69 | 70 | $msgstr = apcu_fetch($this->getKey($msgid), $success); 71 | 72 | return $success && is_string($msgstr) ? $msgstr : $msgid; 73 | } 74 | 75 | public function set(string $msgid, string $msgstr): void 76 | { 77 | apcu_store($this->getKey($msgid), $msgstr, $this->ttl); 78 | } 79 | 80 | public function has(string $msgid): bool 81 | { 82 | return apcu_exists($this->getKey($msgid)); 83 | } 84 | 85 | /** @inheritDoc */ 86 | public function setAll(array $translations): void 87 | { 88 | $keys = array_map(function (string $msgid): string { 89 | return $this->getKey($msgid); 90 | }, array_keys($translations)); 91 | $translations = array_combine($keys, $translations); 92 | assert(is_array($translations)); 93 | 94 | apcu_store($translations, null, $this->ttl); 95 | } 96 | 97 | private function getKey(string $msgid): string 98 | { 99 | return $this->prefix . $this->locale . '.' . $this->domain . '.' . $msgid; 100 | } 101 | 102 | private function ensureTranslationsLoaded(): void 103 | { 104 | // Try to prevent cache slam if multiple processes are trying to load translations. There is still a race 105 | // between the exists check and creating the entry, but at least it's small 106 | $key = $this->getKey(self::LOADED_KEY); 107 | $loaded = apcu_exists($key) || apcu_entry($key, static function (): int { 108 | return 0; 109 | }, $this->ttl); 110 | if ($loaded) { 111 | return; 112 | } 113 | 114 | $this->parser->parseIntoCache($this); 115 | apcu_store($this->getKey(self::LOADED_KEY), 1, $this->ttl); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Cache/ApcuCacheFactory.php: -------------------------------------------------------------------------------- 1 | ttl, $this->reloadOnMiss, $this->prefix); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Cache/CacheFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $msgstr` entries 26 | * 27 | * This will overwrite existing values for `$msgid`, but is not guaranteed to clear cache of existing entries 28 | * not present in `$translations`. 29 | * 30 | * @param array $translations 31 | */ 32 | public function setAll(array $translations): void; 33 | } 34 | -------------------------------------------------------------------------------- /src/Cache/GetAllInterface.php: -------------------------------------------------------------------------------- 1 | */ 10 | public function getAll(): array; 11 | } 12 | -------------------------------------------------------------------------------- /src/Cache/InMemoryCache.php: -------------------------------------------------------------------------------- 1 | */ 14 | private array $cache = []; 15 | 16 | public function __construct(MoParser $parser) 17 | { 18 | $parser->parseIntoCache($this); 19 | } 20 | 21 | public function get(string $msgid): string 22 | { 23 | return array_key_exists($msgid, $this->cache) ? $this->cache[$msgid] : $msgid; 24 | } 25 | 26 | public function set(string $msgid, string $msgstr): void 27 | { 28 | $this->cache[$msgid] = $msgstr; 29 | } 30 | 31 | public function has(string $msgid): bool 32 | { 33 | return array_key_exists($msgid, $this->cache); 34 | } 35 | 36 | /** @inheritDoc */ 37 | public function setAll(array $translations): void 38 | { 39 | $this->cache = $translations; 40 | } 41 | 42 | /** @inheritDoc */ 43 | public function getAll(): array 44 | { 45 | return $this->cache; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/CacheException.php: -------------------------------------------------------------------------------- 1 | 7 | Copyright (c) 2009 Danilo Segan 8 | Copyright (c) 2016 Michal Čihař 9 | 10 | This file is part of MoTranslator. 11 | 12 | This program is free software; you can redistribute it and/or modify 13 | it under the terms of the GNU General Public License as published by 14 | the Free Software Foundation; either version 2 of the License, or 15 | (at your option) any later version. 16 | 17 | This program is distributed in the hope that it will be useful, 18 | but WITHOUT ANY WARRANTY; without even the implied warranty of 19 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20 | GNU General Public License for more details. 21 | 22 | You should have received a copy of the GNU General Public License along 23 | with this program; if not, write to the Free Software Foundation, Inc., 24 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 25 | */ 26 | 27 | namespace PhpMyAdmin\MoTranslator; 28 | 29 | use PhpMyAdmin\MoTranslator\Cache\CacheFactoryInterface; 30 | use PhpMyAdmin\MoTranslator\Cache\InMemoryCache; 31 | 32 | use function file_exists; 33 | use function getenv; 34 | use function in_array; 35 | use function preg_match; 36 | use function sprintf; 37 | 38 | class Loader 39 | { 40 | /** 41 | * Loader instance. 42 | * 43 | * @static 44 | */ 45 | private static Loader|null $instance = null; 46 | 47 | /** 48 | * Factory to return a factory responsible for returning a `CacheInterface` 49 | * 50 | * @static 51 | */ 52 | private static CacheFactoryInterface|null $cacheFactory = null; 53 | 54 | /** 55 | * Default gettext domain to use. 56 | */ 57 | private string $defaultDomain = ''; 58 | 59 | /** 60 | * Configured locale. 61 | */ 62 | private string $locale = ''; 63 | 64 | /** 65 | * Loaded domains. 66 | * 67 | * @var array> 68 | */ 69 | private array $domains = []; 70 | 71 | /** 72 | * Bound paths for domains. 73 | * 74 | * @var array 75 | */ 76 | private array $paths = ['' => './']; 77 | 78 | /** 79 | * Returns the singleton Loader object. 80 | * 81 | * @return Loader object 82 | */ 83 | public static function getInstance(): Loader 84 | { 85 | if (self::$instance === null) { 86 | self::$instance = new self(); 87 | } 88 | 89 | return self::$instance; 90 | } 91 | 92 | /** 93 | * Loads global localization functions. 94 | */ 95 | public static function loadFunctions(): void 96 | { 97 | require_once __DIR__ . '/functions.php'; 98 | } 99 | 100 | /** 101 | * Figure out all possible locale names and start with the most 102 | * specific ones. I.e. for sr_CS.UTF-8@latin, look through all of 103 | * sr_CS.UTF-8@latin, sr_CS@latin, sr@latin, sr_CS.UTF-8, sr_CS, sr. 104 | * 105 | * @param string $locale Locale code 106 | * 107 | * @return string[] list of locales to try for any POSIX-style locale specification 108 | * @psalm-return list 109 | */ 110 | public static function listLocales(string $locale): array 111 | { 112 | $localeNames = []; 113 | 114 | if ($locale !== '') { 115 | if ( 116 | preg_match( 117 | '/^(?P[a-z]{2,3})' // language code 118 | . '(?:_(?P[A-Z]{2}))?' // country code 119 | . '(?:\\.(?P[-A-Za-z0-9_]+))?' // charset 120 | . '(?:@(?P[-A-Za-z0-9_]+))?$/', // @ modifier 121 | $locale, 122 | $matches, 123 | ) === 1 124 | ) { 125 | $lang = $matches['lang'] ?? ''; 126 | $country = $matches['country'] ?? ''; 127 | $charset = $matches['charset'] ?? ''; 128 | $modifier = $matches['modifier'] ?? ''; 129 | 130 | if ($modifier !== '') { 131 | if ($country !== '') { 132 | if ($charset !== '') { 133 | $localeNames[] = sprintf('%s_%s.%s@%s', $lang, $country, $charset, $modifier); 134 | } 135 | 136 | $localeNames[] = sprintf('%s_%s@%s', $lang, $country, $modifier); 137 | } elseif ($charset !== '') { 138 | $localeNames[] = sprintf('%s.%s@%s', $lang, $charset, $modifier); 139 | } 140 | 141 | $localeNames[] = sprintf('%s@%s', $lang, $modifier); 142 | } 143 | 144 | if ($country !== '') { 145 | if ($charset !== '') { 146 | $localeNames[] = sprintf('%s_%s.%s', $lang, $country, $charset); 147 | } 148 | 149 | $localeNames[] = sprintf('%s_%s', $lang, $country); 150 | } elseif ($charset !== '') { 151 | $localeNames[] = sprintf('%s.%s', $lang, $charset); 152 | } 153 | 154 | if ($lang !== '') { 155 | $localeNames[] = $lang; 156 | } 157 | } 158 | 159 | // If the locale name doesn't match POSIX style, just include it as-is. 160 | if (! in_array($locale, $localeNames, true)) { 161 | $localeNames[] = $locale; 162 | } 163 | } 164 | 165 | return $localeNames; 166 | } 167 | 168 | /** 169 | * Sets factory responsible for composing a `CacheInterface` 170 | */ 171 | public static function setCacheFactory(CacheFactoryInterface|null $cacheFactory): void 172 | { 173 | self::$cacheFactory = $cacheFactory; 174 | } 175 | 176 | /** 177 | * Returns Translator object for domain or for default domain. 178 | * 179 | * @param string $domain Translation domain 180 | */ 181 | public function getTranslator(string $domain = ''): Translator 182 | { 183 | if ($domain === '') { 184 | $domain = $this->defaultDomain; 185 | } 186 | 187 | $this->domains[$this->locale] ??= []; 188 | 189 | if (! isset($this->domains[$this->locale][$domain])) { 190 | $base = $this->paths[$domain] ?? './'; 191 | 192 | $localeNames = self::listLocales($this->locale); 193 | 194 | $filename = ''; 195 | foreach ($localeNames as $locale) { 196 | $filename = $base . '/' . $locale . '/LC_MESSAGES/' . $domain . '.mo'; 197 | if (file_exists($filename)) { 198 | break; 199 | } 200 | } 201 | 202 | // We don't care about invalid path, we will get fallback 203 | // translator here 204 | $moParser = new MoParser($filename); 205 | if (self::$cacheFactory instanceof CacheFactoryInterface) { 206 | $cache = self::$cacheFactory->getInstance($moParser, $this->locale, $domain); 207 | } else { 208 | $cache = new InMemoryCache($moParser); 209 | } 210 | 211 | $this->domains[$this->locale][$domain] = new Translator($cache); 212 | } 213 | 214 | return $this->domains[$this->locale][$domain]; 215 | } 216 | 217 | /** 218 | * Sets the path for a domain. 219 | * 220 | * @param string $domain Domain name 221 | * @param string $path Path where to find locales 222 | */ 223 | public function bindtextdomain(string $domain, string $path): void 224 | { 225 | $this->paths[$domain] = $path; 226 | } 227 | 228 | /** 229 | * Sets the default domain. 230 | * 231 | * @param string $domain Domain name 232 | */ 233 | public function textdomain(string $domain): void 234 | { 235 | $this->defaultDomain = $domain; 236 | } 237 | 238 | /** 239 | * Sets a requested locale. 240 | * 241 | * @param string $locale Locale name 242 | * 243 | * @return string Set or current locale 244 | */ 245 | public function setlocale(string $locale): string 246 | { 247 | if (! empty($locale)) { 248 | $this->locale = $locale; 249 | } 250 | 251 | return $this->locale; 252 | } 253 | 254 | /** 255 | * Detects currently configured locale. 256 | * 257 | * It checks: 258 | * 259 | * - global lang variable 260 | * - environment for LC_ALL, LC_MESSAGES and LANG 261 | * 262 | * @return string with locale name 263 | */ 264 | public function detectlocale(): string 265 | { 266 | if (isset($GLOBALS['lang'])) { 267 | return $GLOBALS['lang']; 268 | } 269 | 270 | $locale = getenv('LC_ALL'); 271 | if ($locale !== false) { 272 | return $locale; 273 | } 274 | 275 | $locale = getenv('LC_MESSAGES'); 276 | if ($locale !== false) { 277 | return $locale; 278 | } 279 | 280 | $locale = getenv('LANG'); 281 | if ($locale !== false) { 282 | return $locale; 283 | } 284 | 285 | return 'en'; 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/MoParser.php: -------------------------------------------------------------------------------- 1 | filename === null) { 58 | return; 59 | } 60 | 61 | if (! is_readable($this->filename)) { 62 | $this->error = self::ERROR_DOES_NOT_EXIST; 63 | 64 | return; 65 | } 66 | 67 | $stream = new StringReader($this->filename); 68 | 69 | try { 70 | $magic = $stream->read(0, 4); 71 | if ($magic === self::MAGIC_LE) { 72 | $unpack = 'V'; 73 | } elseif ($magic === self::MAGIC_BE) { 74 | $unpack = 'N'; 75 | } else { 76 | $this->error = self::ERROR_BAD_MAGIC; 77 | 78 | return; 79 | } 80 | 81 | /* Parse header */ 82 | $total = $stream->readint($unpack, 8); 83 | $originals = $stream->readint($unpack, 12); 84 | $translations = $stream->readint($unpack, 16); 85 | 86 | /* get original and translations tables */ 87 | $totalTimesTwo = (int) ($total * 2);// Fix for issue #36 on ARM 88 | $tableOriginals = $stream->readintarray($unpack, $originals, $totalTimesTwo); 89 | $tableTranslations = $stream->readintarray($unpack, $translations, $totalTimesTwo); 90 | 91 | /* read all strings to the cache */ 92 | for ($i = 0; $i < $total; ++$i) { 93 | $iTimesTwo = $i * 2; 94 | $iPlusOne = $iTimesTwo + 1; 95 | $iPlusTwo = $iTimesTwo + 2; 96 | $original = $stream->read($tableOriginals[$iPlusTwo], $tableOriginals[$iPlusOne]); 97 | $translation = $stream->read($tableTranslations[$iPlusTwo], $tableTranslations[$iPlusOne]); 98 | $cache->set($original, $translation); 99 | } 100 | } catch (ReaderException) { 101 | $this->error = self::ERROR_READING; 102 | 103 | return; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/ReaderException.php: -------------------------------------------------------------------------------- 1 | . 7 | Copyright (c) 2016 Michal Čihař 8 | 9 | This file is part of MoTranslator. 10 | 11 | This program is free software; you can redistribute it and/or modify 12 | it under the terms of the GNU General Public License as published by 13 | the Free Software Foundation; either version 2 of the License, or 14 | (at your option) any later version. 15 | 16 | This program is distributed in the hope that it will be useful, 17 | but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | GNU General Public License for more details. 20 | 21 | You should have received a copy of the GNU General Public License along 22 | with this program; if not, write to the Free Software Foundation, Inc., 23 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 24 | */ 25 | 26 | namespace PhpMyAdmin\MoTranslator; 27 | 28 | use Exception; 29 | 30 | /** 31 | * Exception thrown when file can not be read. 32 | */ 33 | class ReaderException extends Exception 34 | { 35 | } 36 | -------------------------------------------------------------------------------- /src/StringReader.php: -------------------------------------------------------------------------------- 1 | . 7 | Copyright (c) 2016 Michal Čihař 8 | 9 | This file is part of MoTranslator. 10 | 11 | This program is free software; you can redistribute it and/or modify 12 | it under the terms of the GNU General Public License as published by 13 | the Free Software Foundation; either version 2 of the License, or 14 | (at your option) any later version. 15 | 16 | This program is distributed in the hope that it will be useful, 17 | but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | GNU General Public License for more details. 20 | 21 | You should have received a copy of the GNU General Public License along 22 | with this program; if not, write to the Free Software Foundation, Inc., 23 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 24 | */ 25 | 26 | namespace PhpMyAdmin\MoTranslator; 27 | 28 | use function file_get_contents; 29 | use function strlen; 30 | use function substr; 31 | use function unpack; 32 | 33 | use const PHP_INT_MAX; 34 | 35 | /** 36 | * Simple wrapper around string buffer for 37 | * random access and values parsing. 38 | */ 39 | class StringReader 40 | { 41 | private string $string; 42 | private int $length; 43 | 44 | /** @param string $filename Name of file to load */ 45 | public function __construct(string $filename) 46 | { 47 | $this->string = (string) file_get_contents($filename); 48 | $this->length = strlen($this->string); 49 | } 50 | 51 | /** 52 | * Read number of bytes from given offset. 53 | * 54 | * @param int $pos Offset 55 | * @param int $bytes Number of bytes to read 56 | */ 57 | public function read(int $pos, int $bytes): string 58 | { 59 | if ($pos + $bytes > $this->length) { 60 | throw new ReaderException('Not enough bytes!'); 61 | } 62 | 63 | return substr($this->string, $pos, $bytes); 64 | } 65 | 66 | /** 67 | * Reads a 32bit integer from the stream. 68 | * 69 | * @param string $unpack Unpack string 70 | * @param int $pos Position 71 | * 72 | * @return int Integer from the stream 73 | */ 74 | public function readint(string $unpack, int $pos): int 75 | { 76 | $data = unpack($unpack, $this->read($pos, 4)); 77 | if ($data === false) { 78 | return PHP_INT_MAX; 79 | } 80 | 81 | $result = $data[1]; 82 | 83 | /* We're reading unsigned int, but PHP will happily 84 | * give us negative number on 32-bit platforms. 85 | * 86 | * See also documentation: 87 | * https://secure.php.net/manual/en/function.unpack.php#refsect1-function.unpack-notes 88 | */ 89 | return $result < 0 ? PHP_INT_MAX : $result; 90 | } 91 | 92 | /** 93 | * Reads an array of integers from the stream. 94 | * 95 | * @param string $unpack Unpack string 96 | * @param int $pos Position 97 | * @param int $count How many elements should be read 98 | * 99 | * @return int[] Array of Integers 100 | */ 101 | public function readintarray(string $unpack, int $pos, int $count): array 102 | { 103 | $data = unpack($unpack . $count, $this->read($pos, 4 * $count)); 104 | if ($data === false) { 105 | return []; 106 | } 107 | 108 | return $data; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Translator.php: -------------------------------------------------------------------------------- 1 | . 7 | Copyright (c) 2005 Nico Kaiser 8 | Copyright (c) 2016 Michal Čihař 9 | 10 | This file is part of MoTranslator. 11 | 12 | This program is free software; you can redistribute it and/or modify 13 | it under the terms of the GNU General Public License as published by 14 | the Free Software Foundation; either version 2 of the License, or 15 | (at your option) any later version. 16 | 17 | This program is distributed in the hope that it will be useful, 18 | but WITHOUT ANY WARRANTY; without even the implied warranty of 19 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20 | GNU General Public License for more details. 21 | 22 | You should have received a copy of the GNU General Public License along 23 | with this program; if not, write to the Free Software Foundation, Inc., 24 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 25 | */ 26 | 27 | namespace PhpMyAdmin\MoTranslator; 28 | 29 | use PhpMyAdmin\MoTranslator\Cache\CacheInterface; 30 | use PhpMyAdmin\MoTranslator\Cache\GetAllInterface; 31 | use PhpMyAdmin\MoTranslator\Cache\InMemoryCache; 32 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 33 | use Throwable; 34 | 35 | use function array_key_exists; 36 | use function count; 37 | use function explode; 38 | use function is_numeric; 39 | use function ltrim; 40 | use function preg_replace; 41 | use function rtrim; 42 | use function sprintf; 43 | use function str_contains; 44 | use function str_starts_with; 45 | use function stripos; 46 | use function strtolower; 47 | use function substr; 48 | use function trim; 49 | 50 | /** 51 | * Provides a simple gettext replacement that works independently from 52 | * the system's gettext abilities. 53 | * It can read MO files and use them for translating strings. 54 | * 55 | * It caches ll strings and translations to speed up the string lookup. 56 | */ 57 | class Translator 58 | { 59 | /** 60 | * None error. 61 | */ 62 | public const ERROR_NONE = 0; 63 | 64 | /** 65 | * File does not exist. 66 | */ 67 | public const ERROR_DOES_NOT_EXIST = 1; 68 | 69 | /** 70 | * File has bad magic number. 71 | */ 72 | public const ERROR_BAD_MAGIC = 2; 73 | 74 | /** 75 | * Error while reading file, probably too short. 76 | */ 77 | public const ERROR_READING = 3; 78 | 79 | /** 80 | * Big endian mo file magic bytes. 81 | */ 82 | public const MAGIC_BE = "\x95\x04\x12\xde"; 83 | 84 | /** 85 | * Little endian mo file magic bytes. 86 | */ 87 | public const MAGIC_LE = "\xde\x12\x04\x95"; 88 | 89 | /** 90 | * Parse error code (0 if no error). 91 | */ 92 | public int $error = self::ERROR_NONE; 93 | 94 | /** 95 | * Cache header field for plural forms. 96 | */ 97 | private string|null $pluralEquation = null; 98 | 99 | /** 100 | * Evaluator for plurals 101 | */ 102 | private ExpressionLanguage|null $pluralExpression = null; 103 | 104 | /** 105 | * number of plurals 106 | */ 107 | private int|null $pluralCount = null; 108 | 109 | private CacheInterface $cache; 110 | 111 | /** @param CacheInterface|string|null $cache Mo file to load (null for no file) or a CacheInterface implementation */ 112 | public function __construct(CacheInterface|string|null $cache) 113 | { 114 | if (! $cache instanceof CacheInterface) { 115 | $cache = new InMemoryCache(new MoParser($cache)); 116 | } 117 | 118 | $this->cache = $cache; 119 | } 120 | 121 | /** 122 | * Translates a string. 123 | * 124 | * @param string $msgid String to be translated 125 | * 126 | * @return string translated string (or original, if not found) 127 | */ 128 | public function gettext(string $msgid): string 129 | { 130 | return $this->cache->get($msgid); 131 | } 132 | 133 | /** 134 | * Check if a string is translated. 135 | * 136 | * @param string $msgid String to be checked 137 | */ 138 | public function exists(string $msgid): bool 139 | { 140 | return $this->cache->has($msgid); 141 | } 142 | 143 | /** 144 | * Sanitize plural form expression for use in ExpressionLanguage. 145 | * 146 | * @param string $expr Expression to sanitize 147 | * 148 | * @return string sanitized plural form expression 149 | */ 150 | public static function sanitizePluralExpression(string $expr): string 151 | { 152 | // Parse equation 153 | $expr = explode(';', $expr); 154 | $expr = count($expr) >= 2 ? $expr[1] : $expr[0]; 155 | 156 | $expr = trim(strtolower($expr)); 157 | // Strip plural prefix 158 | if (str_starts_with($expr, 'plural')) { 159 | $expr = ltrim(substr($expr, 6)); 160 | } 161 | 162 | // Strip equals 163 | if (str_starts_with($expr, '=')) { 164 | $expr = ltrim(substr($expr, 1)); 165 | } 166 | 167 | // Cleanup from unwanted chars 168 | $expr = preg_replace('@[^n0-9:\(\)\?=!<>/%&| ]@', '', $expr); 169 | 170 | return (string) $expr; 171 | } 172 | 173 | /** 174 | * Extracts number of plurals from plurals form expression. 175 | * 176 | * @param string $expr Expression to process 177 | * 178 | * @return int Total number of plurals 179 | */ 180 | public static function extractPluralCount(string $expr): int 181 | { 182 | $parts = explode(';', $expr, 2); 183 | $nplurals = explode('=', trim($parts[0]), 2); 184 | if (strtolower(rtrim($nplurals[0])) !== 'nplurals') { 185 | return 1; 186 | } 187 | 188 | if (count($nplurals) === 1) { 189 | return 1; 190 | } 191 | 192 | return (int) $nplurals[1]; 193 | } 194 | 195 | /** 196 | * Parse full PO header and extract only plural forms line. 197 | * 198 | * @param string $header Gettext header 199 | * 200 | * @return string verbatim plural form header field 201 | */ 202 | public static function extractPluralsForms(string $header): string 203 | { 204 | $headers = explode("\n", $header); 205 | $expr = 'nplurals=2; plural=n == 1 ? 0 : 1;'; 206 | foreach ($headers as $header) { 207 | if (stripos($header, 'Plural-Forms:') !== 0) { 208 | continue; 209 | } 210 | 211 | $expr = substr($header, 13); 212 | } 213 | 214 | return $expr; 215 | } 216 | 217 | /** 218 | * Get possible plural forms from MO header. 219 | * 220 | * @return string plural form header 221 | */ 222 | private function getPluralForms(): string 223 | { 224 | // lets assume message number 0 is header 225 | // this is true, right? 226 | 227 | // cache header field for plural forms 228 | if ($this->pluralEquation === null) { 229 | $header = $this->cache->get(''); 230 | 231 | $expr = self::extractPluralsForms($header); 232 | $this->pluralEquation = self::sanitizePluralExpression($expr); 233 | $this->pluralCount = self::extractPluralCount($expr); 234 | } 235 | 236 | return $this->pluralEquation; 237 | } 238 | 239 | /** 240 | * Detects which plural form to take. 241 | * 242 | * @param int $n count of objects 243 | * 244 | * @return int array index of the right plural form 245 | */ 246 | private function selectString(int $n): int 247 | { 248 | if ($this->pluralExpression === null) { 249 | $this->pluralExpression = new ExpressionLanguage(); 250 | } 251 | 252 | try { 253 | $evaluatedPlural = $this->pluralExpression->evaluate($this->getPluralForms(), ['n' => $n]); 254 | $plural = is_numeric($evaluatedPlural) ? (int) $evaluatedPlural : 0; 255 | } catch (Throwable) { 256 | $plural = 0; 257 | } 258 | 259 | if ($plural >= $this->pluralCount) { 260 | $plural = $this->pluralCount - 1; 261 | } 262 | 263 | return $plural; 264 | } 265 | 266 | /** 267 | * Plural version of gettext. 268 | * 269 | * @param string $msgid Single form 270 | * @param string $msgidPlural Plural form 271 | * @param int $number Number of objects 272 | * 273 | * @return string translated plural form 274 | */ 275 | public function ngettext(string $msgid, string $msgidPlural, int $number): string 276 | { 277 | // this should contains all strings separated by NULLs 278 | $key = $msgid . "\u{0}" . $msgidPlural; 279 | if (! $this->cache->has($key)) { 280 | return $number !== 1 ? $msgidPlural : $msgid; 281 | } 282 | 283 | $result = $this->cache->get($key); 284 | 285 | // find out the appropriate form 286 | $select = $this->selectString($number); 287 | 288 | $list = explode("\u{0}", $result); 289 | 290 | if (array_key_exists($select, $list)) { 291 | return $list[$select]; 292 | } 293 | 294 | return $list[0]; 295 | } 296 | 297 | /** 298 | * Translate with context. 299 | * 300 | * @param string $msgctxt Context 301 | * @param string $msgid String to be translated 302 | * 303 | * @return string translated plural form 304 | */ 305 | public function pgettext(string $msgctxt, string $msgid): string 306 | { 307 | $key = $msgctxt . "\u{4}" . $msgid; 308 | $ret = $this->gettext($key); 309 | if ($ret === $key) { 310 | return $msgid; 311 | } 312 | 313 | return $ret; 314 | } 315 | 316 | /** 317 | * Plural version of pgettext. 318 | * 319 | * @param string $msgctxt Context 320 | * @param string $msgid Single form 321 | * @param string $msgidPlural Plural form 322 | * @param int $number Number of objects 323 | * 324 | * @return string translated plural form 325 | */ 326 | public function npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string 327 | { 328 | $key = $msgctxt . "\u{4}" . $msgid; 329 | $ret = $this->ngettext($key, $msgidPlural, $number); 330 | if (str_contains($ret, "\u{4}")) { 331 | return $msgid; 332 | } 333 | 334 | return $ret; 335 | } 336 | 337 | /** 338 | * Set translation in place 339 | * 340 | * @param string $msgid String to be set 341 | * @param string $msgstr Translation 342 | */ 343 | public function setTranslation(string $msgid, string $msgstr): void 344 | { 345 | $this->cache->set($msgid, $msgstr); 346 | } 347 | 348 | /** 349 | * Set the translations 350 | * 351 | * @param array $translations The translations "key => value" array 352 | */ 353 | public function setTranslations(array $translations): void 354 | { 355 | $this->cache->setAll($translations); 356 | } 357 | 358 | /** 359 | * Get the translations 360 | * 361 | * @return array The translations "key => value" array 362 | */ 363 | public function getTranslations(): array 364 | { 365 | if ($this->cache instanceof GetAllInterface) { 366 | return $this->cache->getAll(); 367 | } 368 | 369 | throw new CacheException(sprintf( 370 | "Cache '%s' does not support getting translations", 371 | $this->cache::class, 372 | )); 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | 7 | Copyright (c) 2009 Danilo Segan 8 | Copyright (c) 2016 Michal Čihař 9 | 10 | This file is part of MoTranslator. 11 | 12 | This program is free software; you can redistribute it and/or modify 13 | it under the terms of the GNU General Public License as published by 14 | the Free Software Foundation; either version 2 of the License, or 15 | (at your option) any later version. 16 | 17 | This program is distributed in the hope that it will be useful, 18 | but WITHOUT ANY WARRANTY; without even the implied warranty of 19 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20 | GNU General Public License for more details. 21 | 22 | You should have received a copy of the GNU General Public License along 23 | with this program; if not, write to the Free Software Foundation, Inc., 24 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 25 | */ 26 | 27 | use PhpMyAdmin\MoTranslator\Loader; 28 | 29 | // phpcs:disable Squiz.Functions.GlobalFunction 30 | 31 | if (! function_exists('_setlocale')) { 32 | /** 33 | * Sets a requested locale. 34 | * 35 | * @param int $category Locale category, ignored 36 | * @param string $locale Locale name 37 | * 38 | * @return string Set or current locale 39 | */ 40 | function _setlocale(int $category, string $locale): string 41 | { 42 | return Loader::getInstance()->setlocale($locale); 43 | } 44 | } 45 | 46 | if (! function_exists('_bindtextdomain')) { 47 | /** 48 | * Sets the path for a domain. 49 | * 50 | * @param string $domain Domain name 51 | * @param string $path Path where to find locales 52 | */ 53 | function _bindtextdomain(string $domain, string $path): void 54 | { 55 | Loader::getInstance()->bindtextdomain($domain, $path); 56 | } 57 | } 58 | 59 | if (! function_exists('_bind_textdomain_codeset')) { 60 | /** 61 | * Dummy compatibility function, MoTranslator assumes 62 | * everything is using same character set on input and 63 | * output. 64 | * 65 | * Generally it is wise to output in UTF-8 and have 66 | * mo files in UTF-8. 67 | * 68 | * @param string $domain Domain where to set character set 69 | * @param string $codeset Character set to set 70 | */ 71 | function _bind_textdomain_codeset(string $domain, string $codeset): void 72 | { 73 | } 74 | } 75 | 76 | if (! function_exists('_textdomain')) { 77 | /** 78 | * Sets the default domain. 79 | * 80 | * @param string $domain Domain name 81 | */ 82 | function _textdomain(string $domain): void 83 | { 84 | Loader::getInstance()->textdomain($domain); 85 | } 86 | } 87 | 88 | if (! function_exists('_gettext')) { 89 | /** 90 | * Translates a string. 91 | * 92 | * @param string $msgid String to be translated 93 | * 94 | * @return string translated string (or original, if not found) 95 | */ 96 | function _gettext(string $msgid): string 97 | { 98 | return Loader::getInstance()->getTranslator()->gettext($msgid); 99 | } 100 | } 101 | 102 | if (! function_exists('__')) { 103 | /** 104 | * Translates a string, alias for _gettext. 105 | * 106 | * @param string $msgid String to be translated 107 | * 108 | * @return string translated string (or original, if not found) 109 | */ 110 | function __(string $msgid): string 111 | { 112 | return Loader::getInstance()->getTranslator()->gettext($msgid); 113 | } 114 | } 115 | 116 | if (! function_exists('_ngettext')) { 117 | /** 118 | * Plural version of gettext. 119 | * 120 | * @param string $msgid Single form 121 | * @param string $msgidPlural Plural form 122 | * @param int $number Number of objects 123 | * 124 | * @return string translated plural form 125 | */ 126 | function _ngettext(string $msgid, string $msgidPlural, int $number): string 127 | { 128 | return Loader::getInstance()->getTranslator()->ngettext($msgid, $msgidPlural, $number); 129 | } 130 | } 131 | 132 | if (! function_exists('_pgettext')) { 133 | /** 134 | * Translate with context. 135 | * 136 | * @param string $msgctxt Context 137 | * @param string $msgid String to be translated 138 | * 139 | * @return string translated plural form 140 | */ 141 | function _pgettext(string $msgctxt, string $msgid): string 142 | { 143 | return Loader::getInstance()->getTranslator()->pgettext($msgctxt, $msgid); 144 | } 145 | } 146 | 147 | if (! function_exists('_npgettext')) { 148 | /** 149 | * Plural version of pgettext. 150 | * 151 | * @param string $msgctxt Context 152 | * @param string $msgid Single form 153 | * @param string $msgidPlural Plural form 154 | * @param int $number Number of objects 155 | * 156 | * @return string translated plural form 157 | */ 158 | function _npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string 159 | { 160 | return Loader::getInstance()->getTranslator()->npgettext($msgctxt, $msgid, $msgidPlural, $number); 161 | } 162 | } 163 | 164 | if (! function_exists('_dgettext')) { 165 | /** 166 | * Translates a string. 167 | * 168 | * @param string $domain Domain to use 169 | * @param string $msgid String to be translated 170 | * 171 | * @return string translated string (or original, if not found) 172 | */ 173 | function _dgettext(string $domain, string $msgid): string 174 | { 175 | return Loader::getInstance()->getTranslator($domain)->gettext($msgid); 176 | } 177 | } 178 | 179 | if (! function_exists('_dngettext')) { 180 | /** 181 | * Plural version of gettext. 182 | * 183 | * @param string $domain Domain to use 184 | * @param string $msgid Single form 185 | * @param string $msgidPlural Plural form 186 | * @param int $number Number of objects 187 | * 188 | * @return string translated plural form 189 | */ 190 | function _dngettext(string $domain, string $msgid, string $msgidPlural, int $number): string 191 | { 192 | return Loader::getInstance()->getTranslator($domain)->ngettext($msgid, $msgidPlural, $number); 193 | } 194 | } 195 | 196 | if (! function_exists('_dpgettext')) { 197 | /** 198 | * Translate with context. 199 | * 200 | * @param string $domain Domain to use 201 | * @param string $msgctxt Context 202 | * @param string $msgid String to be translated 203 | * 204 | * @return string translated plural form 205 | */ 206 | function _dpgettext(string $domain, string $msgctxt, string $msgid): string 207 | { 208 | return Loader::getInstance()->getTranslator($domain)->pgettext($msgctxt, $msgid); 209 | } 210 | } 211 | 212 | if (! function_exists('_dnpgettext')) { 213 | /** 214 | * Plural version of pgettext. 215 | * 216 | * @param string $domain Domain to use 217 | * @param string $msgctxt Context 218 | * @param string $msgid Single form 219 | * @param string $msgidPlural Plural form 220 | * @param int $number Number of objects 221 | * 222 | * @return string translated plural form 223 | */ 224 | function _dnpgettext(string $domain, string $msgctxt, string $msgid, string $msgidPlural, int $number): string 225 | { 226 | return Loader::getInstance()->getTranslator($domain)->npgettext($msgctxt, $msgid, $msgidPlural, $number); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /tests/Cache/ApcuCacheFactoryTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('APCu extension is not installed and enabled for CLI'); 32 | } 33 | 34 | protected function tearDown(): void 35 | { 36 | parent::tearDown(); 37 | 38 | apcu_clear_cache(); 39 | } 40 | 41 | public function testGetInstanceReturnApcuCache(): void 42 | { 43 | $factory = new ApcuCacheFactory(); 44 | $instance = $factory->getInstance(new MoParser(null), 'foo', 'bar'); 45 | self::assertInstanceOf(ApcuCache::class, $instance); 46 | } 47 | 48 | public function testConstructorSetsTtl(): void 49 | { 50 | $locale = 'foo'; 51 | $domain = 'bar'; 52 | $msgid = 'Column'; 53 | $ttl = 1; 54 | 55 | $factory = new ApcuCacheFactory($ttl); 56 | $parser = new MoParser(__DIR__ . '/../data/little.mo'); 57 | $factory->getInstance($parser, $locale, $domain); 58 | sleep($ttl * 2); 59 | 60 | apcu_fetch('mo_' . $locale . '.' . $domain . '.' . $msgid, $success); 61 | self::assertFalse($success); 62 | } 63 | 64 | public function testConstructorSetsReloadOnMiss(): void 65 | { 66 | $expected = 'Column'; 67 | $locale = 'foo'; 68 | $domain = 'bar'; 69 | $msgid = 'Column'; 70 | 71 | $factory = new ApcuCacheFactory(0, false); 72 | $parser = new MoParser(__DIR__ . '/../data/little.mo'); 73 | 74 | $instance = $factory->getInstance($parser, $locale, $domain); 75 | 76 | apcu_delete('mo_' . $locale . '.' . $domain . '.' . $msgid); 77 | $actual = $instance->get($msgid); 78 | self::assertSame($expected, $actual); 79 | } 80 | 81 | public function testConstructorSetsPrefix(): void 82 | { 83 | $expected = 'Pole'; 84 | $locale = 'foo'; 85 | $domain = 'bar'; 86 | $msgid = 'Column'; 87 | $prefix = 'baz_'; 88 | 89 | $factory = new ApcuCacheFactory(0, true, $prefix); 90 | $parser = new MoParser(__DIR__ . '/../data/little.mo'); 91 | 92 | $factory->getInstance($parser, $locale, $domain); 93 | 94 | $actual = apcu_fetch($prefix . $locale . '.' . $domain . '.' . $msgid); 95 | self::assertSame($expected, $actual); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Cache/ApcuCacheTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('APCu extension is not installed and enabled for CLI'); 36 | } 37 | 38 | protected function tearDown(): void 39 | { 40 | parent::tearDown(); 41 | 42 | apcu_clear_cache(); 43 | } 44 | 45 | public function testConstructorLoadsCache(): void 46 | { 47 | $expected = 'Pole'; 48 | $locale = 'foo'; 49 | $domain = 'bar'; 50 | $msgid = 'Column'; 51 | 52 | new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), $locale, $domain); 53 | 54 | $actual = apcu_fetch('mo_' . $locale . '.' . $domain . '.' . $msgid); 55 | self::assertSame($expected, $actual); 56 | } 57 | 58 | public function testConstructorSetsTtl(): void 59 | { 60 | $locale = 'foo'; 61 | $domain = 'bar'; 62 | $msgid = 'Column'; 63 | $ttl = 1; 64 | 65 | new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), $locale, $domain, $ttl); 66 | sleep($ttl * 2); 67 | 68 | apcu_fetch('mo_' . $locale . '.' . $domain . '.' . $msgid, $success); 69 | self::assertFalse($success); 70 | apcu_fetch('mo_' . $locale . '.' . $domain . '.' . ApcuCache::LOADED_KEY, $success); 71 | self::assertFalse($success); 72 | } 73 | 74 | public function testConstructorSetsReloadOnMiss(): void 75 | { 76 | $expected = 'Column'; 77 | $locale = 'foo'; 78 | $domain = 'bar'; 79 | $msgid = 'Column'; 80 | $prefix = 'baz_'; 81 | 82 | $cache = new ApcuCache( 83 | new MoParser(__DIR__ . '/../data/little.mo'), 84 | $locale, 85 | $domain, 86 | 0, 87 | false, 88 | $prefix, 89 | ); 90 | 91 | apcu_delete($prefix . $locale . '.' . $domain . '.' . $msgid); 92 | $actual = $cache->get($msgid); 93 | self::assertSame($expected, $actual); 94 | } 95 | 96 | public function testConstructorSetsPrefix(): void 97 | { 98 | $expected = 'Pole'; 99 | $locale = 'foo'; 100 | $domain = 'bar'; 101 | $msgid = 'Column'; 102 | $prefix = 'baz_'; 103 | 104 | new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), $locale, $domain, 0, true, $prefix); 105 | 106 | $actual = apcu_fetch($prefix . $locale . '.' . $domain . '.' . $msgid); 107 | self::assertSame($expected, $actual); 108 | } 109 | 110 | public function testEnsureTranslationsLoadedSetsLoadedKey(): void 111 | { 112 | $expected = 1; 113 | $locale = 'foo'; 114 | $domain = 'bar'; 115 | 116 | new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), $locale, $domain); 117 | 118 | $actual = apcu_fetch('mo_' . $locale . '.' . $domain . '.' . ApcuCache::LOADED_KEY); 119 | self::assertSame($expected, $actual); 120 | } 121 | 122 | public function testEnsureTranslationsLoadedHonorsLock(): void 123 | { 124 | $locale = 'foo'; 125 | $domain = 'bar'; 126 | $msgid = 'Column'; 127 | 128 | $lock = 'mo_' . $locale . '.' . $domain . '.' . ApcuCache::LOADED_KEY; 129 | apcu_entry($lock, static function () { 130 | sleep(1); 131 | 132 | return 1; 133 | }); 134 | 135 | new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), $locale, $domain); 136 | 137 | $actual = apcu_fetch($lock); 138 | self::assertSame(1, $actual); 139 | apcu_fetch('mo_' . $locale . '.' . $domain . '.' . $msgid, $success); 140 | self::assertFalse($success); 141 | } 142 | 143 | public function testGetReturnsMsgstr(): void 144 | { 145 | $expected = 'Pole'; 146 | $msgid = 'Column'; 147 | 148 | $cache = new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), 'foo', 'bar'); 149 | 150 | $actual = $cache->get($msgid); 151 | self::assertSame($expected, $actual); 152 | } 153 | 154 | public function testGetReturnsMsgidForCacheMiss(): void 155 | { 156 | $expected = 'Column'; 157 | 158 | $cache = new ApcuCache(new MoParser(null), 'foo', 'bar'); 159 | 160 | $actual = $cache->get($expected); 161 | self::assertSame($expected, $actual); 162 | } 163 | 164 | public function testStoresMsgidOnCacheMiss(): void 165 | { 166 | $expected = 'Column'; 167 | $locale = 'foo'; 168 | $domain = 'bar'; 169 | 170 | $cache = new ApcuCache(new MoParser(null), $locale, $domain); 171 | $cache->get($expected); 172 | 173 | $actual = apcu_fetch('mo_' . $locale . '.' . $domain . '.' . $expected); 174 | self::assertSame($expected, $actual); 175 | } 176 | 177 | public function testGetReloadsOnCacheMiss(): void 178 | { 179 | $expected = 'Pole'; 180 | $locale = 'foo'; 181 | $domain = 'bar'; 182 | $msgid = 'Column'; 183 | 184 | $cache = new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), $locale, $domain); 185 | 186 | apcu_delete('mo_' . $locale . '.' . $domain . '.' . ApcuCache::LOADED_KEY); 187 | $actual = $cache->get($msgid); 188 | self::assertSame($expected, $actual); 189 | } 190 | 191 | public function testReloadOnMissHonorsLock(): void 192 | { 193 | $expected = 'Pole'; 194 | $locale = 'foo'; 195 | $domain = 'bar'; 196 | $msgid = 'Column'; 197 | 198 | $cache = new ApcuCache(new MoParser(null), $locale, $domain); 199 | 200 | $method = new ReflectionMethod($cache, 'reloadOnMiss'); 201 | $method->setAccessible(true); 202 | 203 | $key = 'mo_' . $locale . '.' . $domain . '.' . $msgid; 204 | apcu_entry($key, static function () use ($expected): string { 205 | sleep(1); 206 | 207 | return $expected; 208 | }); 209 | $actual = $method->invoke($cache, $msgid); 210 | 211 | self::assertSame($expected, $actual); 212 | } 213 | 214 | public function testSetSetsMsgstr(): void 215 | { 216 | $expected = 'Pole'; 217 | $msgid = 'Column'; 218 | 219 | $cache = new ApcuCache(new MoParser(null), 'foo', 'bar'); 220 | $cache->set($msgid, $expected); 221 | 222 | $actual = $cache->get($msgid); 223 | self::assertSame($expected, $actual); 224 | } 225 | 226 | public function testHasReturnsFalse(): void 227 | { 228 | $cache = new ApcuCache(new MoParser(null), 'foo', 'bar'); 229 | $actual = $cache->has('Column'); 230 | self::assertFalse($actual); 231 | } 232 | 233 | public function testHasReturnsTrue(): void 234 | { 235 | $cache = new ApcuCache(new MoParser(__DIR__ . '/../data/little.mo'), 'foo', 'bar'); 236 | $actual = $cache->has('Column'); 237 | self::assertTrue($actual); 238 | } 239 | 240 | public function testSetAllSetsTranslations(): void 241 | { 242 | $translations = [ 243 | 'foo' => 'bar', 244 | 'and' => 'another', 245 | ]; 246 | 247 | $cache = new ApcuCache(new MoParser(null), 'foo', 'bar'); 248 | $cache->setAll($translations); 249 | 250 | foreach ($translations as $msgid => $expected) { 251 | $actual = $cache->get($msgid); 252 | self::assertSame($expected, $actual); 253 | } 254 | } 255 | 256 | public function testCacheStoresPluralForms(): void 257 | { 258 | $expected = ['first', 'second']; 259 | $plural = ["%d pig went to the market\n", "%d pigs went to the market\n"]; 260 | $msgid = implode(chr(0), $plural); 261 | 262 | $cache = new ApcuCache(new MoParser(null), 'foo', 'bar'); 263 | $cache->set($msgid, implode(chr(0), $expected)); 264 | 265 | $msgstr = $cache->get($msgid); 266 | $actual = explode(chr(0), $msgstr); 267 | self::assertSame($expected, $actual); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /tests/Cache/ApcuDisabledTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('ext-apcu is enabled'); 21 | } 22 | 23 | $this->expectException(CacheException::class); 24 | $this->expectExceptionMessage('APCu extension must be installed and enabled'); 25 | new ApcuCache(new MoParser(null), 'foo', 'bar'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Cache/InMemoryCacheTest.php: -------------------------------------------------------------------------------- 1 | get('Column'); 21 | self::assertSame($expected, $actual); 22 | } 23 | 24 | public function testGetReturnsMsgidForCacheMiss(): void 25 | { 26 | $expected = 'Column'; 27 | $cache = new InMemoryCache(new MoParser(null)); 28 | $actual = $cache->get($expected); 29 | self::assertSame($expected, $actual); 30 | } 31 | 32 | public function testSetSetsMsgstr(): void 33 | { 34 | $expected = 'Pole'; 35 | $msgid = 'Column'; 36 | $cache = new InMemoryCache(new MoParser(null)); 37 | $cache->set($msgid, $expected); 38 | $actual = $cache->get($msgid); 39 | self::assertSame($expected, $actual); 40 | } 41 | 42 | public function testHasReturnsFalse(): void 43 | { 44 | $cache = new InMemoryCache(new MoParser(null)); 45 | $actual = $cache->has('Column'); 46 | self::assertFalse($actual); 47 | } 48 | 49 | public function testHasReturnsTrue(): void 50 | { 51 | $cache = new InMemoryCache(new MoParser(__DIR__ . '/../data/little.mo')); 52 | $actual = $cache->has('Column'); 53 | self::assertTrue($actual); 54 | } 55 | 56 | public function testSetAllSetsTranslations(): void 57 | { 58 | $translations = [ 59 | 'foo' => 'bar', 60 | 'and' => 'another', 61 | ]; 62 | $cache = new InMemoryCache(new MoParser(null)); 63 | $cache->setAll($translations); 64 | foreach ($translations as $msgid => $expected) { 65 | $actual = $cache->get($msgid); 66 | self::assertSame($expected, $actual); 67 | } 68 | } 69 | 70 | public function testGetAllReturnsTranslations(): void 71 | { 72 | $expected = [ 73 | 'foo' => 'bar', 74 | 'and' => 'another', 75 | ]; 76 | $cache = new InMemoryCache(new MoParser(null)); 77 | $cache->setAll($expected); 78 | $actual = $cache->getAll(); 79 | self::assertSame($expected, $actual); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/FunctionsTest.php: -------------------------------------------------------------------------------- 1 | $expected */ 24 | #[DataProvider('localeList')] 25 | public function testListLocales(string $locale, array $expected): void 26 | { 27 | self::assertSame($expected, Loader::listLocales($locale)); 28 | } 29 | 30 | /** @return list}> */ 31 | public static function localeList(): array 32 | { 33 | return [ 34 | [ 35 | 'cs_CZ', 36 | [ 37 | 'cs_CZ', 38 | 'cs', 39 | ], 40 | ], 41 | [ 42 | 'sr_CS.UTF-8@latin', 43 | [ 44 | 'sr_CS.UTF-8@latin', 45 | 'sr_CS@latin', 46 | 'sr@latin', 47 | 'sr_CS.UTF-8', 48 | 'sr_CS', 49 | 'sr', 50 | ], 51 | ], 52 | // For a locale containing country code, we prefer 53 | // full locale name, but if that's not found, fall back 54 | // to the language only locale name. 55 | [ 56 | 'sr_RS', 57 | [ 58 | 'sr_RS', 59 | 'sr', 60 | ], 61 | ], 62 | // If language code is used, it's the only thing returned. 63 | [ 64 | 'sr', 65 | ['sr'], 66 | ], 67 | // There is support for language and charset only. 68 | [ 69 | 'sr.UTF-8', 70 | [ 71 | 'sr.UTF-8', 72 | 'sr', 73 | ], 74 | ], 75 | 76 | // It can also split out character set from the full locale name. 77 | [ 78 | 'sr_RS.UTF-8', 79 | [ 80 | 'sr_RS.UTF-8', 81 | 'sr_RS', 82 | 'sr', 83 | ], 84 | ], 85 | 86 | // There is support for @modifier in locale names as well. 87 | [ 88 | 'sr_RS.UTF-8@latin', 89 | [ 90 | 'sr_RS.UTF-8@latin', 91 | 'sr_RS@latin', 92 | 'sr@latin', 93 | 'sr_RS.UTF-8', 94 | 'sr_RS', 95 | 'sr', 96 | ], 97 | ], 98 | [ 99 | 'sr.UTF-8@latin', 100 | [ 101 | 'sr.UTF-8@latin', 102 | 'sr@latin', 103 | 'sr.UTF-8', 104 | 'sr', 105 | ], 106 | ], 107 | 108 | // We can pass in only language and modifier. 109 | [ 110 | 'sr@latin', 111 | [ 112 | 'sr@latin', 113 | 'sr', 114 | ], 115 | ], 116 | 117 | // If locale name is not following the regular POSIX pattern, 118 | // it's used verbatim. 119 | [ 120 | 'something', 121 | ['something'], 122 | ], 123 | 124 | // Passing in an empty string returns an empty array. 125 | [ 126 | '', 127 | [], 128 | ], 129 | ]; 130 | } 131 | 132 | private function getLoader(string $domain, string $locale): Loader 133 | { 134 | $loader = new Loader(); 135 | $loader->setlocale($locale); 136 | $loader->textdomain($domain); 137 | $loader->bindtextdomain($domain, __DIR__ . '/data/locale/'); 138 | 139 | return $loader; 140 | } 141 | 142 | public function testLocaleChange(): void 143 | { 144 | $loader = new Loader(); 145 | $loader->setlocale('cs'); 146 | $loader->textdomain('phpmyadmin'); 147 | $loader->bindtextdomain('phpmyadmin', __DIR__ . '/data/locale/'); 148 | $translator = $loader->getTranslator('phpmyadmin'); 149 | self::assertSame('Typ', $translator->gettext('Type')); 150 | $loader->setlocale('be_BY'); 151 | $translator = $loader->getTranslator('phpmyadmin'); 152 | self::assertSame('Тып', $translator->gettext('Type')); 153 | } 154 | 155 | #[DataProvider('translatorData')] 156 | public function testGetTranslator(string $domain, string $locale, string $otherdomain, string $expected): void 157 | { 158 | $loader = $this->getLoader($domain, $locale); 159 | $translator = $loader->getTranslator($otherdomain); 160 | self::assertSame( 161 | $expected, 162 | $translator->gettext('Type'), 163 | ); 164 | } 165 | 166 | /** @return list */ 167 | public static function translatorData(): array 168 | { 169 | return [ 170 | [ 171 | 'phpmyadmin', 172 | 'cs', 173 | '', 174 | 'Typ', 175 | ], 176 | [ 177 | 'phpmyadmin', 178 | 'cs_CZ', 179 | '', 180 | 'Typ', 181 | ], 182 | [ 183 | 'phpmyadmin', 184 | 'be_BY', 185 | '', 186 | 'Тып', 187 | ], 188 | [ 189 | 'phpmyadmin', 190 | 'be@latin', 191 | '', 192 | 'Typ', 193 | ], 194 | [ 195 | 'phpmyadmin', 196 | 'cs', 197 | 'other', 198 | 'Type', 199 | ], 200 | [ 201 | 'other', 202 | 'cs', 203 | 'phpmyadmin', 204 | 'Type', 205 | ], 206 | ]; 207 | } 208 | 209 | public function testInstance(): void 210 | { 211 | $loader = Loader::getInstance(); 212 | $loader->setlocale('cs'); 213 | $loader->textdomain('phpmyadmin'); 214 | $loader->bindtextdomain('phpmyadmin', __DIR__ . '/data/locale/'); 215 | 216 | $translator = $loader->getTranslator(); 217 | self::assertSame( 218 | 'Typ', 219 | $translator->gettext('Type'), 220 | ); 221 | 222 | /* Ensure the object survives */ 223 | $loader = Loader::getInstance(); 224 | $translator = $loader->getTranslator(); 225 | self::assertSame( 226 | 'Typ', 227 | $translator->gettext('Type'), 228 | ); 229 | 230 | /* Ensure the object can support different locale files for the same domain */ 231 | $loader = Loader::getInstance(); 232 | $loader->setlocale('be_BY'); 233 | $loader->bindtextdomain('phpmyadmin', __DIR__ . '/data/locale/'); 234 | $translator = $loader->getTranslator(); 235 | self::assertSame( 236 | 'Тып', 237 | $translator->gettext('Type'), 238 | ); 239 | } 240 | 241 | public function testDetect(): void 242 | { 243 | $GLOBALS['lang'] = 'foo'; 244 | $loader = Loader::getInstance(); 245 | self::assertSame( 246 | 'foo', 247 | $loader->detectlocale(), 248 | ); 249 | unset($GLOBALS['lang']); 250 | } 251 | 252 | public function testDetectEnv(): void 253 | { 254 | $loader = Loader::getInstance(); 255 | foreach (['LC_MESSAGES', 'LC_ALL', 'LANG'] as $var) { 256 | putenv($var); 257 | if (getenv($var) === false) { 258 | continue; 259 | } 260 | 261 | $this->markTestSkipped('Unsetting environment does not work'); 262 | } 263 | 264 | unset($GLOBALS['lang']); 265 | putenv('LC_ALL=baz'); 266 | self::assertSame( 267 | 'baz', 268 | $loader->detectlocale(), 269 | ); 270 | putenv('LC_ALL'); 271 | putenv('LC_MESSAGES=bar'); 272 | self::assertSame( 273 | 'bar', 274 | $loader->detectlocale(), 275 | ); 276 | putenv('LC_MESSAGES'); 277 | putenv('LANG=barr'); 278 | self::assertSame( 279 | 'barr', 280 | $loader->detectlocale(), 281 | ); 282 | putenv('LANG'); 283 | self::assertSame( 284 | 'en', 285 | $loader->detectlocale(), 286 | ); 287 | } 288 | 289 | public function testSetCacheFactory(): void 290 | { 291 | $expected = 'Foo'; 292 | $locale = 'be_BY'; 293 | $domain = 'apcu'; 294 | 295 | $cache = $this->createMock(CacheInterface::class); 296 | $cache->method('get') 297 | ->willReturn($expected); 298 | /** @var CacheFactoryInterface&MockObject $factory */ 299 | $factory = $this->createMock(CacheFactoryInterface::class); 300 | $factory->expects($this->once()) 301 | ->method('getInstance') 302 | ->with($this->isInstanceOf(MoParser::class), $locale, $domain) 303 | ->willReturn($cache); 304 | 305 | Loader::setCacheFactory($factory); 306 | $loader = Loader::getInstance(); 307 | $loader->setlocale($locale); 308 | $loader->bindtextdomain($domain, __DIR__ . '/data/locale/'); 309 | $translator = $loader->getTranslator($domain); 310 | 311 | $actual = $translator->gettext('Type'); 312 | self::assertSame($expected, $actual); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /tests/MoFilesTest.php: -------------------------------------------------------------------------------- 1 | getTranslator($filename); 26 | self::assertSame( 27 | 'Pole', 28 | $parser->gettext('Column'), 29 | ); 30 | // Non existing string 31 | self::assertSame( 32 | 'Column parser', 33 | $parser->gettext('Column parser'), 34 | ); 35 | } 36 | 37 | #[DataProvider('provideMoFiles')] 38 | public function testMoFilePlurals(string $filename): void 39 | { 40 | $parser = $this->getTranslator($filename); 41 | $expected2 = '%d sekundy'; 42 | if (str_contains($filename, 'invalid-formula.mo') || str_contains($filename, 'lessplurals.mo')) { 43 | $expected0 = '%d sekunda'; 44 | $expected2 = '%d sekunda'; 45 | } elseif (str_contains($filename, 'plurals.mo') || str_contains($filename, 'noheader.mo')) { 46 | $expected0 = '%d sekundy'; 47 | } else { 48 | $expected0 = '%d sekund'; 49 | } 50 | 51 | self::assertSame($expected0, $parser->ngettext('%d second', '%d seconds', 0)); 52 | self::assertSame('%d sekunda', $parser->ngettext('%d second', '%d seconds', 1)); 53 | self::assertSame($expected2, $parser->ngettext('%d second', '%d seconds', 2)); 54 | self::assertSame($expected0, $parser->ngettext('%d second', '%d seconds', 5)); 55 | self::assertSame($expected0, $parser->ngettext('%d second', '%d seconds', 10)); 56 | // Non existing string 57 | self::assertSame('"%d" seconds', $parser->ngettext('"%d" second', '"%d" seconds', 10)); 58 | } 59 | 60 | #[DataProvider('provideMoFiles')] 61 | public function testMoFileContext(string $filename): void 62 | { 63 | $parser = $this->getTranslator($filename); 64 | self::assertSame('Tabulka', $parser->pgettext('Display format', 'Table')); 65 | } 66 | 67 | #[DataProvider('provideNotTranslatedFiles')] 68 | public function testMoFileNotTranslated(string $filename): void 69 | { 70 | $parser = $this->getTranslator($filename); 71 | self::assertSame('%d second', $parser->ngettext('%d second', '%d seconds', 1)); 72 | } 73 | 74 | /** @return list */ 75 | public static function provideMoFiles(): array 76 | { 77 | return self::getFiles('./tests/data/*.mo'); 78 | } 79 | 80 | /** @return list */ 81 | public static function provideErrorMoFiles(): array 82 | { 83 | return self::getFiles('./tests/data/error/*.mo'); 84 | } 85 | 86 | /** @return list */ 87 | public static function provideNotTranslatedFiles(): array 88 | { 89 | return self::getFiles('./tests/data/not-translated/*.mo'); 90 | } 91 | 92 | #[DataProvider('provideErrorMoFiles')] 93 | public function testEmptyMoFile(string $file): void 94 | { 95 | $parser = new MoParser($file); 96 | $translator = new Translator(new InMemoryCache($parser)); 97 | if (basename($file) === 'magic.mo') { 98 | self::assertSame(Translator::ERROR_BAD_MAGIC, $parser->error); 99 | } else { 100 | self::assertSame(Translator::ERROR_READING, $parser->error); 101 | } 102 | 103 | self::assertSame('Table', $translator->pgettext('Display format', 'Table')); 104 | self::assertSame('"%d" seconds', $translator->ngettext('"%d" second', '"%d" seconds', 10)); 105 | } 106 | 107 | #[DataProvider('provideMoFiles')] 108 | public function testExists(string $file): void 109 | { 110 | $parser = $this->getTranslator($file); 111 | self::assertTrue($parser->exists('Column')); 112 | self::assertFalse($parser->exists('Column parser')); 113 | } 114 | 115 | /** 116 | * @param string $pattern path names pattern to match 117 | * 118 | * @return list 119 | */ 120 | private static function getFiles(string $pattern): array 121 | { 122 | $files = glob($pattern); 123 | if ($files === false) { 124 | return []; 125 | } 126 | 127 | $result = []; 128 | foreach ($files as $file) { 129 | $result[] = [$file]; 130 | } 131 | 132 | return $result; 133 | } 134 | 135 | private function getTranslator(string $filename): Translator 136 | { 137 | return new Translator(new InMemoryCache(new MoParser($filename))); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/PluralFormulaTest.php: -------------------------------------------------------------------------------- 1 | */ 29 | public static function pluralExtractionData(): array 30 | { 31 | return [ 32 | // It defaults to a "Western-style" plural header. 33 | [ 34 | '', 35 | 'nplurals=2; plural=n == 1 ? 0 : 1;', 36 | ], 37 | // Extracting it from the middle of the header works. 38 | [ 39 | "Content-type: text/html; charset=UTF-8\n" 40 | . "Plural-Forms: nplurals=1; plural=0;\n" 41 | . "Last-Translator: nobody\n", 42 | ' nplurals=1; plural=0;', 43 | ], 44 | // It's also case-insensitive. 45 | [ 46 | "PLURAL-forms: nplurals=1; plural=0;\n", 47 | ' nplurals=1; plural=0;', 48 | ], 49 | // It falls back to default if it's not on a separate line. 50 | [ 51 | 'Content-type: text/html; charset=UTF-8' // note the missing \n here 52 | . "Plural-Forms: nplurals=1; plural=0;\n" 53 | . "Last-Translator: nobody\n", 54 | 'nplurals=2; plural=n == 1 ? 0 : 1;', 55 | ], 56 | ]; 57 | } 58 | 59 | #[DataProvider('pluralCounts')] 60 | public function testPluralCounts(string $expr, int $expected): void 61 | { 62 | self::assertSame( 63 | $expected, 64 | Translator::extractPluralCount($expr), 65 | ); 66 | } 67 | 68 | /** @return list */ 69 | public static function pluralCounts(): array 70 | { 71 | return [ 72 | [ 73 | '', 74 | 1, 75 | ], 76 | [ 77 | 'foo=2; expr', 78 | 1, 79 | ], 80 | [ 81 | 'nplurals=2; epxr', 82 | 2, 83 | ], 84 | [ 85 | ' nplurals = 3 ; epxr', 86 | 3, 87 | ], 88 | [ 89 | ' nplurals = 4 ; epxr ; ', 90 | 4, 91 | ], 92 | [ 93 | 'nplurals', 94 | 1, 95 | ], 96 | ]; 97 | } 98 | 99 | #[DataProvider('pluralExpressions')] 100 | public function testPluralExpression(string $expr, string $expected): void 101 | { 102 | self::assertSame( 103 | $expected, 104 | Translator::sanitizePluralExpression($expr), 105 | ); 106 | } 107 | 108 | /** @return list */ 109 | public static function pluralExpressions(): array 110 | { 111 | return [ 112 | [ 113 | '', 114 | '', 115 | ], 116 | [ 117 | 'nplurals=2; plural=n == 1 ? 0 : 1;', 118 | 'n == 1 ? 0 : 1', 119 | ], 120 | [ 121 | ' nplurals=1; plural=0;', 122 | '0', 123 | ], 124 | [ 125 | "nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n", 126 | 'n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5', 127 | ], 128 | [ 129 | ' nplurals=1; plural=baz(n);', 130 | '(n)', 131 | ], 132 | [ 133 | ' plural=n', 134 | 'n', 135 | ], 136 | [ 137 | 'nplurals', 138 | 'n', 139 | ], 140 | ]; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/PluralTest.php: -------------------------------------------------------------------------------- 1 | getTranslator(''); 31 | $result = $parser->npgettext('context', "%d pig went to the market\n", "%d pigs went to the market\n", $number); 32 | self::assertSame($expected, $result); 33 | } 34 | 35 | /** 36 | * Data provider for test_npgettext. 37 | * 38 | * @return list 39 | */ 40 | public static function providerTestNpgettext(): array 41 | { 42 | return [ 43 | [ 44 | 1, 45 | "%d pig went to the market\n", 46 | ], 47 | [ 48 | 2, 49 | "%d pigs went to the market\n", 50 | ], 51 | ]; 52 | } 53 | 54 | /** 55 | * Test for ngettext 56 | */ 57 | public function testNgettext(): void 58 | { 59 | $parser = $this->getTranslator(''); 60 | $translationKey = implode(chr(0), ["%d pig went to the market\n", "%d pigs went to the market\n"]); 61 | $parser->setTranslation($translationKey, ''); 62 | $result = $parser->ngettext("%d pig went to the market\n", "%d pigs went to the market\n", 1); 63 | self::assertSame('', $result); 64 | } 65 | 66 | /** @return list */ 67 | public static function dataProviderPluralForms(): array 68 | { 69 | return [ 70 | ['Plural-Forms: nplurals=2; plural=n != 1;'], 71 | ['Plural-Forms: nplurals=1; plural=0;'], 72 | ['Plural-Forms: nplurals=2; plural=(n > 1);'], 73 | [ 74 | 'Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n' 75 | . '%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;', 76 | ], 77 | ['Plural-Forms: nplurals=2; plural=n >= 2 && (n < 11 || n > 99);'], 78 | ['Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;'], 79 | ['Plural-Forms: nplurals=3; plural=n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2;'], 80 | [ 81 | 'Plural-Forms: nplurals=2; plural=n != 1 && n != 2 && n != 3 &' 82 | . '& (n % 10 == 4 || n % 10 == 6 || n % 10 == 9);', 83 | ], 84 | ]; 85 | } 86 | 87 | /** 88 | * Test for ngettext 89 | * 90 | * @see https://github.com/phpmyadmin/motranslator/issues/37 91 | */ 92 | #[DataProvider('dataProviderPluralForms')] 93 | public function testNgettextSelectString(string $pluralForms): void 94 | { 95 | $parser = $this->getTranslator(''); 96 | $parser->setTranslation( 97 | '', 98 | "Project-Id-Version: phpMyAdmin 5.1.0-dev\n" 99 | . "Report-Msgid-Bugs-To: translators@phpmyadmin.net\n" 100 | . "PO-Revision-Date: 2020-09-01 09:12+0000\n" 101 | . "Last-Translator: William Desportes \n" 102 | . 'Language-Team: English (United Kingdom) ' 103 | . "\n" 104 | . "Language: en_GB\n" 105 | . "MIME-Version: 1.0\n" 106 | . "Content-Type: text\/plain; charset=UTF-8\n" 107 | . "Content-Transfer-Encoding: 8bit\n" 108 | . $pluralForms . "\n" 109 | . "X-Generator: Weblate 4.2.1-dev\n" 110 | . '', 111 | ); 112 | $translationKey = implode(chr(0), ["%d pig went to the market\n", "%d pigs went to the market\n"]); 113 | $parser->setTranslation($translationKey, 'ok'); 114 | $result = $parser->ngettext("%d pig went to the market\n", "%d pigs went to the market\n", 1); 115 | self::assertSame('ok', $result); 116 | } 117 | 118 | private function getTranslator(string $filename): Translator 119 | { 120 | return new Translator(new InMemoryCache(new MoParser($filename))); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/StringReaderTest.php: -------------------------------------------------------------------------------- 1 | read(-1, -1); 24 | self::assertSame('', $actual); 25 | } 26 | 27 | public function testReadIntArray(): void 28 | { 29 | $tempFile = (string) tempnam(sys_get_temp_dir(), 'phpMyAdmin_StringReaderTest'); 30 | file_put_contents($tempFile, "\0\0\0\0\0\0\0\0\0\0\0\0"); 31 | self::assertFileExists($tempFile); 32 | $stringReader = new StringReader($tempFile); 33 | unlink($tempFile); 34 | $actual = $stringReader->readintarray('V', 2, 2); 35 | self::assertSame([ 36 | 1 => 0, 37 | 2 => 0, 38 | ], $actual); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/TranslatorTest.php: -------------------------------------------------------------------------------- 1 | gettext('Column'); 25 | self::assertSame($expected, $actual); 26 | } 27 | 28 | public function testConstructorWithNullParam(): void 29 | { 30 | $expected = 'Column'; 31 | $translator = new Translator(null); 32 | $actual = $translator->gettext($expected); 33 | self::assertSame($expected, $actual); 34 | } 35 | 36 | /** 37 | * Test on empty gettext 38 | */ 39 | public function testGettext(): void 40 | { 41 | $translator = $this->getTranslator(''); 42 | self::assertSame('Test', $translator->gettext('Test')); 43 | } 44 | 45 | /** 46 | * Test set a translation 47 | */ 48 | public function testSetTranslation(): void 49 | { 50 | $translator = $this->getTranslator(''); 51 | $translator->setTranslation('Test', 'Translation'); 52 | self::assertSame('Translation', $translator->gettext('Test')); 53 | } 54 | 55 | /** 56 | * Test get and set all translations 57 | */ 58 | public function testGetSetTranslations(): void 59 | { 60 | $transTable = ['Test' => 'Translation']; 61 | $translator = $this->getTranslator(''); 62 | $translator->setTranslations($transTable); 63 | self::assertSame('Translation', $translator->gettext('Test')); 64 | self::assertSame($transTable, $translator->getTranslations()); 65 | $translator = $this->getTranslator(null); 66 | $translator->setTranslations($transTable); 67 | self::assertSame($transTable, $translator->getTranslations()); 68 | self::assertSame('Translation', $translator->gettext('Test')); 69 | $transTable = [ 70 | 'Test' => 'Translation', 71 | 'shouldIWriteTests' => 'as much as possible', 72 | 'is it hard' => 'it depends', 73 | ]; 74 | $translator = $this->getTranslator(''); 75 | $translator->setTranslations($transTable); 76 | self::assertSame($transTable, $translator->getTranslations()); 77 | self::assertSame('as much as possible', $translator->gettext('shouldIWriteTests')); 78 | $translator = $this->getTranslator(null); 79 | $translator->setTranslations($transTable); 80 | self::assertSame($transTable, $translator->getTranslations()); 81 | self::assertSame('it depends', $translator->gettext('is it hard')); 82 | } 83 | 84 | public function testGetTranslationsThrowsException(): void 85 | { 86 | /** @var CacheInterface&MockObject $cache */ 87 | $cache = $this->createMock(CacheInterface::class); 88 | $translator = new Translator($cache); 89 | 90 | $this->expectException(CacheException::class); 91 | $translator->getTranslations(); 92 | } 93 | 94 | private function getTranslator(string|null $filename): Translator 95 | { 96 | return new Translator(new InMemoryCache(new MoParser($filename))); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/data/big.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/big.mo -------------------------------------------------------------------------------- /tests/data/error/big.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/error/big.mo -------------------------------------------------------------------------------- /tests/data/error/dos.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/error/dos.mo -------------------------------------------------------------------------------- /tests/data/error/empty.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/error/empty.mo -------------------------------------------------------------------------------- /tests/data/error/fpd.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/error/fpd.mo -------------------------------------------------------------------------------- /tests/data/error/fpdle.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/error/fpdle.mo -------------------------------------------------------------------------------- /tests/data/error/magic.mo: -------------------------------------------------------------------------------- 1 | 1234 2 | -------------------------------------------------------------------------------- /tests/data/invalid-formula.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/invalid-formula.mo -------------------------------------------------------------------------------- /tests/data/lessplurals.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/lessplurals.mo -------------------------------------------------------------------------------- /tests/data/little.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/little.mo -------------------------------------------------------------------------------- /tests/data/locale/be/LC_MESSAGES/phpmyadmin.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/locale/be/LC_MESSAGES/phpmyadmin.mo -------------------------------------------------------------------------------- /tests/data/locale/be@latin/LC_MESSAGES/phpmyadmin.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/locale/be@latin/LC_MESSAGES/phpmyadmin.mo -------------------------------------------------------------------------------- /tests/data/locale/cs/LC_MESSAGES/phpmyadmin.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/locale/cs/LC_MESSAGES/phpmyadmin.mo -------------------------------------------------------------------------------- /tests/data/noheader.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/noheader.mo -------------------------------------------------------------------------------- /tests/data/not-translated/fpd1.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/not-translated/fpd1.mo -------------------------------------------------------------------------------- /tests/data/not-translated/invalid-equation.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/not-translated/invalid-equation.mo -------------------------------------------------------------------------------- /tests/data/plurals.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpmyadmin/motranslator/28b03785c0d298897bba74f4a35467dbb126ffdb/tests/data/plurals.mo --------------------------------------------------------------------------------