#!/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. ' f'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.') NAUTILUS_SELECTION = 'NAUTILUS_SCRIPT_SELECTED_FILE_PATHS' NEMO_SELECTION = 'NEMO_SCRIPT_SELECTED_FILE_PATHS' SCRIPT_NAME = 'relative-xopp-background' def parse_args() -> List[Path]: """ :return: List of absolute paths to .xopp files """ if len(argv) < 2: usage() # get list of selected paths selected_paths: List[Path] = [] selected_paths += selection_from_env(NAUTILUS_SELECTION) selected_paths += selection_from_env(NEMO_SELECTION) # if no selected files from NAUTILUS or NEMO were added if len(selected_paths) == 0: selected_paths = [Path(file) for file in argv[1:]] # for each selected path # if it is a file, check if it ends with .xopp # if it is a directory, recursively add all .xopp files xopp_files: List[Path] = [] path: Path for path in selected_paths: if not path.exists(): notify(summary='Note', message=f'Skipped non existing path {path}') continue elif path.is_dir(): for child in path.glob(r'**/*.xopp'): if child.is_file(): xopp_files.append(child.absolute()) else: if path.suffix == '.xopp': xopp_files.append(path.absolute()) # don't return duplicates return list(set(xopp_files)) def selection_from_env(key: str) -> List[Path]: """ Splits the value of environment key "key" by newlines and returns each element as Path object. """ newline_delimited_selection = getenv(key) if newline_delimited_selection: selected_files = newline_delimited_selection.split('\n') # if last filename is empty, remove it if len(selected_files[-1]) < 1: selected_files = selected_files[:-1] return [Path(file) for file in selected_files] return [] def usage(): usage_str = f'Usage:\n' \ f'\t{SCRIPT_NAME} <.xopp file or folder> [<.xopp file or folder>] [...]\n' \ f'\tenv {NAUTILUS_SELECTION}= {SCRIPT_NAME}' notify(summary='Usage', message=usage_str) exit(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}') page: Element for page in root: if page.tag == 'page': background: Element for background in page: if background.tag == 'background' \ and 'type' in background.attrib \ and background.attrib['type'] == 'pdf' \ and 'domain' in background.attrib \ and background.attrib['domain'] == 'absolute' \ and 'filename' in background.attrib: print(f'type: {background.tag}, attrib: {background.attrib}') abs_pdf: Path = Path(background.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 background.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!') xopp_file: Path # Optional: Backup old .xopp file # Current date as string including milliseconds # date_str = datetime.today().strftime('%Y-%m-%d_%H-%M-%S_%f')[:-3] # 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()