diff --git a/LICENSE b/LICENSE index 17cb286..d159169 100644 --- a/LICENSE +++ b/LICENSE @@ -1,117 +1,339 @@ -GNU GENERAL PUBLIC LICENSE -Version 2, June 1991 + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 -Copyright (C) 1989, 1991 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + Preamble -Preamble + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. -To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. -For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. -We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. -Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. -Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. + The precise terms and conditions for copying, distribution and +modification follow. -The precise terms and conditions for copying, distribution and modification follow. + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". -0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. -1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: -2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. - a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. - b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) - c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. -In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: -3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, - a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, - b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) - c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. -The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. -If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. -4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. -5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. -6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. -7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. -8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. -9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. -Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. -10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + NO WARRANTY -NO WARRANTY + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. -11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. -12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + END OF TERMS AND CONDITIONS -END OF TERMS AND CONDITIONS + How to Apply These Terms to Your New Programs -How to Apply These Terms to Your New Programs + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + Copyright (C) - one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. - This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. +Also add information on how to contact you by electronic and paper mail. -If the program is interactive, make it output a short notice like this when it starts in an interactive mode: +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: - Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. -The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: - Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. -signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md index f624c97..bdc6621 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ # Mirage2 -Mirage2 is a fast and very minimal Gtk+ image viewer. \ No newline at end of file +Mirage2 is a fast and very minimal Gtk+ image viewer. + +# Notes +Still Work in progress! + +
Install Setup
+``` +sudo apt-get install python3.8 python3-setproctitle python3-gi +``` + +# TODO +
    +
  • Add flip and rotate functionality.
  • +
  • Add scroll in and out functionality.
  • +
  • Add save and save as functionality.
  • +
  • Add play back of gif animation plus controls to modify speed and played frames.
  • +
  • Add simple image effects.
  • +
+ +# Images +![1 Mirage2 loaded with image and thumbnails. ](images/pic1.png) diff --git a/images/pic1.png b/images/pic1.png new file mode 100644 index 0000000..911f3d7 Binary files /dev/null and b/images/pic1.png differ diff --git a/src/__builtins__.py b/src/__builtins__.py new file mode 100644 index 0000000..e1bfefc --- /dev/null +++ b/src/__builtins__.py @@ -0,0 +1,76 @@ +import builtins + +# Python imports +import builtins + +# Lib imports + +# Application imports +from controller import IPCServerMixin + + + +class Builtins(IPCServerMixin): + """Docstring for __builtins__ extender""" + + def __init__(self): + # NOTE: The format used is list of [type, target, data] Where: + # type is useful context for control flow, + # target is the method to call, + # data is the method parameters to give + # Where data may be any kind of data + self._gui_events = [] + self._module_events = [] + self.is_ipc_alive = False + self.ipc_authkey = b'mirage-ipc' + self.ipc_address = '127.0.0.1' + self.ipc_port = 8877 + self.ipc_timeout = 15.0 + + # Makeshift fake "events" type system FIFO + def _pop_gui_event(self): + if len(self._gui_events) > 0: + return self._gui_events.pop(0) + return None + + def _pop_module_event(self): + if len(self._module_events) > 0: + return self._module_events.pop(0) + return None + + + def push_gui_event(self, event): + if len(event) == 3: + self._gui_events.append(event) + return None + + raise Exception("Invald event format! Please do: [type, target, data]") + + def push_module_event(self, event): + if len(event) == 3: + self._module_events.append(event) + return None + + raise Exception("Invald event format! Please do: [type, target, data]") + + def read_gui_event(self): + return self._gui_events[0] + + def read_module_event(self): + return self._module_events[0] + + def consume_gui_event(self): + return self._pop_gui_event() + + def consume_module_event(self): + return self._pop_module_event() + + + +# NOTE: Just reminding myself we can add to builtins two different ways... +# __builtins__.update({"event_system": Builtins()}) +builtins.app_name = "Mirage2" +builtins.event_system = Builtins() +builtins.event_sleep_time = 0.2 +builtins.debug = False +builtins.trace_debug = False diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..5e629c1 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,51 @@ +# Python imports +import os, inspect, time + +# Lib imports + +# Application imports +from utils import Settings +from controller import Controller +from __builtins__ import Builtins + + + + +class Main(Builtins): + def __init__(self, args, unknownargs): + if not debug: + event_system.create_ipc_server() + + # NOTE: Keeping here just incase I change my mind... + # time.sleep(0.2) + # if not trace_debug: + # if not event_system.is_ipc_alive: + # if unknownargs: + # for arg in unknownargs: + # if os.path.isdir(arg): + # message = f"FILE|{arg}" + # event_system.send_ipc_message(message) + # + # raise Exception("IPC Server Exists: Will send data to it and close...") + + + settings = Settings() + settings.create_window() + + controller = Controller(settings, args, unknownargs) + if not controller: + raise Exception("Controller exited and doesn't exist...") + + # Gets the methods from the classes and sets to handler. + # Then, builder from settings will connect to any signals it needs. + classes = [controller] + handlers = {} + for c in classes: + methods = None + try: + methods = inspect.getmembers(c, predicate=inspect.ismethod) + handlers.update(methods) + except Exception as e: + print(repr(e)) + + settings.get_builder().connect_signals(handlers) diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..a58f911 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,41 @@ +#!/usr/bin/python3 + + +# Python imports +import argparse, faulthandler, traceback +from setproctitle import setproctitle + +import tracemalloc +tracemalloc.start() + + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from __init__ import Main + + +if __name__ == "__main__": + try: + # import web_pdb + # web_pdb.set_trace() + + setproctitle('Mirage2') + faulthandler.enable() # For better debug info + + parser = argparse.ArgumentParser() + # Add long and short arguments + parser.add_argument("--file", "-f", default=None, help="JOpen an image.") + parser.add_argument("--dir", "-d", default=None, help="load a dir with images.") + + # Read arguments (If any...) + args, unknownargs = parser.parse_known_args() + + Main(args, unknownargs) + Gtk.main() + except Exception as e: + traceback.print_exc() + quit() diff --git a/src/controller/Controller.py b/src/controller/Controller.py new file mode 100644 index 0000000..acba0f8 --- /dev/null +++ b/src/controller/Controller.py @@ -0,0 +1,140 @@ +# Python imports +import os +import threading, subprocess, time + + +# Gtk imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GLib, GdkPixbuf + +# Application imports +from .mixins import * +from . import Controller_Data + + + +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs).start() + + return wrapper + + +class Controller(TreeViewUpdateMixin, Controller_Data): + def __init__(self, _settings, args, unknownargs): + self.setup_controller_data(_settings) + self.window.show_all() + self.handle_args(args, unknownargs) + + + def tear_down(self, widget=None, eve=None): + event_system.send_ipc_message("close server") + + time.sleep(event_sleep_time) + Gtk.main_quit() + + + @threaded + def gui_event_observer(self): + while True: + time.sleep(event_sleep_time) + event = event_system.consume_gui_event() + if event: + try: + type, target, data = event + if not type: + method = getattr(self.__class__, target) + GLib.idle_add(method, *(self, *data,)) + else: + method = getattr(self.__class__, "hadle_gui_event_and_call_back") + GLib.idle_add(method, *(self, type, target, data)) + except Exception as e: + print(repr(e)) + + def hadle_gui_event_and_call_back(self, type, target, parameters): + method = getattr(self.__class__, target) + data = method(*(self, *parameters)) + event_system.push_module_event([type, None, (data,)]) + + + def handle_args(self, args=None, unknownargs=None): + if args.dir and os.path.isdir(args.dir): + self.load_store(self.view, self.thumbnail_store, arg) + + if args.file and os.path.isfile(args.file): + path = "/" + '/'.join(rgs.file.split("/")[:-1]) + self.load_store(self.view, self.thumbnail_store, path) + image = Gtk.Image.new_from_pixbuf(self._get_pixbuf(args.file)) + self._load_image(image) + + if unknownargs: + for arg in unknownargs: + if os.path.isdir(arg): + self.load_store(self.view, self.thumbnail_store, arg) + elif os.path.isfile(arg): + path = "/" + '/'.join(arg.split("/")[:-1]) + self.load_store(self.view, self.thumbnail_store, path) + image = Gtk.Image.new_from_pixbuf(self._get_pixbuf(arg)) + self._load_image(image) + + + + def _on_drag_data_received(self, widget, drag_context, x, y, data, info, time): + if info == 80: + uri = data.get_uris()[0].split("file://")[1] + if os.path.isfile(uri) and uri.endswith(self.images_filter): + try: + image = Gtk.Image.new_from_pixbuf(self._get_pixbuf(uri)) + except Exception as e: + image = Gtk.Image.new_from_pixbuf(self._get_pixbuf(self.blank_image)) + + self._load_image(image) + elif os.path.isdir(uri): + self.load_store(self.view, self.thumbnail_store, uri) + + + def load_image_from_treeview(self, widget): + store, iter = widget.get_selection().get_selected() + uri = store.get_value(iter, 1) + image = Gtk.Image.new_from_pixbuf(self._get_pixbuf(uri)) + self._load_image(image) + + def _get_pixbuf(self, uri): + self.current_path_label.set_label(uri) + self.current_img = uri + geom_rec = self.image_area.get_parent().get_parent().get_allocated_size()[0] + width = geom_rec.width - 15 + height = geom_rec.height - 15 + self.image_area.set_size_request(width, height) + return GdkPixbuf.Pixbuf.new_from_file_at_scale(uri, width, height, True) + + def _load_image(self, img): + self.clear_children(self.image_area) + self.image_area.add(img) + self.image_area.show_all() + + @threaded + def scale_image_from_parent_resize(self, widget, allocation): + if not self.image_update_lock: + GLib.idle_add(self._on_scale_image_from_parent_resize, ()) + + + def _on_scale_image_from_parent_resize(self, eve): + if self.current_img: + self.image_update_lock = True + image = Gtk.Image.new_from_pixbuf(self._get_pixbuf(self.current_img)) + self._load_image(image) + self.image_update_lock = False + + def get_clipboard_data(self): + proc = subprocess.Popen(['xclip','-selection', 'clipboard', '-o'], stdout=subprocess.PIPE) + retcode = proc.wait() + data = proc.stdout.read() + return data.decode("utf-8").strip() + + def set_clipboard_data(self, data): + proc = subprocess.Popen(['xclip','-selection','clipboard'], stdin=subprocess.PIPE) + proc.stdin.write(data) + proc.stdin.close() + retcode = proc.wait() diff --git a/src/controller/Controller_Data.py b/src/controller/Controller_Data.py new file mode 100644 index 0000000..c74e03d --- /dev/null +++ b/src/controller/Controller_Data.py @@ -0,0 +1,67 @@ +# Python imports +import os, signal + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk, Gdk, GLib + +# Application imports +from . import View + + + +class Controller_Data: + def clear_children(self, widget): + ''' Clear children of a gtk widget. ''' + for child in widget.get_children(): + widget.remove(child) + + def clear_console(self): + os.system('cls' if os.name == 'nt' else 'clear') + + def call_method(self, _method_name, data = None): + method_name = str(_method_name) + method = getattr(self, method_name, lambda data: f"No valid key passed...\nkey={method_name}\nargs={data}") + return method(data) if data else method() + + def has_method(self, obj, name): + return callable(getattr(obj, name, None)) + + def setup_controller_data(self, _settings): + self.settings = _settings + self.builder = self.settings.get_builder() + self.window = self.settings.get_main_window() + self.logger = self.settings.get_logger() + + self.home_path = self.settings.get_home_path() + self.success_color = self.settings.get_success_color() + self.warning_color = self.settings.get_warning_color() + self.error_color = self.settings.get_error_color() + + + self.current_path_label = self.builder.get_object("current_path_label") + self.thumbnails_view = self.builder.get_object("thumbnails_view") + self.thumbnail_store = self.builder.get_object("thumbnail_store") + self.image_area = self.builder.get_object("image_area") + self.blank_image = self.settings.get_blank_image() + + + self.thumbnails_view.connect("drag-data-received", self._on_drag_data_received) + URI_TARGET_TYPE = 80 + uri_target = Gtk.TargetEntry.new('text/uri-list', Gtk.TargetFlags(0), URI_TARGET_TYPE) + targets = [ uri_target ] + action = Gdk.DragAction.COPY + self.thumbnails_view.enable_model_drag_dest(targets, action) + self.thumbnails_view.enable_model_drag_source(0, targets, action) + + self.images_filter = self.settings.get_images_filter() + self.view = View(self.images_filter, self.blank_image) + self.current_img = None + self.image_update_lock = False + + + + self.window.connect("delete-event", self.tear_down) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self.tear_down) diff --git a/src/controller/IPCServerMixin.py b/src/controller/IPCServerMixin.py new file mode 100644 index 0000000..7410fb8 --- /dev/null +++ b/src/controller/IPCServerMixin.py @@ -0,0 +1,64 @@ +# Python imports +import threading, socket, time +from multiprocessing.connection import Listener, Client + +# Lib imports + +# Application imports + + +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() + return wrapper + + + + +class IPCServerMixin: + + @threaded + def create_ipc_server(self): + listener = Listener((self.ipc_address, self.ipc_port), authkey=self.ipc_authkey) + self.is_ipc_alive = True + while True: + conn = listener.accept() + start_time = time.time() + + print(f"New Connection: {listener.last_accepted}") + while True: + msg = conn.recv() + if debug: + print(msg) + + if "FILE|" in msg: + file = msg.split("FILE|")[1].strip() + if file: + event_system.push_gui_event([None, "handle_file_from_ipc", (file,)]) + + conn.close() + break + + + if msg == 'close connection': + conn.close() + break + if msg == 'close server': + conn.close() + break + + # NOTE: Not perfect but insures we don't lockup the connection for too long. + end_time = time.time() + if (end - start) > self.ipc_timeout: + conn.close() + + listener.close() + + + def send_ipc_message(self, message="Empty Data..."): + try: + conn = Client((self.ipc_address, self.ipc_port), authkey=self.ipc_authkey) + conn.send(message) + conn.send('close connection') + except Exception as e: + print(repr(e)) diff --git a/src/controller/View.py b/src/controller/View.py new file mode 100644 index 0000000..aa2c16f --- /dev/null +++ b/src/controller/View.py @@ -0,0 +1,78 @@ +# Python imports +import hashlib, re +from os import listdir +from os.path import isdir, isfile, join + +# Lib imports + + +# Application imports +from .icons.icon import Icon + + +class View(Icon): + def __init__(self, img_filter, default_icon): + self.DEFAULT_ICON = default_icon + self._hide_hidden = True + self._images = [] + self.fimages = img_filter + self.VIDEO_ICON_WH = [256, 128] + + def load_directory(self, _path): + path = _path + self._images = [] + + if not isdir(path): + return "" + + for f in listdir(path): + file = join(path, f) + if self._hide_hidden: + if f.startswith('.'): + continue + + if isfile(file): + lowerName = file.lower() + if lowerName.endswith(self.fimages): + self._images.append(f) + + self._images.sort(key=self._natural_keys) + + + def get_pixbuf_icon_str_combo(self): + data = [] + dir = self.get_current_directory() + for file in self._files: + icon = self.create_icon(dir, file).get_pixbuf() + data.append([icon, file]) + + return data + + + def get_gtk_icon_str_combo(self): + data = [] + dir = self.get_current_directory() + for file in self._files: + icon = self.create_icon(dir, file) + data.append([icon, file[0]]) + + return data + + + def get_images(self): + return self._hash_set(self._images) + + def _hash_text(self, text): + return hashlib.sha256(str.encode(text)).hexdigest()[:18] + + def _hash_set(self, arry): + data = [] + for arr in arry: + data.append([arr, self._hash_text(arr)]) + return data + + def _atoi(self, text): + return int(text) if text.isdigit() else text + + def _natural_keys(self, text): + return [ self._atoi(c) for c in re.split('(\d+)',text) ] diff --git a/src/controller/__init__.py b/src/controller/__init__.py new file mode 100644 index 0000000..c3eeae6 --- /dev/null +++ b/src/controller/__init__.py @@ -0,0 +1,8 @@ +""" + Gtk Bound Signal Module +""" +from .mixins import * +from .View import View +from .IPCServerMixin import IPCServerMixin +from .Controller_Data import Controller_Data +from .Controller import Controller diff --git a/src/controller/icons/__init__.py b/src/controller/icons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controller/icons/icon.py b/src/controller/icons/icon.py new file mode 100644 index 0000000..2f3ba4c --- /dev/null +++ b/src/controller/icons/icon.py @@ -0,0 +1,78 @@ +# Python Imports +import os, subprocess, threading, hashlib +from os.path import isfile + +# Gtk imports +import gi +gi.require_version('GdkPixbuf', '2.0') +from gi.repository import GdkPixbuf + +# Application imports +from .mixins.desktopiconmixin import DesktopIconMixin +from .mixins.videoiconmixin import VideoIconMixin + + +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs).start() + return wrapper + + +class Icon(DesktopIconMixin, VideoIconMixin): + def create_icon(self, dir, file): + full_path = f"{dir}/{file}" + return self.get_icon_image(dir, file, full_path) + + def get_icon_image(self, dir, file, full_path): + try: + thumbnl = None + + if file.lower().endswith(self.fimages): # Image Icon + thumbnl = self.create_scaled_image(full_path, self.VIDEO_ICON_WH) + + return thumbnl + except Exception as e: + return None + + def create_thumbnail(self, dir, file): + full_path = f"{dir}/{file}" + try: + file_hash = hashlib.sha256(str.encode(full_path)).hexdigest() + hash_img_pth = f"{self.ABS_THUMBS_PTH}/{file_hash}.jpg" + if isfile(hash_img_pth) == False: + self.generate_video_thumbnail(full_path, hash_img_pth) + + thumbnl = self.create_scaled_image(hash_img_pth, self.VIDEO_ICON_WH) + if thumbnl == None: # If no icon whatsoever, return internal default + thumbnl = GdkPixbuf.Pixbuf.new_from_file(f"{self.DEFAULT_ICONS}/video.png") + + return thumbnl + except Exception as e: + print("Thumbnail generation issue:") + print( repr(e) ) + return GdkPixbuf.Pixbuf.new_from_file(f"{self.DEFAULT_ICONS}/video.png") + + + def create_scaled_image(self, path, wxh): + try: + if path.lower().endswith(".gif"): + return GdkPixbuf.PixbufAnimation.new_from_file(path) \ + .get_static_image() \ + .scale_simple(wxh[0], wxh[1], GdkPixbuf.InterpType.BILINEAR) + else: + return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, wxh[0], wxh[1], True) + except Exception as e: + print("Image Scaling Issue:") + print( repr(e) ) + return None + + def create_from_file(self, path): + try: + return GdkPixbuf.Pixbuf.new_from_file(path) + except Exception as e: + print("Image from file Issue:") + print( repr(e) ) + return None + + def return_generic_icon(self): + return GdkPixbuf.Pixbuf.new_from_file(self.DEFAULT_ICON) diff --git a/src/controller/icons/mixins/__init__.py b/src/controller/icons/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controller/icons/mixins/desktopiconmixin.py b/src/controller/icons/mixins/desktopiconmixin.py new file mode 100644 index 0000000..9f5ed2e --- /dev/null +++ b/src/controller/icons/mixins/desktopiconmixin.py @@ -0,0 +1,62 @@ +# Python Imports +import os, subprocess, hashlib +from os.path import isfile + +# Gtk imports + +# Application imports +from .xdg.DesktopEntry import DesktopEntry + + +class DesktopIconMixin: + def parse_desktop_files(self, full_path): + try: + xdgObj = DesktopEntry(full_path) + icon = xdgObj.getIcon() + alt_icon_path = "" + + if "steam" in icon: + name = xdgObj.getName() + file_hash = hashlib.sha256(str.encode(name)).hexdigest() + hash_img_pth = self.STEAM_ICONS_PTH + "/" + file_hash + ".jpg" + + if isfile(hash_img_pth) == True: + # Use video sizes since headers are bigger + return self.create_scaled_image(hash_img_pth, self.VIDEO_ICON_WH) + + exec_str = xdgObj.getExec() + parts = exec_str.split("steam://rungameid/") + id = parts[len(parts) - 1] + imageLink = self.STEAM_BASE_URL + id + "/header.jpg" + proc = subprocess.Popen(["wget", "-O", hash_img_pth, imageLink]) + proc.wait() + + # Use video thumbnail sizes since headers are bigger + return self.create_scaled_image(hash_img_pth, self.VIDEO_ICON_WH) + elif os.path.exists(icon): + return self.create_scaled_image(icon, self.SYS_ICON_WH) + else: + alt_icon_path = "" + + for dir in self.ICON_DIRS: + alt_icon_path = self.traverse_icons_folder(dir, icon) + if alt_icon_path != "": + break + + return self.create_scaled_image(alt_icon_path, self.SYS_ICON_WH) + except Exception as e: + print(".desktop icon generation issue:") + print( repr(e) ) + return None + + def traverse_icons_folder(self, path, icon): + alt_icon_path = "" + + for (dirpath, dirnames, filenames) in os.walk(path): + for file in filenames: + appNM = "application-x-" + icon + if icon in file or appNM in file: + alt_icon_path = dirpath + "/" + file + break + + return alt_icon_path diff --git a/src/controller/icons/mixins/videoiconmixin.py b/src/controller/icons/mixins/videoiconmixin.py new file mode 100644 index 0000000..fc35e9d --- /dev/null +++ b/src/controller/icons/mixins/videoiconmixin.py @@ -0,0 +1,53 @@ +# Python Imports +import subprocess + +# Gtk imports + +# Application imports + + +class VideoIconMixin: + def generate_video_thumbnail(self, full_path, hash_img_pth): + try: + proc = subprocess.Popen([self.FFMPG_THUMBNLR, "-t", "65%", "-s", "300", "-c", "jpg", "-i", full_path, "-o", hash_img_pth]) + proc.wait() + except Exception as e: + self.logger.debug(repr(e)) + self.ffprobe_generate_video_thumbnail(full_path, hash_img_pth) + + + def ffprobe_generate_video_thumbnail(self, full_path, hash_img_pth): + proc = None + try: + # Stream duration + command = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path] + data = subprocess.run(command, stdout=subprocess.PIPE) + duration = data.stdout.decode('utf-8') + + # Format (container) duration + if "N/A" in duration: + command = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path] + data = subprocess.run(command , stdout=subprocess.PIPE) + duration = data.stdout.decode('utf-8') + + # Stream duration type: image2 + if "N/A" in duration: + command = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-f", "image2", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path] + data = subprocess.run(command, stdout=subprocess.PIPE) + duration = data.stdout.decode('utf-8') + + # Format (container) duration type: image2 + if "N/A" in duration: + command = ["ffprobe", "-v", "error", "-f", "image2", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path] + data = subprocess.run(command , stdout=subprocess.PIPE) + duration = data.stdout.decode('utf-8') + + # Get frame roughly 35% through video + grabTime = str( int( float( duration.split(".")[0] ) * 0.35) ) + command = ["ffmpeg", "-ss", grabTime, "-an", "-i", full_path, "-s", "320x180", "-vframes", "1", hash_img_pth] + proc = subprocess.Popen(command, stdout=subprocess.PIPE) + proc.wait() + except Exception as e: + print("Video thumbnail generation issue in thread:") + print( repr(e) ) + self.logger.debug(repr(e)) diff --git a/src/controller/icons/mixins/xdg/BaseDirectory.py b/src/controller/icons/mixins/xdg/BaseDirectory.py new file mode 100644 index 0000000..a7c31b1 --- /dev/null +++ b/src/controller/icons/mixins/xdg/BaseDirectory.py @@ -0,0 +1,160 @@ +""" +This module is based on a rox module (LGPL): + +http://cvs.sourceforge.net/viewcvs.py/rox/ROX-Lib2/python/rox/basedir.py?rev=1.9&view=log + +The freedesktop.org Base Directory specification provides a way for +applications to locate shared data and configuration: + + http://standards.freedesktop.org/basedir-spec/ + +(based on version 0.6) + +This module can be used to load and save from and to these directories. + +Typical usage: + + from rox import basedir + + for dir in basedir.load_config_paths('mydomain.org', 'MyProg', 'Options'): + print "Load settings from", dir + + dir = basedir.save_config_path('mydomain.org', 'MyProg') + print >>file(os.path.join(dir, 'Options'), 'w'), "foo=2" + +Note: see the rox.Options module for a higher-level API for managing options. +""" + +import os, stat + +_home = os.path.expanduser('~') +xdg_data_home = os.environ.get('XDG_DATA_HOME') or \ + os.path.join(_home, '.local', 'share') + +xdg_data_dirs = [xdg_data_home] + \ + (os.environ.get('XDG_DATA_DIRS') or '/usr/local/share:/usr/share').split(':') + +xdg_config_home = os.environ.get('XDG_CONFIG_HOME') or \ + os.path.join(_home, '.config') + +xdg_config_dirs = [xdg_config_home] + \ + (os.environ.get('XDG_CONFIG_DIRS') or '/etc/xdg').split(':') + +xdg_cache_home = os.environ.get('XDG_CACHE_HOME') or \ + os.path.join(_home, '.cache') + +xdg_data_dirs = [x for x in xdg_data_dirs if x] +xdg_config_dirs = [x for x in xdg_config_dirs if x] + +def save_config_path(*resource): + """Ensure ``$XDG_CONFIG_HOME//`` exists, and return its path. + 'resource' should normally be the name of your application. Use this + when saving configuration settings. + """ + resource = os.path.join(*resource) + assert not resource.startswith('/') + path = os.path.join(xdg_config_home, resource) + if not os.path.isdir(path): + os.makedirs(path, 0o700) + return path + +def save_data_path(*resource): + """Ensure ``$XDG_DATA_HOME//`` exists, and return its path. + 'resource' should normally be the name of your application or a shared + resource. Use this when saving or updating application data. + """ + resource = os.path.join(*resource) + assert not resource.startswith('/') + path = os.path.join(xdg_data_home, resource) + if not os.path.isdir(path): + os.makedirs(path) + return path + +def save_cache_path(*resource): + """Ensure ``$XDG_CACHE_HOME//`` exists, and return its path. + 'resource' should normally be the name of your application or a shared + resource.""" + resource = os.path.join(*resource) + assert not resource.startswith('/') + path = os.path.join(xdg_cache_home, resource) + if not os.path.isdir(path): + os.makedirs(path) + return path + +def load_config_paths(*resource): + """Returns an iterator which gives each directory named 'resource' in the + configuration search path. Information provided by earlier directories should + take precedence over later ones, and the user-specific config dir comes + first.""" + resource = os.path.join(*resource) + for config_dir in xdg_config_dirs: + path = os.path.join(config_dir, resource) + if os.path.exists(path): yield path + +def load_first_config(*resource): + """Returns the first result from load_config_paths, or None if there is nothing + to load.""" + for x in load_config_paths(*resource): + return x + return None + +def load_data_paths(*resource): + """Returns an iterator which gives each directory named 'resource' in the + application data search path. Information provided by earlier directories + should take precedence over later ones.""" + resource = os.path.join(*resource) + for data_dir in xdg_data_dirs: + path = os.path.join(data_dir, resource) + if os.path.exists(path): yield path + +def get_runtime_dir(strict=True): + """Returns the value of $XDG_RUNTIME_DIR, a directory path. + + This directory is intended for 'user-specific non-essential runtime files + and other file objects (such as sockets, named pipes, ...)', and + 'communication and synchronization purposes'. + + As of late 2012, only quite new systems set $XDG_RUNTIME_DIR. If it is not + set, with ``strict=True`` (the default), a KeyError is raised. With + ``strict=False``, PyXDG will create a fallback under /tmp for the current + user. This fallback does *not* provide the same guarantees as the + specification requires for the runtime directory. + + The strict default is deliberately conservative, so that application + developers can make a conscious decision to allow the fallback. + """ + try: + return os.environ['XDG_RUNTIME_DIR'] + except KeyError: + if strict: + raise + + import getpass + fallback = '/tmp/pyxdg-runtime-dir-fallback-' + getpass.getuser() + create = False + + try: + # This must be a real directory, not a symlink, so attackers can't + # point it elsewhere. So we use lstat to check it. + st = os.lstat(fallback) + except OSError as e: + import errno + if e.errno == errno.ENOENT: + create = True + else: + raise + else: + # The fallback must be a directory + if not stat.S_ISDIR(st.st_mode): + os.unlink(fallback) + create = True + # Must be owned by the user and not accessible by anyone else + elif (st.st_uid != os.getuid()) \ + or (st.st_mode & (stat.S_IRWXG | stat.S_IRWXO)): + os.rmdir(fallback) + create = True + + if create: + os.mkdir(fallback, 0o700) + + return fallback diff --git a/src/controller/icons/mixins/xdg/Config.py b/src/controller/icons/mixins/xdg/Config.py new file mode 100644 index 0000000..3f5d654 --- /dev/null +++ b/src/controller/icons/mixins/xdg/Config.py @@ -0,0 +1,39 @@ +""" +Functions to configure Basic Settings +""" + +language = "C" +windowmanager = None +icon_theme = "hicolor" +icon_size = 48 +cache_time = 5 +root_mode = False + +def setWindowManager(wm): + global windowmanager + windowmanager = wm + +def setIconTheme(theme): + global icon_theme + icon_theme = theme + import xdg.IconTheme + xdg.IconTheme.themes = [] + +def setIconSize(size): + global icon_size + icon_size = size + +def setCacheTime(time): + global cache_time + cache_time = time + +def setLocale(lang): + import locale + lang = locale.normalize(lang) + locale.setlocale(locale.LC_ALL, lang) + import xdg.Locale + xdg.Locale.update(lang) + +def setRootMode(boolean): + global root_mode + root_mode = boolean diff --git a/src/controller/icons/mixins/xdg/DesktopEntry.py b/src/controller/icons/mixins/xdg/DesktopEntry.py new file mode 100644 index 0000000..803993e --- /dev/null +++ b/src/controller/icons/mixins/xdg/DesktopEntry.py @@ -0,0 +1,435 @@ +""" +Complete implementation of the XDG Desktop Entry Specification +http://standards.freedesktop.org/desktop-entry-spec/ + +Not supported: +- Encoding: Legacy Mixed +- Does not check exec parameters +- Does not check URL's +- Does not completly validate deprecated/kde items +- Does not completly check categories +""" + +from .IniFile import IniFile +from . import Locale + +from .IniFile import is_ascii + +from .Exceptions import ParsingError +from .util import which +import os.path +import re +import warnings + +class DesktopEntry(IniFile): + "Class to parse and validate Desktop Entries" + + defaultGroup = 'Desktop Entry' + + def __init__(self, filename=None): + """Create a new DesktopEntry. + + If filename exists, it will be parsed as a desktop entry file. If not, + or if filename is None, a blank DesktopEntry is created. + """ + self.content = dict() + if filename and os.path.exists(filename): + self.parse(filename) + elif filename: + self.new(filename) + + def __str__(self): + return self.getName() + + def parse(self, file): + """Parse a desktop entry file. + + This can raise :class:`~xdg.Exceptions.ParsingError`, + :class:`~xdg.Exceptions.DuplicateGroupError` or + :class:`~xdg.Exceptions.DuplicateKeyError`. + """ + IniFile.parse(self, file, ["Desktop Entry", "KDE Desktop Entry"]) + + def findTryExec(self): + """Looks in the PATH for the executable given in the TryExec field. + + Returns the full path to the executable if it is found, None if not. + Raises :class:`~xdg.Exceptions.NoKeyError` if TryExec is not present. + """ + tryexec = self.get('TryExec', strict=True) + return which(tryexec) + + # start standard keys + def getType(self): + return self.get('Type') + def getVersion(self): + """deprecated, use getVersionString instead """ + return self.get('Version', type="numeric") + def getVersionString(self): + return self.get('Version') + def getName(self): + return self.get('Name', locale=True) + def getGenericName(self): + return self.get('GenericName', locale=True) + def getNoDisplay(self): + return self.get('NoDisplay', type="boolean") + def getComment(self): + return self.get('Comment', locale=True) + def getIcon(self): + return self.get('Icon', locale=True) + def getHidden(self): + return self.get('Hidden', type="boolean") + def getOnlyShowIn(self): + return self.get('OnlyShowIn', list=True) + def getNotShowIn(self): + return self.get('NotShowIn', list=True) + def getTryExec(self): + return self.get('TryExec') + def getExec(self): + return self.get('Exec') + def getPath(self): + return self.get('Path') + def getTerminal(self): + return self.get('Terminal', type="boolean") + def getMimeType(self): + """deprecated, use getMimeTypes instead """ + return self.get('MimeType', list=True, type="regex") + def getMimeTypes(self): + return self.get('MimeType', list=True) + def getCategories(self): + return self.get('Categories', list=True) + def getStartupNotify(self): + return self.get('StartupNotify', type="boolean") + def getStartupWMClass(self): + return self.get('StartupWMClass') + def getURL(self): + return self.get('URL') + # end standard keys + + # start kde keys + def getServiceTypes(self): + return self.get('ServiceTypes', list=True) + def getDocPath(self): + return self.get('DocPath') + def getKeywords(self): + return self.get('Keywords', list=True, locale=True) + def getInitialPreference(self): + return self.get('InitialPreference') + def getDev(self): + return self.get('Dev') + def getFSType(self): + return self.get('FSType') + def getMountPoint(self): + return self.get('MountPoint') + def getReadonly(self): + return self.get('ReadOnly', type="boolean") + def getUnmountIcon(self): + return self.get('UnmountIcon', locale=True) + # end kde keys + + # start deprecated keys + def getMiniIcon(self): + return self.get('MiniIcon', locale=True) + def getTerminalOptions(self): + return self.get('TerminalOptions') + def getDefaultApp(self): + return self.get('DefaultApp') + def getProtocols(self): + return self.get('Protocols', list=True) + def getExtensions(self): + return self.get('Extensions', list=True) + def getBinaryPattern(self): + return self.get('BinaryPattern') + def getMapNotify(self): + return self.get('MapNotify') + def getEncoding(self): + return self.get('Encoding') + def getSwallowTitle(self): + return self.get('SwallowTitle', locale=True) + def getSwallowExec(self): + return self.get('SwallowExec') + def getSortOrder(self): + return self.get('SortOrder', list=True) + def getFilePattern(self): + return self.get('FilePattern', type="regex") + def getActions(self): + return self.get('Actions', list=True) + # end deprecated keys + + # desktop entry edit stuff + def new(self, filename): + """Make this instance into a new, blank desktop entry. + + If filename has a .desktop extension, Type is set to Application. If it + has a .directory extension, Type is Directory. Other extensions will + cause :class:`~xdg.Exceptions.ParsingError` to be raised. + """ + if os.path.splitext(filename)[1] == ".desktop": + type = "Application" + elif os.path.splitext(filename)[1] == ".directory": + type = "Directory" + else: + raise ParsingError("Unknown extension", filename) + + self.content = dict() + self.addGroup(self.defaultGroup) + self.set("Type", type) + self.filename = filename + # end desktop entry edit stuff + + # validation stuff + def checkExtras(self): + # header + if self.defaultGroup == "KDE Desktop Entry": + self.warnings.append('[KDE Desktop Entry]-Header is deprecated') + + # file extension + if self.fileExtension == ".kdelnk": + self.warnings.append("File extension .kdelnk is deprecated") + elif self.fileExtension != ".desktop" and self.fileExtension != ".directory": + self.warnings.append('Unknown File extension') + + # Type + try: + self.type = self.content[self.defaultGroup]["Type"] + except KeyError: + self.errors.append("Key 'Type' is missing") + + # Name + try: + self.name = self.content[self.defaultGroup]["Name"] + except KeyError: + self.errors.append("Key 'Name' is missing") + + def checkGroup(self, group): + # check if group header is valid + if not (group == self.defaultGroup \ + or re.match("^Desktop Action [a-zA-Z0-9-]+$", group) \ + or (re.match("^X-", group) and is_ascii(group))): + self.errors.append("Invalid Group name: %s" % group) + else: + #OnlyShowIn and NotShowIn + if ("OnlyShowIn" in self.content[group]) and ("NotShowIn" in self.content[group]): + self.errors.append("Group may either have OnlyShowIn or NotShowIn, but not both") + + def checkKey(self, key, value, group): + # standard keys + if key == "Type": + if value == "ServiceType" or value == "Service" or value == "FSDevice": + self.warnings.append("Type=%s is a KDE extension" % key) + elif value == "MimeType": + self.warnings.append("Type=MimeType is deprecated") + elif not (value == "Application" or value == "Link" or value == "Directory"): + self.errors.append("Value of key 'Type' must be Application, Link or Directory, but is '%s'" % value) + + if self.fileExtension == ".directory" and not value == "Directory": + self.warnings.append("File extension is .directory, but Type is '%s'" % value) + elif self.fileExtension == ".desktop" and value == "Directory": + self.warnings.append("Files with Type=Directory should have the extension .directory") + + if value == "Application": + if "Exec" not in self.content[group]: + self.warnings.append("Type=Application needs 'Exec' key") + if value == "Link": + if "URL" not in self.content[group]: + self.warnings.append("Type=Link needs 'URL' key") + + elif key == "Version": + self.checkValue(key, value) + + elif re.match("^Name"+xdg.Locale.regex+"$", key): + pass # locale string + + elif re.match("^GenericName"+xdg.Locale.regex+"$", key): + pass # locale string + + elif key == "NoDisplay": + self.checkValue(key, value, type="boolean") + + elif re.match("^Comment"+xdg.Locale.regex+"$", key): + pass # locale string + + elif re.match("^Icon"+xdg.Locale.regex+"$", key): + self.checkValue(key, value) + + elif key == "Hidden": + self.checkValue(key, value, type="boolean") + + elif key == "OnlyShowIn": + self.checkValue(key, value, list=True) + self.checkOnlyShowIn(value) + + elif key == "NotShowIn": + self.checkValue(key, value, list=True) + self.checkOnlyShowIn(value) + + elif key == "TryExec": + self.checkValue(key, value) + self.checkType(key, "Application") + + elif key == "Exec": + self.checkValue(key, value) + self.checkType(key, "Application") + + elif key == "Path": + self.checkValue(key, value) + self.checkType(key, "Application") + + elif key == "Terminal": + self.checkValue(key, value, type="boolean") + self.checkType(key, "Application") + + elif key == "Actions": + self.checkValue(key, value, list=True) + self.checkType(key, "Application") + + elif key == "MimeType": + self.checkValue(key, value, list=True) + self.checkType(key, "Application") + + elif key == "Categories": + self.checkValue(key, value) + self.checkType(key, "Application") + self.checkCategories(value) + + elif re.match("^Keywords"+xdg.Locale.regex+"$", key): + self.checkValue(key, value, type="localestring", list=True) + self.checkType(key, "Application") + + elif key == "StartupNotify": + self.checkValue(key, value, type="boolean") + self.checkType(key, "Application") + + elif key == "StartupWMClass": + self.checkType(key, "Application") + + elif key == "URL": + self.checkValue(key, value) + self.checkType(key, "URL") + + # kde extensions + elif key == "ServiceTypes": + self.checkValue(key, value, list=True) + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif key == "DocPath": + self.checkValue(key, value) + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif key == "InitialPreference": + self.checkValue(key, value, type="numeric") + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif key == "Dev": + self.checkValue(key, value) + self.checkType(key, "FSDevice") + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif key == "FSType": + self.checkValue(key, value) + self.checkType(key, "FSDevice") + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif key == "MountPoint": + self.checkValue(key, value) + self.checkType(key, "FSDevice") + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif key == "ReadOnly": + self.checkValue(key, value, type="boolean") + self.checkType(key, "FSDevice") + self.warnings.append("Key '%s' is a KDE extension" % key) + + elif re.match("^UnmountIcon"+xdg.Locale.regex+"$", key): + self.checkValue(key, value) + self.checkType(key, "FSDevice") + self.warnings.append("Key '%s' is a KDE extension" % key) + + # deprecated keys + elif key == "Encoding": + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif re.match("^MiniIcon"+xdg.Locale.regex+"$", key): + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "TerminalOptions": + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "DefaultApp": + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "Protocols": + self.checkValue(key, value, list=True) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "Extensions": + self.checkValue(key, value, list=True) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "BinaryPattern": + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "MapNotify": + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif re.match("^SwallowTitle"+xdg.Locale.regex+"$", key): + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "SwallowExec": + self.checkValue(key, value) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "FilePattern": + self.checkValue(key, value, type="regex", list=True) + self.warnings.append("Key '%s' is deprecated" % key) + + elif key == "SortOrder": + self.checkValue(key, value, list=True) + self.warnings.append("Key '%s' is deprecated" % key) + + # "X-" extensions + elif re.match("^X-[a-zA-Z0-9-]+", key): + pass + + else: + self.errors.append("Invalid key: %s" % key) + + def checkType(self, key, type): + if not self.getType() == type: + self.errors.append("Key '%s' only allowed in Type=%s" % (key, type)) + + def checkOnlyShowIn(self, value): + values = self.getList(value) + valid = ["GNOME", "KDE", "LXDE", "MATE", "Razor", "ROX", "TDE", "Unity", + "XFCE", "Old"] + for item in values: + if item not in valid and item[0:2] != "X-": + self.errors.append("'%s' is not a registered OnlyShowIn value" % item); + + def checkCategories(self, value): + values = self.getList(value) + + main = ["AudioVideo", "Audio", "Video", "Development", "Education", "Game", "Graphics", "Network", "Office", "Science", "Settings", "System", "Utility"] + if not any(item in main for item in values): + self.errors.append("Missing main category") + + additional = ['Building', 'Debugger', 'IDE', 'GUIDesigner', 'Profiling', 'RevisionControl', 'Translation', 'Calendar', 'ContactManagement', 'Database', 'Dictionary', 'Chart', 'Email', 'Finance', 'FlowChart', 'PDA', 'ProjectManagement', 'Presentation', 'Spreadsheet', 'WordProcessor', '2DGraphics', 'VectorGraphics', 'RasterGraphics', '3DGraphics', 'Scanning', 'OCR', 'Photography', 'Publishing', 'Viewer', 'TextTools', 'DesktopSettings', 'HardwareSettings', 'Printing', 'PackageManager', 'Dialup', 'InstantMessaging', 'Chat', 'IRCClient', 'Feed', 'FileTransfer', 'HamRadio', 'News', 'P2P', 'RemoteAccess', 'Telephony', 'TelephonyTools', 'VideoConference', 'WebBrowser', 'WebDevelopment', 'Midi', 'Mixer', 'Sequencer', 'Tuner', 'TV', 'AudioVideoEditing', 'Player', 'Recorder', 'DiscBurning', 'ActionGame', 'AdventureGame', 'ArcadeGame', 'BoardGame', 'BlocksGame', 'CardGame', 'KidsGame', 'LogicGame', 'RolePlaying', 'Shooter', 'Simulation', 'SportsGame', 'StrategyGame', 'Art', 'Construction', 'Music', 'Languages', 'ArtificialIntelligence', 'Astronomy', 'Biology', 'Chemistry', 'ComputerScience', 'DataVisualization', 'Economy', 'Electricity', 'Geography', 'Geology', 'Geoscience', 'History', 'Humanities', 'ImageProcessing', 'Literature', 'Maps', 'Math', 'NumericalAnalysis', 'MedicalSoftware', 'Physics', 'Robotics', 'Spirituality', 'Sports', 'ParallelComputing', 'Amusement', 'Archiving', 'Compression', 'Electronics', 'Emulator', 'Engineering', 'FileTools', 'FileManager', 'TerminalEmulator', 'Filesystem', 'Monitor', 'Security', 'Accessibility', 'Calculator', 'Clock', 'TextEditor', 'Documentation', 'Adult', 'Core', 'KDE', 'GNOME', 'XFCE', 'GTK', 'Qt', 'Motif', 'Java', 'ConsoleOnly'] + allcategories = additional + main + + for item in values: + if item not in allcategories and not item.startswith("X-"): + self.errors.append("'%s' is not a registered Category" % item); + + def checkCategorie(self, value): + """Deprecated alias for checkCategories - only exists for backwards + compatibility. + """ + warnings.warn("checkCategorie is deprecated, use checkCategories", + DeprecationWarning) + return self.checkCategories(value) diff --git a/src/controller/icons/mixins/xdg/Exceptions.py b/src/controller/icons/mixins/xdg/Exceptions.py new file mode 100644 index 0000000..7096b61 --- /dev/null +++ b/src/controller/icons/mixins/xdg/Exceptions.py @@ -0,0 +1,84 @@ +""" +Exception Classes for the xdg package +""" + +debug = False + +class Error(Exception): + """Base class for exceptions defined here.""" + def __init__(self, msg): + self.msg = msg + Exception.__init__(self, msg) + def __str__(self): + return self.msg + +class ValidationError(Error): + """Raised when a file fails to validate. + + The filename is the .file attribute. + """ + def __init__(self, msg, file): + self.msg = msg + self.file = file + Error.__init__(self, "ValidationError in file '%s': %s " % (file, msg)) + +class ParsingError(Error): + """Raised when a file cannot be parsed. + + The filename is the .file attribute. + """ + def __init__(self, msg, file): + self.msg = msg + self.file = file + Error.__init__(self, "ParsingError in file '%s', %s" % (file, msg)) + +class NoKeyError(Error): + """Raised when trying to access a nonexistant key in an INI-style file. + + Attributes are .key, .group and .file. + """ + def __init__(self, key, group, file): + Error.__init__(self, "No key '%s' in group %s of file %s" % (key, group, file)) + self.key = key + self.group = group + self.file = file + +class DuplicateKeyError(Error): + """Raised when the same key occurs twice in an INI-style file. + + Attributes are .key, .group and .file. + """ + def __init__(self, key, group, file): + Error.__init__(self, "Duplicate key '%s' in group %s of file %s" % (key, group, file)) + self.key = key + self.group = group + self.file = file + +class NoGroupError(Error): + """Raised when trying to access a nonexistant group in an INI-style file. + + Attributes are .group and .file. + """ + def __init__(self, group, file): + Error.__init__(self, "No group: %s in file %s" % (group, file)) + self.group = group + self.file = file + +class DuplicateGroupError(Error): + """Raised when the same key occurs twice in an INI-style file. + + Attributes are .group and .file. + """ + def __init__(self, group, file): + Error.__init__(self, "Duplicate group: %s in file %s" % (group, file)) + self.group = group + self.file = file + +class NoThemeError(Error): + """Raised when trying to access a nonexistant icon theme. + + The name of the theme is the .theme attribute. + """ + def __init__(self, theme): + Error.__init__(self, "No such icon-theme: %s" % theme) + self.theme = theme diff --git a/src/controller/icons/mixins/xdg/IconTheme.py b/src/controller/icons/mixins/xdg/IconTheme.py new file mode 100644 index 0000000..2ff3c05 --- /dev/null +++ b/src/controller/icons/mixins/xdg/IconTheme.py @@ -0,0 +1,445 @@ +""" +Complete implementation of the XDG Icon Spec +http://standards.freedesktop.org/icon-theme-spec/ +""" + +import os, time +import re + +from . import IniFile, Config +from .IniFile import is_ascii +from .BaseDirectory import xdg_data_dirs +from .Exceptions import NoThemeError, debug + + +class IconTheme(IniFile): + "Class to parse and validate IconThemes" + def __init__(self): + IniFile.__init__(self) + + def __repr__(self): + return self.name + + def parse(self, file): + IniFile.parse(self, file, ["Icon Theme", "KDE Icon Theme"]) + self.dir = os.path.dirname(file) + (nil, self.name) = os.path.split(self.dir) + + def getDir(self): + return self.dir + + # Standard Keys + def getName(self): + return self.get('Name', locale=True) + def getComment(self): + return self.get('Comment', locale=True) + def getInherits(self): + return self.get('Inherits', list=True) + def getDirectories(self): + return self.get('Directories', list=True) + def getScaledDirectories(self): + return self.get('ScaledDirectories', list=True) + def getHidden(self): + return self.get('Hidden', type="boolean") + def getExample(self): + return self.get('Example') + + # Per Directory Keys + def getSize(self, directory): + return self.get('Size', type="integer", group=directory) + def getContext(self, directory): + return self.get('Context', group=directory) + def getType(self, directory): + value = self.get('Type', group=directory) + if value: + return value + else: + return "Threshold" + def getMaxSize(self, directory): + value = self.get('MaxSize', type="integer", group=directory) + if value or value == 0: + return value + else: + return self.getSize(directory) + def getMinSize(self, directory): + value = self.get('MinSize', type="integer", group=directory) + if value or value == 0: + return value + else: + return self.getSize(directory) + def getThreshold(self, directory): + value = self.get('Threshold', type="integer", group=directory) + if value or value == 0: + return value + else: + return 2 + + def getScale(self, directory): + value = self.get('Scale', type="integer", group=directory) + return value or 1 + + # validation stuff + def checkExtras(self): + # header + if self.defaultGroup == "KDE Icon Theme": + self.warnings.append('[KDE Icon Theme]-Header is deprecated') + + # file extension + if self.fileExtension == ".theme": + pass + elif self.fileExtension == ".desktop": + self.warnings.append('.desktop fileExtension is deprecated') + else: + self.warnings.append('Unknown File extension') + + # Check required keys + # Name + try: + self.name = self.content[self.defaultGroup]["Name"] + except KeyError: + self.errors.append("Key 'Name' is missing") + + # Comment + try: + self.comment = self.content[self.defaultGroup]["Comment"] + except KeyError: + self.errors.append("Key 'Comment' is missing") + + # Directories + try: + self.directories = self.content[self.defaultGroup]["Directories"] + except KeyError: + self.errors.append("Key 'Directories' is missing") + + def checkGroup(self, group): + # check if group header is valid + if group == self.defaultGroup: + try: + self.name = self.content[group]["Name"] + except KeyError: + self.errors.append("Key 'Name' in Group '%s' is missing" % group) + try: + self.name = self.content[group]["Comment"] + except KeyError: + self.errors.append("Key 'Comment' in Group '%s' is missing" % group) + elif group in self.getDirectories(): + try: + self.type = self.content[group]["Type"] + except KeyError: + self.type = "Threshold" + try: + self.name = self.content[group]["Size"] + except KeyError: + self.errors.append("Key 'Size' in Group '%s' is missing" % group) + elif not (re.match(r"^\[X-", group) and is_ascii(group)): + self.errors.append("Invalid Group name: %s" % group) + + def checkKey(self, key, value, group): + # standard keys + if group == self.defaultGroup: + if re.match("^Name"+xdg.Locale.regex+"$", key): + pass + elif re.match("^Comment"+xdg.Locale.regex+"$", key): + pass + elif key == "Inherits": + self.checkValue(key, value, list=True) + elif key == "Directories": + self.checkValue(key, value, list=True) + elif key == "ScaledDirectories": + self.checkValue(key, value, list=True) + elif key == "Hidden": + self.checkValue(key, value, type="boolean") + elif key == "Example": + self.checkValue(key, value) + elif re.match("^X-[a-zA-Z0-9-]+", key): + pass + else: + self.errors.append("Invalid key: %s" % key) + elif group in self.getDirectories(): + if key == "Size": + self.checkValue(key, value, type="integer") + elif key == "Context": + self.checkValue(key, value) + elif key == "Type": + self.checkValue(key, value) + if value not in ["Fixed", "Scalable", "Threshold"]: + self.errors.append("Key 'Type' must be one out of 'Fixed','Scalable','Threshold', but is %s" % value) + elif key == "MaxSize": + self.checkValue(key, value, type="integer") + if self.type != "Scalable": + self.errors.append("Key 'MaxSize' give, but Type is %s" % self.type) + elif key == "MinSize": + self.checkValue(key, value, type="integer") + if self.type != "Scalable": + self.errors.append("Key 'MinSize' give, but Type is %s" % self.type) + elif key == "Threshold": + self.checkValue(key, value, type="integer") + if self.type != "Threshold": + self.errors.append("Key 'Threshold' give, but Type is %s" % self.type) + elif key == "Scale": + self.checkValue(key, value, type="integer") + elif re.match("^X-[a-zA-Z0-9-]+", key): + pass + else: + self.errors.append("Invalid key: %s" % key) + + +class IconData(IniFile): + "Class to parse and validate IconData Files" + def __init__(self): + IniFile.__init__(self) + + def __repr__(self): + displayname = self.getDisplayName() + if displayname: + return "" % displayname + else: + return "" + + def parse(self, file): + IniFile.parse(self, file, ["Icon Data"]) + + # Standard Keys + def getDisplayName(self): + """Retrieve the display name from the icon data, if one is specified.""" + return self.get('DisplayName', locale=True) + def getEmbeddedTextRectangle(self): + """Retrieve the embedded text rectangle from the icon data as a list of + numbers (x0, y0, x1, y1), if it is specified.""" + return self.get('EmbeddedTextRectangle', type="integer", list=True) + def getAttachPoints(self): + """Retrieve the anchor points for overlays & emblems from the icon data, + as a list of co-ordinate pairs, if they are specified.""" + return self.get('AttachPoints', type="point", list=True) + + # validation stuff + def checkExtras(self): + # file extension + if self.fileExtension != ".icon": + self.warnings.append('Unknown File extension') + + def checkGroup(self, group): + # check if group header is valid + if not (group == self.defaultGroup \ + or (re.match(r"^\[X-", group) and is_ascii(group))): + self.errors.append("Invalid Group name: %s" % group.encode("ascii", "replace")) + + def checkKey(self, key, value, group): + # standard keys + if re.match("^DisplayName"+xdg.Locale.regex+"$", key): + pass + elif key == "EmbeddedTextRectangle": + self.checkValue(key, value, type="integer", list=True) + elif key == "AttachPoints": + self.checkValue(key, value, type="point", list=True) + elif re.match("^X-[a-zA-Z0-9-]+", key): + pass + else: + self.errors.append("Invalid key: %s" % key) + + + +icondirs = [] +for basedir in xdg_data_dirs: + icondirs.append(os.path.join(basedir, "icons")) + icondirs.append(os.path.join(basedir, "pixmaps")) +icondirs.append(os.path.expanduser("~/.icons")) + +# just cache variables, they give a 10x speed improvement +themes = [] +theme_cache = {} +dir_cache = {} +icon_cache = {} + +def getIconPath(iconname, size = None, theme = None, extensions = ["png", "svg", "xpm"]): + """Get the path to a specified icon. + + size : + Icon size in pixels. Defaults to ``xdg.Config.icon_size``. + theme : + Icon theme name. Defaults to ``xdg.Config.icon_theme``. If the icon isn't + found in the specified theme, it will be looked up in the basic 'hicolor' + theme. + extensions : + List of preferred file extensions. + + Example:: + + >>> getIconPath("inkscape", 32) + '/usr/share/icons/hicolor/32x32/apps/inkscape.png' + """ + + global themes + + if size == None: + size = xdg.Config.icon_size + if theme == None: + theme = xdg.Config.icon_theme + + # if we have an absolute path, just return it + if os.path.isabs(iconname): + return iconname + + # check if it has an extension and strip it + if os.path.splitext(iconname)[1][1:] in extensions: + iconname = os.path.splitext(iconname)[0] + + # parse theme files + if (themes == []) or (themes[0].name != theme): + themes = list(__get_themes(theme)) + + # more caching (icon looked up in the last 5 seconds?) + tmp = (iconname, size, theme, tuple(extensions)) + try: + timestamp, icon = icon_cache[tmp] + except KeyError: + pass + else: + if (time.time() - timestamp) >= xdg.Config.cache_time: + del icon_cache[tmp] + else: + return icon + + for thme in themes: + icon = LookupIcon(iconname, size, thme, extensions) + if icon: + icon_cache[tmp] = (time.time(), icon) + return icon + + # cache stuff again (directories looked up in the last 5 seconds?) + for directory in icondirs: + if (directory not in dir_cache \ + or (int(time.time() - dir_cache[directory][1]) >= xdg.Config.cache_time \ + and dir_cache[directory][2] < os.path.getmtime(directory))) \ + and os.path.isdir(directory): + dir_cache[directory] = (os.listdir(directory), time.time(), os.path.getmtime(directory)) + + for dir, values in dir_cache.items(): + for extension in extensions: + try: + if iconname + "." + extension in values[0]: + icon = os.path.join(dir, iconname + "." + extension) + icon_cache[tmp] = [time.time(), icon] + return icon + except UnicodeDecodeError as e: + if debug: + raise e + else: + pass + + # we haven't found anything? "hicolor" is our fallback + if theme != "hicolor": + icon = getIconPath(iconname, size, "hicolor") + icon_cache[tmp] = [time.time(), icon] + return icon + +def getIconData(path): + """Retrieve the data from the .icon file corresponding to the given file. If + there is no .icon file, it returns None. + + Example:: + + getIconData("/usr/share/icons/Tango/scalable/places/folder.svg") + """ + if os.path.isfile(path): + icon_file = os.path.splitext(path)[0] + ".icon" + if os.path.isfile(icon_file): + data = IconData() + data.parse(icon_file) + return data + +def __get_themes(themename): + """Generator yielding IconTheme objects for a specified theme and any themes + from which it inherits. + """ + for dir in icondirs: + theme_file = os.path.join(dir, themename, "index.theme") + if os.path.isfile(theme_file): + break + theme_file = os.path.join(dir, themename, "index.desktop") + if os.path.isfile(theme_file): + break + else: + if debug: + raise NoThemeError(themename) + return + + theme = IconTheme() + theme.parse(theme_file) + yield theme + for subtheme in theme.getInherits(): + for t in __get_themes(subtheme): + yield t + +def LookupIcon(iconname, size, theme, extensions): + # look for the cache + if theme.name not in theme_cache: + theme_cache[theme.name] = [] + theme_cache[theme.name].append(time.time() - (xdg.Config.cache_time + 1)) # [0] last time of lookup + theme_cache[theme.name].append(0) # [1] mtime + theme_cache[theme.name].append(dict()) # [2] dir: [subdir, [items]] + + # cache stuff (directory lookuped up the in the last 5 seconds?) + if int(time.time() - theme_cache[theme.name][0]) >= xdg.Config.cache_time: + theme_cache[theme.name][0] = time.time() + for subdir in theme.getDirectories(): + for directory in icondirs: + dir = os.path.join(directory,theme.name,subdir) + if (dir not in theme_cache[theme.name][2] \ + or theme_cache[theme.name][1] < os.path.getmtime(os.path.join(directory,theme.name))) \ + and subdir != "" \ + and os.path.isdir(dir): + theme_cache[theme.name][2][dir] = [subdir, os.listdir(dir)] + theme_cache[theme.name][1] = os.path.getmtime(os.path.join(directory,theme.name)) + + for dir, values in theme_cache[theme.name][2].items(): + if DirectoryMatchesSize(values[0], size, theme): + for extension in extensions: + if iconname + "." + extension in values[1]: + return os.path.join(dir, iconname + "." + extension) + + minimal_size = 2**31 + closest_filename = "" + for dir, values in theme_cache[theme.name][2].items(): + distance = DirectorySizeDistance(values[0], size, theme) + if distance < minimal_size: + for extension in extensions: + if iconname + "." + extension in values[1]: + closest_filename = os.path.join(dir, iconname + "." + extension) + minimal_size = distance + + return closest_filename + +def DirectoryMatchesSize(subdir, iconsize, theme): + Type = theme.getType(subdir) + Size = theme.getSize(subdir) + Threshold = theme.getThreshold(subdir) + MinSize = theme.getMinSize(subdir) + MaxSize = theme.getMaxSize(subdir) + if Type == "Fixed": + return Size == iconsize + elif Type == "Scaleable": + return MinSize <= iconsize <= MaxSize + elif Type == "Threshold": + return Size - Threshold <= iconsize <= Size + Threshold + +def DirectorySizeDistance(subdir, iconsize, theme): + Type = theme.getType(subdir) + Size = theme.getSize(subdir) + Threshold = theme.getThreshold(subdir) + MinSize = theme.getMinSize(subdir) + MaxSize = theme.getMaxSize(subdir) + if Type == "Fixed": + return abs(Size - iconsize) + elif Type == "Scalable": + if iconsize < MinSize: + return MinSize - iconsize + elif iconsize > MaxSize: + return MaxSize - iconsize + return 0 + elif Type == "Threshold": + if iconsize < Size - Threshold: + return MinSize - iconsize + elif iconsize > Size + Threshold: + return iconsize - MaxSize + return 0 diff --git a/src/controller/icons/mixins/xdg/IniFile.py b/src/controller/icons/mixins/xdg/IniFile.py new file mode 100644 index 0000000..74ab858 --- /dev/null +++ b/src/controller/icons/mixins/xdg/IniFile.py @@ -0,0 +1,419 @@ +""" +Base Class for DesktopEntry, IconTheme and IconData +""" + +import re, os, stat, io +from .Exceptions import (ParsingError, DuplicateGroupError, NoGroupError, + NoKeyError, DuplicateKeyError, ValidationError, + debug) +# import xdg.Locale +from . import Locale +from .util import u + +def is_ascii(s): + """Return True if a string consists entirely of ASCII characters.""" + try: + s.encode('ascii', 'strict') + return True + except UnicodeError: + return False + +class IniFile: + defaultGroup = '' + fileExtension = '' + + filename = '' + + tainted = False + + def __init__(self, filename=None): + self.content = dict() + if filename: + self.parse(filename) + + def __cmp__(self, other): + return cmp(self.content, other.content) + + def parse(self, filename, headers=None): + '''Parse an INI file. + + headers -- list of headers the parser will try to select as a default header + ''' + # for performance reasons + content = self.content + + if not os.path.isfile(filename): + raise ParsingError("File not found", filename) + + try: + # The content should be UTF-8, but legacy files can have other + # encodings, including mixed encodings in one file. We don't attempt + # to decode them, but we silence the errors. + fd = io.open(filename, 'r', encoding='utf-8', errors='replace') + except IOError as e: + if debug: + raise e + else: + return + + # parse file + for line in fd: + line = line.strip() + # empty line + if not line: + continue + # comment + elif line[0] == '#': + continue + # new group + elif line[0] == '[': + currentGroup = line.lstrip("[").rstrip("]") + if debug and self.hasGroup(currentGroup): + raise DuplicateGroupError(currentGroup, filename) + else: + content[currentGroup] = {} + # key + else: + try: + key, value = line.split("=", 1) + except ValueError: + raise ParsingError("Invalid line: " + line, filename) + + key = key.strip() # Spaces before/after '=' should be ignored + try: + if debug and self.hasKey(key, currentGroup): + raise DuplicateKeyError(key, currentGroup, filename) + else: + content[currentGroup][key] = value.strip() + except (IndexError, UnboundLocalError): + raise ParsingError("Parsing error on key, group missing", filename) + + fd.close() + + self.filename = filename + self.tainted = False + + # check header + if headers: + for header in headers: + if header in content: + self.defaultGroup = header + break + else: + raise ParsingError("[%s]-Header missing" % headers[0], filename) + + # start stuff to access the keys + def get(self, key, group=None, locale=False, type="string", list=False, strict=False): + # set default group + if not group: + group = self.defaultGroup + + # return key (with locale) + if (group in self.content) and (key in self.content[group]): + if locale: + value = self.content[group][self.__addLocale(key, group)] + else: + value = self.content[group][key] + else: + if strict or debug: + if group not in self.content: + raise NoGroupError(group, self.filename) + elif key not in self.content[group]: + raise NoKeyError(key, group, self.filename) + else: + value = "" + + if list == True: + values = self.getList(value) + result = [] + else: + values = [value] + + for value in values: + if type == "boolean": + value = self.__getBoolean(value) + elif type == "integer": + try: + value = int(value) + except ValueError: + value = 0 + elif type == "numeric": + try: + value = float(value) + except ValueError: + value = 0.0 + elif type == "regex": + value = re.compile(value) + elif type == "point": + x, y = value.split(",") + value = int(x), int(y) + + if list == True: + result.append(value) + else: + result = value + + return result + # end stuff to access the keys + + # start subget + def getList(self, string): + if re.search(r"(? 0: + key = key + "[" + xdg.Locale.langs[0] + "]" + + try: + self.content[group][key] = value + except KeyError: + raise NoGroupError(group, self.filename) + + self.tainted = (value == self.get(key, group)) + + def addGroup(self, group): + if self.hasGroup(group): + if debug: + raise DuplicateGroupError(group, self.filename) + else: + self.content[group] = {} + self.tainted = True + + def removeGroup(self, group): + existed = group in self.content + if existed: + del self.content[group] + self.tainted = True + else: + if debug: + raise NoGroupError(group, self.filename) + return existed + + def removeKey(self, key, group=None, locales=True): + # set default group + if not group: + group = self.defaultGroup + + try: + if locales: + for name in list(self.content[group]): + if re.match("^" + key + xdg.Locale.regex + "$", name) and name != key: + del self.content[group][name] + value = self.content[group].pop(key) + self.tainted = True + return value + except KeyError as e: + if debug: + if e == group: + raise NoGroupError(group, self.filename) + else: + raise NoKeyError(key, group, self.filename) + else: + return "" + + # misc + def groups(self): + return self.content.keys() + + def hasGroup(self, group): + return group in self.content + + def hasKey(self, key, group=None): + # set default group + if not group: + group = self.defaultGroup + + return key in self.content[group] + + def getFileName(self): + return self.filename diff --git a/src/controller/icons/mixins/xdg/Locale.py b/src/controller/icons/mixins/xdg/Locale.py new file mode 100644 index 0000000..d0a70d2 --- /dev/null +++ b/src/controller/icons/mixins/xdg/Locale.py @@ -0,0 +1,79 @@ +""" +Helper Module for Locale settings + +This module is based on a ROX module (LGPL): + +http://cvs.sourceforge.net/viewcvs.py/rox/ROX-Lib2/python/rox/i18n.py?rev=1.3&view=log +""" + +import os +from locale import normalize + +regex = r"(\[([a-zA-Z]+)(_[a-zA-Z]+)?(\.[a-zA-Z0-9-]+)?(@[a-zA-Z]+)?\])?" + +def _expand_lang(locale): + locale = normalize(locale) + COMPONENT_CODESET = 1 << 0 + COMPONENT_MODIFIER = 1 << 1 + COMPONENT_TERRITORY = 1 << 2 + # split up the locale into its base components + mask = 0 + pos = locale.find('@') + if pos >= 0: + modifier = locale[pos:] + locale = locale[:pos] + mask |= COMPONENT_MODIFIER + else: + modifier = '' + pos = locale.find('.') + codeset = '' + if pos >= 0: + locale = locale[:pos] + pos = locale.find('_') + if pos >= 0: + territory = locale[pos:] + locale = locale[:pos] + mask |= COMPONENT_TERRITORY + else: + territory = '' + language = locale + ret = [] + for i in range(mask+1): + if not (i & ~mask): # if all components for this combo exist ... + val = language + if i & COMPONENT_TERRITORY: val += territory + if i & COMPONENT_CODESET: val += codeset + if i & COMPONENT_MODIFIER: val += modifier + ret.append(val) + ret.reverse() + return ret + +def expand_languages(languages=None): + # Get some reasonable defaults for arguments that were not supplied + if languages is None: + languages = [] + for envar in ('LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG'): + val = os.environ.get(envar) + if val: + languages = val.split(':') + break + #if 'C' not in languages: + # languages.append('C') + + # now normalize and expand the languages + nelangs = [] + for lang in languages: + for nelang in _expand_lang(lang): + if nelang not in nelangs: + nelangs.append(nelang) + return nelangs + +def update(language=None): + global langs + if language: + langs = expand_languages([language]) + else: + langs = expand_languages() + +langs = [] +update() diff --git a/src/controller/icons/mixins/xdg/Menu.py b/src/controller/icons/mixins/xdg/Menu.py new file mode 100644 index 0000000..fcf1ac1 --- /dev/null +++ b/src/controller/icons/mixins/xdg/Menu.py @@ -0,0 +1,1125 @@ +""" +Implementation of the XDG Menu Specification +http://standards.freedesktop.org/menu-spec/ + +Example code: + +from xdg.Menu import parse, Menu, MenuEntry + +def print_menu(menu, tab=0): + for submenu in menu.Entries: + if isinstance(submenu, Menu): + print ("\t" * tab) + unicode(submenu) + print_menu(submenu, tab+1) + elif isinstance(submenu, MenuEntry): + print ("\t" * tab) + unicode(submenu.DesktopEntry) + +print_menu(parse()) +""" + +import os +import locale +import subprocess +import ast +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +from .BaseDirectory import xdg_data_dirs, xdg_config_dirs +from . import DesktopEntry, Locale, Config +from .Exceptions import ParsingError +from .util import PY3 + + +def _strxfrm(s): + """Wrapper around locale.strxfrm that accepts unicode strings on Python 2. + + See Python bug #2481. + """ + if (not PY3) and isinstance(s, unicode): + s = s.encode('utf-8') + return locale.strxfrm(s) + + +DELETED = "Deleted" +NO_DISPLAY = "NoDisplay" +HIDDEN = "Hidden" +EMPTY = "Empty" +NOT_SHOW_IN = "NotShowIn" +NO_EXEC = "NoExec" + + +class Menu: + """Menu containing sub menus under menu.Entries + + Contains both Menu and MenuEntry items. + """ + def __init__(self): + # Public stuff + self.Name = "" + self.Directory = None + self.Entries = [] + self.Doc = "" + self.Filename = "" + self.Depth = 0 + self.Parent = None + self.NotInXml = False + + # Can be True, False, DELETED, NO_DISPLAY, HIDDEN, EMPTY or NOT_SHOW_IN + self.Show = True + self.Visible = 0 + + # Private stuff, only needed for parsing + self.AppDirs = [] + self.DefaultLayout = None + self.Deleted = None + self.Directories = [] + self.DirectoryDirs = [] + self.Layout = None + self.MenuEntries = [] + self.Moves = [] + self.OnlyUnallocated = None + self.Rules = [] + self.Submenus = [] + + def __str__(self): + return self.Name + + def __add__(self, other): + for dir in other.AppDirs: + self.AppDirs.append(dir) + + for dir in other.DirectoryDirs: + self.DirectoryDirs.append(dir) + + for directory in other.Directories: + self.Directories.append(directory) + + if other.Deleted is not None: + self.Deleted = other.Deleted + + if other.OnlyUnallocated is not None: + self.OnlyUnallocated = other.OnlyUnallocated + + if other.Layout: + self.Layout = other.Layout + + if other.DefaultLayout: + self.DefaultLayout = other.DefaultLayout + + for rule in other.Rules: + self.Rules.append(rule) + + for move in other.Moves: + self.Moves.append(move) + + for submenu in other.Submenus: + self.addSubmenu(submenu) + + return self + + # FIXME: Performance: cache getName() + def __cmp__(self, other): + return locale.strcoll(self.getName(), other.getName()) + + def _key(self): + """Key function for locale-aware sorting.""" + return _strxfrm(self.getName()) + + def __lt__(self, other): + try: + other = other._key() + except AttributeError: + pass + return self._key() < other + + def __eq__(self, other): + try: + return self.Name == unicode(other) + except NameError: # unicode() becomes str() in Python 3 + return self.Name == str(other) + + """ PUBLIC STUFF """ + def getEntries(self, show_hidden=False): + """Interator for a list of Entries visible to the user.""" + for entry in self.Entries: + if show_hidden: + yield entry + elif entry.Show is True: + yield entry + + # FIXME: Add searchEntry/seaqrchMenu function + # search for name/comment/genericname/desktopfileid + # return multiple items + + def getMenuEntry(self, desktopfileid, deep=False): + """Searches for a MenuEntry with a given DesktopFileID.""" + for menuentry in self.MenuEntries: + if menuentry.DesktopFileID == desktopfileid: + return menuentry + if deep: + for submenu in self.Submenus: + submenu.getMenuEntry(desktopfileid, deep) + + def getMenu(self, path): + """Searches for a Menu with a given path.""" + array = path.split("/", 1) + for submenu in self.Submenus: + if submenu.Name == array[0]: + if len(array) > 1: + return submenu.getMenu(array[1]) + else: + return submenu + + def getPath(self, org=False, toplevel=False): + """Returns this menu's path in the menu structure.""" + parent = self + names = [] + while 1: + if org: + names.append(parent.Name) + else: + names.append(parent.getName()) + if parent.Depth > 0: + parent = parent.Parent + else: + break + names.reverse() + path = "" + if not toplevel: + names.pop(0) + for name in names: + path = os.path.join(path, name) + return path + + def getName(self): + """Returns the menu's localised name.""" + try: + return self.Directory.DesktopEntry.getName() + except AttributeError: + return self.Name + + def getGenericName(self): + """Returns the menu's generic name.""" + try: + return self.Directory.DesktopEntry.getGenericName() + except AttributeError: + return "" + + def getComment(self): + """Returns the menu's comment text.""" + try: + return self.Directory.DesktopEntry.getComment() + except AttributeError: + return "" + + def getIcon(self): + """Returns the menu's icon, filename or simple name""" + try: + return self.Directory.DesktopEntry.getIcon() + except AttributeError: + return "" + + def sort(self): + self.Entries = [] + self.Visible = 0 + + for submenu in self.Submenus: + submenu.sort() + + _submenus = set() + _entries = set() + + for order in self.Layout.order: + if order[0] == "Filename": + _entries.add(order[1]) + elif order[0] == "Menuname": + _submenus.add(order[1]) + + for order in self.Layout.order: + if order[0] == "Separator": + separator = Separator(self) + if len(self.Entries) > 0 and isinstance(self.Entries[-1], Separator): + separator.Show = False + self.Entries.append(separator) + elif order[0] == "Filename": + menuentry = self.getMenuEntry(order[1]) + if menuentry: + self.Entries.append(menuentry) + elif order[0] == "Menuname": + submenu = self.getMenu(order[1]) + if submenu: + if submenu.Layout.inline: + self.merge_inline(submenu) + else: + self.Entries.append(submenu) + elif order[0] == "Merge": + if order[1] == "files" or order[1] == "all": + self.MenuEntries.sort() + for menuentry in self.MenuEntries: + if menuentry.DesktopFileID not in _entries: + self.Entries.append(menuentry) + elif order[1] == "menus" or order[1] == "all": + self.Submenus.sort() + for submenu in self.Submenus: + if submenu.Name not in _submenus: + if submenu.Layout.inline: + self.merge_inline(submenu) + else: + self.Entries.append(submenu) + + # getHidden / NoDisplay / OnlyShowIn / NotOnlyShowIn / Deleted / NoExec + for entry in self.Entries: + entry.Show = True + self.Visible += 1 + if isinstance(entry, Menu): + if entry.Deleted is True: + entry.Show = DELETED + self.Visible -= 1 + elif isinstance(entry.Directory, MenuEntry): + if entry.Directory.DesktopEntry.getNoDisplay(): + entry.Show = NO_DISPLAY + self.Visible -= 1 + elif entry.Directory.DesktopEntry.getHidden(): + entry.Show = HIDDEN + self.Visible -= 1 + elif isinstance(entry, MenuEntry): + if entry.DesktopEntry.getNoDisplay(): + entry.Show = NO_DISPLAY + self.Visible -= 1 + elif entry.DesktopEntry.getHidden(): + entry.Show = HIDDEN + self.Visible -= 1 + elif entry.DesktopEntry.getTryExec() and not entry.DesktopEntry.findTryExec(): + entry.Show = NO_EXEC + self.Visible -= 1 + elif xdg.Config.windowmanager: + if (entry.DesktopEntry.OnlyShowIn != [] and ( + xdg.Config.windowmanager not in entry.DesktopEntry.OnlyShowIn + ) + ) or ( + xdg.Config.windowmanager in entry.DesktopEntry.NotShowIn + ): + entry.Show = NOT_SHOW_IN + self.Visible -= 1 + elif isinstance(entry, Separator): + self.Visible -= 1 + # remove separators at the beginning and at the end + if len(self.Entries) > 0: + if isinstance(self.Entries[0], Separator): + self.Entries[0].Show = False + if len(self.Entries) > 1: + if isinstance(self.Entries[-1], Separator): + self.Entries[-1].Show = False + + # show_empty tag + for entry in self.Entries[:]: + if isinstance(entry, Menu) and not entry.Layout.show_empty and entry.Visible == 0: + entry.Show = EMPTY + self.Visible -= 1 + if entry.NotInXml is True: + self.Entries.remove(entry) + + """ PRIVATE STUFF """ + def addSubmenu(self, newmenu): + for submenu in self.Submenus: + if submenu == newmenu: + submenu += newmenu + break + else: + self.Submenus.append(newmenu) + newmenu.Parent = self + newmenu.Depth = self.Depth + 1 + + # inline tags + def merge_inline(self, submenu): + """Appends a submenu's entries to this menu + See the section of the spec about the "inline" attribute + """ + if len(submenu.Entries) == 1 and submenu.Layout.inline_alias: + menuentry = submenu.Entries[0] + menuentry.DesktopEntry.set("Name", submenu.getName(), locale=True) + menuentry.DesktopEntry.set("GenericName", submenu.getGenericName(), locale=True) + menuentry.DesktopEntry.set("Comment", submenu.getComment(), locale=True) + self.Entries.append(menuentry) + elif len(submenu.Entries) <= submenu.Layout.inline_limit or submenu.Layout.inline_limit == 0: + if submenu.Layout.inline_header: + header = Header(submenu.getName(), submenu.getGenericName(), submenu.getComment()) + self.Entries.append(header) + for entry in submenu.Entries: + self.Entries.append(entry) + else: + self.Entries.append(submenu) + + +class Move: + "A move operation" + def __init__(self, old="", new=""): + self.Old = old + self.New = new + + def __cmp__(self, other): + return cmp(self.Old, other.Old) + + +class Layout: + "Menu Layout class" + def __init__(self, show_empty=False, inline=False, inline_limit=4, + inline_header=True, inline_alias=False): + self.show_empty = show_empty + self.inline = inline + self.inline_limit = inline_limit + self.inline_header = inline_header + self.inline_alias = inline_alias + self._order = [] + self._default_order = [ + ['Merge', 'menus'], + ['Merge', 'files'] + ] + + @property + def order(self): + return self._order if self._order else self._default_order + + @order.setter + def order(self, order): + self._order = order + + +class Rule: + """Include / Exclude Rules Class""" + + TYPE_INCLUDE, TYPE_EXCLUDE = 0, 1 + + @classmethod + def fromFilename(cls, type, filename): + tree = ast.Expression( + body=ast.Compare( + left=ast.Str(filename), + ops=[ast.Eq()], + comparators=[ast.Attribute( + value=ast.Name(id='menuentry', ctx=ast.Load()), + attr='DesktopFileID', + ctx=ast.Load() + )] + ), + lineno=1, col_offset=0 + ) + ast.fix_missing_locations(tree) + rule = Rule(type, tree) + return rule + + def __init__(self, type, expression): + # Type is TYPE_INCLUDE or TYPE_EXCLUDE + self.Type = type + # expression is ast.Expression + self.expression = expression + self.code = compile(self.expression, '', 'eval') + + def __str__(self): + return ast.dump(self.expression) + + def apply(self, menuentries, run): + for menuentry in menuentries: + if run == 2 and (menuentry.MatchedInclude is True or + menuentry.Allocated is True): + continue + if eval(self.code): + if self.Type is Rule.TYPE_INCLUDE: + menuentry.Add = True + menuentry.MatchedInclude = True + else: + menuentry.Add = False + return menuentries + + +class MenuEntry: + "Wrapper for 'Menu Style' Desktop Entries" + + TYPE_USER = "User" + TYPE_SYSTEM = "System" + TYPE_BOTH = "Both" + + def __init__(self, filename, dir="", prefix=""): + # Create entry + self.DesktopEntry = DesktopEntry(os.path.join(dir, filename)) + self.setAttributes(filename, dir, prefix) + + # Can True, False DELETED, HIDDEN, EMPTY, NOT_SHOW_IN or NO_EXEC + self.Show = True + + # Semi-Private + self.Original = None + self.Parents = [] + + # Private Stuff + self.Allocated = False + self.Add = False + self.MatchedInclude = False + + # Caching + self.Categories = self.DesktopEntry.getCategories() + + def save(self): + """Save any changes to the desktop entry.""" + if self.DesktopEntry.tainted: + self.DesktopEntry.write() + + def getDir(self): + """Return the directory containing the desktop entry file.""" + return self.DesktopEntry.filename.replace(self.Filename, '') + + def getType(self): + """Return the type of MenuEntry, System/User/Both""" + if not xdg.Config.root_mode: + if self.Original: + return self.TYPE_BOTH + elif xdg_data_dirs[0] in self.DesktopEntry.filename: + return self.TYPE_USER + else: + return self.TYPE_SYSTEM + else: + return self.TYPE_USER + + def setAttributes(self, filename, dir="", prefix=""): + self.Filename = filename + self.Prefix = prefix + self.DesktopFileID = os.path.join(prefix, filename).replace("/", "-") + + if not os.path.isabs(self.DesktopEntry.filename): + self.__setFilename() + + def updateAttributes(self): + if self.getType() == self.TYPE_SYSTEM: + self.Original = MenuEntry(self.Filename, self.getDir(), self.Prefix) + self.__setFilename() + + def __setFilename(self): + if not xdg.Config.root_mode: + path = xdg_data_dirs[0] + else: + path = xdg_data_dirs[1] + + if self.DesktopEntry.getType() == "Application": + dir_ = os.path.join(path, "applications") + else: + dir_ = os.path.join(path, "desktop-directories") + + self.DesktopEntry.filename = os.path.join(dir_, self.Filename) + + def __cmp__(self, other): + return locale.strcoll(self.DesktopEntry.getName(), other.DesktopEntry.getName()) + + def _key(self): + """Key function for locale-aware sorting.""" + return _strxfrm(self.DesktopEntry.getName()) + + def __lt__(self, other): + try: + other = other._key() + except AttributeError: + pass + return self._key() < other + + def __eq__(self, other): + if self.DesktopFileID == str(other): + return True + else: + return False + + def __repr__(self): + return self.DesktopFileID + + +class Separator: + "Just a dummy class for Separators" + def __init__(self, parent): + self.Parent = parent + self.Show = True + + +class Header: + "Class for Inline Headers" + def __init__(self, name, generic_name, comment): + self.Name = name + self.GenericName = generic_name + self.Comment = comment + + def __str__(self): + return self.Name + + +TYPE_DIR, TYPE_FILE = 0, 1 + + +def _check_file_path(value, filename, type): + path = os.path.dirname(filename) + if not os.path.isabs(value): + value = os.path.join(path, value) + value = os.path.abspath(value) + if not os.path.exists(value): + return False + if type == TYPE_DIR and os.path.isdir(value): + return value + if type == TYPE_FILE and os.path.isfile(value): + return value + return False + + +def _get_menu_file_path(filename): + dirs = list(xdg_config_dirs) + if xdg.Config.root_mode is True: + dirs.pop(0) + for d in dirs: + menuname = os.path.join(d, "menus", filename) + if os.path.isfile(menuname): + return menuname + + +def _to_bool(value): + if isinstance(value, bool): + return value + return value.lower() == "true" + + +# remove duplicate entries from a list +def _dedupe(_list): + _set = {} + _list.reverse() + _list = [_set.setdefault(e, e) for e in _list if e not in _set] + _list.reverse() + return _list + + +class XMLMenuBuilder(object): + + def __init__(self, debug=False): + self.debug = debug + + def parse(self, filename=None): + """Load an applications.menu file. + + filename : str, optional + The default is ``$XDG_CONFIG_DIRS/menus/${XDG_MENU_PREFIX}applications.menu``. + """ + # convert to absolute path + if filename and not os.path.isabs(filename): + filename = _get_menu_file_path(filename) + # use default if no filename given + if not filename: + candidate = os.environ.get('XDG_MENU_PREFIX', '') + "applications.menu" + filename = _get_menu_file_path(candidate) + if not filename: + raise ParsingError('File not found', "/etc/xdg/menus/%s" % candidate) + # check if it is a .menu file + if not filename.endswith(".menu"): + raise ParsingError('Not a .menu file', filename) + # create xml parser + try: + tree = etree.parse(filename) + except: + raise ParsingError('Not a valid .menu file', filename) + + # parse menufile + self._merged_files = set() + self._directory_dirs = set() + self.cache = MenuEntryCache() + + menu = self.parse_menu(tree.getroot(), filename) + menu.tree = tree + menu.filename = filename + + self.handle_moves(menu) + self.post_parse(menu) + + # generate the menu + self.generate_not_only_allocated(menu) + self.generate_only_allocated(menu) + + # and finally sort + menu.sort() + + return menu + + def parse_menu(self, node, filename): + menu = Menu() + self.parse_node(node, filename, menu) + return menu + + def parse_node(self, node, filename, parent=None): + num_children = len(node) + for child in node: + tag, text = child.tag, child.text + text = text.strip() if text else None + if tag == 'Menu': + menu = self.parse_menu(child, filename) + parent.addSubmenu(menu) + elif tag == 'AppDir' and text: + self.parse_app_dir(text, filename, parent) + elif tag == 'DefaultAppDirs': + self.parse_default_app_dir(filename, parent) + elif tag == 'DirectoryDir' and text: + self.parse_directory_dir(text, filename, parent) + elif tag == 'DefaultDirectoryDirs': + self.parse_default_directory_dir(filename, parent) + elif tag == 'Name' and text: + parent.Name = text + elif tag == 'Directory' and text: + parent.Directories.append(text) + elif tag == 'OnlyUnallocated': + parent.OnlyUnallocated = True + elif tag == 'NotOnlyUnallocated': + parent.OnlyUnallocated = False + elif tag == 'Deleted': + parent.Deleted = True + elif tag == 'NotDeleted': + parent.Deleted = False + elif tag == 'Include' or tag == 'Exclude': + parent.Rules.append(self.parse_rule(child)) + elif tag == 'MergeFile': + if child.attrib.get("type", None) == "parent": + self.parse_merge_file("applications.menu", child, filename, parent) + elif text: + self.parse_merge_file(text, child, filename, parent) + elif tag == 'MergeDir' and text: + self.parse_merge_dir(text, child, filename, parent) + elif tag == 'DefaultMergeDirs': + self.parse_default_merge_dirs(child, filename, parent) + elif tag == 'Move': + parent.Moves.append(self.parse_move(child)) + elif tag == 'Layout': + if num_children > 1: + parent.Layout = self.parse_layout(child) + elif tag == 'DefaultLayout': + if num_children > 1: + parent.DefaultLayout = self.parse_layout(child) + elif tag == 'LegacyDir' and text: + self.parse_legacy_dir(text, child.attrib.get("prefix", ""), filename, parent) + elif tag == 'KDELegacyDirs': + self.parse_kde_legacy_dirs(filename, parent) + + def parse_layout(self, node): + layout = Layout( + show_empty=_to_bool(node.attrib.get("show_empty", False)), + inline=_to_bool(node.attrib.get("inline", False)), + inline_limit=int(node.attrib.get("inline_limit", 4)), + inline_header=_to_bool(node.attrib.get("inline_header", True)), + inline_alias=_to_bool(node.attrib.get("inline_alias", False)) + ) + for child in node: + tag, text = child.tag, child.text + text = text.strip() if text else None + if tag == "Menuname" and text: + layout.order.append([ + "Menuname", + text, + _to_bool(child.attrib.get("show_empty", False)), + _to_bool(child.attrib.get("inline", False)), + int(child.attrib.get("inline_limit", 4)), + _to_bool(child.attrib.get("inline_header", True)), + _to_bool(child.attrib.get("inline_alias", False)) + ]) + elif tag == "Separator": + layout.order.append(['Separator']) + elif tag == "Filename" and text: + layout.order.append(["Filename", text]) + elif tag == "Merge": + layout.order.append([ + "Merge", + child.attrib.get("type", "all") + ]) + return layout + + def parse_move(self, node): + old, new = "", "" + for child in node: + tag, text = child.tag, child.text + text = text.strip() if text else None + if tag == "Old" and text: + old = text + elif tag == "New" and text: + new = text + return Move(old, new) + + # ---------- parsing + + def parse_rule(self, node): + type = Rule.TYPE_INCLUDE if node.tag == 'Include' else Rule.TYPE_EXCLUDE + tree = ast.Expression(lineno=1, col_offset=0) + expr = self.parse_bool_op(node, ast.Or()) + if expr: + tree.body = expr + else: + tree.body = ast.Name('False', ast.Load()) + ast.fix_missing_locations(tree) + return Rule(type, tree) + + def parse_bool_op(self, node, operator): + values = [] + for child in node: + rule = self.parse_rule_node(child) + if rule: + values.append(rule) + num_values = len(values) + if num_values > 1: + return ast.BoolOp(operator, values) + elif num_values == 1: + return values[0] + return None + + def parse_rule_node(self, node): + tag = node.tag + if tag == 'Or': + return self.parse_bool_op(node, ast.Or()) + elif tag == 'And': + return self.parse_bool_op(node, ast.And()) + elif tag == 'Not': + expr = self.parse_bool_op(node, ast.Or()) + return ast.UnaryOp(ast.Not(), expr) if expr else None + elif tag == 'All': + return ast.Name('True', ast.Load()) + elif tag == 'Category': + category = node.text + return ast.Compare( + left=ast.Str(category), + ops=[ast.In()], + comparators=[ast.Attribute( + value=ast.Name(id='menuentry', ctx=ast.Load()), + attr='Categories', + ctx=ast.Load() + )] + ) + elif tag == 'Filename': + filename = node.text + return ast.Compare( + left=ast.Str(filename), + ops=[ast.Eq()], + comparators=[ast.Attribute( + value=ast.Name(id='menuentry', ctx=ast.Load()), + attr='DesktopFileID', + ctx=ast.Load() + )] + ) + + # ---------- App/Directory Dir Stuff + + def parse_app_dir(self, value, filename, parent): + value = _check_file_path(value, filename, TYPE_DIR) + if value: + parent.AppDirs.append(value) + + def parse_default_app_dir(self, filename, parent): + for d in reversed(xdg_data_dirs): + self.parse_app_dir(os.path.join(d, "applications"), filename, parent) + + def parse_directory_dir(self, value, filename, parent): + value = _check_file_path(value, filename, TYPE_DIR) + if value: + parent.DirectoryDirs.append(value) + + def parse_default_directory_dir(self, filename, parent): + for d in reversed(xdg_data_dirs): + self.parse_directory_dir(os.path.join(d, "desktop-directories"), filename, parent) + + # ---------- Merge Stuff + + def parse_merge_file(self, value, child, filename, parent): + if child.attrib.get("type", None) == "parent": + for d in xdg_config_dirs: + rel_file = filename.replace(d, "").strip("/") + if rel_file != filename: + for p in xdg_config_dirs: + if d == p: + continue + if os.path.isfile(os.path.join(p, rel_file)): + self.merge_file(os.path.join(p, rel_file), child, parent) + break + else: + value = _check_file_path(value, filename, TYPE_FILE) + if value: + self.merge_file(value, child, parent) + + def parse_merge_dir(self, value, child, filename, parent): + value = _check_file_path(value, filename, TYPE_DIR) + if value: + for item in os.listdir(value): + try: + if item.endswith(".menu"): + self.merge_file(os.path.join(value, item), child, parent) + except UnicodeDecodeError: + continue + + def parse_default_merge_dirs(self, child, filename, parent): + basename = os.path.splitext(os.path.basename(filename))[0] + for d in reversed(xdg_config_dirs): + self.parse_merge_dir(os.path.join(d, "menus", basename + "-merged"), child, filename, parent) + + def merge_file(self, filename, child, parent): + # check for infinite loops + if filename in self._merged_files: + if self.debug: + raise ParsingError('Infinite MergeFile loop detected', filename) + else: + return + self._merged_files.add(filename) + # load file + try: + tree = etree.parse(filename) + except IOError: + if self.debug: + raise ParsingError('File not found', filename) + else: + return + except: + if self.debug: + raise ParsingError('Not a valid .menu file', filename) + else: + return + root = tree.getroot() + self.parse_node(root, filename, parent) + + # ---------- Legacy Dir Stuff + + def parse_legacy_dir(self, dir_, prefix, filename, parent): + m = self.merge_legacy_dir(dir_, prefix, filename, parent) + if m: + parent += m + + def merge_legacy_dir(self, dir_, prefix, filename, parent): + dir_ = _check_file_path(dir_, filename, TYPE_DIR) + if dir_ and dir_ not in self._directory_dirs: + self._directory_dirs.add(dir_) + m = Menu() + m.AppDirs.append(dir_) + m.DirectoryDirs.append(dir_) + m.Name = os.path.basename(dir_) + m.NotInXml = True + + for item in os.listdir(dir_): + try: + if item == ".directory": + m.Directories.append(item) + elif os.path.isdir(os.path.join(dir_, item)): + m.addSubmenu(self.merge_legacy_dir( + os.path.join(dir_, item), + prefix, + filename, + parent + )) + except UnicodeDecodeError: + continue + + self.cache.add_menu_entries([dir_], prefix, True) + menuentries = self.cache.get_menu_entries([dir_], False) + + for menuentry in menuentries: + categories = menuentry.Categories + if len(categories) == 0: + r = Rule.fromFilename(Rule.TYPE_INCLUDE, menuentry.DesktopFileID) + m.Rules.append(r) + if not dir_ in parent.AppDirs: + categories.append("Legacy") + menuentry.Categories = categories + + return m + + def parse_kde_legacy_dirs(self, filename, parent): + try: + proc = subprocess.Popen( + ['kde-config', '--path', 'apps'], + stdout=subprocess.PIPE, + universal_newlines=True + ) + output = proc.communicate()[0].splitlines() + except OSError: + # If kde-config doesn't exist, ignore this. + return + try: + for dir_ in output[0].split(":"): + self.parse_legacy_dir(dir_, "kde", filename, parent) + except IndexError: + pass + + def post_parse(self, menu): + # unallocated / deleted + if menu.Deleted is None: + menu.Deleted = False + if menu.OnlyUnallocated is None: + menu.OnlyUnallocated = False + + # Layout Tags + if not menu.Layout or not menu.DefaultLayout: + if menu.DefaultLayout: + menu.Layout = menu.DefaultLayout + elif menu.Layout: + if menu.Depth > 0: + menu.DefaultLayout = menu.Parent.DefaultLayout + else: + menu.DefaultLayout = Layout() + else: + if menu.Depth > 0: + menu.Layout = menu.Parent.DefaultLayout + menu.DefaultLayout = menu.Parent.DefaultLayout + else: + menu.Layout = Layout() + menu.DefaultLayout = Layout() + + # add parent's app/directory dirs + if menu.Depth > 0: + menu.AppDirs = menu.Parent.AppDirs + menu.AppDirs + menu.DirectoryDirs = menu.Parent.DirectoryDirs + menu.DirectoryDirs + + # remove duplicates + menu.Directories = _dedupe(menu.Directories) + menu.DirectoryDirs = _dedupe(menu.DirectoryDirs) + menu.AppDirs = _dedupe(menu.AppDirs) + + # go recursive through all menus + for submenu in menu.Submenus: + self.post_parse(submenu) + + # reverse so handling is easier + menu.Directories.reverse() + menu.DirectoryDirs.reverse() + menu.AppDirs.reverse() + + # get the valid .directory file out of the list + for directory in menu.Directories: + for dir in menu.DirectoryDirs: + if os.path.isfile(os.path.join(dir, directory)): + menuentry = MenuEntry(directory, dir) + if not menu.Directory: + menu.Directory = menuentry + elif menuentry.Type == MenuEntry.TYPE_SYSTEM: + if menu.Directory.Type == MenuEntry.TYPE_USER: + menu.Directory.Original = menuentry + if menu.Directory: + break + + # Finally generate the menu + def generate_not_only_allocated(self, menu): + for submenu in menu.Submenus: + self.generate_not_only_allocated(submenu) + + if menu.OnlyUnallocated is False: + self.cache.add_menu_entries(menu.AppDirs) + menuentries = [] + for rule in menu.Rules: + menuentries = rule.apply(self.cache.get_menu_entries(menu.AppDirs), 1) + + for menuentry in menuentries: + if menuentry.Add is True: + menuentry.Parents.append(menu) + menuentry.Add = False + menuentry.Allocated = True + menu.MenuEntries.append(menuentry) + + def generate_only_allocated(self, menu): + for submenu in menu.Submenus: + self.generate_only_allocated(submenu) + + if menu.OnlyUnallocated is True: + self.cache.add_menu_entries(menu.AppDirs) + menuentries = [] + for rule in menu.Rules: + menuentries = rule.apply(self.cache.get_menu_entries(menu.AppDirs), 2) + for menuentry in menuentries: + if menuentry.Add is True: + menuentry.Parents.append(menu) + # menuentry.Add = False + # menuentry.Allocated = True + menu.MenuEntries.append(menuentry) + + def handle_moves(self, menu): + for submenu in menu.Submenus: + self.handle_moves(submenu) + # parse move operations + for move in menu.Moves: + move_from_menu = menu.getMenu(move.Old) + if move_from_menu: + # FIXME: this is assigned, but never used... + move_to_menu = menu.getMenu(move.New) + + menus = move.New.split("/") + oldparent = None + while len(menus) > 0: + if not oldparent: + oldparent = menu + newmenu = oldparent.getMenu(menus[0]) + if not newmenu: + newmenu = Menu() + newmenu.Name = menus[0] + if len(menus) > 1: + newmenu.NotInXml = True + oldparent.addSubmenu(newmenu) + oldparent = newmenu + menus.pop(0) + + newmenu += move_from_menu + move_from_menu.Parent.Submenus.remove(move_from_menu) + + +class MenuEntryCache: + "Class to cache Desktop Entries" + def __init__(self): + self.cacheEntries = {} + self.cacheEntries['legacy'] = [] + self.cache = {} + + def add_menu_entries(self, dirs, prefix="", legacy=False): + for dir_ in dirs: + if not dir_ in self.cacheEntries: + self.cacheEntries[dir_] = [] + self.__addFiles(dir_, "", prefix, legacy) + + def __addFiles(self, dir_, subdir, prefix, legacy): + for item in os.listdir(os.path.join(dir_, subdir)): + if item.endswith(".desktop"): + try: + menuentry = MenuEntry(os.path.join(subdir, item), dir_, prefix) + except ParsingError: + continue + + self.cacheEntries[dir_].append(menuentry) + if legacy: + self.cacheEntries['legacy'].append(menuentry) + elif os.path.isdir(os.path.join(dir_, subdir, item)) and not legacy: + self.__addFiles(dir_, os.path.join(subdir, item), prefix, legacy) + + def get_menu_entries(self, dirs, legacy=True): + entries = [] + ids = set() + # handle legacy items + appdirs = dirs[:] + if legacy: + appdirs.append("legacy") + # cache the results again + key = "".join(appdirs) + try: + return self.cache[key] + except KeyError: + pass + for dir_ in appdirs: + for menuentry in self.cacheEntries[dir_]: + try: + if menuentry.DesktopFileID not in ids: + ids.add(menuentry.DesktopFileID) + entries.append(menuentry) + elif menuentry.getType() == MenuEntry.TYPE_SYSTEM: + # FIXME: This is only 99% correct, but still... + idx = entries.index(menuentry) + entry = entries[idx] + if entry.getType() == MenuEntry.TYPE_USER: + entry.Original = menuentry + except UnicodeDecodeError: + continue + self.cache[key] = entries + return entries + + +def parse(filename=None, debug=False): + """Helper function. + Equivalent to calling xdg.Menu.XMLMenuBuilder().parse(filename) + """ + return XMLMenuBuilder(debug).parse(filename) diff --git a/src/controller/icons/mixins/xdg/MenuEditor.py b/src/controller/icons/mixins/xdg/MenuEditor.py new file mode 100644 index 0000000..2c68515 --- /dev/null +++ b/src/controller/icons/mixins/xdg/MenuEditor.py @@ -0,0 +1,541 @@ +""" CLass to edit XDG Menus """ +import os +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +from .Menu import Menu, MenuEntry, Layout, Separator, XMLMenuBuilder +from .BaseDirectory import xdg_config_dirs, xdg_data_dirs +from .Exceptions import ParsingError +from .Config import setRootMode + +# XML-Cleanups: Move / Exclude +# FIXME: proper reverte/delete +# FIXME: pass AppDirs/DirectoryDirs around in the edit/move functions +# FIXME: catch Exceptions +# FIXME: copy functions +# FIXME: More Layout stuff +# FIXME: unod/redo function / remove menu... +# FIXME: Advanced MenuEditing Stuff: LegacyDir/MergeFile +# Complex Rules/Deleted/OnlyAllocated/AppDirs/DirectoryDirs + + +class MenuEditor(object): + + def __init__(self, menu=None, filename=None, root=False): + self.menu = None + self.filename = None + self.tree = None + self.parser = XMLMenuBuilder() + self.parse(menu, filename, root) + + # fix for creating two menus with the same name on the fly + self.filenames = [] + + def parse(self, menu=None, filename=None, root=False): + if root: + setRootMode(True) + + if isinstance(menu, Menu): + self.menu = menu + elif menu: + self.menu = self.parser.parse(menu) + else: + self.menu = self.parser.parse() + + if root: + self.filename = self.menu.Filename + elif filename: + self.filename = filename + else: + self.filename = os.path.join(xdg_config_dirs[0], "menus", os.path.split(self.menu.Filename)[1]) + + try: + self.tree = etree.parse(self.filename) + except IOError: + root = etree.fromtring(""" + + + Applications + %s + +""" % self.menu.Filename) + self.tree = etree.ElementTree(root) + except ParsingError: + raise ParsingError('Not a valid .menu file', self.filename) + + #FIXME: is this needed with etree ? + self.__remove_whitespace_nodes(self.tree) + + def save(self): + self.__saveEntries(self.menu) + self.__saveMenu() + + def createMenuEntry(self, parent, name, command=None, genericname=None, comment=None, icon=None, terminal=None, after=None, before=None): + menuentry = MenuEntry(self.__getFileName(name, ".desktop")) + menuentry = self.editMenuEntry(menuentry, name, genericname, comment, command, icon, terminal) + + self.__addEntry(parent, menuentry, after, before) + + self.menu.sort() + + return menuentry + + def createMenu(self, parent, name, genericname=None, comment=None, icon=None, after=None, before=None): + menu = Menu() + + menu.Parent = parent + menu.Depth = parent.Depth + 1 + menu.Layout = parent.DefaultLayout + menu.DefaultLayout = parent.DefaultLayout + + menu = self.editMenu(menu, name, genericname, comment, icon) + + self.__addEntry(parent, menu, after, before) + + self.menu.sort() + + return menu + + def createSeparator(self, parent, after=None, before=None): + separator = Separator(parent) + + self.__addEntry(parent, separator, after, before) + + self.menu.sort() + + return separator + + def moveMenuEntry(self, menuentry, oldparent, newparent, after=None, before=None): + self.__deleteEntry(oldparent, menuentry, after, before) + self.__addEntry(newparent, menuentry, after, before) + + self.menu.sort() + + return menuentry + + def moveMenu(self, menu, oldparent, newparent, after=None, before=None): + self.__deleteEntry(oldparent, menu, after, before) + self.__addEntry(newparent, menu, after, before) + + root_menu = self.__getXmlMenu(self.menu.Name) + if oldparent.getPath(True) != newparent.getPath(True): + self.__addXmlMove(root_menu, os.path.join(oldparent.getPath(True), menu.Name), os.path.join(newparent.getPath(True), menu.Name)) + + self.menu.sort() + + return menu + + def moveSeparator(self, separator, parent, after=None, before=None): + self.__deleteEntry(parent, separator, after, before) + self.__addEntry(parent, separator, after, before) + + self.menu.sort() + + return separator + + def copyMenuEntry(self, menuentry, oldparent, newparent, after=None, before=None): + self.__addEntry(newparent, menuentry, after, before) + + self.menu.sort() + + return menuentry + + def editMenuEntry(self, menuentry, name=None, genericname=None, comment=None, command=None, icon=None, terminal=None, nodisplay=None, hidden=None): + deskentry = menuentry.DesktopEntry + + if name: + if not deskentry.hasKey("Name"): + deskentry.set("Name", name) + deskentry.set("Name", name, locale=True) + if comment: + if not deskentry.hasKey("Comment"): + deskentry.set("Comment", comment) + deskentry.set("Comment", comment, locale=True) + if genericname: + if not deskentry.hasKey("GenericName"): + deskentry.set("GenericName", genericname) + deskentry.set("GenericName", genericname, locale=True) + if command: + deskentry.set("Exec", command) + if icon: + deskentry.set("Icon", icon) + + if terminal: + deskentry.set("Terminal", "true") + elif not terminal: + deskentry.set("Terminal", "false") + + if nodisplay is True: + deskentry.set("NoDisplay", "true") + elif nodisplay is False: + deskentry.set("NoDisplay", "false") + + if hidden is True: + deskentry.set("Hidden", "true") + elif hidden is False: + deskentry.set("Hidden", "false") + + menuentry.updateAttributes() + + if len(menuentry.Parents) > 0: + self.menu.sort() + + return menuentry + + def editMenu(self, menu, name=None, genericname=None, comment=None, icon=None, nodisplay=None, hidden=None): + # Hack for legacy dirs + if isinstance(menu.Directory, MenuEntry) and menu.Directory.Filename == ".directory": + xml_menu = self.__getXmlMenu(menu.getPath(True, True)) + self.__addXmlTextElement(xml_menu, 'Directory', menu.Name + ".directory") + menu.Directory.setAttributes(menu.Name + ".directory") + # Hack for New Entries + elif not isinstance(menu.Directory, MenuEntry): + if not name: + name = menu.Name + filename = self.__getFileName(name, ".directory").replace("/", "") + if not menu.Name: + menu.Name = filename.replace(".directory", "") + xml_menu = self.__getXmlMenu(menu.getPath(True, True)) + self.__addXmlTextElement(xml_menu, 'Directory', filename) + menu.Directory = MenuEntry(filename) + + deskentry = menu.Directory.DesktopEntry + + if name: + if not deskentry.hasKey("Name"): + deskentry.set("Name", name) + deskentry.set("Name", name, locale=True) + if genericname: + if not deskentry.hasKey("GenericName"): + deskentry.set("GenericName", genericname) + deskentry.set("GenericName", genericname, locale=True) + if comment: + if not deskentry.hasKey("Comment"): + deskentry.set("Comment", comment) + deskentry.set("Comment", comment, locale=True) + if icon: + deskentry.set("Icon", icon) + + if nodisplay is True: + deskentry.set("NoDisplay", "true") + elif nodisplay is False: + deskentry.set("NoDisplay", "false") + + if hidden is True: + deskentry.set("Hidden", "true") + elif hidden is False: + deskentry.set("Hidden", "false") + + menu.Directory.updateAttributes() + + if isinstance(menu.Parent, Menu): + self.menu.sort() + + return menu + + def hideMenuEntry(self, menuentry): + self.editMenuEntry(menuentry, nodisplay=True) + + def unhideMenuEntry(self, menuentry): + self.editMenuEntry(menuentry, nodisplay=False, hidden=False) + + def hideMenu(self, menu): + self.editMenu(menu, nodisplay=True) + + def unhideMenu(self, menu): + self.editMenu(menu, nodisplay=False, hidden=False) + xml_menu = self.__getXmlMenu(menu.getPath(True, True), False) + deleted = xml_menu.findall('Deleted') + not_deleted = xml_menu.findall('NotDeleted') + for node in deleted + not_deleted: + xml_menu.remove(node) + + def deleteMenuEntry(self, menuentry): + if self.getAction(menuentry) == "delete": + self.__deleteFile(menuentry.DesktopEntry.filename) + for parent in menuentry.Parents: + self.__deleteEntry(parent, menuentry) + self.menu.sort() + return menuentry + + def revertMenuEntry(self, menuentry): + if self.getAction(menuentry) == "revert": + self.__deleteFile(menuentry.DesktopEntry.filename) + menuentry.Original.Parents = [] + for parent in menuentry.Parents: + index = parent.Entries.index(menuentry) + parent.Entries[index] = menuentry.Original + index = parent.MenuEntries.index(menuentry) + parent.MenuEntries[index] = menuentry.Original + menuentry.Original.Parents.append(parent) + self.menu.sort() + return menuentry + + def deleteMenu(self, menu): + if self.getAction(menu) == "delete": + self.__deleteFile(menu.Directory.DesktopEntry.filename) + self.__deleteEntry(menu.Parent, menu) + xml_menu = self.__getXmlMenu(menu.getPath(True, True)) + parent = self.__get_parent_node(xml_menu) + parent.remove(xml_menu) + self.menu.sort() + return menu + + def revertMenu(self, menu): + if self.getAction(menu) == "revert": + self.__deleteFile(menu.Directory.DesktopEntry.filename) + menu.Directory = menu.Directory.Original + self.menu.sort() + return menu + + def deleteSeparator(self, separator): + self.__deleteEntry(separator.Parent, separator, after=True) + + self.menu.sort() + + return separator + + """ Private Stuff """ + def getAction(self, entry): + if isinstance(entry, Menu): + if not isinstance(entry.Directory, MenuEntry): + return "none" + elif entry.Directory.getType() == "Both": + return "revert" + elif entry.Directory.getType() == "User" and ( + len(entry.Submenus) + len(entry.MenuEntries) + ) == 0: + return "delete" + + elif isinstance(entry, MenuEntry): + if entry.getType() == "Both": + return "revert" + elif entry.getType() == "User": + return "delete" + else: + return "none" + + return "none" + + def __saveEntries(self, menu): + if not menu: + menu = self.menu + if isinstance(menu.Directory, MenuEntry): + menu.Directory.save() + for entry in menu.getEntries(hidden=True): + if isinstance(entry, MenuEntry): + entry.save() + elif isinstance(entry, Menu): + self.__saveEntries(entry) + + def __saveMenu(self): + if not os.path.isdir(os.path.dirname(self.filename)): + os.makedirs(os.path.dirname(self.filename)) + self.tree.write(self.filename, encoding='utf-8') + + def __getFileName(self, name, extension): + postfix = 0 + while 1: + if postfix == 0: + filename = name + extension + else: + filename = name + "-" + str(postfix) + extension + if extension == ".desktop": + dir = "applications" + elif extension == ".directory": + dir = "desktop-directories" + if not filename in self.filenames and not os.path.isfile( + os.path.join(xdg_data_dirs[0], dir, filename) + ): + self.filenames.append(filename) + break + else: + postfix += 1 + + return filename + + def __getXmlMenu(self, path, create=True, element=None): + # FIXME: we should also return the menu's parent, + # to avoid looking for it later on + # @see Element.getiterator() + if not element: + element = self.tree + + if "/" in path: + (name, path) = path.split("/", 1) + else: + name = path + path = "" + + found = None + for node in element.findall("Menu"): + name_node = node.find('Name') + if name_node.text == name: + if path: + found = self.__getXmlMenu(path, create, node) + else: + found = node + if found: + break + if not found and create: + node = self.__addXmlMenuElement(element, name) + if path: + found = self.__getXmlMenu(path, create, node) + else: + found = node + + return found + + def __addXmlMenuElement(self, element, name): + menu_node = etree.SubElement('Menu', element) + name_node = etree.SubElement('Name', menu_node) + name_node.text = name + return menu_node + + def __addXmlTextElement(self, element, name, text): + node = etree.SubElement(name, element) + node.text = text + return node + + def __addXmlFilename(self, element, filename, type_="Include"): + # remove old filenames + includes = element.findall('Include') + excludes = element.findall('Exclude') + rules = includes + excludes + for rule in rules: + #FIXME: this finds only Rules whose FIRST child is a Filename element + if rule[0].tag == "Filename" and rule[0].text == filename: + element.remove(rule) + # shouldn't it remove all occurences, like the following: + #filename_nodes = rule.findall('.//Filename'): + #for fn in filename_nodes: + #if fn.text == filename: + ##element.remove(rule) + #parent = self.__get_parent_node(fn) + #parent.remove(fn) + + # add new filename + node = etree.SubElement(type_, element) + self.__addXmlTextElement(node, 'Filename', filename) + return node + + def __addXmlMove(self, element, old, new): + node = etree.SubElement("Move", element) + self.__addXmlTextElement(node, 'Old', old) + self.__addXmlTextElement(node, 'New', new) + return node + + def __addXmlLayout(self, element, layout): + # remove old layout + for node in element.findall("Layout"): + element.remove(node) + + # add new layout + node = etree.SubElement("Layout", element) + for order in layout.order: + if order[0] == "Separator": + child = etree.SubElement("Separator", node) + elif order[0] == "Filename": + child = self.__addXmlTextElement(node, "Filename", order[1]) + elif order[0] == "Menuname": + child = self.__addXmlTextElement(node, "Menuname", order[1]) + elif order[0] == "Merge": + child = etree.SubElement("Merge", node) + child.attrib["type"] = order[1] + return node + + def __addLayout(self, parent): + layout = Layout() + layout.order = [] + layout.show_empty = parent.Layout.show_empty + layout.inline = parent.Layout.inline + layout.inline_header = parent.Layout.inline_header + layout.inline_alias = parent.Layout.inline_alias + layout.inline_limit = parent.Layout.inline_limit + + layout.order.append(["Merge", "menus"]) + for entry in parent.Entries: + if isinstance(entry, Menu): + layout.parseMenuname(entry.Name) + elif isinstance(entry, MenuEntry): + layout.parseFilename(entry.DesktopFileID) + elif isinstance(entry, Separator): + layout.parseSeparator() + layout.order.append(["Merge", "files"]) + + parent.Layout = layout + + return layout + + def __addEntry(self, parent, entry, after=None, before=None): + if after or before: + if after: + index = parent.Entries.index(after) + 1 + elif before: + index = parent.Entries.index(before) + parent.Entries.insert(index, entry) + else: + parent.Entries.append(entry) + + xml_parent = self.__getXmlMenu(parent.getPath(True, True)) + + if isinstance(entry, MenuEntry): + parent.MenuEntries.append(entry) + entry.Parents.append(parent) + self.__addXmlFilename(xml_parent, entry.DesktopFileID, "Include") + elif isinstance(entry, Menu): + parent.addSubmenu(entry) + + if after or before: + self.__addLayout(parent) + self.__addXmlLayout(xml_parent, parent.Layout) + + def __deleteEntry(self, parent, entry, after=None, before=None): + parent.Entries.remove(entry) + + xml_parent = self.__getXmlMenu(parent.getPath(True, True)) + + if isinstance(entry, MenuEntry): + entry.Parents.remove(parent) + parent.MenuEntries.remove(entry) + self.__addXmlFilename(xml_parent, entry.DesktopFileID, "Exclude") + elif isinstance(entry, Menu): + parent.Submenus.remove(entry) + + if after or before: + self.__addLayout(parent) + self.__addXmlLayout(xml_parent, parent.Layout) + + def __deleteFile(self, filename): + try: + os.remove(filename) + except OSError: + pass + try: + self.filenames.remove(filename) + except ValueError: + pass + + def __remove_whitespace_nodes(self, node): + for child in node: + text = child.text.strip() + if not text: + child.text = '' + tail = child.tail.strip() + if not tail: + child.tail = '' + if len(child): + self.__remove_whilespace_nodes(child) + + def __get_parent_node(self, node): + # elements in ElementTree doesn't hold a reference to their parent + for parent, child in self.__iter_parent(): + if child is node: + return child + + def __iter_parent(self): + for parent in self.tree.getiterator(): + for child in parent: + yield parent, child diff --git a/src/controller/icons/mixins/xdg/Mime.py b/src/controller/icons/mixins/xdg/Mime.py new file mode 100644 index 0000000..60c4efd --- /dev/null +++ b/src/controller/icons/mixins/xdg/Mime.py @@ -0,0 +1,780 @@ +""" +This module is based on a rox module (LGPL): + +http://cvs.sourceforge.net/viewcvs.py/rox/ROX-Lib2/python/rox/mime.py?rev=1.21&view=log + +This module provides access to the shared MIME database. + +types is a dictionary of all known MIME types, indexed by the type name, e.g. +types['application/x-python'] + +Applications can install information about MIME types by storing an +XML file as /packages/.xml and running the +update-mime-database command, which is provided by the freedesktop.org +shared mime database package. + +See http://www.freedesktop.org/standards/shared-mime-info-spec/ for +information about the format of these files. + +(based on version 0.13) +""" + +import os +import re +import stat +import sys +import fnmatch + +from . import BaseDirectory, Locale + +from .dom import minidom, XML_NAMESPACE +from collections import defaultdict + +FREE_NS = 'http://www.freedesktop.org/standards/shared-mime-info' + +types = {} # Maps MIME names to type objects + +exts = None # Maps extensions to types +globs = None # List of (glob, type) pairs +literals = None # Maps liternal names to types +magic = None + +PY3 = (sys.version_info[0] >= 3) + +def _get_node_data(node): + """Get text of XML node""" + return ''.join([n.nodeValue for n in node.childNodes]).strip() + +def lookup(media, subtype = None): + """Get the MIMEtype object for the given type. + + This remains for backwards compatibility; calling MIMEtype now does + the same thing. + + The name can either be passed as one part ('text/plain'), or as two + ('text', 'plain'). + """ + return MIMEtype(media, subtype) + +class MIMEtype(object): + """Class holding data about a MIME type. + + Calling the class will return a cached instance, so there is only one + instance for each MIME type. The name can either be passed as one part + ('text/plain'), or as two ('text', 'plain'). + """ + def __new__(cls, media, subtype=None): + if subtype is None and '/' in media: + media, subtype = media.split('/', 1) + assert '/' not in subtype + media = media.lower() + subtype = subtype.lower() + + try: + return types[(media, subtype)] + except KeyError: + mtype = super(MIMEtype, cls).__new__(cls) + mtype._init(media, subtype) + types[(media, subtype)] = mtype + return mtype + + # If this is done in __init__, it is automatically called again each time + # the MIMEtype is returned by __new__, which we don't want. So we call it + # explicitly only when we construct a new instance. + def _init(self, media, subtype): + self.media = media + self.subtype = subtype + self._comment = None + + def _load(self): + "Loads comment for current language. Use get_comment() instead." + resource = os.path.join('mime', self.media, self.subtype + '.xml') + for path in BaseDirectory.load_data_paths(resource): + doc = minidom.parse(path) + if doc is None: + continue + for comment in doc.documentElement.getElementsByTagNameNS(FREE_NS, 'comment'): + lang = comment.getAttributeNS(XML_NAMESPACE, 'lang') or 'en' + goodness = 1 + (lang in xdg.Locale.langs) + if goodness > self._comment[0]: + self._comment = (goodness, _get_node_data(comment)) + if goodness == 2: return + + # FIXME: add get_icon method + def get_comment(self): + """Returns comment for current language, loading it if needed.""" + # Should we ever reload? + if self._comment is None: + self._comment = (0, str(self)) + self._load() + return self._comment[1] + + def canonical(self): + """Returns the canonical MimeType object if this is an alias.""" + update_cache() + s = str(self) + if s in aliases: + return lookup(aliases[s]) + return self + + def inherits_from(self): + """Returns a set of Mime types which this inherits from.""" + update_cache() + return set(lookup(t) for t in inheritance[str(self)]) + + def __str__(self): + return self.media + '/' + self.subtype + + def __repr__(self): + return 'MIMEtype(%r, %r)' % (self.media, self.subtype) + + def __hash__(self): + return hash(self.media) ^ hash(self.subtype) + +class UnknownMagicRuleFormat(ValueError): + pass + +class DiscardMagicRules(Exception): + "Raised when __NOMAGIC__ is found, and caught to discard previous rules." + pass + +class MagicRule: + also = None + + def __init__(self, start, value, mask, word, range): + self.start = start + self.value = value + self.mask = mask + self.word = word + self.range = range + + rule_ending_re = re.compile(br'(?:~(\d+))?(?:\+(\d+))?\n$') + + @classmethod + def from_file(cls, f): + """Read a rule from the binary magics file. Returns a 2-tuple of + the nesting depth and the MagicRule.""" + line = f.readline() + #print line + + # [indent] '>' + nest_depth, line = line.split(b'>', 1) + nest_depth = int(nest_depth) if nest_depth else 0 + + # start-offset '=' + start, line = line.split(b'=', 1) + start = int(start) + + if line == b'__NOMAGIC__\n': + raise DiscardMagicRules + + # value length (2 bytes, big endian) + if sys.version_info[0] >= 3: + lenvalue = int.from_bytes(line[:2], byteorder='big') + else: + lenvalue = (ord(line[0])<<8)+ord(line[1]) + line = line[2:] + + # value + # This can contain newlines, so we may need to read more lines + while len(line) <= lenvalue: + line += f.readline() + value, line = line[:lenvalue], line[lenvalue:] + + # ['&' mask] + if line.startswith(b'&'): + # This can contain newlines, so we may need to read more lines + while len(line) <= lenvalue: + line += f.readline() + mask, line = line[1:lenvalue+1], line[lenvalue+1:] + else: + mask = None + + # ['~' word-size] ['+' range-length] + ending = cls.rule_ending_re.match(line) + if not ending: + # Per the spec, this will be caught and ignored, to allow + # for future extensions. + raise UnknownMagicRuleFormat(repr(line)) + + word, range = ending.groups() + word = int(word) if (word is not None) else 1 + range = int(range) if (range is not None) else 1 + + return nest_depth, cls(start, value, mask, word, range) + + def maxlen(self): + l = self.start + len(self.value) + self.range + if self.also: + return max(l, self.also.maxlen()) + return l + + def match(self, buffer): + if self.match0(buffer): + if self.also: + return self.also.match(buffer) + return True + + def match0(self, buffer): + l=len(buffer) + lenvalue = len(self.value) + for o in range(self.range): + s=self.start+o + e=s+lenvalue + if l [(priority, rule), ...] + + def merge_file(self, fname): + """Read a magic binary file, and add its rules to this MagicDB.""" + with open(fname, 'rb') as f: + line = f.readline() + if line != b'MIME-Magic\0\n': + raise IOError('Not a MIME magic file') + + while True: + shead = f.readline().decode('ascii') + #print(shead) + if not shead: + break + if shead[0] != '[' or shead[-2:] != ']\n': + raise ValueError('Malformed section heading', shead) + pri, tname = shead[1:-2].split(':') + #print shead[1:-2] + pri = int(pri) + mtype = lookup(tname) + try: + rule = MagicMatchAny.from_file(f) + except DiscardMagicRules: + self.bytype.pop(mtype, None) + rule = MagicMatchAny.from_file(f) + if rule is None: + continue + #print rule + + self.bytype[mtype].append((pri, rule)) + + def finalise(self): + """Prepare the MagicDB for matching. + + This should be called after all rules have been merged into it. + """ + maxlen = 0 + self.alltypes = [] # (priority, mimetype, rule) + + for mtype, rules in self.bytype.items(): + for pri, rule in rules: + self.alltypes.append((pri, mtype, rule)) + maxlen = max(maxlen, rule.maxlen()) + + self.maxlen = maxlen # Number of bytes to read from files + self.alltypes.sort(key=lambda x: x[0], reverse=True) + + def match_data(self, data, max_pri=100, min_pri=0, possible=None): + """Do magic sniffing on some bytes. + + max_pri & min_pri can be used to specify the maximum & minimum priority + rules to look for. possible can be a list of mimetypes to check, or None + (the default) to check all mimetypes until one matches. + + Returns the MIMEtype found, or None if no entries match. + """ + if possible is not None: + types = [] + for mt in possible: + for pri, rule in self.bytype[mt]: + types.append((pri, mt, rule)) + types.sort(key=lambda x: x[0]) + else: + types = self.alltypes + + for priority, mimetype, rule in types: + #print priority, max_pri, min_pri + if priority > max_pri: + continue + if priority < min_pri: + break + + if rule.match(data): + return mimetype + + def match(self, path, max_pri=100, min_pri=0, possible=None): + """Read data from the file and do magic sniffing on it. + + max_pri & min_pri can be used to specify the maximum & minimum priority + rules to look for. possible can be a list of mimetypes to check, or None + (the default) to check all mimetypes until one matches. + + Returns the MIMEtype found, or None if no entries match. Raises IOError + if the file can't be opened. + """ + with open(path, 'rb') as f: + buf = f.read(self.maxlen) + return self.match_data(buf, max_pri, min_pri, possible) + + def __repr__(self): + return '' % len(self.alltypes) + +class GlobDB(object): + def __init__(self): + """Prepare the GlobDB. It can't actually be used until .finalise() is + called, but merge_file() can be used to add data before that. + """ + # Maps mimetype to {(weight, glob, flags), ...} + self.allglobs = defaultdict(set) + + def merge_file(self, path): + """Loads name matching information from a globs2 file."""# + allglobs = self.allglobs + with open(path) as f: + for line in f: + if line.startswith('#'): continue # Comment + + fields = line[:-1].split(':') + weight, type_name, pattern = fields[:3] + weight = int(weight) + mtype = lookup(type_name) + if len(fields) > 3: + flags = fields[3].split(',') + else: + flags = () + + if pattern == '__NOGLOBS__': + # This signals to discard any previous globs + allglobs.pop(mtype, None) + continue + + allglobs[mtype].add((weight, pattern, tuple(flags))) + + def finalise(self): + """Prepare the GlobDB for matching. + + This should be called after all files have been merged into it. + """ + self.exts = defaultdict(list) # Maps extensions to [(type, weight),...] + self.cased_exts = defaultdict(list) + self.globs = [] # List of (regex, type, weight) triplets + self.literals = {} # Maps literal names to (type, weight) + self.cased_literals = {} + + for mtype, globs in self.allglobs.items(): + mtype = mtype.canonical() + for weight, pattern, flags in globs: + + cased = 'cs' in flags + + if pattern.startswith('*.'): + # *.foo -- extension pattern + rest = pattern[2:] + if not ('*' in rest or '[' in rest or '?' in rest): + if cased: + self.cased_exts[rest].append((mtype, weight)) + else: + self.exts[rest.lower()].append((mtype, weight)) + continue + + if ('*' in pattern or '[' in pattern or '?' in pattern): + # Translate the glob pattern to a regex & compile it + re_flags = 0 if cased else re.I + pattern = re.compile(fnmatch.translate(pattern), flags=re_flags) + self.globs.append((pattern, mtype, weight)) + else: + # No wildcards - literal pattern + if cased: + self.cased_literals[pattern] = (mtype, weight) + else: + self.literals[pattern.lower()] = (mtype, weight) + + # Sort globs by weight & length + self.globs.sort(reverse=True, key=lambda x: (x[2], len(x[0].pattern)) ) + + def first_match(self, path): + """Return the first match found for a given path, or None if no match + is found.""" + try: + return next(self._match_path(path))[0] + except StopIteration: + return None + + def all_matches(self, path): + """Return a list of (MIMEtype, glob weight) pairs for the path.""" + return list(self._match_path(path)) + + def _match_path(self, path): + """Yields pairs of (mimetype, glob weight).""" + leaf = os.path.basename(path) + + # Literals (no wildcards) + if leaf in self.cased_literals: + yield self.cased_literals[leaf] + + lleaf = leaf.lower() + if lleaf in self.literals: + yield self.literals[lleaf] + + # Extensions + ext = leaf + while 1: + p = ext.find('.') + if p < 0: break + ext = ext[p + 1:] + if ext in self.cased_exts: + for res in self.cased_exts[ext]: + yield res + ext = lleaf + while 1: + p = ext.find('.') + if p < 0: break + ext = ext[p+1:] + if ext in self.exts: + for res in self.exts[ext]: + yield res + + # Other globs + for (regex, mime_type, weight) in self.globs: + if regex.match(leaf): + yield (mime_type, weight) + +# Some well-known types +text = lookup('text', 'plain') +octet_stream = lookup('application', 'octet-stream') +inode_block = lookup('inode', 'blockdevice') +inode_char = lookup('inode', 'chardevice') +inode_dir = lookup('inode', 'directory') +inode_fifo = lookup('inode', 'fifo') +inode_socket = lookup('inode', 'socket') +inode_symlink = lookup('inode', 'symlink') +inode_door = lookup('inode', 'door') +app_exe = lookup('application', 'executable') + +_cache_uptodate = False + +def _cache_database(): + global globs, magic, aliases, inheritance, _cache_uptodate + + _cache_uptodate = True + + aliases = {} # Maps alias Mime types to canonical names + inheritance = defaultdict(set) # Maps to sets of parent mime types. + + # Load aliases + for path in BaseDirectory.load_data_paths(os.path.join('mime', 'aliases')): + with open(path, 'r') as f: + for line in f: + alias, canonical = line.strip().split(None, 1) + aliases[alias] = canonical + + # Load filename patterns (globs) + globs = GlobDB() + for path in BaseDirectory.load_data_paths(os.path.join('mime', 'globs2')): + globs.merge_file(path) + globs.finalise() + + # Load magic sniffing data + magic = MagicDB() + for path in BaseDirectory.load_data_paths(os.path.join('mime', 'magic')): + magic.merge_file(path) + magic.finalise() + + # Load subclasses + for path in BaseDirectory.load_data_paths(os.path.join('mime', 'subclasses')): + with open(path, 'r') as f: + for line in f: + sub, parent = line.strip().split(None, 1) + inheritance[sub].add(parent) + +def update_cache(): + if not _cache_uptodate: + _cache_database() + +def get_type_by_name(path): + """Returns type of file by its name, or None if not known""" + update_cache() + return globs.first_match(path) + +def get_type_by_contents(path, max_pri=100, min_pri=0): + """Returns type of file by its contents, or None if not known""" + update_cache() + + return magic.match(path, max_pri, min_pri) + +def get_type_by_data(data, max_pri=100, min_pri=0): + """Returns type of the data, which should be bytes.""" + update_cache() + + return magic.match_data(data, max_pri, min_pri) + +def _get_type_by_stat(st_mode): + """Match special filesystem objects to Mimetypes.""" + if stat.S_ISDIR(st_mode): return inode_dir + elif stat.S_ISCHR(st_mode): return inode_char + elif stat.S_ISBLK(st_mode): return inode_block + elif stat.S_ISFIFO(st_mode): return inode_fifo + elif stat.S_ISLNK(st_mode): return inode_symlink + elif stat.S_ISSOCK(st_mode): return inode_socket + return inode_door + +def get_type(path, follow=True, name_pri=100): + """Returns type of file indicated by path. + + This function is *deprecated* - :func:`get_type2` is more accurate. + + :param path: pathname to check (need not exist) + :param follow: when reading file, follow symbolic links + :param name_pri: Priority to do name matches. 100=override magic + + This tries to use the contents of the file, and falls back to the name. It + can also handle special filesystem objects like directories and sockets. + """ + update_cache() + + try: + if follow: + st = os.stat(path) + else: + st = os.lstat(path) + except: + t = get_type_by_name(path) + return t or text + + if stat.S_ISREG(st.st_mode): + # Regular file + t = get_type_by_contents(path, min_pri=name_pri) + if not t: t = get_type_by_name(path) + if not t: t = get_type_by_contents(path, max_pri=name_pri) + if t is None: + if stat.S_IMODE(st.st_mode) & 0o111: + return app_exe + else: + return text + return t + else: + return _get_type_by_stat(st.st_mode) + +def get_type2(path, follow=True): + """Find the MIMEtype of a file using the XDG recommended checking order. + + This first checks the filename, then uses file contents if the name doesn't + give an unambiguous MIMEtype. It can also handle special filesystem objects + like directories and sockets. + + :param path: file path to examine (need not exist) + :param follow: whether to follow symlinks + + :rtype: :class:`MIMEtype` + + .. versionadded:: 1.0 + """ + update_cache() + + try: + st = os.stat(path) if follow else os.lstat(path) + except OSError: + return get_type_by_name(path) or octet_stream + + if not stat.S_ISREG(st.st_mode): + # Special filesystem objects + return _get_type_by_stat(st.st_mode) + + mtypes = sorted(globs.all_matches(path), key=(lambda x: x[1]), reverse=True) + if mtypes: + max_weight = mtypes[0][1] + i = 1 + for mt, w in mtypes[1:]: + if w < max_weight: + break + i += 1 + mtypes = mtypes[:i] + if len(mtypes) == 1: + return mtypes[0][0] + + possible = [mt for mt,w in mtypes] + else: + possible = None # Try all magic matches + + try: + t = magic.match(path, possible=possible) + except IOError: + t = None + + if t: + return t + elif mtypes: + return mtypes[0][0] + elif stat.S_IMODE(st.st_mode) & 0o111: + return app_exe + else: + return text if is_text_file(path) else octet_stream + +def is_text_file(path): + """Guess whether a file contains text or binary data. + + Heuristic: binary if the first 32 bytes include ASCII control characters. + This rule may change in future versions. + + .. versionadded:: 1.0 + """ + try: + f = open(path, 'rb') + except IOError: + return False + + with f: + return _is_text(f.read(32)) + +if PY3: + def _is_text(data): + return not any(b <= 0x8 or 0xe <= b < 0x20 or b == 0x7f for b in data) +else: + def _is_text(data): + return not any(b <= '\x08' or '\x0e' <= b < '\x20' or b == '\x7f' \ + for b in data) + +_mime2ext_cache = None +_mime2ext_cache_uptodate = False + +def get_extensions(mimetype): + """Retrieve the set of filename extensions matching a given MIMEtype. + + Extensions are returned without a leading dot, e.g. 'py'. If no extensions + are registered for the MIMEtype, returns an empty set. + + The extensions are stored in a cache the first time this is called. + + .. versionadded:: 1.0 + """ + global _mime2ext_cache, _mime2ext_cache_uptodate + update_cache() + if not _mime2ext_cache_uptodate: + _mime2ext_cache = defaultdict(set) + for ext, mtypes in globs.exts.items(): + for mtype, prio in mtypes: + _mime2ext_cache[mtype].add(ext) + _mime2ext_cache_uptodate = True + + return _mime2ext_cache[mimetype] + + +def install_mime_info(application, package_file): + """Copy 'package_file' as ``~/.local/share/mime/packages/.xml.`` + If package_file is None, install ``/.xml``. + If already installed, does nothing. May overwrite an existing + file with the same name (if the contents are different)""" + application += '.xml' + + new_data = open(package_file).read() + + # See if the file is already installed + package_dir = os.path.join('mime', 'packages') + resource = os.path.join(package_dir, application) + for x in BaseDirectory.load_data_paths(resource): + try: + old_data = open(x).read() + except: + continue + if old_data == new_data: + return # Already installed + + global _cache_uptodate + _cache_uptodate = False + + # Not already installed; add a new copy + # Create the directory structure... + new_file = os.path.join(BaseDirectory.save_data_path(package_dir), application) + + # Write the file... + open(new_file, 'w').write(new_data) + + # Update the database... + command = 'update-mime-database' + if os.spawnlp(os.P_WAIT, command, command, BaseDirectory.save_data_path('mime')): + os.unlink(new_file) + raise Exception("The '%s' command returned an error code!\n" \ + "Make sure you have the freedesktop.org shared MIME package:\n" \ + "http://standards.freedesktop.org/shared-mime-info/" % command) diff --git a/src/controller/icons/mixins/xdg/RecentFiles.py b/src/controller/icons/mixins/xdg/RecentFiles.py new file mode 100644 index 0000000..fbe608c --- /dev/null +++ b/src/controller/icons/mixins/xdg/RecentFiles.py @@ -0,0 +1,181 @@ +""" +Implementation of the XDG Recent File Storage Specification +http://standards.freedesktop.org/recent-file-spec +""" + +import xml.dom.minidom, xml.sax.saxutils +import os, time, fcntl +from .Exceptions import ParsingError + +class RecentFiles: + def __init__(self): + self.RecentFiles = [] + self.filename = "" + + def parse(self, filename=None): + """Parse a list of recently used files. + + filename defaults to ``~/.recently-used``. + """ + if not filename: + filename = os.path.join(os.getenv("HOME"), ".recently-used") + + try: + doc = xml.dom.minidom.parse(filename) + except IOError: + raise ParsingError('File not found', filename) + except xml.parsers.expat.ExpatError: + raise ParsingError('Not a valid .menu file', filename) + + self.filename = filename + + for child in doc.childNodes: + if child.nodeType == xml.dom.Node.ELEMENT_NODE: + if child.tagName == "RecentFiles": + for recent in child.childNodes: + if recent.nodeType == xml.dom.Node.ELEMENT_NODE: + if recent.tagName == "RecentItem": + self.__parseRecentItem(recent) + + self.sort() + + def __parseRecentItem(self, item): + recent = RecentFile() + self.RecentFiles.append(recent) + + for attribute in item.childNodes: + if attribute.nodeType == xml.dom.Node.ELEMENT_NODE: + if attribute.tagName == "URI": + recent.URI = attribute.childNodes[0].nodeValue + elif attribute.tagName == "Mime-Type": + recent.MimeType = attribute.childNodes[0].nodeValue + elif attribute.tagName == "Timestamp": + recent.Timestamp = int(attribute.childNodes[0].nodeValue) + elif attribute.tagName == "Private": + recent.Prviate = True + elif attribute.tagName == "Groups": + + for group in attribute.childNodes: + if group.nodeType == xml.dom.Node.ELEMENT_NODE: + if group.tagName == "Group": + recent.Groups.append(group.childNodes[0].nodeValue) + + def write(self, filename=None): + """Write the list of recently used files to disk. + + If the instance is already associated with a file, filename can be + omitted to save it there again. + """ + if not filename and not self.filename: + raise ParsingError('File not found', filename) + elif not filename: + filename = self.filename + + f = open(filename, "w") + fcntl.lockf(f, fcntl.LOCK_EX) + f.write('\n') + f.write("\n") + + for r in self.RecentFiles: + f.write(" \n") + f.write(" %s\n" % xml.sax.saxutils.escape(r.URI)) + f.write(" %s\n" % r.MimeType) + f.write(" %s\n" % r.Timestamp) + if r.Private == True: + f.write(" \n") + if len(r.Groups) > 0: + f.write(" \n") + for group in r.Groups: + f.write(" %s\n" % group) + f.write(" \n") + f.write(" \n") + + f.write("\n") + fcntl.lockf(f, fcntl.LOCK_UN) + f.close() + + def getFiles(self, mimetypes=None, groups=None, limit=0): + """Get a list of recently used files. + + The parameters can be used to filter by mime types, by group, or to + limit the number of items returned. By default, the entire list is + returned, except for items marked private. + """ + tmp = [] + i = 0 + for item in self.RecentFiles: + if groups: + for group in groups: + if group in item.Groups: + tmp.append(item) + i += 1 + elif mimetypes: + for mimetype in mimetypes: + if mimetype == item.MimeType: + tmp.append(item) + i += 1 + else: + if item.Private == False: + tmp.append(item) + i += 1 + if limit != 0 and i == limit: + break + + return tmp + + def addFile(self, item, mimetype, groups=None, private=False): + """Add a recently used file. + + item should be the URI of the file, typically starting with ``file:///``. + """ + # check if entry already there + if item in self.RecentFiles: + index = self.RecentFiles.index(item) + recent = self.RecentFiles[index] + else: + # delete if more then 500 files + if len(self.RecentFiles) == 500: + self.RecentFiles.pop() + # add entry + recent = RecentFile() + self.RecentFiles.append(recent) + + recent.URI = item + recent.MimeType = mimetype + recent.Timestamp = int(time.time()) + recent.Private = private + if groups: + recent.Groups = groups + + self.sort() + + def deleteFile(self, item): + """Remove a recently used file, by URI, from the list. + """ + if item in self.RecentFiles: + self.RecentFiles.remove(item) + + def sort(self): + self.RecentFiles.sort() + self.RecentFiles.reverse() + + +class RecentFile: + def __init__(self): + self.URI = "" + self.MimeType = "" + self.Timestamp = "" + self.Private = False + self.Groups = [] + + def __cmp__(self, other): + return cmp(self.Timestamp, other.Timestamp) + + def __lt__ (self, other): + return self.Timestamp < other.Timestamp + + def __eq__(self, other): + return self.URI == str(other) + + def __str__(self): + return self.URI diff --git a/src/controller/icons/mixins/xdg/__init__.py b/src/controller/icons/mixins/xdg/__init__.py new file mode 100644 index 0000000..b5a117e --- /dev/null +++ b/src/controller/icons/mixins/xdg/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ "BaseDirectory", "DesktopEntry", "Menu", "Exceptions", "IniFile", "IconTheme", "Locale", "Config", "Mime", "RecentFiles", "MenuEditor" ] + +__version__ = "0.26" diff --git a/src/controller/icons/mixins/xdg/util.py b/src/controller/icons/mixins/xdg/util.py new file mode 100644 index 0000000..1637aa5 --- /dev/null +++ b/src/controller/icons/mixins/xdg/util.py @@ -0,0 +1,75 @@ +import sys + +PY3 = sys.version_info[0] >= 3 + +if PY3: + def u(s): + return s +else: + # Unicode-like literals + def u(s): + return s.decode('utf-8') + +try: + # which() is available from Python 3.3 + from shutil import which +except ImportError: + import os + # This is a copy of which() from Python 3.3 + def which(cmd, mode=os.F_OK | os.X_OK, path=None): + """Given a command, mode, and a PATH string, return the path which + conforms to the given mode on the PATH, or None if there is no such + file. + + `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result + of os.environ.get("PATH"), or can be overridden with a custom search + path. + + """ + # Check that a given file can be accessed with the correct mode. + # Additionally check that `file` is not a directory, as on Windows + # directories pass the os.access check. + def _access_check(fn, mode): + return (os.path.exists(fn) and os.access(fn, mode) + and not os.path.isdir(fn)) + + # If we're given a path with a directory part, look it up directly rather + # than referring to PATH directories. This includes checking relative to the + # current directory, e.g. ./script + if os.path.dirname(cmd): + if _access_check(cmd, mode): + return cmd + return None + + path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep) + + if sys.platform == "win32": + # The current directory takes precedence on Windows. + if not os.curdir in path: + path.insert(0, os.curdir) + + # PATHEXT is necessary to check on Windows. + pathext = os.environ.get("PATHEXT", "").split(os.pathsep) + # See if the given file matches any of the expected path extensions. + # This will allow us to short circuit when given "python.exe". + # If it does match, only test that one, otherwise we have to try + # others. + if any(cmd.lower().endswith(ext.lower()) for ext in pathext): + files = [cmd] + else: + files = [cmd + ext for ext in pathext] + else: + # On other platforms you don't have things like PATHEXT to tell you + # what file suffixes are executable, so just pass on cmd as-is. + files = [cmd] + + seen = set() + for dir in path: + normdir = os.path.normcase(dir) + if not normdir in seen: + seen.add(normdir) + for thefile in files: + name = os.path.join(dir, thefile) + if _access_check(name, mode): + return name + return None diff --git a/src/controller/mixins/TreeViewUpdateMixin.py b/src/controller/mixins/TreeViewUpdateMixin.py new file mode 100644 index 0000000..c5193ef --- /dev/null +++ b/src/controller/mixins/TreeViewUpdateMixin.py @@ -0,0 +1,49 @@ +# Python imports +import threading + + +# Gtk imports +import gi +gi.require_version("Gtk", "3.0") +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk, Gdk, GLib, Gio, GdkPixbuf + +# Application imports + + + +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs).start() + + return wrapper + + +class TreeViewUpdateMixin: + """docstring for DummyMixin""" + def load_store(self, view, store, dir): + store.clear() + view.load_directory(dir) + files = view.get_images() + + for i, file in enumerate(files): + store.append([None, f"{dir}/{file[0]}"]) + self.create_icon(i, view, store, dir, file[0]) + + @threaded + def create_icon(self, i, view, store, dir, file): + icon = view.create_icon(dir, file) + fpath = f"{dir}/{file}" + GLib.idle_add(self.update_store, (i, store, icon, view, fpath,)) + + def update_store(self, item): + i, store, icon, view, fpath = item + itr = store.get_iter(i) + + if not icon: + if fpath.endswith(".gif"): + icon = GdkPixbuf.PixbufAnimation.get_static_image(fpath) + else: + icon = GdkPixbuf.Pixbuf.new_from_file(view.DEFAULT_ICON) + + store.set_value(itr, 0, icon) diff --git a/src/controller/mixins/__init__.py b/src/controller/mixins/__init__.py new file mode 100644 index 0000000..183df50 --- /dev/null +++ b/src/controller/mixins/__init__.py @@ -0,0 +1 @@ +from .TreeViewUpdateMixin import TreeViewUpdateMixin diff --git a/src/utils/Logger.py b/src/utils/Logger.py new file mode 100644 index 0000000..06eed47 --- /dev/null +++ b/src/utils/Logger.py @@ -0,0 +1,56 @@ +# Python imports +import os, logging + +# Application imports + + +class Logger: + def __init__(self, config_path): + self._CONFIG_PATH = config_path + + def get_logger(self, loggerName = "NO_LOGGER_NAME_PASSED", createFile = True): + """ + Create a new logging object and return it. + :note: + NOSET # Don't know the actual log level of this... (defaulting or literally none?) + Log Levels (From least to most) + Type Value + CRITICAL 50 + ERROR 40 + WARNING 30 + INFO 20 + DEBUG 10 + :param loggerName: Sets the name of the logger object. (Used in log lines) + :param createFile: Whether we create a log file or just pump to terminal + + :return: the logging object we created + """ + + globalLogLvl = logging.DEBUG # Keep this at highest so that handlers can filter to their desired levels + chLogLevel = logging.CRITICAL # Prety musch the only one we change ever + fhLogLevel = logging.DEBUG + log = logging.getLogger(loggerName) + log.setLevel(globalLogLvl) + + # Set our log output styles + fFormatter = logging.Formatter('[%(asctime)s] %(pathname)s:%(lineno)d %(levelname)s - %(message)s', '%m-%d %H:%M:%S') + cFormatter = logging.Formatter('%(pathname)s:%(lineno)d] %(levelname)s - %(message)s') + + ch = logging.StreamHandler() + ch.setLevel(level=chLogLevel) + ch.setFormatter(cFormatter) + log.addHandler(ch) + + if createFile: + folder = self._CONFIG_PATH + file = f"{folder}/application.log" + + if not os.path.exists(folder): + os.mkdir(folder) + + fh = logging.FileHandler(file) + fh.setLevel(level=fhLogLevel) + fh.setFormatter(fFormatter) + log.addHandler(fh) + + return log diff --git a/src/utils/Settings.py b/src/utils/Settings.py new file mode 100644 index 0000000..3af0b58 --- /dev/null +++ b/src/utils/Settings.py @@ -0,0 +1,110 @@ +# Python imports +import os + +# Gtk imports +import gi, cairo +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') + +from gi.repository import Gtk +from gi.repository import Gdk + + +# Application imports +from . import Logger + + + +class Settings: + def __init__(self): + self._SCRIPT_PTH = os.path.dirname(os.path.realpath(__file__)) + self._USER_HOME = os.path.expanduser('~') + self._CONFIG_PATH = f"{self._USER_HOME}/.config/{app_name.lower()}" + self._GLADE_FILE = f"{self._CONFIG_PATH}/Main_Window.glade" + self._CSS_FILE = f"{self._CONFIG_PATH}/stylesheet.css" + self._DEFAULT_ICONS = f"{self._CONFIG_PATH}/icons" + self._WINDOW_ICON = f"{self._DEFAULT_ICONS}/{app_name.lower()}.png" + self._BLANK_ICON = f"{self._DEFAULT_ICONS}/mirage_blank.png" + self._USR_PATH = f"/usr/share/{app_name.lower()}" + + if not os.path.exists(self._CONFIG_PATH): + os.mkdir(self._CONFIG_PATH) + if not os.path.exists(self._GLADE_FILE): + self._GLADE_FILE = f"{self._USR_PATH}/Main_Window.glade" + if not os.path.exists(self._CSS_FILE): + self._CSS_FILE = f"{self._USR_PATH}/stylesheet.css" + if not os.path.exists(self._WINDOW_ICON): + self._WINDOW_ICON = f"{self._USR_PATH}/icons/{app_name.lower()}.png" + if not os.path.exists(self._BLANK_ICON): + self._BLANK_ICON = f"{self._USR_PATH}/icons/mirage_blank.png" + if not os.path.exists(self._DEFAULT_ICONS): + self.DEFAULT_ICONS = f"{self._USR_PATH}/icons" + + # '_filters' + self._images_filter = ('.png', '.jpg', '.jpeg', '.gif', '.ico', '.tga') + + self._success_color = "#88cc27" + self._warning_color = "#ffa800" + self._error_color = "#ff0000" + + self._main_window = None + self._logger = Logger(self._CONFIG_PATH).get_logger() + self._builder = Gtk.Builder() + self._builder.add_from_file(self._GLADE_FILE) + + + + def create_window(self): + # Get window and connect signals + self._main_window = self._builder.get_object("Main_Window") + self.set_window_data() + + def set_window_data(self): + self._main_window.set_icon_from_file(self._WINDOW_ICON) + screen = self._main_window.get_screen() + visual = screen.get_rgba_visual() + + if visual != None and screen.is_composited(): + self._main_window.set_visual(visual) + self._main_window.set_app_paintable(True) + self._main_window.connect("draw", self.draw_area) + + # bind css file + cssProvider = Gtk.CssProvider() + cssProvider.load_from_path(self._CSS_FILE) + screen = Gdk.Screen.get_default() + styleContext = Gtk.StyleContext() + styleContext.add_provider_for_screen(screen, cssProvider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + + def get_monitor_data(self): + screen = self._builder.get_object("Main_Window").get_screen() + monitors = [] + for m in range(screen.get_n_monitors()): + monitors.append(screen.get_monitor_geometry(m)) + + for monitor in monitors: + print("{}x{}|{}+{}".format(monitor.width, monitor.height, monitor.x, monitor.y)) + + return monitors + + def draw_area(self, widget, cr): + cr.set_source_rgba(0, 0, 0, 0.54) + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.paint() + cr.set_operator(cairo.OPERATOR_OVER) + + + + + def get_builder(self): return self._builder + def get_logger(self): return self._logger + def get_main_window(self): return self._main_window + def get_home_path(self): return self._USER_HOME + + # Filter returns + def get_images_filter(self): return self._images_filter + + def get_blank_image(self): return self._BLANK_ICON + def get_success_color(self): return self._success_color + def get_warning_color(self): return self._warning_color + def get_error_color(self): return self._error_color diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..415301e --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,6 @@ +""" + Utils module +""" + +from .Logger import Logger +from .Settings import Settings diff --git a/user_config/usr/share/mirage2/Main_Window.glade b/user_config/usr/share/mirage2/Main_Window.glade new file mode 100644 index 0000000..25e2a0e --- /dev/null +++ b/user_config/usr/share/mirage2/Main_Window.glade @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + 800 + 600 + False + Mirage2 + + + + True + False + vertical + + + True + False + + + + + + + + + + + + False + True + 0 + + + + + True + False + 5 + 5 + + + False + True + 1 + + + + + True + False + + + 256 + True + True + in + + + True + False + + + True + True + thumbnail_store + False + False + False + True + + + + + + + column + + + + 0 + + + + + + + + + + + False + True + 0 + + + + + True + True + in + + + True + False + + + 800 + 600 + True + False + True + + + + + + + + + + True + True + 1 + + + + + True + True + 2 + + + + + + diff --git a/user_config/usr/share/mirage2/icons/mirage2.png b/user_config/usr/share/mirage2/icons/mirage2.png new file mode 100644 index 0000000..a470132 Binary files /dev/null and b/user_config/usr/share/mirage2/icons/mirage2.png differ diff --git a/user_config/usr/share/mirage2/icons/mirage_blank.png b/user_config/usr/share/mirage2/icons/mirage_blank.png new file mode 100644 index 0000000..433dc1b Binary files /dev/null and b/user_config/usr/share/mirage2/icons/mirage_blank.png differ diff --git a/user_config/usr/share/mirage2/mirage2.png b/user_config/usr/share/mirage2/mirage2.png new file mode 100644 index 0000000..a470132 Binary files /dev/null and b/user_config/usr/share/mirage2/mirage2.png differ diff --git a/user_config/usr/share/mirage2/stylesheet.css b/user_config/usr/share/mirage2/stylesheet.css new file mode 100644 index 0000000..c0383f6 --- /dev/null +++ b/user_config/usr/share/mirage2/stylesheet.css @@ -0,0 +1,86 @@ +/* Set fm to have transparent window */ +box, +iconview, +notebook, +paned, +stack, +scrolledwindow, +treeview.view, +.content-view, +.view { + background: rgba(19, 21, 25, 0.14); + color: rgba(255, 255, 255, 1); +} + +notebook > header > tabs > tab:checked { + /* Neon Blue 00e8ff */ + background-color: rgba(0, 232, 255, 0.2); + /* Dark Bergundy */ + /* background-color: rgba(116, 0, 0, 0.25); */ + + color: rgba(255, 255, 255, 0.8); +} + +#message_view { + font: 16px "Monospace"; +} + +.view:selected, +.view:selected:hover { + box-shadow: inset 0 0 0 9999px rgba(21, 158, 167, 0.34); + color: rgba(255, 255, 255, 0.5); +} + +.alert-border { + border: 2px solid rgba(116, 0, 0, 0.64); +} + +.search-border { + border: 2px solid rgba(136, 204, 39, 1); +} + +.notebook-selected-focus { + /* Neon Blue 00e8ff border */ + border: 2px solid rgba(0, 232, 255, 0.34); + /* Dark Bergundy */ + /* border: 2px solid rgba(116, 0, 0, 0.64); */ +} + +.notebook-unselected-focus { + /* Neon Blue 00e8ff border */ + /* border: 2px solid rgba(0, 232, 255, 0.25); */ + /* Dark Bergundy */ + /* border: 2px solid rgba(116, 0, 0, 0.64); */ + /* Snow White */ + border: 2px solid rgba(255, 255, 255, 0.24); +} + + + + + +/* * { + background: rgba(0, 0, 0, 0.14); + color: rgba(255, 255, 255, 1); +} */ + +/* * selection { + background-color: rgba(116, 0, 0, 0.65); + color: rgba(255, 255, 255, 0.5); +} */ + +/* Rubberband coloring */ +/* .rubberband, +rubberband, +flowbox rubberband, +treeview.view rubberband, +.content-view rubberband, +.content-view .rubberband, +XfdesktopIconView.view .rubberband { + border: 1px solid #6c6c6c; + background-color: rgba(21, 158, 167, 0.57); +} + +XfdesktopIconView.view:active { + background-color: rgba(172, 102, 21, 1); +} */