diff --git a/extensions/fablabchemnitz/batch_task/BaseExtension.py b/extensions/fablabchemnitz/batch_task/BaseExtension.py
new file mode 100644
index 00000000..7e558f4d
--- /dev/null
+++ b/extensions/fablabchemnitz/batch_task/BaseExtension.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+
+# pylint: disable=too-many-ancestors
+
+# standard library
+import os
+import sys
+import re
+import argparse
+from shutil import copy2
+# from subprocess import Popen, PIPE
+# import time
+# from lxml import etree
+
+# local library
+import inkex
+from inkex.command import inkscape
+from inkex.elements import _selected as selection
+
+MIN_PYTHON_VERSION = (3, 6) # Mainly for f-strings
+if (sys.version_info.major, sys.version_info.minor) < (3, 6):
+ inkex.Effect.msg(f"Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} or later required.")
+ sys.exit(1)
+
+
+class BaseExtension(inkex.Effect):
+ """Custom class that makes creation of extensions easier.
+
+ Users of this class need not worry about boilerplates, such as how to
+ call inkscape via shell, and the management of tempfiles. Useful functions
+ are also provided."""
+
+ def __init__(self, custom_effect, args_adder=None):
+ """Init base class.
+
+ In a typical Inkscape extension that does not make use of BaseExtension,
+ the effect is determined by the "effect" method of the extension class.
+ This init function will take in a method, and run it in the "effect" method
+ together with the other boilerplate.
+
+ This init method takes in a function under the custom_effect argument.
+ This function will handle the user's effects, minus the boilerplate. It
+ has to return a list[str] object, with each str being a verb that inkscape
+ can execute."""
+
+ inkex.Effect.__init__(self)
+ self.custom_effect = custom_effect
+
+ self._msg = self.msg # The old msg function provided by inkex (only accepts strings)
+ def msg(*args, sep=' '):
+ """Improved msg method, similar to Python's print"""
+ self._msg(sep.join([str(arg) for arg in args]))
+ self.msg = msg
+
+ if args_adder is not None:
+ args_adder(self.arg_parser)
+ self.args_adder = args_adder
+
+
+
+
+ def z_sort(self, alist):
+ """Return new list sorted in document order (depth-first traversal)."""
+ return list(self.z_iter(alist))
+
+
+ def z_iter(self, alist):
+ """Return iterator over ids in document order (depth-first traversal)."""
+ id_list = list(alist)
+ count = len(id_list)
+ for element in self.document.getroot().iter():
+ # element_id = element.get('id')
+ # if element_id is not None and element_id in id_list:
+ if element in alist:
+ id_list.remove(element)
+ yield element
+ count -= 1
+ if not count:
+ return
+
+ @staticmethod
+ def show(obj):
+ """Returns a str representation of object"""
+ def rep(obj):
+ if hasattr(obj, 'get_id'):
+ return f"{type(obj).__name__}({obj.get_id()})"
+ return f"{type(obj).__name__}"
+
+
+ if type(obj).__name__ == 'ElementList':
+ return ('ElementList(' +
+ ', '.join([rep(child) for child in obj.values()]) +
+ ')')
+ if isinstance(obj, list):
+ return '[' + ', '.join(rep(child) for child in obj) + ']'
+
+
+ return rep(obj)
+
+
+ def find(self, obj: any, xpath='/*') -> list:
+ """Returns a list of objects which satisfies XPath
+
+ Args:
+ obj (any): Parent object to recurse into. Examples include root, selected, or a group.
+ xpath (str, optional): Defaults to '/*'.
+
+ Returns:
+ list: [description]
+ """
+
+ BASIC_TAGS = ('circle', 'ellipse', 'line', 'polygon', 'polyline', 'rect', 'path', 'image', 'g')
+ SPECIAL_TAGS = {
+ 'l': "svg:g[@inkscape:groupmode='layer']",
+ 'p': 'svg:path'
+ }
+
+ xpath = re.sub(r'((?<=/)(' + '|'.join(BASIC_TAGS) + r')\b)', r'svg:\1', xpath)
+ for k, v in SPECIAL_TAGS.items():
+ xpath = re.sub('(?<=/)' + k + r'\b', v, xpath)
+
+ xpath = re.sub(r'(?<=\[)(\d+):(\d+)(?=\])', r'position()>=\1 and position()<\2', xpath)
+
+ if type(obj).__name__ != 'ElementList':
+ obj = [obj]
+
+ output = []
+ for child in obj:
+ matches = child.xpath(xpath, namespaces={
+ 'svg': 'http://www.w3.org/2000/svg',
+ 'inkscape': 'http://www.inkscape.org/namespaces/inkscape'})
+ for match in matches:
+ if type(match).__name__ not in ('Defs', 'NamedView', 'Metadata'):
+ output.append(match)
+
+ return output
+
+
+ def effect(self):
+ """Main entry point to process current document. Not to be called externally."""
+
+ actions_list = self.custom_effect(self)
+
+ if actions_list is None or actions_list == []:
+ self.msg("No actions received. Perhaps you are calling inkex object methods?")
+ elif isinstance(actions_list, list):
+ tempfile = self.options.input_file + "-BaseExtension.svg"
+
+ # prepare
+ copy2(self.options.input_file, tempfile)
+
+ #disabled because it leads to crash Inkscape: https://gitlab.com/inkscape/inkscape/-/issues/2487
+ #actions_list.append("FileSave")
+ #actions_list.append("FileQuit")
+ #extra_param = "--with-gui"
+
+ #workaround to fix it (we use export to tempfile instead processing and saving again)
+ actions_list.append("export-type:svg")
+ actions_list.append("export-filename:{}".format(tempfile))
+ actions_list.append("export-do")
+ extra_param = "--batch-process"
+
+ actions = ";".join(actions_list)
+ inkscape(tempfile, extra_param, actions=actions)
+
+
+ # finish up
+ # replace current document with content of temp copy file
+ self.document = inkex.load_svg(tempfile)
+ # update self.svg
+ self.svg = self.document.getroot()
+
+
+ # Clean up tempfile
+ try:
+ os.remove(tempfile)
+ except Exception: # pylint: disable=broad-except
+ pass
+
+ def call(self, child, ext_options):
+ """Used to call an extension from another extension"""
+
+ old_options = self.options
+
+ parser = argparse.ArgumentParser()
+ child.args_adder(parser)
+ self.options = parser.parse_args([])
+
+ for k, v in ext_options.items():
+ setattr(self.options, k, v)
+
+ output = child.custom_effect(self)
+ self.options = old_options
+
+ return output
diff --git a/extensions/fablabchemnitz/batch_task/batch_task.inx b/extensions/fablabchemnitz/batch_task/batch_task.inx
new file mode 100644
index 00000000..8453f35f
--- /dev/null
+++ b/extensions/fablabchemnitz/batch_task/batch_task.inx
@@ -0,0 +1,177 @@
+
+
+ Batch Task
+ fablabchemnitz.de.batch_task
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+ all
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extensions/fablabchemnitz/batch_task/batch_task.py b/extensions/fablabchemnitz/batch_task/batch_task.py
new file mode 100644
index 00000000..e3cbc3ce
--- /dev/null
+++ b/extensions/fablabchemnitz/batch_task/batch_task.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+import os
+import sys
+import re
+import subprocess
+
+from BaseExtension import BaseExtension
+
+# For linting purposes
+from argparse import ArgumentParser
+
+"""If syntax error occurs here, change inkscape interpreter to python3"""
+
+"""I have yet to find a way for an extension to call another extension with parameters,
+without GUI. This extension can be run as part of a standalone extension (using BaseExtension)
+or imported for use by another extension. This workaround is done via the 'option' arg in
+the 'custom_effect' function"""
+
+
+def custom_effect(self: BaseExtension):
+ """Note: The init of the BaseExtension class will set its 'custom_effect' attr
+ to this function. Hence, the self arg is of type BaseExtension."""
+
+
+ selected = self.svg.selected
+ root = self.document.getroot()
+ actions_list = []
+
+ proc = subprocess.run("inkscape --verb-list | grep -oP '^.+?(?=:)'",
+ shell=True, capture_output=True)
+ valid_actions_and_verbs = proc.stdout.decode().splitlines()
+
+ proc = subprocess.run("inkscape --action-list | grep -oP '^.+?(?= *:)'",
+ shell=True, capture_output=True)
+ valid_actions_and_verbs += proc.stdout.decode().splitlines()
+
+
+ self.options.dry_run = self.options.dry_run == 'true'
+
+ def verify_action(action):
+ if ':' in action:
+ action = action.split(':')[0]
+ if action not in valid_actions_and_verbs:
+ raise ValueError(action)
+
+ def select_do_individually(objs, actions):
+ for obj in objs:
+ actions_list.append("EditDeselect")
+ actions_list.append("select-by-id:" + obj.get_id())
+ if isinstance(actions, str):
+ actions = [actions]
+ for action in actions:
+ verify_action(action)
+ actions_list.append(action)
+
+ def select_do_on_all(objs, actions):
+ for obj in objs:
+ actions_list.append("select-by-id:" + obj.get_id())
+
+ if isinstance(actions, str):
+ actions = [actions]
+ for action in actions:
+ verify_action(action)
+ actions_list.append(action)
+ effects = []
+ try:
+ if self.options.tab_effect is None:
+ if self.options.effects is not None:
+ self.options.tab_effect = 'Multi'
+ elif self.options.effect1 is not None:
+ self.options.tab_effect = 'Simple'
+
+ elif self.options.tab_effect in ('Preset', 'Simple'):
+ for attr in ('effect_' + self.options.tab_effect.lower() + str(i) for i in range(1, 4)):
+ e = getattr(self.options, attr)
+ if e != None:
+ effects += [e.strip()]
+ if effects == []:
+ raise ValueError
+ elif self.options.tab_effect == 'Multi':
+ if self.options.effects is None:
+ raise ValueError
+ for line in self.options.effects.split('\\n'):
+ effects += [e.strip() for e in line.split(';') if e != '']
+ except ValueError:
+ self.msg("No effects inputted! Quitting...")
+ sys.exit(0)
+
+
+ if self.options.target == 'root':
+ objects = self.find(root, '/svg:svg' + self.options.xpath)
+ elif self.options.target == 'selected':
+ objects = self.find(selected, self.options.xpath)
+ if objects == []:
+ self.msg(f"No objects satisfies XPath: '{self.options.xpath}'.")
+ self.msg("Root:", self.show(root))
+ self.msg("Selected:", self.show(selected))
+ sys.exit(0)
+
+
+ try:
+ if self.options.mode == 'all':
+ select_do_on_all(objects, effects)
+ elif self.options.mode == 'indiv':
+ select_do_individually(objects, effects)
+ except ValueError as e:
+ self.msg(f"'{e.args[0]}' is not a valid action or verb in inkscape.")
+ sys.exit(1)
+
+ if self.options.dry_run:
+ self.msg(f"{'DRY RUN':=^40}")
+ self.msg("Root:", self.show(self.find(root, '/*')))
+ self.msg("Selected:", self.show(selected))
+ self.msg()
+ self.msg("XPath:", self.show(objects))
+ self.msg()
+ self.msg("Actions:", actions_list)
+ sys.exit(0)
+ return actions_list
+
+
+def args_adder(arg_parser: ArgumentParser):
+
+ arg_parser.add_argument("--target", default='root', help="Object to apply xpath find on")
+ arg_parser.add_argument("--xpath", default='/*', help="For selection of objects")
+ arg_parser.add_argument("--tab_main", default=None)
+ arg_parser.add_argument("--Simple", default=None)
+ arg_parser.add_argument("--Multi", default=None)
+ arg_parser.add_argument("--mode", default="all", help="Mode to apply effects on objects")
+ arg_parser.add_argument("--tab_effect", default=None)
+ for arg in (*(x + str(y) for x in ('effect_preset', 'effect_simple') for y in range(1, 4)), 'effects'):
+ arg_parser.add_argument(f"--{arg}", default=None, help="Inkscape verb for path op")
+ arg_parser.add_argument("--dry_run", default='false')
+ arg_parser.add_argument("--null_notebook", default='false')
+
+ #import inkex
+ #for key, value in arg_parser.parse_args()._get_kwargs():
+ # if value is not None:
+ # inkex.utils.debug("{}={}".format(key, value))
+
+BatchTask = BaseExtension(custom_effect, args_adder=args_adder)
+
+if __name__ == '__main__':
+ BatchTask.run()
+
+
+
+
+# Namespace(Multi='SelectionDiff', Simple='SelectionDiff', dry_run='false', effect1='SelectionBreakApart', effect2=None, effect3=None, effects=None, ids=['image25'], input_file='/tmp/ink_ext_XXXXXX.svgIDCKU0', mode='all', null='null', output=<_io.BufferedWriter name=''>, selected_nodes=[], tab_effect='Simple', tab_main='Options', target='root', xpath='/*')
+
+
+# Namespace(Multi=None, Simple=None, dry_run='false', effect1='SelectionDelete', effect2=None, effect3=None, effects=None, mode='all', null='false', tab_effect=None, tab_main=None, target='root', xpath='/*')