diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86cfc6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# IntelliJ project files +.idea + +**/.cache +build/** +dist/** +XDG_Prefs.egg-info/** diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 0000000..81ad522 --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1,15 @@ +Since this project is open-source, you are welcome to contribute. + +This project uses multiple git branches: +* feature branches are used to add enhancements to the project. They must +be named after the issue they're referring (e.g. issue XP-003 is targeted in +feature branch XP-003). They are later merged into develop when the feature +is finished. +* develop branch is the core branch. It is used to group all done features. It +can occasionally contain hotfixes. +* release branches are created each time a new release is started. They are +merged from develop when all planned features are ready. They can also +contain specific release fixes (e.g. change of configuration, version +number) +* master branch is merged from release branches (a tag must be added each +time a release is pulled) \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index fd6bfac..b133d8b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,124 @@ -# xdg-prefs +# XDG-Prefs +> Remy Chaput -Program that allows you to change the default application on your Linux system, using XDG standard. \ No newline at end of file +On GNU/Linux systems, each file has an associated MIME Type (or Media Type) +that represents the kind of content (for example, *image/jpeg*). +Your system uses a database to list your preferences for default applications +(for example, to use *eog* as default application for *image/jpeg*). + +*XDG-Prefs* uses the [XDG Specifications][xdg-spec] to read and modify this +database, allowing you to change your preferred default applications for +each MIME type. The [XDG Specifications][xdg-spec] is the reference +specification for MIME types and default applications, meaning that the +preferences you will set in *XDG-Prefs* will be recognized by all other +XDG-compliant softwares (such as `xdg-open`, which is typically used when +you double-click on a file). + +Usually, Desktop Environments (such as *Gnome*, or *KDE*) provide some kind of +tool to manage these preferences ; this software works on every Window Manager +(including those that are not Desktop Environments, such as *i3wm*). + +*Note*: *XDG-Prefs* is not associated with the +[Freedesktop Organization][freedesktop], and is not an official software of the +[XDG Specifications][xdg-spec]. + +## Getting started + +Download the Wheel (.whl) file in the [releases] section and install it using +`pip install XDG_Prefs--py3-none-any.whl` (replace `` with +the number of the version you downloaded, such as `0.1`: +`XDG_Prefs-0.1-py3-none-any.whl`). +Please note that you must use Python3.5 or later (you might need to replace +`pip` with `pip3` on some distributions, such as Debian). + +Alternatively, you can clone this project on your computer and run + `python setup.py install`. This is recommended if you want to contribute. +Again, you will need to use Python3.5 or later (you might need to replace +`python` with `python3` on some distributions, such as Debian). + +This will install the required files and create a `xdg-prefs` executable. + +## How to use + +Launch `xdg-prefs` (for example from the command line). On the interface you +will see 3 panels (each associated to a tab): +1. **Associations**: allows you to see the current default application for each +MIME Type. +Simply click on the list, on the left of a given MIME Type to see the list +of possible applications. Click on one of them to set it as the default +application. +2. **List MIME Types**: allows you to see the list of known MIME types on your +computer, and to search for specifics MIME types. Even MIME types which do +not have an associated default application are listed here. +3. **List Applications**: allows you to see the list of known applications on +your computer (that is, those with a *.desktop* file). You can see the list +of MIME types each application is able to handle, and a description of the +application. + +*XDG-Prefs* will print logs on the bottom of the interface, especially when +you set a new default application. + +## Features + +* Python implementation of multiples XDG Specifications. +Directly reads the files that compose each of the following databases: + * [Shared MIME Database][mime-spec] (list of all MIME types) + * [Desktop Entry][apps-spec] (list of applications) + * [MIME Applications Associations][xdg-spec] (preferences for default + applications) +* Qt5 interface + * allows to see and filter list of MIME types + * allows to see and filter list of applications + * allows to see and change the default application associated to each MIME type +* Works with every window manager + +## Dependencies + +This project only depends on +* Python3.5 (should work with later versions) +* PySide2 (Qt5 for Python ; tested with version 5.9.0a1) +* configparser (Python standard library to read config files) +* [future_fstrings](https://pypi.org/project/future-fstrings/) +(in order to use PEP498's F-strings in Python3.5) +* Uses code from https://github.com/wor/desktop_file_parser +(in order to parse [Desktop files][apps-spec]) + +## Contributing + +This is an Open-Source projects, your contributions are very welcome. + +If you have an idea for a new feature, an optimization or if you notice a bug, +feel free to [open an issue][issues]. + +You are also welcome to contribute to the code directly, in this case please +refer to the [Contributing guidelines][contrib]. + +## Related projects + +Here's a list of other projects related to the [XDG Specifications][xdg-spec] +and/or the setting of default applications on GNU/Linux: + +* [PyXDG](https://www.freedesktop.org/wiki/Software/pyxdg/) +* [xdg-utils](https://www.freedesktop.org/wiki/Software/xdg-utils/), including: + * [xdg-mime](http://portland.freedesktop.org/doc/xdg-mime.html) + * [xdg-open](http://portland.freedesktop.org/doc/xdg-open.html) +* gnome-default-applications-properties + +## Licensing + +This project is licensed under the [Apache License][license]. + +Basically, this means that you are allowed to modify and distribute this +project, but you must include the [License][license] file and state the +changes you've made (please refer to the [License][license] file or the +https://choosealicense.com/licenses/apache-2.0/ website for the full +list of permissions, conditions and limitations). + +[issues]:issues/new +[releases]:releases/ +[xdg-spec]:https://www.freedesktop.org/wiki/Specifications/mime-apps-spec/ +[freedesktop]:https://www.freedesktop.org/wiki/ +[mime-spec]:https://www.freedesktop.org/wiki/Specifications/shared-mime-info-spec/ +[apps-spec]:https://www.freedesktop.org/wiki/Specifications/desktop-entry-spec/ +[contrib]:./CONTRIBUTING +[license]:./LICENSE diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c8691b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyside2 +future_fstrings diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4e8148d --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +import os +from setuptools import setup + + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + + +setup( + name='XDG-Prefs', + version='0.1', + + packages=['xdgprefs', 'xdgprefs.core', 'xdgprefs.gui'], + install_requires=['PySide2', 'future-fstrings'], + + entry_points={ + 'gui_scripts': [ + 'xdg-prefs = xdgprefs.__main__:main' + ] + }, + + + author='Remy Chaput', + author_email='rchaput.pro@gmail.com', + description='A GUI program to view and change your default programs\' ' + 'preferences (which program should open which type of file), ' + 'using the XDG Specifications', + long_description=read('README.md'), + long_description_content_type='text/markdown', + + license='Apache', + keywords='GUI MIME preferences XDG', + platforms=['GNU/Linux'], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: X11 Applications', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: Apache Software License', + 'Natural Language :: English', + 'Operating System :: Unix', + 'Programming Language :: Python :: 3.5', + 'Topic :: Utilities' + ] +) diff --git a/xdgprefs/__init__.py b/xdgprefs/__init__.py new file mode 100644 index 0000000..f35cd07 --- /dev/null +++ b/xdgprefs/__init__.py @@ -0,0 +1,2 @@ +from . import core +from . import gui diff --git a/xdgprefs/__main__.py b/xdgprefs/__main__.py new file mode 100644 index 0000000..c1c89fb --- /dev/null +++ b/xdgprefs/__main__.py @@ -0,0 +1,20 @@ +""" +Entry-point of the xdg-prefs software. +""" + + +import sys +from PySide2.QtWidgets import QApplication + +from xdgprefs.gui.main_window import MainWindow + + +def main(): + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() diff --git a/xdgprefs/core/__init__.py b/xdgprefs/core/__init__.py new file mode 100644 index 0000000..634b7c4 --- /dev/null +++ b/xdgprefs/core/__init__.py @@ -0,0 +1,15 @@ +from .app_database import AppDatabase +from .associations_database import AssociationsDatabase +from .desktop_entry import DesktopEntry +from .mime_database import MimeDatabase +from .mime_type import MimeType +from . import os_env +from . import xdg_mime_wrapper + +__all__ = ['AppDatabase', + 'AssociationsDatabase', + 'DesktopEntry', + 'MimeDatabase', + 'MimeType', + 'os_env', + 'xdg_mime_wrapper'] diff --git a/xdgprefs/core/app_database.py b/xdgprefs/core/app_database.py new file mode 100644 index 0000000..f0a147d --- /dev/null +++ b/xdgprefs/core/app_database.py @@ -0,0 +1,71 @@ +# -*- coding: future_fstrings -*- +""" +This module defines functions and class to handle the Application Database +(i.e. the list of Desktop Entries that represent applications). +""" + + +import os +import logging + +from xdgprefs.core.os_env import xdg_data_dirs +from xdgprefs.core import desktop_entry_parser as parser + + +def app_dirs(only_existing=True): + """ + List all the application directories. + + Application directories are the `application` subdirectory in each + of the XDG_DATA_DIRS directories. + + :param only_existing: If set to `True`, only the existing directories + will be returned. Otherwise, all possible locations are listed. + + :return: A list of paths. + """ + dirs = xdg_data_dirs() + dirs = [os.path.join(d, 'applications/') for d in dirs] + if only_existing: + dirs = [d for d in dirs if os.path.exists(d)] + return dirs + + +class AppDatabase(object): + + def __init__(self): + self.logger = logging.getLogger('AppDatabase') + self.apps = {} + + self._build_db() + + def _build_db(self): + self.logger.debug('Building the App Database...') + # First, loop on all applications directories + for app_dir in app_dirs(): + self.logger.debug(f'Looking in {app_dir}...') + # Next, loop on each file (recursively) + for (dirpath, _, filenames) in os.walk(app_dir): + for filename in filenames: + if filename.endswith('.desktop'): + filepath = os.path.join(dirpath, filename) + _id = os.path.relpath(filepath, app_dir) + app = parser.parse(filepath, _id) + self._add_app(app) + + def _add_app(self, app): + if app is not None: + self.apps[app.appid] = app + + def get_app(self, appid): + if appid in self.apps: + return self.apps[appid] + else: + return None + + @property + def size(self): + return len(self.apps) + + def __str__(self): + return f'' diff --git a/xdgprefs/core/associations_database.py b/xdgprefs/core/associations_database.py new file mode 100644 index 0000000..437ba4b --- /dev/null +++ b/xdgprefs/core/associations_database.py @@ -0,0 +1,197 @@ +# -*- coding: future_fstrings -*- +""" +This module defines the database that lists associations between +MIME Types and Applications (represented by Desktop Entries). + +This database can be used to view and to change such associations +(e.g. the default application used to open a given MIME Type). + +https://specifications.freedesktop.org/mime-apps-spec/latest/index.html +""" + + +import configparser +import logging +import os +from collections import defaultdict + +from xdgprefs.core import os_env + + +ADDED = 'Added Applications' +REMOVED = 'Removed Applications' +DEFAULT = 'Default Applications' + + +class Associations(object): + + def __init__(self): + self.added = [] + self.removed = [] + self.default = [] + + def extend_added(self, apps): + for app in apps: + if app not in self.added and app not in self.removed: + self.added.append(app) + + def extend_removed(self, apps): + for app in apps: + if app not in self.removed: + self.removed.append(app) + + def extend_default(self, apps): + for app in apps: + if app not in self.default: + self.default.append(app) + + +def mimeapps_files(only_existing=True): + desktop = os_env.get_current_desktop_environment() + prefixes = [''] + [name + '-' for name in desktop] + config_home = os_env.xdg_config_home() + config_dirs = os_env.xdg_config_dirs() + data_home = os_env.xdg_data_home() + data_dirs = os_env.xdg_data_dirs() + + files = [] + + # CONFIG_HOME + for prefix in prefixes: + path = os.path.join(config_home, prefix + 'mimeapps.list') + files.append(path) + + # CONFIG_DIRS + for directory in config_dirs: + for prefix in prefixes: + path = os.path.join(directory, prefix + 'mimeapps.list') + files.append(path) + + # DATA_HOME + for prefix in prefixes: + path = os.path.join(data_home, 'applications', prefix + 'mimeapps.list') + files.append(path) + + # DATA_DIRS + for directory in data_dirs: + for prefix in prefixes: + path = os.path.join(directory, 'applications', + prefix + 'mimeapps.list') + files.append(path) + + if only_existing: + files = [f for f in files if os.path.exists(f)] + return files + + +def cache_files(only_existing=True): + desktop = os_env.get_current_desktop_environment() + prefixes = [''] + [name + '-' for name in desktop] + + dirs = [os_env.xdg_data_home()] + os_env.xdg_data_dirs() + dirs = [os.path.join(d, 'applications') for d in dirs] + + files = [] + for _dir in dirs: + for prefix in prefixes: + file = os.path.join(_dir, prefix + 'mimeinfo.cache') + files.append(file) + + if only_existing: + files = [f for f in files if os.path.exists(f)] + + return files + + +class ArrayInterpolation(configparser.Interpolation): + + def before_read(self, parser, section, option, value): + values = value.split(';') + # if the line is 'd1;d2;', the last element is empty, let's remove it + if values[-1].strip() == '': + values.pop(-1) + return values + + def before_write(self, parser, section, option, value): + return ';'.join(value) + ';' + + +def parse_mimeapps(file_path): + config = configparser.ConfigParser(delimiters='=', + interpolation=ArrayInterpolation()) + try: + config.read(file_path) + for section in [ADDED, REMOVED, DEFAULT]: + if section not in config.sections(): + config[section] = {} + return config + except configparser.Error as e: + print(e) + return None + + +class AssociationsDatabase(object): + + def __init__(self): + self.logger = logging.getLogger('AssociationsDatabase') + self.associations = defaultdict(Associations) + self.config_path = os.path.join(os_env.xdg_config_home(), + 'mimeapps.list') + self.config = parse_mimeapps(self.config_path) + + self._build_db() + + def _build_db(self): + files = mimeapps_files(True) + for file in files: + self._parse_mimeapps_file(file) + files = cache_files(True) + for file in files: + self._parse_cache_file(file) + + def _parse_mimeapps_file(self, path): + config = parse_mimeapps(path) + if config is None: + self.logger.warning(f'Badly formatted file: {path}') + return + section = config['Added Applications'] + for mimetype, apps in section.items(): + self.associations[mimetype].extend_added(apps) + section = config['Removed Applications'] + for mimetype, apps in section.items(): + self.associations[mimetype].extend_removed(apps) + section = config['Default Applications'] + for mimetype, apps in section.items(): + self.associations[mimetype].extend_default(apps) + + def _parse_cache_file(self, path): + config = parse_mimeapps(path) + for mimetype, apps in config['MIME Cache'].items(): + assoc = self.associations[mimetype] + assoc.extend_default(apps) + + def get_apps_for_mimetype(self, mimetype): + if mimetype in self.associations: + assoc = self.associations[mimetype] + return assoc.default + return [] + + def set_app_for_mimetype(self, mimetype, app): + section = self.config[DEFAULT] + apps = section.get(mimetype, fallback=[]) + if app in apps: + apps.remove(app) + apps.insert(0, app) + return self.save_config() + + def save_config(self): + try: + with open(self.config_path, 'w') as f: + self.config.write(f, space_around_delimiters=False) + return True + except: + return False + + @property + def size(self): + return len(self.associations) diff --git a/xdgprefs/core/desktop_entry.py b/xdgprefs/core/desktop_entry.py new file mode 100644 index 0000000..5906e90 --- /dev/null +++ b/xdgprefs/core/desktop_entry.py @@ -0,0 +1,120 @@ +# -*- coding: future_fstrings -*- +""" +This module defines code relative to Desktop Entries, i.e. applications +which are compliant with the XDG specifications. + +https://specifications.freedesktop.org/desktop-entry-spec/latest/ +""" + +import logging +from collections import defaultdict +from typing import Optional + + +class Entry(object): + """ + An Entry, i.e. a single line of a Desktop Entry file. + + Such entries have a key, a value, and an optional locale: + Key[Locale]=Value + """ + + def __init__(self, key: str, + value: str, + locale: Optional[str]): + self.key = key + self.value = value + self.locale = locale + + +class EntryGroup(object): + """ + An Entry Group, i.e. a set of unique entries identified by (key,locale). + """ + + def __init__(self, name: str): + self.name = name + self.entries = defaultdict(lambda: defaultdict(lambda: None)) + + def add_entry(self, entry: Entry): + """Add an entry to the group.""" + self.entries[entry.key][entry.locale] = entry + + def get_entry(self, entry_key, entry_locale=None) -> Optional[Entry]: + """Return an entry identified by its key and locale, or None.""" + entries = self.entries[entry_key] + # TODO: search the best matching locale. + if entry_locale is not None and entry_locale not in entries: + # The specified locale is not found, so we use the default one. + return entries[None] + else: + return entries[entry_locale] + + def get_entry_value(self, entry_key, entry_locale=None): + """Return the value of an entry, or None.""" + entry = self.get_entry(entry_key, entry_locale) + return entry.value if entry is not None else None + + +class DesktopEntry(object): + """ + A Desktop Entry file defines an application, and is composed of multiple + Entry Groups. The default one is named 'Desktop Entry'. + """ + + logger = logging.getLogger('DesktopEntry') + + def __init__(self, groups, appid): + self.groups = groups + self.appid = appid + + def get_entry(self, entry_key, groupname='Desktop Entry'): + if groupname not in self.groups: + self.logger.warning(f'[{self.appid}] Group {groupname} not found!') + return None + group = self.groups[groupname] + return group.get_entry(entry_key) + + def get_entry_value(self, entry_key, groupname='Desktop Entry'): + entry = self.get_entry(entry_key, groupname) + return entry.value if entry is not None else None + + @property + def name(self): + return self.get_entry_value('Name') + + @property + def generic_name(self): + return self.get_entry_value('GenericName') + + @property + def comment(self): + return self.get_entry_value('Comment') + + @property + def icon(self): + return self.get_entry_value('Icon') + + @property + def hidden(self): + return self.get_entry_value('Hidden') + + @property + def only_show_in(self): + return self.get_entry_value('OnlyShowIn') + + @property + def not_show_in(self): + return self.get_entry_value('NotShowIn') + + @property + def mime_type(self): + return self.get_entry_value('MimeType') + + @property + def is_vendor(self): + return self.appid.startswith('vnd-') + + @property + def is_extension(self): + return self.appid.startswith('x-') diff --git a/xdgprefs/core/desktop_entry_parser.py b/xdgprefs/core/desktop_entry_parser.py new file mode 100644 index 0000000..a20f74f --- /dev/null +++ b/xdgprefs/core/desktop_entry_parser.py @@ -0,0 +1,158 @@ +# -*- coding: future_fstrings -*- +""" +Desktop file tokenizer and parser. +Source: https://github.com/wor/desktop_file_parser +This work was copied and modified from wor's work (licensed under GPL). +""" + +import re +from collections import OrderedDict +import logging + +from xdgprefs.core.desktop_entry import DesktopEntry, EntryGroup, Entry + + +logger = logging.getLogger('DesktopEntryParser') + + +def convert_bool(entry: Entry): + """Try and convert an entry's value to a boolean, or return the value.""" + if entry.value in ['0', 'false']: + return False + elif entry.value in ['1', 'true']: + return True + else: + msg = f'Key {entry.key} does not have a boolean value ({entry.value})' + logger.warning(msg) + return entry.value + + +def split(text): + """Split a text, taking escape characters into account.""" + # See https://stackoverflow.com/a/21882672 + escape = '\\' + ret = [] + current = [] + itr = iter(text) + for ch in itr: + if ch == escape: + try: + # skip the next character; it has been escaped! + current.append(next(itr)) + except StopIteration: + current.append(escape) + elif ch == ';' or ch == ',': + # split! (add current to the list and reset it) + ret.append(''.join(current)) + current = [] + else: + current.append(ch) + if len(current) > 0: + ret.append(''.join(current)) + return ret + + +def tok_gen(text): + """ + Simplified token generator. + As Desktop files are not really that complex to tokenize, this function + replaces the tokenizer dependency. + + Parameters: + text: str. Desktop file as string. + Returns: + (str,()). + """ + reg = r"""(?P^(.+?)(\[.+?\])?=(.*)$\n?)|"""\ + r"""(?P^#(.*)\n)|"""\ + r"""(?P^[ \t\r\f\v]*\n)|"""\ + r"""(?P^\[(.+?)\]\s*$\n?)""" + r = re.compile(reg, re.MULTILINE) + + tok_gen.groups = OrderedDict(sorted(r.groupindex.items(), + key=lambda t: t[1])) + + # Make tok_gen.groups contain mapping from regex group name to submatch + # range. Submatch range start-1 is the whole match. + last_i = None + for i in tok_gen.groups.items(): + if last_i is None: + last_i = i + continue + tok_gen.groups[last_i[0]] = (last_i[1], i[1]-1) + last_i = i + tok_gen.groups[last_i[0]] = (last_i[1], r.groups) + + pos = 0 + while True: + m = r.match(text, pos) + if not m: + if pos != len(text): + raise SyntaxError("Tokenization failed!") + break + pos = m.end() + start = tok_gen.groups[m.lastgroup][0] + end = tok_gen.groups[m.lastgroup][1] + yield m.lastgroup, m.groups()[start:end] + + +def parse(filepath, name): + """ + Parses desktop entry file. + + Args: + filepath: The complete path to the Desktop Entry file. + name: The name of the file (e.g. com-mycompany-myapp.desktop) + Returns: + DesktopFile. Instance of DesktopFile class which represents the + parsed desktop file. + """ + with open(filepath, 'r') as f: + tokens = tok_gen(f.read()) + + # Desktop files entry groups + entry_groups = {} + + # Refine symbol stream. + try: + current_group = None + for t in tokens: + tok_name = t[0] + subvalues = t[1] + if tok_name == "EMPTY_LINE": + continue + elif tok_name == "COMMENT_LINE": + continue + elif tok_name == "GROUP_HEADER": + current_group = subvalues[0] + entry_groups[current_group] = EntryGroup(current_group) + continue + elif tok_name == "ENTRY": + entry_name = subvalues[0] + entry_value = subvalues[2] + entry_locale = subvalues[1].strip("[]") \ + if subvalues[1] else None + entry = Entry(entry_name, entry_value, entry_locale) + + # Check boolean entries + if entry.key in ["NoDisplay", "Hidden", "Terminal", + "StartupNotify", "X-MultipleArgs"]: + entry.value = convert_bool(entry) + # Check multiple string entries (string lists) + elif entry.key in ["OnlyShowIn", "NotShowIn", "Actions", + "MimeType", "Categories", "Keywords"]: + entry.value = split(entry.value) + + entry_groups[current_group].add_entry(entry) + else: + msg = f'Token unrecognized while parsing Desktop file: {name}' + logger.warning(msg) + except SyntaxError as e: + msg = f'Syntax error {e} when parsing Desktop file: {name}' + logger.error(msg) + return None + + name = name.replace('/', '-') + + df = DesktopEntry(entry_groups, name) + return df diff --git a/xdgprefs/core/mime_database.py b/xdgprefs/core/mime_database.py new file mode 100644 index 0000000..0bbca4a --- /dev/null +++ b/xdgprefs/core/mime_database.py @@ -0,0 +1,90 @@ +# -*- coding: future_fstrings -*- +""" +This module provides functions and classes used to handle the Mime Database +(i.e. the set of known MIME Types, with associated meta information). + +https://www.linuxtopia.org/online_books/linux_desktop_guides/gnome_2.14_admin_guide/mimetypes-database.html +""" + + +import os +import logging +from typing import Dict + +from xdgprefs.core.os_env import xdg_data_dirs, xdg_data_home +from xdgprefs.core.mime_type import MimeType, MimeTypeParser + + +def mime_dirs(only_existing=True): + """ + List all the MIME directories. + + MIME directories are the `mime` subdirectory in each of the + XDG_DATA_HOME:XDG_DATA_DIRS directories, noted in the + specifications. + + :arg only_existing: If set to `True`, only the existing directories will + be returned. Otherwise, all possible locations are listed. + + :return: A list of paths. + """ + home = xdg_data_home() + dirs = [home] + xdg_data_dirs() + dirs = [os.path.join(d, 'mime') for d in dirs] + if only_existing: + dirs = [d for d in dirs if os.path.exists(d)] + return dirs + + +class MimeDatabase(object): + """ + This class finds and holds all Media Types registered on the computer. + + It is used to build the database in a first step, and then query it. + """ + + def __init__(self): + self.logger = logging.getLogger('MimeDatabase') + self.types = {} + + self._build_db() + + def _build_db(self): + """Build the database, searching in the directories.""" + self.logger.debug('Building the Mime Database...') + # First, loop on all directories. + for mime_dir in mime_dirs(): + self.logger.debug(f'Looking in {mime_dir}...') + # Next, loop on the subdirectories. + # Ignore the `packages` subdirectory (not a MEDIA). + subdirs = [f.path for f in os.scandir(mime_dir) if f.is_dir() + and f.name != 'packages'] + for media_dir in subdirs: + # Loop on each file (describing a Mime Type) + files = [f.path for f in os.scandir(media_dir) if f.is_file()] + for filepath in files: + mimetype = MimeTypeParser.parse(filepath) + self._add_type(mimetype) + + def _add_type(self, mimetype): + """Adds a MimeType to the database.""" + if mimetype is None: + return + if mimetype.identifier in self.types: + self.logger.warning(f'MimeType {mimetype.identifier} already ' + f'in the database, overwriting!') + self.types[mimetype.identifier] = mimetype + + def get_type(self, identifier): + """Return the MimeType associated to an identifier.""" + if identifier in self.types: + return self.types[identifier] + else: + return None + + @property + def size(self): + return len(self.types) + + def __str__(self): + return f'' diff --git a/xdgprefs/core/mime_type.py b/xdgprefs/core/mime_type.py new file mode 100644 index 0000000..50fd9aa --- /dev/null +++ b/xdgprefs/core/mime_type.py @@ -0,0 +1,143 @@ +# -*- coding: future_fstrings -*- +""" +This module defines the MimeType class as well as a MimeTypeParser (used +to parse media types from their XML files). +""" + + +import logging +from typing import List, Optional +from xml.etree import ElementTree + +from xdgprefs.core import os_env + + +class MimeType(object): + """ + Defines a MIME Type (or Media Type). + + Media Types are defined in the following RFC: + + - https://tools.ietf.org/html/rfc2045 + - https://tools.ietf.org/html/rfc6838 + """ + + def __init__(self, + _type: str, + subtype: str, + comment: str, + extensions: List[str], + icon: Optional[str]): + # Data + self.type = _type + self.subtype = subtype + self.comment = comment + self.extensions = extensions + self.icon = icon + + # Computed data + self.identifier = '{}/{}'.format(self.type, self.subtype) + + @property + def is_extension(self): + return self.subtype.startswith('x-') \ + or self.subtype.startswith('x.') + + @property + def is_vendor(self): + return self.subtype.startswith('vnd-') \ + or self.subtype.startswith('vnd.') + + @property + def is_personal(self): + return self.subtype.startswith('prs-') \ + or self.subtype.startswith('prs.') + + def __repr__(self): + return self.identifier + + def __str__(self): + return '{}/{} ({})'.format(self.type, self.subtype, self.comment) + + +class MimeTypeParser: + """ + Helper class to parse the XML files describing media types. + + https://specifications.freedesktop.org/shared-mime-info-spec/shared-mime-info-spec-0.11.html + """ + + logger = logging.getLogger('MimeTypeParser') + xmlns = '{http://www.freedesktop.org/standards/shared-mime-info}' + + @classmethod + def parse(cls, filepath): + """Parse an XML file and return the corresponding MimeType.""" + tree = ElementTree.parse(filepath) + # The root element represents a Mime Type + root = tree.getroot() + if not cls._check_tag(filepath, root): + return None + if not cls._check_attrib(filepath, root): + return None + _type, subtype = cls._get_type_subtype(root) + comment = cls._get_comment(root) + extensions = cls._get_extensions(root) + icon = cls._get_icon(root) + return MimeType(_type, subtype, comment, extensions, icon) + + @classmethod + def _check_tag(cls, filepath, root): + """Check if the root element has the correct tag.""" + correct = f'{cls.xmlns}mime-type' + if root.tag != correct: + cls.logger.warning(f'The root element of {filepath} is ' + f'{root.tag}, should be {correct}! Ignoring ' + f'this file.') + return False + return True + + @classmethod + def _check_attrib(cls, filepath, root): + """Check if the root element has the correct attributes.""" + if 'type' not in root.attrib: + cls.logger.warning(f'The root element of {filepath} does not ' + f'have a type attribute! Ignoring this file.') + return False + return True + + @classmethod + def _get_type_subtype(cls, root): + """Return the type and subtype from the root element.""" + identifier = root.attrib['type'] + _type, subtype = identifier.split('/') + return _type, subtype + + @classmethod + def _get_comment(cls, root): + """Return the comment describing the media type.""" + comments = root.findall(f'{cls.xmlns}comment') + os_lang = os_env.get_language() + # TODO: pick the comment matching the OS lang instead of the default + for comment in comments: + comment_lang = comment.attrib.get('lang', '') + if comment_lang == '': + return comment.text + return '' + + @classmethod + def _get_extensions(cls, root): + """Return all glob extensions associated to the media type.""" + extensions = [] + for glob in root.findall(f'{cls.xmlns}glob'): + if 'pattern' in glob.attrib: + extensions.append(glob.attrib['pattern']) + return extensions + + @classmethod + def _get_icon(cls, root): + """Return the name of the icon associated to the media type.""" + elem = root.find(f'{cls.xmlns}generic-icon') + if elem is not None: + return elem.attrib.get('name', None) + return None diff --git a/xdgprefs/core/os_env.py b/xdgprefs/core/os_env.py new file mode 100644 index 0000000..06de4f1 --- /dev/null +++ b/xdgprefs/core/os_env.py @@ -0,0 +1,76 @@ +# -*- coding: future_fstrings -*- +""" +This module allows access to various environment variables of the Operating +System, such as XDG configuration values, the current Desktop Environment, +or the language. + +XDG BaseDir specification: +https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html +""" + + +import os + + +def xdg_data_home(): + """Base directory where user specific data files should be stored.""" + value = os.getenv('XDG_DATA_HOME') or '$HOME/.local/share/' + return os.path.expandvars(value) + + +def xdg_config_home(): + """Base directory where user specific config files should be stored.""" + value = os.getenv('XDG_CONFIG_HOME') or '$HOME/.config/' + return os.path.expandvars(value) + + +def xdg_data_dirs(): + """Ordered set of directories for data files.""" + value = os.getenv('XDG_DATA_DIRS') or '/usr/local/share/:/usr/share/' + value = value.split(':') + return value + + +def xdg_config_dirs(): + """Ordered set of directories for config files.""" + value = os.getenv('XDG_CONFIG_DIRS') or '/etc/xdg/' + return value.split(':') + + +def xdg_cache_home(): + """Base directory where user specific cached data should be stored.""" + value = os.getenv('XDG_CACHE_HOME') or '' + return os.path.expandvars(value) + + +def xdg_runtime_dir(): + """Base directory for runtime files, such as sockets.""" + value = os.getenv('XDG_RUNTIME_DIR') or '' + return os.path.expandvars(value) + + +def get_current_desktop_environment(): + """ + Returns the list of identifier (lowercase) that the current Desktop + Environment is known as (e.g. 'gnome', 'kde', 'i3'). + + :rtype: list + """ + desktop = os.getenv('XDG_CURRENT_DESKTOP') or '' + desktop = desktop.split(',') + desktop = [name.lower() for name in desktop] + return desktop + + +def get_language(): + """ + Returns the BCP47 language from the environment (e.g. `en`). + + https://tools.ietf.org/html/bcp47 + + :rtype: str + """ + lang = os.getenv('LANGUAGE') or '' + if ':' in lang: + lang = lang.split(':')[1] + return lang diff --git a/xdgprefs/core/xdg_mime_wrapper.py b/xdgprefs/core/xdg_mime_wrapper.py new file mode 100644 index 0000000..9dfd71e --- /dev/null +++ b/xdgprefs/core/xdg_mime_wrapper.py @@ -0,0 +1,111 @@ +# -*- coding: future_fstrings -*- +""" +This module defines wrapper functions for the `xdg-mime` software. + +These functions can be used to query the user preferences (i.e. which desktop +application should be used to open a given media type) and to update them. +""" +import shutil +import subprocess +import logging + + +logger = logging.getLogger('XdgMimeWrapper') + + +def _find_xdg_mime(): + """Find the path to the `xdg-mime` executable.""" + # Try to find the `xdg-mime` executable using `which`. + path = _try_which() + if path is not None: + logger.info(f'xdg-mime found at {path}') + # Check if the executable works. + ret = _try_path(path) + if not ret: + logger.warning(f'which found xdg-mime at {path} but the' + f'executable seems not to be working!') + return path + + # Nothing was found! + logger.error('xdg-mime was not found on this computer! Impossible to get' + 'or set the media type associations.') + return None + + +def _try_which(): + """Try to find `xdg-mime` using the `which` function (in `shutil`).""" + return shutil.which('xdg-mime') + + +def _try_path(path): + """Try an absolute or relative path for the `xdg-mime` executable.""" + try: + res = subprocess.run([path, '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) + if res.returncode is not 0: + logger.warning(f'Unknown error for path {path} ({res.returncode}):' + f' {res.stderr}') + return False + except FileNotFoundError: + logger.warning(f'File not found: {path}') + return False + except PermissionError: + logger.warning(f'File is not executable: {path}') + return False + return True + + +bin_path = _find_xdg_mime() +logger.debug(f'Found xdg-mime: {bin_path}') + + +def get_default_app(mime_type): + """ + Get the application that is registered as 'default' to open the + specified MIME Type. + + :param mime_type: The identifier of the MIME Type, e.g. 'image/jpeg'. + :type mime_type: str + + :return: The identifier of the desktop application, e.g. 'gimp.desktop'. + :rtype: str + """ + if bin_path is None: + logger.error('Can\'t get the default app if xdg-mime was not found!') + return None + res = subprocess.run([bin_path, 'query', 'default', mime_type], + capture_output=True, + text=True) + if res.returncode is not 0: + logger.warning(f'Unknown error while querying default application' + f' ({res.returncode}): {res.stderr}') + return None + return res.stdout.replace('\n', '') + + +def set_default_app(mime_type, app): + """ + Set the default application to open the specified MIME Type. + + :param mime_type: The identifier of the MIME Type, e.g. 'image/jpeg'. + :type mime_type: str + + :param app: The identifier of the desktop application, e.g. 'gimp.desktop'. + :type: str + + :return: True if the application was correctly registered as the + default one (according to the xdg-mime backend, i.e. if the return code + was 0), False otherwise. + """ + if bin_path is None: + logger.critical('Can\t set the default app if xdg-mime was not found!') + return False + res = subprocess.run([bin_path, 'default', app, mime_type], + capture_output=True, + text=True) + if res.returncode is not 0: + logger.error(f'Unknown error while setting default application' + f' ({res.returncode}): {res.stderr}') + return res.returncode is 0 diff --git a/xdgprefs/gui/__init__.py b/xdgprefs/gui/__init__.py new file mode 100644 index 0000000..f25433d --- /dev/null +++ b/xdgprefs/gui/__init__.py @@ -0,0 +1,9 @@ +from .apps_panel import AppsPanel +from .associations_panel import AssociationsPanel +from .mime_type_panel import MimeTypePanel +from .main_window import MainWindow + +__all__ = ['AppsPanel', + 'AssociationsPanel', + 'MimeTypePanel', + 'MainWindow'] diff --git a/xdgprefs/gui/app_item.py b/xdgprefs/gui/app_item.py new file mode 100644 index 0000000..b9fab3d --- /dev/null +++ b/xdgprefs/gui/app_item.py @@ -0,0 +1,33 @@ +# -*- coding: future_fstrings -*- +""" +This module defines a single Application Item in the AppsPanel. +""" + + +from xdgprefs.gui.custom_item import CustomItem + + +def _get_icon(icon_name): + """Return the path to an icon.""" + theme = 'Adwaita' + size = '256x256' + path = f'/usr/share/icons/{theme}/{size}/mimetypes/{icon_name}.png' + return path + + +def _get_types(type_list): + if type_list is None: + return '' + else: + return ', '.join(type_list) + + +class AppItem(CustomItem): + + def __init__(self, app, listview): + CustomItem.__init__(self, listview, + app.name, + app.comment, + _get_types(app.mime_type), + _get_icon(app.icon)) + self.app = app diff --git a/xdgprefs/gui/apps_panel.py b/xdgprefs/gui/apps_panel.py new file mode 100644 index 0000000..bab8630 --- /dev/null +++ b/xdgprefs/gui/apps_panel.py @@ -0,0 +1,114 @@ +# -*- coding: future_fstrings -*- +""" +This module defines Qt Widgets that allow to view the list of applications +as a Qt List (using a custom widget for the layout). +""" + + +from PySide2.QtWidgets import QListWidget, QWidget, \ + QLabel, QGridLayout, QLineEdit, QCheckBox + +from xdgprefs.core import DesktopEntry +from xdgprefs.gui.app_item import AppItem + + +class AppsPanel(QWidget): + """ + This class defines the Qt List that will show all applications. + """ + + def __init__(self, appdb): + QWidget.__init__(self) + + self.appdb = appdb + self.app_map = {} + + self.setup_ui() + + for app in self.appdb.apps.values(): + item = AppItem(app, self.list_widget) + self.app_map[app] = item + self.list_widget.addItem(item) + + self.setLayout(self.grid) + + self.on_filter_update() + + # noinspection PyAttributeOutsideInit + def setup_ui(self): + self.grid = QGridLayout() + + self.edit_search = QLineEdit(self) + self.edit_search.setPlaceholderText("Type to filter") + self.edit_search.textChanged.connect(self.on_filter_update) + + self.checkbox_mimetype = QCheckBox(self) + self.checkbox_mimetype.setText("Include apps without Mime types") + self.checkbox_mimetype.setChecked(False) + self.checkbox_mimetype.stateChanged.connect(self.on_filter_update) + + self.checkbox_vendor = QCheckBox(self) + self.checkbox_vendor.setText("Include vendor apps (vnd-*)") + self.checkbox_vendor.setChecked(True) + self.checkbox_vendor.stateChanged.connect(self.on_filter_update) + + self.checkbox_ext = QCheckBox(self) + self.checkbox_ext.setText("Include extensions apps (x-*)") + self.checkbox_ext.setChecked(True) + self.checkbox_ext.stateChanged.connect(self.on_filter_update) + + self.text_status = QLabel(self) + + self.list_widget = QListWidget(self) + self.list_widget.setSelectionMode(QListWidget.NoSelection) + self.list_widget.setAlternatingRowColors(True) + self.list_widget.setStyleSheet(''' + QListView::item { + border: 1px solid #e0e0eb; + } + ''') + + self.grid.addWidget(self.edit_search, 0, 1, 1, 3) + self.grid.addWidget(self.checkbox_mimetype, 1, 1, 1, 1) + self.grid.addWidget(self.checkbox_vendor, 1, 2, 1, 1) + self.grid.addWidget(self.checkbox_ext, 1, 3, 1, 1) + self.grid.addWidget(self.text_status, 2, 1, 1, 3) + self.grid.addWidget(self.list_widget, 3, 1, 1, 3) + + def on_filter_update(self): + filter_text = self.edit_search.text() + mimetype = self.checkbox_mimetype.isChecked() + vendor = self.checkbox_vendor.isChecked() + ext = self.checkbox_ext.isChecked() + + nb_shown = 0 + nb_total = 0 + for app in self.app_map: + matches = self.matches(app, filter_text, mimetype, vendor, ext) + # If it matches, show it + self.app_map[app].setHidden(not matches) + # Count the number of shown apps + nb_shown += int(matches) + nb_total += 1 + self.update_text(nb_shown, nb_total) + + def matches(self, + app: DesktopEntry, + filter_text: str, + mimetype_check: bool, + vendor_check: bool, + ext_check: bool): + if not mimetype_check and \ + (app.mime_type is None or len(app.mime_type) == 0): + return False + if not vendor_check and app.is_vendor: + return False + if not ext_check and app.is_extension: + return False + if app.name.find(filter_text) == -1: + return False + return True + + def update_text(self, nb_shown, nb_total): + text = f'Applications shown: {nb_shown} / {nb_total}' + self.text_status.setText(text) diff --git a/xdgprefs/gui/association_item.py b/xdgprefs/gui/association_item.py new file mode 100644 index 0000000..e752afa --- /dev/null +++ b/xdgprefs/gui/association_item.py @@ -0,0 +1,44 @@ +# -*- coding: future_fstrings -*- +""" +This module defines a single AssociationItem in the AssociationsPanel. +""" + + +from threading import Thread + +from PySide2.QtWidgets import QComboBox + +from xdgprefs.gui.mime_item import MimeTypeItem + + +class AssociationItem(MimeTypeItem): + + def __init__(self, mime_type, apps, main_window, listview): + MimeTypeItem.__init__(self, mime_type, listview) + + self.apps = apps + self.main_window = main_window + + self.selector = QComboBox() + self.selector.addItems(self.apps) + self.selector.currentTextChanged.connect(self._on_selected) + + self.hbox.addWidget(self.selector, 2) + + def _on_selected(self, _): + mime = self.mime_type.identifier + app = self.selector.currentText() + self.main_window.status.showMessage(f'Setting {mime} to {app}...') + def run(): + success = self.main_window.assocdb.set_app_for_mimetype(mime, app) + if success: + msg = f'{app} was successfully set to open {mime}.' + else: + msg = f'Could not set {app} to open {mime}, please check ' \ + f'the logs!' + self.main_window.status.showMessage(msg) + t = Thread(target=run) + t.start() + + def __hash__(self): + return hash(self.mime_type) diff --git a/xdgprefs/gui/associations_panel.py b/xdgprefs/gui/associations_panel.py new file mode 100644 index 0000000..c497695 --- /dev/null +++ b/xdgprefs/gui/associations_panel.py @@ -0,0 +1,122 @@ +# -*- coding: future_fstrings -*- +""" +This modules defines widgets that allow to view the associations between +MIME Types and Applications as a Qt List, and to modify the associated +application for each MIME Type. +""" + + +from PySide2.QtWidgets import QListWidget, QWidget, \ + QLabel, QCheckBox, QLineEdit, QGridLayout + +from xdgprefs.core import MimeType +from xdgprefs.gui.association_item import AssociationItem + + +class AssociationsPanel(QWidget): + """ + This class defines the Qt List that will show all Mime Types. + """ + + def __init__(self, main_window): + QWidget.__init__(self) + + self.assocdb = main_window.assocdb + self.mimedb = main_window.mimedb + self.appdb = main_window.appdb + + self.item_map = {} + + self.setup_ui() + + for mime_id in self.assocdb.associations.keys(): + mime = self.mimedb.get_type(mime_id) + if mime is not None: + apps = self.assocdb.get_apps_for_mimetype(mime_id) + item = AssociationItem(mime, apps, main_window, + self.list_widget) + self.item_map[mime] = item + self.list_widget.addItem(item) + + self.setLayout(self.grid) + + self.on_filter_update() + + # noinspection PyAttributeOutsideInit + def setup_ui(self): + self.grid = QGridLayout() + + self.edit_search = QLineEdit(self) + self.edit_search.setPlaceholderText("Type to filter") + self.edit_search.textChanged.connect(self.on_filter_update) + + self.checkbox_personal = QCheckBox(self) + self.checkbox_personal.setText("Include personal Mime types (prs-*)") + self.checkbox_personal.setChecked(True) + self.checkbox_personal.stateChanged.connect(self.on_filter_update) + + self.checkbox_vendor = QCheckBox(self) + self.checkbox_vendor.setText("Include vendor Mime types (vnd-*)") + self.checkbox_vendor.setChecked(True) + self.checkbox_vendor.stateChanged.connect(self.on_filter_update) + + self.checkbox_ext = QCheckBox(self) + self.checkbox_ext.setText("Include extensions Mime types (x-*)") + self.checkbox_ext.setChecked(True) + self.checkbox_ext.stateChanged.connect(self.on_filter_update) + + self.text_status = QLabel(self) + + self.list_widget = QListWidget(self) + self.list_widget.setSelectionMode(QListWidget.NoSelection) + self.list_widget.setAlternatingRowColors(True) + self.list_widget.setStyleSheet(''' + QListView::item { + border: 1px solid #c5c5c5; + } + ''') + + self.grid.addWidget(self.edit_search, 0, 1, 1, 3) + self.grid.addWidget(self.checkbox_personal, 1, 1, 1, 1) + self.grid.addWidget(self.checkbox_vendor, 1, 2, 1, 1) + self.grid.addWidget(self.checkbox_ext, 1, 3, 1, 1) + self.grid.addWidget(self.text_status, 2, 1, 1, 3) + self.grid.addWidget(self.list_widget, 3, 1, 1, 3) + + def on_filter_update(self): + filter_text = self.edit_search.text() + personal = self.checkbox_personal.isChecked() + vendor = self.checkbox_vendor.isChecked() + ext = self.checkbox_ext.isChecked() + + nb_shown = 0 + nb_total = 0 + for mime_type in self.item_map: + matches = self.matches(mime_type, filter_text, personal, vendor, + ext) + # If it matches, show it + self.item_map[mime_type].setHidden(not matches) + # Count the number of shown apps + nb_shown += int(matches) + nb_total += 1 + self.update_text(nb_shown, nb_total) + + def matches(self, + mime_type: MimeType, + filter_text: str, + personal_check: bool, + vendor_check: bool, + ext_check: bool): + if not personal_check and mime_type.is_personal: + return False + if not vendor_check and mime_type.is_vendor: + return False + if not ext_check and mime_type.is_extension: + return False + if mime_type.identifier.find(filter_text) == -1: + return False + return True + + def update_text(self, nb_shown, nb_total): + text = f'Mime types shown: {nb_shown} / {nb_total}' + self.text_status.setText(text) diff --git a/xdgprefs/gui/custom_item.py b/xdgprefs/gui/custom_item.py new file mode 100644 index 0000000..cf8e6fd --- /dev/null +++ b/xdgprefs/gui/custom_item.py @@ -0,0 +1,72 @@ +# -*- coding: future_fstrings -*- +""" +This module defines a custom QListWidgetItem that uses the following layout: +- icon on the left +- first line of text, bold +- second line of text +- third line of text, italic +This QListWidgetItem is used to show both MimeTypes and App. +""" + + +from PySide2.QtWidgets import QListWidgetItem, QWidget, \ + QVBoxLayout, QHBoxLayout, QLabel +from PySide2.QtGui import QPixmap +from PySide2.QtCore import QSize + + +class CustomItem(QListWidgetItem): + """ + This class defines a single item of the list. + """ + + icon_size = QSize(64, 64) + + def __init__(self, listview, first, second, third, icon): + """ + :param listview: The parent ListView. + :param first: The first line of text. + :param second: The second line of text. + :param third: The third line of text. + :param icon: The full path to the icon. + """ + QListWidgetItem.__init__(self, listview, type=QListWidgetItem.UserType) + + self.widget = QWidget() + + # Vertical box (texts) + self.vbox = QVBoxLayout() + + self.first_line = QLabel(first) + self.first_line.setWordWrap(True) + + self.second_line = QLabel(second) + self.second_line.setWordWrap(True) + + self.third_line = QLabel(third) + self.third_line.setWordWrap(True) + + for widget in [self.first_line, self.second_line, self.third_line]: + self.vbox.addWidget(widget) + + # Horizontal box (icon + vertical box) + self.hbox = QHBoxLayout() + + self.icon = QLabel() + pixmap = QPixmap(icon) + if not pixmap.isNull(): + pixmap = pixmap.scaled(CustomItem.icon_size) + self.icon.setPixmap(pixmap) + + self.hbox.addWidget(self.icon, 0) + self.hbox.addLayout(self.vbox, 1) + + self.widget.setLayout(self.hbox) + + # Set the widget as the content of the list item + self.setSizeHint(self.widget.sizeHint()) + listview.setItemWidget(self, self.widget) + + self.first_line.setStyleSheet('''font-weight: bold;''') + # self.comment.setStyleSheet('''''') + self.third_line.setStyleSheet('''font-style: italic;''') diff --git a/xdgprefs/gui/main_window.py b/xdgprefs/gui/main_window.py new file mode 100644 index 0000000..2483daa --- /dev/null +++ b/xdgprefs/gui/main_window.py @@ -0,0 +1,50 @@ +# -*- coding: future_fstrings -*- +""" +This module defines the main window, allowing the user to effectively +use the application. +""" + + +from PySide2.QtWidgets import QMainWindow, QTabWidget + +from xdgprefs.gui import MimeTypePanel, AppsPanel, AssociationsPanel +from xdgprefs.core import MimeDatabase, AppDatabase, AssociationsDatabase + + +class MainWindow(QMainWindow): + + def __init__(self): + QMainWindow.__init__(self) + self.setWindowTitle('xdg-prefs') + + # Back-end data + self.mimedb = MimeDatabase() + self.appdb = AppDatabase() + self.assocdb = AssociationsDatabase() + + # Set size + self.resize(400, 600) + + # Menu + self.menu = self.menuBar() + # self.help_menu = self.menu.addMenu('Help') + + # Status + self.status = self.statusBar() + self.status.showMessage('No log') + + # Central widget + self.central = QTabWidget(self) + # First tab + self.page1 = AssociationsPanel(self) + self.central.addTab(self.page1, 'Associations') + # Second tab + self.page2 = MimeTypePanel(self.mimedb) + self.central.addTab(self.page2, 'List MIME Types') + # Third tab + self.page3 = AppsPanel(self.appdb) + self.central.addTab(self.page3, 'List Applications') + + self.setCentralWidget(self.central) + + self.show() diff --git a/xdgprefs/gui/mime_item.py b/xdgprefs/gui/mime_item.py new file mode 100644 index 0000000..31c21e8 --- /dev/null +++ b/xdgprefs/gui/mime_item.py @@ -0,0 +1,33 @@ +# -*- coding: future_fstrings -*- +""" +This module defines a single MimeTypeItem in the MimeTypePanel. +""" + + +from xdgprefs.gui.custom_item import CustomItem + + +def _get_icon(icon_name): + """Return the path to an icon.""" + theme = 'Adwaita' + size = '256x256' + path = f'/usr/share/icons/{theme}/{size}/mimetypes/{icon_name}.png' + return path + + +def _get_extensions(ext_list): + if ext_list is None: + return '' + else: + return ', '.join(ext_list) + + +class MimeTypeItem(CustomItem): + + def __init__(self, mime_type, listview): + CustomItem.__init__(self, listview, + mime_type.identifier, + mime_type.comment, + _get_extensions(mime_type.extensions), + _get_icon(mime_type.icon)) + self.mime_type = mime_type diff --git a/xdgprefs/gui/mime_type_panel.py b/xdgprefs/gui/mime_type_panel.py new file mode 100644 index 0000000..4a43e72 --- /dev/null +++ b/xdgprefs/gui/mime_type_panel.py @@ -0,0 +1,114 @@ +# -*- coding: future_fstrings -*- +""" +This module defines Qt Widgets that allow to view the list of MIME Types +as a Qt List (using a custom widget for the layout). +""" + + +from PySide2.QtWidgets import QListWidget, QWidget, QLabel, QGridLayout, \ + QLineEdit, QCheckBox + +from xdgprefs.core import MimeType +from xdgprefs.gui.mime_item import MimeTypeItem + + +class MimeTypePanel(QWidget): + """ + This class defines the Qt List that will show all Mime Types. + """ + + def __init__(self, mimedb): + QWidget.__init__(self) + + self.mimedb = mimedb + self.item_map = {} + + self.setup_ui() + + for mime_type in self.mimedb.types.values(): + item = MimeTypeItem(mime_type, self.list_widget) + self.item_map[mime_type] = item + self.list_widget.addItem(item) + + self.setLayout(self.grid) + + self.on_filter_update() + + # noinspection PyAttributeOutsideInit + def setup_ui(self): + self.grid = QGridLayout() + + self.edit_search = QLineEdit(self) + self.edit_search.setPlaceholderText("Type to filter") + self.edit_search.textChanged.connect(self.on_filter_update) + + self.checkbox_personal = QCheckBox(self) + self.checkbox_personal.setText("Include personal Mime types (prs-*)") + self.checkbox_personal.setChecked(True) + self.checkbox_personal.stateChanged.connect(self.on_filter_update) + + self.checkbox_vendor = QCheckBox(self) + self.checkbox_vendor.setText("Include vendor Mime types (vnd-*)") + self.checkbox_vendor.setChecked(True) + self.checkbox_vendor.stateChanged.connect(self.on_filter_update) + + self.checkbox_ext = QCheckBox(self) + self.checkbox_ext.setText("Include extensions Mime types (x-*)") + self.checkbox_ext.setChecked(True) + self.checkbox_ext.stateChanged.connect(self.on_filter_update) + + self.text_status = QLabel(self) + + self.list_widget = QListWidget(self) + self.list_widget.setSelectionMode(QListWidget.NoSelection) + self.list_widget.setAlternatingRowColors(True) + self.list_widget.setStyleSheet(''' + QListView::item { + border: 1px solid #c5c5c5; + } + ''') + + self.grid.addWidget(self.edit_search, 0, 1, 1, 3) + self.grid.addWidget(self.checkbox_personal, 1, 1, 1, 1) + self.grid.addWidget(self.checkbox_vendor, 1, 2, 1, 1) + self.grid.addWidget(self.checkbox_ext, 1, 3, 1, 1) + self.grid.addWidget(self.text_status, 2, 1, 1, 3) + self.grid.addWidget(self.list_widget, 3, 1, 1, 3) + + def on_filter_update(self): + filter_text = self.edit_search.text() + personal = self.checkbox_personal.isChecked() + vendor = self.checkbox_vendor.isChecked() + ext = self.checkbox_ext.isChecked() + + nb_shown = 0 + nb_total = 0 + for mime_type in self.item_map: + matches = self.matches(mime_type, filter_text, personal, vendor, + ext) + # If it matches, show it + self.item_map[mime_type].setHidden(not matches) + # Count the number of shown apps + nb_shown += int(matches) + nb_total += 1 + self.update_text(nb_shown, nb_total) + + def matches(self, + mime_type: MimeType, + filter_text: str, + personal_check: bool, + vendor_check: bool, + ext_check: bool): + if not personal_check and mime_type.is_personal: + return False + if not vendor_check and mime_type.is_vendor: + return False + if not ext_check and mime_type.is_extension: + return False + if mime_type.identifier.find(filter_text) == -1: + return False + return True + + def update_text(self, nb_shown, nb_total): + text = f'Mime types shown: {nb_shown} / {nb_total}' + self.text_status.setText(text)