From 4a955d6157e2342b796ad0b87346d53c6f83df2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20=27LEdoian=27=20Turinsk=C3=BD?= Date: Mon, 20 Feb 2023 22:17:25 +0100 Subject: [PATCH] Initial hacked version --- README.md | 33 ++++++++++++ TODO | 3 ++ combiner | 93 +++++++++++++++++++++++++++++++++ defaulter | 133 +++++++++++++++++++++++++++++++++++++++++++++++ example.patterns | 42 +++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 README.md create mode 100644 TODO create mode 100755 combiner create mode 100755 defaulter create mode 100755 example.patterns diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfbdc6b --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# mimeapps.list tools: a set of simple scripts to manage `mimeapps.list` files + +Setting system-wide default applications is actually not very easy in current +Linux distributions. This aims to [help fix it](https://xkcd.com/927/). + + + +Quality: hacked together to get basic work done, may be buggy, works for me :-) +Breaking changes expected, please read diffs or pin your versions. + +## What is here + +Two main scripts: +- `defaulter` takes patterns for default apps and outputs a `mimeapps.list` to stdout +- `combiner` can merge multiple `mimeapps.list`-style files by applying one after another + +There is currently little documentation. All scripts support `--help` option to +give usage, and have a large docstring at the top, which explains what they do. + +The file `example.patterns` serves as an example of the pattern file. It too +has a few comments. + +Proper documentation, tests, etc: not currently implemented. Patches wanted. +Also see the `TODO` file. + +## License + +GPLv2-only + +## Contributing + +Send any feedback, bug reports or patches to +[mimeapps@pokemon.ledoian.cz](mailto:mimeapps@pokemon.ledoian.cz). diff --git a/TODO b/TODO new file mode 100644 index 0000000..03a5303 --- /dev/null +++ b/TODO @@ -0,0 +1,3 @@ +- Defaulter cannot understand any custom additions or deletions +- Expander is not written yet +- Docs is only in comments diff --git a/combiner b/combiner new file mode 100755 index 0000000..f2702ff --- /dev/null +++ b/combiner @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +""" +This is an even simpler script to combine multiple mimeapps.list files into +one. + +This is not too trivial, since an addition can be reverted by a deletion in a +later file and vice versa, and all values should probably be deduplicated. + +The input files are processed left-to-right. This means that the default is +taken from the leftmost (first supplied) file the respective MIME type is +specified (other files serve as fallbacks), but an association added in one +file can be reverted in a later file (and possibly re-added in even later file) +""" + +from pathlib import Path +from dataclasses import dataclass +import sys +import re + +DEBUG = 1 +def _dprint(lvl): + def f(*a, **kw): + if DEBUG >= lvl: + print(*a, **kw | {'file': sys.stderr}) + return f +dprint = _dprint(1) +ddprint = _dprint(2) + +def combine_dicts_with_lists(d1, d2): + result = {} + result |= {k: (d1|d2)[k] for k in d1.keys() ^ d2.keys()} + result |= {k: d1[k] + d2[k] for k in d1.keys() & d2.keys()} + return result + +if '--help' in sys.argv or len(sys.argv) <= 1: + print(f'Usage: {sys.argv[0]} file ...\n\nThis script combines multiple mimeapps.list files into one.', file=sys.stderr) + sys.exit() + +ADDITIONS = {} +DELETIONS = {} +DEFAULTS = {} + +for fn in sys.argv[1:]: + section = None + with open(fn) as f: + for line in f: + line = line.strip() + if line.startswith('['): + section = re.match(r'\[[^]]+\]', line).string + dprint(f'DEBUG: Found section \'{section}\'') + continue + mimetype, apps = line.split('=', 2) + apps = [x for x in apps.split(';') if x != ''] + if section == '[Added Associations]': + if mimetype not in ADDITIONS: + ADDITIONS[mimetype] = [] + for app in apps: + if app not in ADDITIONS[mimetype]: + ADDITIONS[mimetype].append(app) + if mimetype in DELETIONS and app in DELETIONS[mimetype]: + DELETIONS[mimetype].remove(app) + if section == '[Removed Associations]': + if mimetype not in DELETIONS: + DELETIONS[mimetype] = [] + for app in apps: + if app not in DELETIONS[mimetype]: + DELETIONS[mimetype].append(app) + if mimetype in ADDITIONS and app in ADDITIONS[mimetype]: + ADDITIONS[mimetype].remove(app) + if section == '[Default Applications]': + if mimetype not in DEFAULTS: + DEFAULTS[mimetype] = [] + for app in apps: + if app not in DEFAULTS[mimetype]: + dprint(f'adding {app} to {mimetype}') + DEFAULTS[mimetype].append(app) + +if ADDITIONS: + print('[Added Associations]') + for mt, apps in ADDITIONS.items(): + print(f"{mt}={';'.join(apps)};") + print() +if DELETIONS: + print('[Removed Associations]') + for mt, apps in DELETIONS.items(): + print(f"{mt}={';'.join(apps)};") + print() +if DEFAULTS: + print('[Default Applications]') + for mt, apps in DEFAULTS.items(): + print(f"{mt}={';'.join(apps)};") + print() diff --git a/defaulter b/defaulter new file mode 100755 index 0000000..d9d911c --- /dev/null +++ b/defaulter @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +""" +This is a simple program to create mimeapps.list (and in future maybe +mailcap.order + mime package) with full MIME types. + +Mostly workaround to buggy `xdg-mime` script which can not do wildcards in MIME +types. (Also I am not trying to write it in awk.) + +References: +=> https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-latest.html#default XDG spec +=> https://cgit.freedesktop.org/xdg/xdg-utils/tree/scripts/xdg-mime.in#n362 Source of the buggy xdg-mime + +As input, we get a file in similar format to mimeapps.list, process it top-down +and for each application specified we scan its MimeType= directive and find +matches to the _regexes_ on the LHS. Then we dump all the found MIME types with +found apps to the final mimeapps.list, with above matches being preferred +(towards left in mimeapps.list) + +We do not aim to fix the issue with xdg-mime only finding the first app. We +could not possibly do that, since we precompute the list build-time and +distribute the mimeapps.list as-output. (It should be possible to create hooks +into apt/dpkg and just re-generate after every installation, but I cannot do +that right away.) +""" + +import re +from pathlib import Path +from os import environ +from functools import cache +import sys + +APPLICATION_DIR = Path('/usr/share/applications') # FIXME: use XDG_DATA_DIR + +DEBUG = 0 +def _dprint(lvl): + def f(*a, **kw): + if DEBUG >= lvl: + print(*a, **kw | {'file': sys.stderr}) + return f +dprint = _dprint(1) +ddprint = _dprint(2) + +@cache +def read_mimetypes(desktop_filename: str) -> list[str]: + path = APPLICATION_DIR / desktop_filename + dprint(f'DEBUG: Processing file {path}') + if not path.exists(): + print(f'WARNING: File {path} not found, skipping…', file=sys.stderr) + return [] + section = None + with open(path) as f: + for line in f: + line = line.strip() + # Hacky .desktop file parsing + # TODO: Does a library exist? + if line.startswith('['): + section = re.match(r'\[[^]]+\]', line).string + dprint(f'DEBUG: Found section \'{section}\'') + if section == '[Desktop Entry]' and line.startswith('MimeType='): + dprint('DEBUG: Found MimeType line') + types = line.removeprefix('MimeType=').split(';') + types = list(filter(lambda x: x != '', types)) + return types # We believe that there is only one such line. + else: + # No MimeType= line found + print(f'WARNING: MimeType= line not found in {path}', file=sys.stderr) + return [] + +def filter_matching_types(ref: str, types: list[str]): + result = [] + regex = re.compile(ref) + for t in types: + if regex.fullmatch(t): + result.append(t) + return result + +def process_assignment(mtpattern: str, desktop_files: list[str], out_mapping: dict[str, list[str]]) -> None: + # I wonder whether it would be cleaner to just return the additions to the resulting dictionary… + for dfn in desktop_files: + usabletypes = read_mimetypes(dfn) + usabletypes = filter_matching_types(mtpattern, usabletypes) + for mtype in usabletypes: + if mtype not in out_mapping: out_mapping[mtype] = [] + if dfn not in out_mapping[mtype]: # deduplication + out_mapping[mtype].append(dfn) + +def process_file(fn: str, out_mapping: dict[str, list[str]]) -> None: + """Processes directives in fn and fills in mapping of specific mime types to the respective desktop file names.""" + path = Path(fn) + if not path.exists(): + print(f"ERROR: File {path} not found", file=sys.stderr) + sys.exit(1) + with open(path) as f: + for line in f: + line = line.strip() + + line = re.sub(r'#.*$', '', line) + if re.match(r'^[ ]*$', line): continue + + pat, files = line.split('=', maxsplit=2) + files = list(filter(lambda x: x != '', files.split(';'))) + process_assignment(pat, files, out_mapping) + +def dump_mapping(mapping: dict[str, list[str]], filename: str = None) -> None: + if filename is None: + filename = '/dev/stdout' + path = Path(filename) + else: + path = Path(filename) + if path.exists(): + print(f'WARNING: File {filename} already exists, overwriting…', file=sys.stderr) + with open(path, 'w') as f: + def fprint(*a, **kwa): print(*a, **kwa | {'file': f}) + + fprint('[Default Applications]') + for mimetype, desktop_files in mapping.items(): + fprint(f"{mimetype}={';'.join(desktop_files)};") + +def main(): + if '--help' in sys.argv or len(sys.argv) <= 1: + print(f'Usage: {sys.argv[0]} file ...\n\nThis script processes regex-based rules for default applications.', file=sys.stderr) + sys.exit() + + out_mapping = {} + for fn in sys.argv[1:]: + process_file(fn, out_mapping) + dump_mapping(out_mapping) + + +# TODO: def resolve_more_specific_mimetypes(mimetype: str) -> list[str]: ... + +if __name__ == '__main__': main() diff --git a/example.patterns b/example.patterns new file mode 100755 index 0000000..975ec27 --- /dev/null +++ b/example.patterns @@ -0,0 +1,42 @@ +#!/usr/bin/env ./defaulter +# +# This file describes patterns for the [Default Applications] part of +# mimeapps.list. Each line consists of a pattern, '=', and a list of desktop +# files to be used for any type matching the pattern. +# +# The desktop files are read in order for each pattern and according to their +# MimeType= directive, matching lines of mimeapps.list with proper types are +# created. The resulting file contains only the desktop files which claim to +# support the type, in order they matched a pattern. +# +# In order to read the desktop files, they must be present in the filesystem. +# Currently, only /usr/share/applications/ folder is used for finding the +# files. +# +# Patterns are matched against full MIME types, not just prefixes, so if a +# pattern contains no "active" characters, it just serves as an exact match. +# +# Limitations: Patterns cannot contain '#', which introduces comments anywhere +# and '=' which separates the pattern from the list. (Neither of those +# characters are likely to be a part of a MIME type.) Also, this file does not +# support section headers (they would be hard to distinguish from a bracket +# pattern), so a mimeapps.list file, while similar, is in fact not a valid +# input for defaulter. +# +# As an example: the following line uses geeqie for any images it supports, +# then tries eog, etc. If eog supports a file geeqie does not, it becomes the +# default, for files suported by both it becomes a fallback. The resulting +# lines of mimeapps.list would be: +# image/x-just-geeqie=geeqie.desktop; +# image/x-supported-by-both=geeqie.desktop;org.gnome.eog.desktop; +# image/x-just-eog=org.gnome.eog.desktop; +# Any pattern not matching 'image/.*' is not considered and this line will not +# contribute any such line to the output. +image/.*=geeqie.desktop;org.gnome.eog.desktop;feh.desktop; +application/pdf=org.gnome.Evince.desktop;okularApplication_pdf.desktop;mupdf.desktop; +text/.*=org.gnome.gedit.desktop;org.kde.kate.desktop;mousepad.desktop;gvim.desktop; +audio/.*=vlc.desktop;mpv.desktop;audacity.desktop; +video/.*=vlc.desktop;mpv.desktop; +x-scheme-handler/http=chromium.desktop;firefox-esr.desktop; +x-scheme-handler/https=chromium.desktop;firefox-esr.desktop; +x-scheme-handler/mailto=thunderbird.desktop;org.gnome.Evolution.desktop;org.kde.kmail2.desktop;