You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# SPDX-FileCopyrightText: 2023 Pavel 'LEdoian' Turinský <mimeapps@pokemon.ledoian.cz>
|
|
# SPDX-License-Identifier: GPL-2.0-only
|
|
"""
|
|
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, header: str = '[Default Applications]') -> 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(header)
|
|
for mimetype, desktop_files in mapping.items():
|
|
fprint(f"{mimetype}={';'.join(desktop_files)};")
|
|
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.description = 'This script processes regex-based rules for default applications.'
|
|
parser.add_argument('--add', help="Generate [Added Associations] section instead", action='store_true')
|
|
parser.add_argument('--remove', help="Generate [Removed Associations] section instead", action='store_true')
|
|
parser.add_argument('files', help="Source of the rules", nargs='+')
|
|
parser.usage='%(prog)s [--add|--remove] files ...'
|
|
parser.epilog = 'By default a [Default Applications] section is created. There is no option to apply this behaviour.'
|
|
|
|
args = parser.parse_args()
|
|
if sum([args.add, args.remove]) > 1:
|
|
print('Cannot generate conflicting sections!', file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
params = {}
|
|
if args.add: params['header'] = '[Added Associations]'
|
|
if args.remove: params['header'] = '[Removed Associations]'
|
|
|
|
out_mapping = {}
|
|
for fn in args.files:
|
|
process_file(fn, out_mapping)
|
|
dump_mapping(out_mapping, **params)
|
|
|
|
|
|
# TODO: def resolve_more_specific_mimetypes(mimetype: str) -> list[str]: ...
|
|
|
|
if __name__ == '__main__': main()
|