From 13b7af6631096ea6ef3c736829ad2f6fe133bb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20CHAPUT?= Date: Sat, 11 Nov 2017 19:03:04 +0100 Subject: [PATCH 01/19] Added license file --- LICENSE | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +- 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 LICENSE 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..3b22032 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # xdg-prefs -Program that allows you to change the default application on your Linux system, using XDG standard. \ No newline at end of file +Program that allows you to change the default application on your Linux system, +using the [XDG standard](https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-1.0.html). From 91f426bc77207f69a033e7cd3e81e083c4c356ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20CHAPUT?= Date: Fri, 22 Dec 2017 00:11:46 +0100 Subject: [PATCH 02/19] Added contributing and gitignore --- .gitignore | 4 ++++ CONTRIBUTING | 15 +++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00ddf21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# IntelliJ project files +.idea + +**/.cache 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 From 07f7b61edfd6dec6e16c58a2400aa7796eb1412d Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 25 May 2019 22:19:05 +0200 Subject: [PATCH 03/19] XP-001 Allow to retrieve environment variables (e.g. XDG constants, language). --- xdgprefs/__init__.py | 0 xdgprefs/os_env.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 xdgprefs/__init__.py create mode 100644 xdgprefs/os_env.py diff --git a/xdgprefs/__init__.py b/xdgprefs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdgprefs/os_env.py b/xdgprefs/os_env.py new file mode 100644 index 0000000..22dd461 --- /dev/null +++ b/xdgprefs/os_env.py @@ -0,0 +1,81 @@ +""" +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 values (data directory, configuration directory, ...): +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') + if value is None or value == '': + value = '$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') + if value is None or value == '': + value = '$HOME/.config/' + return os.path.expandvars(value) + + +def xdg_data_dirs(): + """Ordered set of directories for data files.""" + value = os.getenv('XDG_DATA_DIRS') + if value is None or value == '': + value = '/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') + if value is None or value == '': + value = '/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') + if value is None or value == '': + value = '' + return os.path.expandvars(value) + + +def xdg_runtime_dir(): + """Base directory for runtime files, such as sockets.""" + value = os.getenv('XDG_RUNTIME_DIR') + return os.path.expandvars(value) + + +def get_current_desktop_environment(): + """ + Returns an identifier of the current Desktop Environment, such as + `gnome`, `kde` or `i3`. + + :rtype: str + """ + return os.getenv('XDG_CURRENT_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') + lang = lang.split(':')[1] + return lang From c87f5416f203c2772675461466adf378f1472c06 Mon Sep 17 00:00:00 2001 From: rchaput Date: Mon, 8 Jul 2019 18:19:39 +0200 Subject: [PATCH 04/19] XP-001 Added MimeType and MimeTypeParser. --- xdgprefs/mime_type.py | 121 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 xdgprefs/mime_type.py diff --git a/xdgprefs/mime_type.py b/xdgprefs/mime_type.py new file mode 100644 index 0000000..986274d --- /dev/null +++ b/xdgprefs/mime_type.py @@ -0,0 +1,121 @@ +""" +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 +from xml.etree import ElementTree + +from xdgprefs 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]): + # Data + self.type = _type + self.subtype = subtype + self.comment = comment + self.extensions = extensions + + # 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: + + logger = logging.getLogger('MimeTypeParser') + xmlns = '{http://www.freedesktop.org/standards/shared-mime-info}' + + @classmethod + def parse(cls, filepath): + 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) + return MimeType(_type, subtype, comment, extensions) + + @classmethod + def _check_tag(cls, filepath, root): + 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): + 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): + identifier = root.attrib['type'] + _type, subtype = identifier.split('/') + return _type, subtype + + @classmethod + def _get_comment(cls, root): + 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['lang'] \ + if 'lang' in comment.attrib else '' + if comment_lang == '': + return comment.text + return '' + + @classmethod + def _get_extensions(cls, root): + extensions = [] + for glob in root.findall(f'{cls.xmlns}glob'): + if 'pattern' in glob.attrib: + extensions.append(glob.attrib['pattern']) + return extensions From 39de8dfffc8de7982e12113038bcf9d046748d1e Mon Sep 17 00:00:00 2001 From: rchaput Date: Mon, 8 Jul 2019 18:22:33 +0200 Subject: [PATCH 05/19] XP-001 Added MimeDatabase. --- xdgprefs/mime_database.py | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 xdgprefs/mime_database.py diff --git a/xdgprefs/mime_database.py b/xdgprefs/mime_database.py new file mode 100644 index 0000000..619336d --- /dev/null +++ b/xdgprefs/mime_database.py @@ -0,0 +1,85 @@ +""" +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.os_env import xdg_data_dirs, xdg_data_home +from xdgprefs.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): + + logger: logging.Logger + types: Dict[str, MimeType] + + def __init__(self): + self.logger = logging.getLogger('MimeDatabase') + self.types = {} + + self._build_db() + + def _build_db(self): + 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}...') + # First, 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): + 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'' From 33ddd814c294ac2c3c5cfc4e4e6ca9ba77623627 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 27 Jul 2019 11:18:58 +0200 Subject: [PATCH 06/19] XP-001 Fixing comments and documentation. --- xdgprefs/mime_database.py | 9 ++++++++- xdgprefs/mime_type.py | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/xdgprefs/mime_database.py b/xdgprefs/mime_database.py index 619336d..3b04d7c 100644 --- a/xdgprefs/mime_database.py +++ b/xdgprefs/mime_database.py @@ -36,6 +36,11 @@ def mime_dirs(only_existing=True): 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. + """ logger: logging.Logger types: Dict[str, MimeType] @@ -47,11 +52,12 @@ def __init__(self): 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}...') - # First, loop on the subdirectories. + # 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'] @@ -72,6 +78,7 @@ def _add_type(self, mimetype): 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: diff --git a/xdgprefs/mime_type.py b/xdgprefs/mime_type.py index 986274d..5c53f11 100644 --- a/xdgprefs/mime_type.py +++ b/xdgprefs/mime_type.py @@ -58,12 +58,18 @@ def __str__(self): 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() @@ -78,6 +84,7 @@ def parse(cls, filepath): @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 ' @@ -88,6 +95,7 @@ def _check_tag(cls, filepath, root): @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.') @@ -96,24 +104,26 @@ def _check_attrib(cls, filepath, root): @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['lang'] \ - if 'lang' in comment.attrib else '' + comment_lang = comment.attrib.get('lang', default='') 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: From c50f505c24325c805cf3ed3c1bba397d481fbf04 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 27 Jul 2019 13:40:34 +0200 Subject: [PATCH 07/19] XP-002 Added Desktop Entry Parser. --- xdgprefs/desktop_entry.py | 106 +++++++++++++++++++++ xdgprefs/desktop_entry_parser.py | 157 +++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 xdgprefs/desktop_entry.py create mode 100644 xdgprefs/desktop_entry_parser.py diff --git a/xdgprefs/desktop_entry.py b/xdgprefs/desktop_entry.py new file mode 100644 index 0000000..ac2907e --- /dev/null +++ b/xdgprefs/desktop_entry.py @@ -0,0 +1,106 @@ +""" +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'. + """ + + def __init__(self, groups, appid): + self.groups = groups + self.appid = appid + self.logger = logging.getLogger('DesktopEntry-' + appid) + + def get_entry(self, entry_key, groupname='Desktop Entry'): + if groupname not in self.groups: + self.logger.warning(f'Group name {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 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') diff --git a/xdgprefs/desktop_entry_parser.py b/xdgprefs/desktop_entry_parser.py new file mode 100644 index 0000000..af8148b --- /dev/null +++ b/xdgprefs/desktop_entry_parser.py @@ -0,0 +1,157 @@ +""" +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.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 From d329dbd4d1e36af170869e0c028734c88a71c203 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 27 Jul 2019 13:50:18 +0200 Subject: [PATCH 08/19] XP-002 Added Desktop Entry Database. --- xdgprefs/app_database.py | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 xdgprefs/app_database.py diff --git a/xdgprefs/app_database.py b/xdgprefs/app_database.py new file mode 100644 index 0000000..9929cb2 --- /dev/null +++ b/xdgprefs/app_database.py @@ -0,0 +1,75 @@ +""" +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 typing import Dict + +from xdgprefs.os_env import xdg_data_dirs +from xdgprefs.desktop_entry import DesktopEntry +from xdgprefs 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): + + logger: logging.Logger + apps: Dict[str, DesktopEntry] + + 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'' From 0efa24747d2d2e99e7574417650b4619fa436a85 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 27 Jul 2019 18:57:41 +0200 Subject: [PATCH 09/19] Re-organized files (package 'core'). --- xdgprefs/core/__init__.py | 1 + xdgprefs/{ => core}/app_database.py | 6 +++--- xdgprefs/{ => core}/desktop_entry.py | 0 xdgprefs/{ => core}/desktop_entry_parser.py | 2 +- xdgprefs/{ => core}/mime_database.py | 4 ++-- xdgprefs/{ => core}/mime_type.py | 4 ++-- xdgprefs/{ => core}/os_env.py | 0 7 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 xdgprefs/core/__init__.py rename xdgprefs/{ => core}/app_database.py (92%) rename xdgprefs/{ => core}/desktop_entry.py (100%) rename xdgprefs/{ => core}/desktop_entry_parser.py (98%) rename xdgprefs/{ => core}/mime_database.py (95%) rename xdgprefs/{ => core}/mime_type.py (97%) rename xdgprefs/{ => core}/os_env.py (100%) diff --git a/xdgprefs/core/__init__.py b/xdgprefs/core/__init__.py new file mode 100644 index 0000000..98b28b8 --- /dev/null +++ b/xdgprefs/core/__init__.py @@ -0,0 +1 @@ +from .app_database import AppDatabase diff --git a/xdgprefs/app_database.py b/xdgprefs/core/app_database.py similarity index 92% rename from xdgprefs/app_database.py rename to xdgprefs/core/app_database.py index 9929cb2..dae6928 100644 --- a/xdgprefs/app_database.py +++ b/xdgprefs/core/app_database.py @@ -8,9 +8,9 @@ import logging from typing import Dict -from xdgprefs.os_env import xdg_data_dirs -from xdgprefs.desktop_entry import DesktopEntry -from xdgprefs import desktop_entry_parser as parser +from xdgprefs.core.os_env import xdg_data_dirs +from xdgprefs.core.desktop_entry import DesktopEntry +from xdgprefs.core import desktop_entry_parser as parser def app_dirs(only_existing=True): diff --git a/xdgprefs/desktop_entry.py b/xdgprefs/core/desktop_entry.py similarity index 100% rename from xdgprefs/desktop_entry.py rename to xdgprefs/core/desktop_entry.py diff --git a/xdgprefs/desktop_entry_parser.py b/xdgprefs/core/desktop_entry_parser.py similarity index 98% rename from xdgprefs/desktop_entry_parser.py rename to xdgprefs/core/desktop_entry_parser.py index af8148b..8f6d4d4 100644 --- a/xdgprefs/desktop_entry_parser.py +++ b/xdgprefs/core/desktop_entry_parser.py @@ -8,7 +8,7 @@ from collections import OrderedDict import logging -from xdgprefs.desktop_entry import DesktopEntry, EntryGroup, Entry +from xdgprefs.core.desktop_entry import DesktopEntry, EntryGroup, Entry logger = logging.getLogger('DesktopEntryParser') diff --git a/xdgprefs/mime_database.py b/xdgprefs/core/mime_database.py similarity index 95% rename from xdgprefs/mime_database.py rename to xdgprefs/core/mime_database.py index 3b04d7c..d309478 100644 --- a/xdgprefs/mime_database.py +++ b/xdgprefs/core/mime_database.py @@ -10,8 +10,8 @@ import logging from typing import Dict -from xdgprefs.os_env import xdg_data_dirs, xdg_data_home -from xdgprefs.mime_type import MimeType, MimeTypeParser +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): diff --git a/xdgprefs/mime_type.py b/xdgprefs/core/mime_type.py similarity index 97% rename from xdgprefs/mime_type.py rename to xdgprefs/core/mime_type.py index 5c53f11..28d819d 100644 --- a/xdgprefs/mime_type.py +++ b/xdgprefs/core/mime_type.py @@ -8,7 +8,7 @@ from typing import List from xml.etree import ElementTree -from xdgprefs import os_env +from xdgprefs.core import os_env class MimeType(object): @@ -116,7 +116,7 @@ def _get_comment(cls, root): 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', default='') + comment_lang = comment.attrib.get('lang', '') if comment_lang == '': return comment.text return '' diff --git a/xdgprefs/os_env.py b/xdgprefs/core/os_env.py similarity index 100% rename from xdgprefs/os_env.py rename to xdgprefs/core/os_env.py From 2d7e16c2596e77816132e72baa22be4109fffb12 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sun, 28 Jul 2019 20:40:34 +0200 Subject: [PATCH 10/19] XP-003 Added a wrapper for xdg-mime. --- xdgprefs/core/xdg_mime_wrapper.py | 84 +++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 xdgprefs/core/xdg_mime_wrapper.py diff --git a/xdgprefs/core/xdg_mime_wrapper.py b/xdgprefs/core/xdg_mime_wrapper.py new file mode 100644 index 0000000..423de71 --- /dev/null +++ b/xdgprefs/core/xdg_mime_wrapper.py @@ -0,0 +1,84 @@ +""" +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: + # 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'], + capture_output=True, + text=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() + + +def get_default_app(mime_type): + 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): + if bin_path is None: + logger.error('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.warning(f'Unknown error while setting default application' + f' ({res.returncode}): {res.stderr}') + return res.returncode is 0 From 9872bbc5d6b8e51788695aa045d5e9ac467a59e6 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sun, 28 Jul 2019 21:04:34 +0200 Subject: [PATCH 11/19] XP-003 Added documentation. --- xdgprefs/core/xdg_mime_wrapper.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/xdgprefs/core/xdg_mime_wrapper.py b/xdgprefs/core/xdg_mime_wrapper.py index 423de71..e825a88 100644 --- a/xdgprefs/core/xdg_mime_wrapper.py +++ b/xdgprefs/core/xdg_mime_wrapper.py @@ -17,6 +17,7 @@ def _find_xdg_mime(): # 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: @@ -58,6 +59,16 @@ def _try_path(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 @@ -72,6 +83,19 @@ def get_default_app(mime_type): 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.error('Can\t set the default app if xdg-mime was not found!') return False From be9837bbc10ff4be6a1e396d308326c518fdc383 Mon Sep 17 00:00:00 2001 From: rchaput Date: Mon, 29 Jul 2019 22:30:42 +0200 Subject: [PATCH 12/19] [Quickfix] Added the icon in MimeType. --- xdgprefs/core/mime_type.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/xdgprefs/core/mime_type.py b/xdgprefs/core/mime_type.py index 28d819d..b82f565 100644 --- a/xdgprefs/core/mime_type.py +++ b/xdgprefs/core/mime_type.py @@ -5,7 +5,7 @@ import logging -from typing import List +from typing import List, Optional from xml.etree import ElementTree from xdgprefs.core import os_env @@ -25,12 +25,14 @@ def __init__(self, _type: str, subtype: str, comment: str, - extensions: List[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) @@ -80,7 +82,8 @@ def parse(cls, filepath): _type, subtype = cls._get_type_subtype(root) comment = cls._get_comment(root) extensions = cls._get_extensions(root) - return MimeType(_type, subtype, comment, extensions) + icon = cls._get_icon(root) + return MimeType(_type, subtype, comment, extensions, icon) @classmethod def _check_tag(cls, filepath, root): @@ -129,3 +132,11 @@ def _get_extensions(cls, root): 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 From 7f3e78a0155a85b523d4ce38f4aaab095206c7b1 Mon Sep 17 00:00:00 2001 From: rchaput Date: Mon, 29 Jul 2019 22:31:08 +0200 Subject: [PATCH 13/19] [Quickfix] Added the comment in DesktopEntry. --- xdgprefs/core/desktop_entry.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/xdgprefs/core/desktop_entry.py b/xdgprefs/core/desktop_entry.py index ac2907e..f00646d 100644 --- a/xdgprefs/core/desktop_entry.py +++ b/xdgprefs/core/desktop_entry.py @@ -61,14 +61,15 @@ class DesktopEntry(object): Entry Groups. The default one is named 'Desktop Entry'. """ + logger = logging.getLogger('DesktopEntry') + def __init__(self, groups, appid): self.groups = groups self.appid = appid - self.logger = logging.getLogger('DesktopEntry-' + appid) def get_entry(self, entry_key, groupname='Desktop Entry'): if groupname not in self.groups: - self.logger.warning(f'Group name {groupname} not found!') + self.logger.warning(f'[{self.appid}] Group {groupname} not found!') return None group = self.groups[groupname] return group.get_entry(entry_key) @@ -85,6 +86,10 @@ def name(self): 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') From 9e54052540445c54b4a2a50c778de461bca1d6a2 Mon Sep 17 00:00:00 2001 From: rchaput Date: Tue, 30 Jul 2019 11:26:17 +0200 Subject: [PATCH 14/19] [Quickfix] Added imports in core/__init__.py --- xdgprefs/__init__.py | 2 ++ xdgprefs/core/__init__.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/xdgprefs/__init__.py b/xdgprefs/__init__.py index e69de29..f35cd07 100644 --- a/xdgprefs/__init__.py +++ b/xdgprefs/__init__.py @@ -0,0 +1,2 @@ +from . import core +from . import gui diff --git a/xdgprefs/core/__init__.py b/xdgprefs/core/__init__.py index 98b28b8..98fc16e 100644 --- a/xdgprefs/core/__init__.py +++ b/xdgprefs/core/__init__.py @@ -1 +1,13 @@ from .app_database import AppDatabase +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', + 'DesktopEntry', + 'MimeDatabase', + 'MimeType', + 'os_env', + 'xdg_mime_wrapper'] From 650021a9d04b35aec2a636da221dc22a31ad8d92 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 12 Oct 2019 20:56:31 +0200 Subject: [PATCH 15/19] [XP-005] Added the AssociationsDatabase module. --- xdgprefs/core/__init__.py | 2 + xdgprefs/core/associations_database.py | 180 +++++++++++++++++++++++++ xdgprefs/core/os_env.py | 13 +- xdgprefs/core/xdg_mime_wrapper.py | 7 +- 4 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 xdgprefs/core/associations_database.py diff --git a/xdgprefs/core/__init__.py b/xdgprefs/core/__init__.py index 98fc16e..634b7c4 100644 --- a/xdgprefs/core/__init__.py +++ b/xdgprefs/core/__init__.py @@ -1,4 +1,5 @@ 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 @@ -6,6 +7,7 @@ from . import xdg_mime_wrapper __all__ = ['AppDatabase', + 'AssociationsDatabase', 'DesktopEntry', 'MimeDatabase', 'MimeType', diff --git a/xdgprefs/core/associations_database.py b/xdgprefs/core/associations_database.py new file mode 100644 index 0000000..f589244 --- /dev/null +++ b/xdgprefs/core/associations_database.py @@ -0,0 +1,180 @@ +""" +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 logging +import os +from collections import defaultdict +from enum import Enum, auto + +from xdgprefs.core import os_env + + +class Associations(object): + + def __init__(self): + self.added = [] + self.removed = [] + self.default = [] + + +class Section(Enum): + ADDED = auto() + REMOVED = auto() + DEFAULT = auto() + + +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 + + +def extend_blacklist(list1, list2, blacklist): + """ + Extend a list with the contents of another list, except for those + in the blacklist. + """ + for elt in list2: + if elt not in blacklist: + list1.append(elt) + + +def extend_unique(list1, list2): + """ + Extend a list with the elements of another list which are not + already in the first list. + """ + extend_blacklist(list1, list2, list1) + + +class AssociationsDatabase(object): + + def __init__(self): + self.logger = logging.getLogger('AssociationsDatabase') + self.associations = defaultdict(Associations) + + 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): + with open(path, 'r') as f: + section = None + for line in f.readlines(): + line = line.strip() + if line == '': + continue + if line == '[Added Associations]': + section = Section.ADDED + elif line == '[Removed Associations]': + section = Section.REMOVED + elif line == '[Default Applications]': + section = Section.DEFAULT + else: + mimetype, apps = self._parse_line(line) + assoc = self.associations[mimetype] + if section is Section.ADDED: + extend_blacklist(assoc.added, apps, assoc.removed) + # for app in apps: + # if app not in assoc.removed: + # assoc.added.append(app) + elif section is Section.REMOVED: + extend_unique(assoc.removed, apps) + elif section is Section.DEFAULT: + extend_unique(assoc.default, apps) + else: + self.logger.warning(f'Badly formatted file: {path}') + + def _parse_cache_file(self, path): + with open(path, 'r') as f: + for line in f.readlines(): + line = line.strip() + if '=' in line: + mimetype, apps = self._parse_line(line) + assoc = self.associations[mimetype] + assoc.default.extend(apps) + + def _parse_line(self, line): + mimetype, apps = line.split('=') + apps = apps.split(';') + if apps[-1] == '': + apps.remove('') + return mimetype, apps + + def get_apps_for_mimetype(self, mimetype): + if mimetype in self.associations: + assoc = self.associations[mimetype] + return assoc.default + return [] + + def get_mimetypes_for_app(self, app): + return [] + + @property + def size(self): + return len(self.associations) diff --git a/xdgprefs/core/os_env.py b/xdgprefs/core/os_env.py index 22dd461..0677438 100644 --- a/xdgprefs/core/os_env.py +++ b/xdgprefs/core/os_env.py @@ -3,7 +3,7 @@ System, such as XDG configuration values, the current Desktop Environment, or the language. -XDG values (data directory, configuration directory, ...): +XDG BaseDir specification: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html """ @@ -60,12 +60,15 @@ def xdg_runtime_dir(): def get_current_desktop_environment(): """ - Returns an identifier of the current Desktop Environment, such as - `gnome`, `kde` or `i3`. + Returns the list of identifier (lowercase) that the current Desktop + Environment is known as (e.g. 'gnome', 'kde', 'i3'). - :rtype: str + :rtype: list """ - return os.getenv('XDG_CURRENT_DESKTOP') + desktop = os.getenv('XDG_CURRENT_DESKTOP') or '' + desktop = desktop.split(',') + desktop = [name.lower() for name in desktop] + return desktop def get_language(): diff --git a/xdgprefs/core/xdg_mime_wrapper.py b/xdgprefs/core/xdg_mime_wrapper.py index e825a88..4f81cec 100644 --- a/xdgprefs/core/xdg_mime_wrapper.py +++ b/xdgprefs/core/xdg_mime_wrapper.py @@ -56,6 +56,7 @@ def _try_path(path): bin_path = _find_xdg_mime() +logger.debug(f'Found xdg-mime: {bin_path}') def get_default_app(mime_type): @@ -97,12 +98,12 @@ def set_default_app(mime_type, app): was 0), False otherwise. """ if bin_path is None: - logger.error('Can\t set the default app if xdg-mime was not found!') + 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.warning(f'Unknown error while setting default application' - f' ({res.returncode}): {res.stderr}') + logger.error(f'Unknown error while setting default application' + f' ({res.returncode}): {res.stderr}') return res.returncode is 0 From 3b589bf4eb534e6e7d4800e6f0648d886791a13d Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 12 Oct 2019 21:14:13 +0200 Subject: [PATCH 16/19] [XP-004] Added the GUI package, containing the MainWindow module, the AppsPanel module, the MimeTypePanel module, and the AssociationsPanel module. --- xdgprefs/core/desktop_entry.py | 8 ++ xdgprefs/gui/__init__.py | 9 +++ xdgprefs/gui/app_item.py | 32 ++++++++ xdgprefs/gui/apps_panel.py | 113 +++++++++++++++++++++++++++ xdgprefs/gui/association_item.py | 44 +++++++++++ xdgprefs/gui/associations_panel.py | 119 +++++++++++++++++++++++++++++ xdgprefs/gui/custom_item.py | 71 +++++++++++++++++ xdgprefs/gui/main_window.py | 49 ++++++++++++ xdgprefs/gui/mime_item.py | 32 ++++++++ xdgprefs/gui/mime_type_panel.py | 112 +++++++++++++++++++++++++++ 10 files changed, 589 insertions(+) create mode 100644 xdgprefs/gui/__init__.py create mode 100644 xdgprefs/gui/app_item.py create mode 100644 xdgprefs/gui/apps_panel.py create mode 100644 xdgprefs/gui/association_item.py create mode 100644 xdgprefs/gui/associations_panel.py create mode 100644 xdgprefs/gui/custom_item.py create mode 100644 xdgprefs/gui/main_window.py create mode 100644 xdgprefs/gui/mime_item.py create mode 100644 xdgprefs/gui/mime_type_panel.py diff --git a/xdgprefs/core/desktop_entry.py b/xdgprefs/core/desktop_entry.py index f00646d..9e18d32 100644 --- a/xdgprefs/core/desktop_entry.py +++ b/xdgprefs/core/desktop_entry.py @@ -109,3 +109,11 @@ def not_show_in(self): @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/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..eec7666 --- /dev/null +++ b/xdgprefs/gui/app_item.py @@ -0,0 +1,32 @@ +""" +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..c89da33 --- /dev/null +++ b/xdgprefs/gui/apps_panel.py @@ -0,0 +1,113 @@ +""" +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..ceb8f87 --- /dev/null +++ b/xdgprefs/gui/association_item.py @@ -0,0 +1,44 @@ +""" +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 +from xdgprefs.core.xdg_mime_wrapper import set_default_app + + +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, text): + mime = self.mime_type.identifier + app = self.selector.currentText() + self.main_window.status.showMessage(f'Setting {mime} to {app}...') + def run(): + success = set_default_app(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..530a1fe --- /dev/null +++ b/xdgprefs/gui/associations_panel.py @@ -0,0 +1,119 @@ +""" +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..c27d0cf --- /dev/null +++ b/xdgprefs/gui/custom_item.py @@ -0,0 +1,71 @@ +""" +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 thext, 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..008bdab --- /dev/null +++ b/xdgprefs/gui/main_window.py @@ -0,0 +1,49 @@ +""" +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..008ed15 --- /dev/null +++ b/xdgprefs/gui/mime_item.py @@ -0,0 +1,32 @@ +""" +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..10eb744 --- /dev/null +++ b/xdgprefs/gui/mime_type_panel.py @@ -0,0 +1,112 @@ +""" +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) From 82464f1c3d42f988411bc691dc8026e2823adfa7 Mon Sep 17 00:00:00 2001 From: rchaput Date: Sat, 19 Oct 2019 16:45:35 +0200 Subject: [PATCH 17/19] [XP-006] Added configparser to read and write the mimeapps.list files. Can change the default app without relying on xdg-mime --- xdgprefs/core/associations_database.py | 136 ++++++++++++++----------- xdgprefs/core/os_env.py | 27 ++--- xdgprefs/gui/association_item.py | 3 +- 3 files changed, 87 insertions(+), 79 deletions(-) diff --git a/xdgprefs/core/associations_database.py b/xdgprefs/core/associations_database.py index f589244..8de3436 100644 --- a/xdgprefs/core/associations_database.py +++ b/xdgprefs/core/associations_database.py @@ -9,14 +9,19 @@ """ +import configparser import logging import os from collections import defaultdict -from enum import Enum, auto from xdgprefs.core import os_env +ADDED = 'Added Applications' +REMOVED = 'Removed Applications' +DEFAULT = 'Default Applications' + + class Associations(object): def __init__(self): @@ -24,11 +29,20 @@ def __init__(self): 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) -class Section(Enum): - ADDED = auto() - REMOVED = auto() - DEFAULT = auto() + 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): @@ -88,22 +102,31 @@ def cache_files(only_existing=True): return files -def extend_blacklist(list1, list2, blacklist): - """ - Extend a list with the contents of another list, except for those - in the blacklist. - """ - for elt in list2: - if elt not in blacklist: - list1.append(elt) +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 extend_unique(list1, list2): - """ - Extend a list with the elements of another list which are not - already in the first list. - """ - extend_blacklist(list1, list2, list1) + +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): @@ -111,6 +134,8 @@ 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() @@ -123,48 +148,25 @@ def _build_db(self): self._parse_cache_file(file) def _parse_mimeapps_file(self, path): - with open(path, 'r') as f: - section = None - for line in f.readlines(): - line = line.strip() - if line == '': - continue - if line == '[Added Associations]': - section = Section.ADDED - elif line == '[Removed Associations]': - section = Section.REMOVED - elif line == '[Default Applications]': - section = Section.DEFAULT - else: - mimetype, apps = self._parse_line(line) - assoc = self.associations[mimetype] - if section is Section.ADDED: - extend_blacklist(assoc.added, apps, assoc.removed) - # for app in apps: - # if app not in assoc.removed: - # assoc.added.append(app) - elif section is Section.REMOVED: - extend_unique(assoc.removed, apps) - elif section is Section.DEFAULT: - extend_unique(assoc.default, apps) - else: - self.logger.warning(f'Badly formatted file: {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): - with open(path, 'r') as f: - for line in f.readlines(): - line = line.strip() - if '=' in line: - mimetype, apps = self._parse_line(line) - assoc = self.associations[mimetype] - assoc.default.extend(apps) - - def _parse_line(self, line): - mimetype, apps = line.split('=') - apps = apps.split(';') - if apps[-1] == '': - apps.remove('') - return mimetype, apps + 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: @@ -175,6 +177,22 @@ def get_apps_for_mimetype(self, mimetype): def get_mimetypes_for_app(self, app): 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/os_env.py b/xdgprefs/core/os_env.py index 0677438..eaa905c 100644 --- a/xdgprefs/core/os_env.py +++ b/xdgprefs/core/os_env.py @@ -13,48 +13,38 @@ def xdg_data_home(): """Base directory where user specific data files should be stored.""" - value = os.getenv('XDG_DATA_HOME') - if value is None or value == '': - value = '$HOME/.local/share/' + 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') - if value is None or value == '': - value = '$HOME/.config/' + 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') - if value is None or value == '': - value = '/usr/local/share/:/usr/share/' + 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') - if value is None or value == '': - value = '/etc/xdg/' + 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') - if value is None or value == '': - value = '' + 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') + value = os.getenv('XDG_RUNTIME_DIR') or '' return os.path.expandvars(value) @@ -79,6 +69,7 @@ def get_language(): :rtype: str """ - lang = os.getenv('LANGUAGE') - lang = lang.split(':')[1] + lang = os.getenv('LANGUAGE') or '' + if ':' in lang: + lang = lang.split(':')[1] return lang diff --git a/xdgprefs/gui/association_item.py b/xdgprefs/gui/association_item.py index ceb8f87..4134286 100644 --- a/xdgprefs/gui/association_item.py +++ b/xdgprefs/gui/association_item.py @@ -8,7 +8,6 @@ from PySide2.QtWidgets import QComboBox from xdgprefs.gui.mime_item import MimeTypeItem -from xdgprefs.core.xdg_mime_wrapper import set_default_app class AssociationItem(MimeTypeItem): @@ -30,7 +29,7 @@ def _on_selected(self, text): app = self.selector.currentText() self.main_window.status.showMessage(f'Setting {mime} to {app}...') def run(): - success = set_default_app(mime, app) + success = self.main_window.assocdb.set_app_for_mimetype(mime, app) if success: msg = f'{app} was successfully set to open {mime}.' else: From 4bbc278ba325a43157182037524d128f0fa0d60f Mon Sep 17 00:00:00 2001 From: rchaput Date: Fri, 25 Oct 2019 21:16:01 +0200 Subject: [PATCH 18/19] [release-v1] Changed the Python version from 3.7 to 3.5, added future-fstrings in order to continue using literal string interpolation. --- xdgprefs/core/app_database.py | 6 +----- xdgprefs/core/associations_database.py | 11 +++++------ xdgprefs/core/desktop_entry.py | 1 + xdgprefs/core/desktop_entry_parser.py | 1 + xdgprefs/core/mime_database.py | 4 +--- xdgprefs/core/mime_type.py | 1 + xdgprefs/core/os_env.py | 1 + xdgprefs/core/xdg_mime_wrapper.py | 6 ++++-- xdgprefs/gui/app_item.py | 1 + xdgprefs/gui/apps_panel.py | 1 + xdgprefs/gui/association_item.py | 3 ++- xdgprefs/gui/associations_panel.py | 7 +++++-- xdgprefs/gui/custom_item.py | 3 ++- xdgprefs/gui/main_window.py | 1 + xdgprefs/gui/mime_item.py | 1 + xdgprefs/gui/mime_type_panel.py | 4 +++- 16 files changed, 31 insertions(+), 21 deletions(-) diff --git a/xdgprefs/core/app_database.py b/xdgprefs/core/app_database.py index dae6928..f0a147d 100644 --- a/xdgprefs/core/app_database.py +++ b/xdgprefs/core/app_database.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines functions and class to handle the Application Database (i.e. the list of Desktop Entries that represent applications). @@ -6,10 +7,8 @@ import os import logging -from typing import Dict from xdgprefs.core.os_env import xdg_data_dirs -from xdgprefs.core.desktop_entry import DesktopEntry from xdgprefs.core import desktop_entry_parser as parser @@ -34,9 +33,6 @@ def app_dirs(only_existing=True): class AppDatabase(object): - logger: logging.Logger - apps: Dict[str, DesktopEntry] - def __init__(self): self.logger = logging.getLogger('AppDatabase') self.apps = {} diff --git a/xdgprefs/core/associations_database.py b/xdgprefs/core/associations_database.py index 8de3436..437ba4b 100644 --- a/xdgprefs/core/associations_database.py +++ b/xdgprefs/core/associations_database.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines the database that lists associations between MIME Types and Applications (represented by Desktop Entries). @@ -91,9 +92,9 @@ def cache_files(only_existing=True): dirs = [os.path.join(d, 'applications') for d in dirs] files = [] - for dir in dirs: + for _dir in dirs: for prefix in prefixes: - file = os.path.join(dir, prefix + 'mimeinfo.cache') + file = os.path.join(_dir, prefix + 'mimeinfo.cache') files.append(file) if only_existing: @@ -134,7 +135,8 @@ 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_path = os.path.join(os_env.xdg_config_home(), + 'mimeapps.list') self.config = parse_mimeapps(self.config_path) self._build_db() @@ -174,9 +176,6 @@ def get_apps_for_mimetype(self, mimetype): return assoc.default return [] - def get_mimetypes_for_app(self, app): - return [] - def set_app_for_mimetype(self, mimetype, app): section = self.config[DEFAULT] apps = section.get(mimetype, fallback=[]) diff --git a/xdgprefs/core/desktop_entry.py b/xdgprefs/core/desktop_entry.py index 9e18d32..5906e90 100644 --- a/xdgprefs/core/desktop_entry.py +++ b/xdgprefs/core/desktop_entry.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines code relative to Desktop Entries, i.e. applications which are compliant with the XDG specifications. diff --git a/xdgprefs/core/desktop_entry_parser.py b/xdgprefs/core/desktop_entry_parser.py index 8f6d4d4..a20f74f 100644 --- a/xdgprefs/core/desktop_entry_parser.py +++ b/xdgprefs/core/desktop_entry_parser.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ Desktop file tokenizer and parser. Source: https://github.com/wor/desktop_file_parser diff --git a/xdgprefs/core/mime_database.py b/xdgprefs/core/mime_database.py index d309478..0bbca4a 100644 --- a/xdgprefs/core/mime_database.py +++ b/xdgprefs/core/mime_database.py @@ -1,3 +1,4 @@ +# -*- 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). @@ -42,9 +43,6 @@ class MimeDatabase(object): It is used to build the database in a first step, and then query it. """ - logger: logging.Logger - types: Dict[str, MimeType] - def __init__(self): self.logger = logging.getLogger('MimeDatabase') self.types = {} diff --git a/xdgprefs/core/mime_type.py b/xdgprefs/core/mime_type.py index b82f565..50fd9aa 100644 --- a/xdgprefs/core/mime_type.py +++ b/xdgprefs/core/mime_type.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines the MimeType class as well as a MimeTypeParser (used to parse media types from their XML files). diff --git a/xdgprefs/core/os_env.py b/xdgprefs/core/os_env.py index eaa905c..06de4f1 100644 --- a/xdgprefs/core/os_env.py +++ b/xdgprefs/core/os_env.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module allows access to various environment variables of the Operating System, such as XDG configuration values, the current Desktop Environment, diff --git a/xdgprefs/core/xdg_mime_wrapper.py b/xdgprefs/core/xdg_mime_wrapper.py index 4f81cec..9dfd71e 100644 --- a/xdgprefs/core/xdg_mime_wrapper.py +++ b/xdgprefs/core/xdg_mime_wrapper.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines wrapper functions for the `xdg-mime` software. @@ -40,8 +41,9 @@ def _try_path(path): """Try an absolute or relative path for the `xdg-mime` executable.""" try: res = subprocess.run([path, '--version'], - capture_output=True, - text=True) + 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}') diff --git a/xdgprefs/gui/app_item.py b/xdgprefs/gui/app_item.py index eec7666..b9fab3d 100644 --- a/xdgprefs/gui/app_item.py +++ b/xdgprefs/gui/app_item.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines a single Application Item in the AppsPanel. """ diff --git a/xdgprefs/gui/apps_panel.py b/xdgprefs/gui/apps_panel.py index c89da33..bab8630 100644 --- a/xdgprefs/gui/apps_panel.py +++ b/xdgprefs/gui/apps_panel.py @@ -1,3 +1,4 @@ +# -*- 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). diff --git a/xdgprefs/gui/association_item.py b/xdgprefs/gui/association_item.py index 4134286..e752afa 100644 --- a/xdgprefs/gui/association_item.py +++ b/xdgprefs/gui/association_item.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines a single AssociationItem in the AssociationsPanel. """ @@ -24,7 +25,7 @@ def __init__(self, mime_type, apps, main_window, listview): self.hbox.addWidget(self.selector, 2) - def _on_selected(self, text): + def _on_selected(self, _): mime = self.mime_type.identifier app = self.selector.currentText() self.main_window.status.showMessage(f'Setting {mime} to {app}...') diff --git a/xdgprefs/gui/associations_panel.py b/xdgprefs/gui/associations_panel.py index 530a1fe..c497695 100644 --- a/xdgprefs/gui/associations_panel.py +++ b/xdgprefs/gui/associations_panel.py @@ -1,3 +1,4 @@ +# -*- 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 @@ -32,7 +33,8 @@ def __init__(self, main_window): 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) + item = AssociationItem(mime, apps, main_window, + self.list_widget) self.item_map[mime] = item self.list_widget.addItem(item) @@ -90,7 +92,8 @@ def on_filter_update(self): nb_shown = 0 nb_total = 0 for mime_type in self.item_map: - matches = self.matches(mime_type, filter_text, personal, vendor, ext) + 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 diff --git a/xdgprefs/gui/custom_item.py b/xdgprefs/gui/custom_item.py index c27d0cf..cf8e6fd 100644 --- a/xdgprefs/gui/custom_item.py +++ b/xdgprefs/gui/custom_item.py @@ -1,9 +1,10 @@ +# -*- 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 thext, italic +- third line of text, italic This QListWidgetItem is used to show both MimeTypes and App. """ diff --git a/xdgprefs/gui/main_window.py b/xdgprefs/gui/main_window.py index 008bdab..2483daa 100644 --- a/xdgprefs/gui/main_window.py +++ b/xdgprefs/gui/main_window.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines the main window, allowing the user to effectively use the application. diff --git a/xdgprefs/gui/mime_item.py b/xdgprefs/gui/mime_item.py index 008ed15..31c21e8 100644 --- a/xdgprefs/gui/mime_item.py +++ b/xdgprefs/gui/mime_item.py @@ -1,3 +1,4 @@ +# -*- coding: future_fstrings -*- """ This module defines a single MimeTypeItem in the MimeTypePanel. """ diff --git a/xdgprefs/gui/mime_type_panel.py b/xdgprefs/gui/mime_type_panel.py index 10eb744..4a43e72 100644 --- a/xdgprefs/gui/mime_type_panel.py +++ b/xdgprefs/gui/mime_type_panel.py @@ -1,3 +1,4 @@ +# -*- 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). @@ -83,7 +84,8 @@ def on_filter_update(self): nb_shown = 0 nb_total = 0 for mime_type in self.item_map: - matches = self.matches(mime_type, filter_text, personal, vendor, ext) + 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 From 6265602a89ba3e3c07ff75f74556ca2f542c66ba Mon Sep 17 00:00:00 2001 From: rchaput Date: Fri, 25 Oct 2019 21:16:44 +0200 Subject: [PATCH 19/19] [release-v1] Added an entry point, updated the Readme and created the setup.py file --- .gitignore | 3 ++ README.md | 126 +++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 2 + setup.py | 44 +++++++++++++++ xdgprefs/__main__.py | 20 +++++++ 5 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 xdgprefs/__main__.py diff --git a/.gitignore b/.gitignore index 00ddf21..86cfc6f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .idea **/.cache +build/** +dist/** +XDG_Prefs.egg-info/** diff --git a/README.md b/README.md index 3b22032..b133d8b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,124 @@ -# xdg-prefs +# XDG-Prefs +> Remy Chaput -Program that allows you to change the default application on your Linux system, -using the [XDG standard](https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-1.0.html). +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/__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()