diff --git a/README.md b/README.md index e69de29..34ea71e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,10 @@ +# Relative Xournal++ background paths + +Replace absolute paths of Xournal++ PDF backgrounds with relative ones. + +## Installation + +Place [relative-xopp-background](relative-xopp-background) in `~/.local/share/nautilus/scripts/relative-xopp-background` and make it executable. + +If missing, install [notify2](https://pypi.org/project/notify2/) and (optionally) +[dbus-python](https://pypi.org/project/dbus-python/) with your package manager or with pip. diff --git a/relative-xopp-background b/relative-xopp-background new file mode 100755 index 0000000..6a1e253 --- /dev/null +++ b/relative-xopp-background @@ -0,0 +1,180 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +import gzip +import traceback +from os import getenv +from pathlib import Path +from sys import argv, stderr +from typing import List +from xml.etree import ElementTree +from datetime import datetime +from xml.etree.ElementTree import Element + +# Set to true if notify2 was successfully imported and initialized +NOTIFY2 = False +MSG_FALLBACK_STR = 'Messages will only be printed to stdout.' +try: + # DEPENDENCIES + # -> https://pypi.org/project/notify2/ + # -> https://pypi.org/project/dbus-python/ + import notify2 + + notify2.init('Relative Xournal++ Background Paths') + NOTIFY2 = True +except ImportError as e: + print(f'Optional dependency "notify2" is not available. {MSG_FALLBACK_STR}') +except Exception as e: + print(f'Error during import or initialization of notify2. Is the dependency "dbus-python" missing? {MSG_FALLBACK_STR}') + + +def main(): + modified_files=0 + xopp_files: List[Path] = [] + try: + xopp_files = parse_args() + for xopp_file in xopp_files: + modified_files += save_relative_paths(xopp_file=xopp_file) + + # Catch exception, print it to stderr and save as file + except Exception as ex: + traceback.print_exc() + + log_file = Path.home().joinpath('relative_paths.error') + with open(file=log_file, mode='w') as file: + traceback.print_exc(file=file) + + notify(summary='Exception', message=getattr(ex, 'message', str(ex))) + exit(1) + + notify(summary='Success!', message=f'Modified {modified_files} out of {len(xopp_files)} files.') + + +def parse_args() -> List[Path]: + NAUTILUS_SELECTION = 'NAUTILUS_SCRIPT_SELECTED_FILE_PATHS' + SCRIPT_NAME = 'relative-xopp-background' + + selected_files_str = getenv(NAUTILUS_SELECTION) + if selected_files_str: + selected_files = selected_files_str.split('\n') + if len(selected_files[-1]) < 1: + selected_files = selected_files[:-1] + return [Path(file) for file in selected_files] + + if len(argv) < 2: + usage_str = f'Usage:\n' \ + f'\t{SCRIPT_NAME} [] [...]\n' \ + f'\tenv {NAUTILUS_SELECTION}= {SCRIPT_NAME}' + print(usage_str, file=stderr) + raise Exception(usage_str) + return [Path(file) for file in argv[1:]] + + +def save_relative_paths(xopp_file: Path) -> int: + """ + Xournal++ stores XML content into gz compressed `.xopp` files. + + This method modifies the content a given `.xopp` file "xopp_file": + In case there are PDF background files with an absolute path and they do + not exist at that location but instead reside next to the "xopp_file" + (located in the same directory), then their path is replaced with a relative one. + + :returns the number of modified .xopp files (0 or 1) + """ + + xml_modified = False + print(f'=== RELATIVE PATHS ===\nxopp_file: {xopp_file}') + + if not xopp_file.exists(): + raise Exception(f'The given file does not exist: {xopp_file}') + if not xopp_file.suffix == '.xopp': + raise Exception(f'Expected a Xournal++ file ending in .xopp: {xopp_file.suffix}') + + # Open gz-compressed file + with gzip.open(xopp_file, mode="rt", encoding="utf-8") as fi: + xopp_str = fi.read() + + root: Element = ElementTree.fromstring(xopp_str) + # + # xml_file: Path = Path('2019_SozialeMedien_Umgang-mit-Social-Media.xopp.xml') + # tree: ElementTree = ElementTree.parse(xml_file) + # root: Element = tree.getroot() + + if not root.tag == 'xournal': + raise Exception(f'Unexpected root element: {root.tag}') + + child1: Element + for child1 in root: + if child1.tag == 'page': + child2: Element + for child2 in child1: + if child2.tag == 'background' \ + and 'type' in child2.attrib \ + and child2.attrib['type'] == 'pdf' \ + and 'domain' in child2.attrib \ + and child2.attrib['domain'] == 'absolute' \ + and 'filename' in child2.attrib: + print(f'type: {child2.tag}, attrib: {child2.attrib}') + abs_pdf: Path = Path(child2.attrib['filename']) + + if not abs_pdf.is_absolute(): + continue # No action required as the path is already relative + if abs_pdf.exists(): + continue # No action required as the background pdf will be found by Xournal++ + if len(abs_pdf.name) < 1: + raise Exception(f'Expected the PDF file name to be non empty: {abs_pdf}') + + rel_pdf: Path = xopp_file.parent.joinpath(abs_pdf.name) + if rel_pdf.exists(): + # Replace absolute with relative path + child2.attrib['filename'] = abs_pdf.name + xml_modified = True + else: + print("The PDF file was neither found at it's absolute path" + f" nor in the same directory as this Xournal++ file: {abs_pdf}") + + if not xml_modified: + print('No modifications.') + return 0 + + print('The xml was modified!') + + # Current date as string including milliseconds + date_str = datetime.today().strftime('%Y-%m-%d_%H-%M-%S_%f')[:-3] + + xopp_file: Path + # Optional: Backup old .xopp file + # xopp_file.rename(xopp_file.parent.joinpath(xopp_file.name + '.' + date_str + '.backup')) + + # Open in binary mode + encoding = 'utf-8' + fo = gzip.open(filename=xopp_file, mode='wb') + + # Write xml header + # Default xml encoding: us-ascii + # -> https://stackoverflow.com/a/28208190/6334421 + fo.write(f'\n'.encode(encoding=encoding)) + + # Write xml content + fo.write(ElementTree.tostring(root, encoding=encoding, method='xml')) + fo.close() + + return 1 + + +def notify(summary: str, message: str) -> None: + """ + If notify2 is initialized, the user is informed of the given + message via a graphical dbus notification. + + If notify2 is not available, the message is instead printed to stdout. + """ + if NOTIFY2: + n = notify2.Notification(summary=summary, message=message) + n.show() + else: + print(f'=== MESSAGE ===\n{summary}\n{message}\n') + + +if __name__ == '__main__': + main()