Advertisement
JK_3

warzone_map_builder.py - inkscape extention

Jul 20th, 2023
42
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 59.72 KB | None | 0 0
  1. #!/usr/bin/env python
  2.  
  3. from argparse import ArgumentParser
  4. from enum import Enum
  5. import json
  6. import re
  7. from typing import Dict, List, Set, Tuple, Union
  8.  
  9. import inkex
  10. from inkex import AbortExtension, NSS
  11. from inkex.utils import debug
  12.  
  13.  
  14. Command = Dict[str, Union[str, int]]
  15.  
  16. SET_MAP_DETAILS_URL = 'https://www.warzone.com/API/SetMapDetails'
  17.  
  18.  
  19. def get_uri(key: str) -> str:
  20.     if ':' in key:
  21.         namespace, key = key.split(':')
  22.         key = f'{{{NSS[namespace]}}}{key}'
  23.     return key
  24.  
  25.  
  26. class Svg:
  27.     ID = 'id'
  28.     GROUP = 'svg:g'
  29.     PATH = 'svg:path'
  30.     TITLE = 'svg:title'
  31.     CLONE = 'svg:use'
  32.     ELLIPSE = 'svg:ellipse'
  33.     RECTANGLE = 'svg:rect'
  34.     TEXT = 'svg:text'
  35.     TSPAN = 'svg:tspan'
  36.  
  37.     STYLE = 'style'
  38.     FILL = 'fill'
  39.     STROKE = 'stroke'
  40.     STROKE_WIDTH = 'stroke-width'
  41.  
  42.  
  43. class Inkscape:
  44.     LABEL = 'inkscape:label'
  45.     GROUP_MODE = 'inkscape:groupmode'
  46.     CONNECTION_START = 'inkscape:connection-start'
  47.     CONNECTION_END = 'inkscape:connection-end'
  48.     CONNECTOR_CURVATURE = 'inkscape:connector-curvature'
  49.     CONNECTOR_TYPE = 'inkscape:connector-type'
  50.  
  51.     LAYER = 'layer'
  52.  
  53.  
  54. class XLink:
  55.     HREF = 'xlink:href'
  56.  
  57.  
  58. class MapLayers:
  59.     BONUS_LINKS = 'WZ:BonusLinks'
  60.     TERRITORIES = 'WZ:Territories'
  61.     BACKGROUND = 'Background'
  62.  
  63.     METADATA = 'WZ:Metadata'
  64.     BONUSES = 'WZ:Bonuses'
  65.     DISTRIBUTION_MODES = 'WZ:DistributionModes'
  66.     CONNECTIONS = 'WZ:Connections'
  67.     WRAP_NORMAL = 'Normal'
  68.     WRAP_HORIZONTAL = 'WrapHorizontally'
  69.     WRAP_VERTICAL = 'WrapVertically'
  70.  
  71.  
  72. class Color:
  73.     BLACK = '#000000'
  74.     WHITE = '#FFFFFF'
  75.  
  76.     CONNECTIONS = BLACK
  77.     TERRITORY_FILL = WHITE
  78.     DEFAULT_BONUS_COLOR = BLACK
  79.     BONUS_LINK_STROKE = '#FFFF00'
  80.  
  81.  
  82. class Warzone:
  83.     TERRITORY_IDENTIFIER = 'Territory_'
  84.     BONUS_LINK_IDENTIFIER = 'BonusLink_'
  85.  
  86.     UNNAMED_TERRITORY_NAME = 'Unnamed'
  87.  
  88.     BONUS_LINK_SIDE = 20
  89.  
  90.     RECT_WIDTH = 20
  91.     RECT_HEIGHT = 15
  92.     RECT_ROUNDING = 4
  93.  
  94.     ARMY_FONT_SIZE = 13     # px
  95.  
  96.  
  97. class Operation(Enum):
  98.     CREATE = 'create'
  99.     UPDATE = 'update'
  100.     DELETE = 'delete'
  101.     ADD_TERRITORIES = 'add'
  102.     REPLACE_TERRITORIES = 'replace'
  103.  
  104.  
  105. class WZMapBuilder(inkex.EffectExtension):
  106.  
  107.     TAB_OPTIONS = ['about', 'territories', 'connections', 'bonuses', 'distributions', 'upload']
  108.     TERRITORY_TAB_OPTIONS = ['create', 'name', 'center-point']
  109.     BONUS_TAB_OPTIONS = ['create-update', 'bonus-territories', 'delete']
  110.     BONUS_CREATE_UPDATE_TAB_OPTIONS = ['create', 'update']
  111.     BONUS_TERRITORY_TAB_OPTIONS = ['add', 'replace']
  112.     DISTRIBUTION_TAB_OPTIONS = ['crud', 'distribution-territories']
  113.     DISTRIBUTION_CRUD_TAB_OPTIONS = ['create', 'update', 'delete']
  114.  
  115.     def add_arguments(self, ap: ArgumentParser) -> None:
  116.         ap.add_argument("--tab", type=str, default='about')
  117.  
  118.         # arguments for territories
  119.         ap.add_argument("--territory_tab", type=str, default='create')
  120.         ap.add_argument("--territory_name", type=str, default=Warzone.UNNAMED_TERRITORY_NAME)
  121.         ap.add_argument("--territory_layer", type=inkex.Boolean, default=True)
  122.         ap.add_argument("--center_point_set_type", type=str, default='ellipse')
  123.  
  124.         # arguments for bonuses
  125.         ap.add_argument("--bonus_name", type=str, default='')
  126.         ap.add_argument("--bonus_tab", type=str, default='create-update')
  127.         ap.add_argument("--bonus_properties_tab", type=str, default='create')
  128.         ap.add_argument("--bonus_name_update", type=str, default='')
  129.         ap.add_argument("--bonus_value", type=str, default='')
  130.         ap.add_argument("--bonus_color", type=str, default='')
  131.         ap.add_argument("--bonus_link_visible", type=inkex.Boolean, default=True)
  132.         ap.add_argument("--bonus_territories_add_replace", type=str, default='add')
  133.  
  134.         # arguments for connections
  135.         ap.add_argument("--connection_type", type=str, default='normal')
  136.  
  137.         # arguments for distribution modes
  138.         ap.add_argument("--distribution_name", type=str, default='')
  139.         ap.add_argument("--distribution_tab", type=str, default='crud')
  140.         ap.add_argument("--distribution_crud_tab", type=str, default='create')
  141.         ap.add_argument("--distribution_name_update", type=str, default='')
  142.         ap.add_argument("--distribution_scenario_names", type=str, default='')
  143.         ap.add_argument("--distribution_territories_add_replace", type=str, default='add')
  144.         ap.add_argument("--distribution_territory_scenario_name", type=str, default='')
  145.  
  146.         # arguments for metadata upload
  147.         ap.add_argument("--upload_email", type=str, default='')
  148.         ap.add_argument("--upload_api_token", type=str, default='')
  149.         ap.add_argument("--upload_map_id", type=int)
  150.         ap.add_argument("--upload_territory_names", type=inkex.Boolean, default=False)
  151.         ap.add_argument("--upload_territory_center_points", type=inkex.Boolean, default=False)
  152.         ap.add_argument("--upload_connections", type=inkex.Boolean, default=False)
  153.         ap.add_argument("--upload_bonuses", type=inkex.Boolean, default=False)
  154.         ap.add_argument("--upload_territory_bonuses", type=inkex.Boolean, default=False)
  155.         ap.add_argument("--upload_distribution_modes", type=inkex.Boolean, default=False)
  156.         ap.add_argument("--upload_territory_distribution_modes", type=inkex.Boolean, default=False)
  157.  
  158.     def effect(self) -> None:
  159.         self._clean_up_tab_inputs()
  160.         self._setup_map_layers()
  161.         return {
  162.             'about': None,
  163.             'territories': {
  164.                 'create': self._create_territories,
  165.                 'name': self._set_territory_name,
  166.                 'center-point': self._set_territory_center_point,
  167.             }[self.options.territory_tab],
  168.             'connections': self._set_connection,
  169.             'bonuses': {
  170.                 'create-update': self._set_bonus,
  171.                 'bonus-territories': self._add_territories_to_bonus,
  172.                 'delete': self._delete_bonus,
  173.             }[self.options.bonus_tab],
  174.             'distributions': {
  175.                 'crud': self._set_distribution_mode,
  176.                 'distribution-territories': self._add_territories_to_distribution_mode
  177.             }[self.options.distribution_tab],
  178.             'upload': self._upload_metadata,
  179.         }[self.options.tab]()
  180.  
  181.     ###########
  182.     # EFFECTS #
  183.     ###########
  184.  
  185.     def _create_territories(self) -> None:
  186.         """
  187.        Converts all selected paths to a Warzone Territories by setting a Warzone Territory ID and
  188.        creating a territory group. Validates all existing territories as well as selected paths. If
  189.        territory-layer checkbox is checked, move territories to the Territories layer.
  190.        :return:
  191.        """
  192.  
  193.         territory_layer = (
  194.             self._get_metadata_layer(MapLayers.TERRITORIES)
  195.             if self.options.territory_layer else None
  196.         )
  197.         existing_territories = set(get_territories(self.svg))
  198.         max_id = self._get_max_territory_id(existing_territories)
  199.  
  200.         territories_to_process = existing_territories.union({
  201.             selected for selected in self.svg.selection.filter(inkex.PathElement)
  202.         })
  203.         for territory in territories_to_process:
  204.             territory_group = create_territory(territory, max_id, territory_layer)
  205.             max_id = max(max_id, get_territory_id(territory_group))
  206.         if not territories_to_process:
  207.             raise AbortExtension("There are no territories selected. Territories must be paths.")
  208.  
  209.     def _set_territory_name(self) -> None:
  210.         """
  211.        Sets the title of the selected territory to the input name.
  212.        :return:
  213.        """
  214.  
  215.         if len(self.svg.selection) != 1:
  216.             raise AbortExtension("Please select exactly one territory.")
  217.  
  218.         element = self.svg.selection.first()
  219.         if isinstance(element, inkex.PathElement):
  220.             raise AbortExtension(
  221.                 f"Please convert selected path into a territory before setting its name:"
  222.                 f" '{element.get_id()}'."
  223.             )
  224.         elif not is_territory_group(element):
  225.             raise AbortExtension("You must select the territory group you want to name.")
  226.  
  227.         title = element.get_or_create(f"./{Svg.TITLE}", inkex.Title)
  228.         title.text = self.options.territory_name
  229.  
  230.     def _set_territory_center_point(self) -> None:
  231.         """
  232.        Sets the center point of the selected territory to the center of the selected ellipse.
  233.        :return:
  234.        """
  235.         if self.options.center_point_set_type == 'ellipse' and len(self.svg.selection) != 2:
  236.             raise AbortExtension("Please select exactly one territory and one ellipse.")
  237.  
  238.         if elements := self.svg.selection.filter(inkex.PathElement):
  239.             raise AbortExtension(
  240.                 f"Please convert selected path into a territory before setting its center point:"
  241.                 f" '{elements.pop().get_id()}'."
  242.             )
  243.  
  244.         territories = [element for element in self.svg.selection if is_territory_group(element)]
  245.         territory = territories.pop()
  246.         bounding_box = self.find(f"./{Svg.PATH}", territory).bounding_box()
  247.  
  248.         if self.options.center_point_set_type == 'default':
  249.             center = self.find(f'./{Svg.PATH}', territory).bounding_box().center
  250.         else:
  251.             ellipse = self.svg.selection.filter(inkex.Ellipse, inkex.Circle).first()
  252.  
  253.             if territory is None or ellipse is None:
  254.                 raise AbortExtension("Please select exactly one territory and one ellipse.")
  255.  
  256.             if (
  257.                 not (bounding_box.left < ellipse.center.x < bounding_box.right)
  258.                 or not (bounding_box.top < ellipse.center.y < bounding_box.bottom)
  259.             ):
  260.                 raise AbortExtension("The center point must be within the territory.")
  261.  
  262.             center = ellipse.center
  263.  
  264.         territory.remove(self.find(f"./{Svg.GROUP}", territory))
  265.         center_point = create_center_point_group(center)
  266.         territory.add(center_point)
  267.  
  268.     def _set_bonus(self) -> None:
  269.         """
  270.        Creates or updates a bonus layer specified by a bonus name OR a selected bonus-link.
  271.        Creates a bonus-link if necessary.
  272.        :return:
  273.        """
  274.         operation = Operation(self.options.bonus_properties_tab)
  275.         self._clean_up_bonus_inputs(operation)
  276.  
  277.         bonus_name = self.options.bonus_name
  278.         bonus_value = self.options.bonus_value
  279.         bonus_color = self.options.bonus_color
  280.         bonus_link_path = self.options.bonus_link_path
  281.         bonus_layer = self.options.bonus_layer
  282.  
  283.         if Operation.CREATE == operation:
  284.             bonus_layer = inkex.Layer.new(f'{bonus_name}: {bonus_value}')
  285.             bonus_layer.add(inkex.Title.new(bonus_color))
  286.             self._get_metadata_layer(MapLayers.BONUSES).add(bonus_layer)
  287.         else:
  288.             bonus_value = bonus_value if bonus_value else get_bonus_value(bonus_layer)
  289.             bonus_layer.label = f'{bonus_name}: {bonus_value}'
  290.             self.find(Svg.TITLE, bonus_layer).text = bonus_color
  291.  
  292.         if self.options.bonus_link_visible:
  293.             bonus_link = self._set_bonus_link(bonus_link_path, bonus_name, bonus_value, bonus_color)
  294.             if find_clone(bonus_link, bonus_layer) is None:
  295.                 bonus_layer.add(inkex.Use.new(bonus_link, 0, 0))
  296.         else:
  297.             remove_bonus_link(bonus_link_path)
  298.  
  299.     def _add_territories_to_bonus(self) -> None:
  300.         """
  301.        Adds or replaces selected territories for bonus layer specified by a bonus name OR a
  302.        selected bonus-link. Raises an error if both are provided and don't have compatible names.
  303.        :return:
  304.        """
  305.         operation = Operation(self.options.bonus_territories_add_replace)
  306.         self._clean_up_bonus_inputs(operation)
  307.  
  308.         bonus_layer = self.options.bonus_layer
  309.         territory_groups = self.options.territories
  310.  
  311.         territory_clones = set()
  312.         for element in bonus_layer.getchildren():
  313.             if isinstance(element, inkex.Use):
  314.                 linked_element = element.href
  315.                 if is_territory_group(linked_element):
  316.                     if operation == Operation.REPLACE_TERRITORIES:
  317.                         bonus_layer.remove(element)
  318.                     else:
  319.                         territory_clones.add(linked_element.get_id())
  320.  
  321.         for territory_group in territory_groups:
  322.             if not territory_group.get_id() in territory_clones:
  323.                 bonus_layer.insert(0, inkex.Use.new(territory_group, 0, 0))
  324.        
  325.         self._set_territory_stroke()
  326.  
  327.     def _delete_bonus(self) -> None:
  328.         self._clean_up_bonus_inputs(Operation.DELETE)
  329.  
  330.         bonus_layer = self.options.bonus_layer
  331.         bonus_link_path = self.options.bonus_link_path
  332.  
  333.         bonus_layer.getparent().remove(bonus_layer)
  334.         if bonus_link_path is not None:
  335.             remove_bonus_link(bonus_link_path)
  336.         self._set_territory_stroke()
  337.  
  338.     def _set_connection(self) -> None:
  339.         territory_groups = [group for group in self.svg.selection if is_territory_group(group)]
  340.         territory_groups.extend([
  341.             element.getparent() for element in self.svg.selection
  342.             if is_territory_group(element.getparent())
  343.         ])
  344.         endpoint_ids = [
  345.             self.find(f"./{Svg.GROUP}/{Svg.RECTANGLE}", group).get_id()
  346.             for group in territory_groups
  347.         ]
  348.  
  349.         if (count := len(endpoint_ids)) != 2:
  350.             raise AbortExtension(
  351.                 f"Must have exactly 2 selected territories. {count} territories are selected."
  352.             )
  353.  
  354.         connector = inkex.PathElement.new(
  355.             "", style=inkex.Style(stroke=Color.CONNECTIONS, stroke_width=1.0),
  356.         )
  357.  
  358.         connector.set(Inkscape.CONNECTION_START, f'#{endpoint_ids[0]}')
  359.         connector.set(Inkscape.CONNECTION_END, f'#{endpoint_ids[1]}')
  360.         connector.set(Inkscape.CONNECTOR_CURVATURE, 0)
  361.         connector.set(Inkscape.CONNECTOR_TYPE, 'polyline')
  362.  
  363.         connection_type_layer = self._get_metadata_layer(
  364.             self.options.connection_type,
  365.             parent=self._get_metadata_layer(MapLayers.CONNECTIONS)
  366.         )
  367.         connection_type_layer.add(connector)
  368.  
  369.     def _set_distribution_mode(self) -> None:
  370.         """
  371.        Create, update, or delete distribution mode or scenarios.
  372.        :return:
  373.        """
  374.         operation = Operation(self.options.distribution_crud_tab)
  375.         self._clean_up_distribution_inputs(operation)
  376.  
  377.         distribution_name = self.options.distribution_name
  378.         scenario_names = self.options.distribution_scenario_names
  379.         distribution_layer = self.options.distribution_layer
  380.  
  381.         if operation == Operation.CREATE:
  382.             distribution_layer = inkex.Layer.new(distribution_name)
  383.             scenario_layers = [inkex.Layer.new(scenario_name) for scenario_name in scenario_names]
  384.             distribution_layer.add(*scenario_layers)
  385.             self._get_metadata_layer(MapLayers.DISTRIBUTION_MODES).add(distribution_layer)
  386.         elif operation == Operation.UPDATE:
  387.             distribution_layer.label = distribution_name
  388.             if scenario_names:
  389.                 for child in distribution_layer.getchildren():
  390.                     if not isinstance(child, inkex.Layer):
  391.                         distribution_layer.remove(child)
  392.  
  393.                 for scenario_name in scenario_names:
  394.                     scenario_layer = distribution_layer.get_or_create(
  395.                         f"./{Svg.GROUP}"
  396.                         f"[@{Inkscape.GROUP_MODE}='{Inkscape.LAYER}'"
  397.                         f" and @{Inkscape.LABEL}='{scenario_name}']",
  398.                         inkex.Layer
  399.                     )
  400.                     scenario_layer.label = scenario_name
  401.         elif operation == Operation.DELETE:
  402.             if not scenario_names:
  403.                 distribution_layer.getparent().remove(distribution_layer)
  404.             else:
  405.                 layers = {
  406.                     layer.label: layer for layer in distribution_layer.getchildren()
  407.                 }
  408.  
  409.                 for scenario_name in scenario_names:
  410.                     distribution_layer.remove(layers[scenario_name])
  411.         else:
  412.             raise AbortExtension(
  413.                 f"Invalid distribution mode operation: '{self.options.distribution_crud_tab}'"
  414.             )
  415.  
  416.     def _add_territories_to_distribution_mode(self) -> None:
  417.         """
  418.        Adds of replaces selected territories for distribution mode layer specified by distribution
  419.        mode name. Raises and error if a scenario is named for a non-scenario distribution mode or
  420.        if no scenario is named for a scenario distribution.
  421.        :return:
  422.        """
  423.         operation = Operation(self.options.distribution_territories_add_replace)
  424.         self._clean_up_distribution_inputs(operation)
  425.  
  426.         distribution_layer = self.options.distribution_layer
  427.         territory_groups = self.options.territories
  428.  
  429.         if operation == Operation.REPLACE_TERRITORIES:
  430.             distribution_layer.remove_all()
  431.  
  432.         existing_territory_ids = set()
  433.         for element in distribution_layer.getchildren():
  434.             linked_element = element.href
  435.             existing_territory_ids.add(linked_element.get_id())
  436.  
  437.         for territory_group in territory_groups:
  438.             if not territory_group.get_id() in existing_territory_ids:
  439.                 distribution_layer.add(inkex.Use.new(territory_group, 0, 0))
  440.  
  441.     def _upload_metadata(self) -> None:
  442.         commands = self._get_set_metadata_commands()
  443.         self._post_map_details(commands)
  444.  
  445.     ##################
  446.     # HELPER METHODS #
  447.     ##################
  448.  
  449.     def _post_map_details(self, commands: List[Command]) -> None:
  450.         import requests
  451.         response = requests.post(
  452.             url=SET_MAP_DETAILS_URL,
  453.             json={
  454.                 'email': self.options.upload_email,
  455.                 'APIToken': self.options.upload_api_token,
  456.                 'mapID': self.options.upload_map_id,
  457.                 'commands': commands,
  458.             }
  459.         )
  460.  
  461.         debug(json.loads(response.text))
  462.  
  463.     def _get_set_metadata_commands(self) -> List[Command]:
  464.         commands = []
  465.         if self.options.upload_territory_names:
  466.             commands += self._get_set_territory_name_commands()
  467.         if self.options.upload_territory_center_points:
  468.             commands += self._get_set_territory_center_point_commands()
  469.         if self.options.upload_connections:
  470.             commands += self._get_add_territory_connections_commands()
  471.         if self.options.upload_bonuses:
  472.             commands += self._get_add_bonus_commands()
  473.         if self.options.upload_territory_bonuses:
  474.             commands += self._get_add_territory_to_bonus_commands()
  475.         if self.options.upload_distribution_modes:
  476.             commands += self._get_add_distribution_mode_commands()
  477.         if self.options.upload_territory_distribution_modes:
  478.             commands += self._get_add_territory_to_distribution_commands()
  479.         return commands
  480.  
  481.     ###################
  482.     # COMMAND GETTERS #
  483.     ###################
  484.  
  485.     def _get_set_territory_name_commands(self) -> List[Command]:
  486.         """
  487.        Parses svg and creates setTerritoryName commands.
  488.  
  489.        A command is created for each territory group with a title.
  490.  
  491.        :return:
  492.        List of setTerritoryName commands
  493.        """
  494.         return [
  495.             {
  496.                 'command': 'setTerritoryName',
  497.                 'id': get_territory_id(territory_group),
  498.                 'name': get_territory_name(territory_group)
  499.             }
  500.             for territory_group in get_territory_groups(self.svg, is_recursive=True)
  501.             if get_territory_name(territory_group)
  502.         ]
  503.  
  504.     def _get_set_territory_center_point_commands(self) -> List[Command]:
  505.         """
  506.        Parses svg and sets territory center points.
  507.  
  508.        A command is created for each Territory
  509.        :return:
  510.        List of setTerritoryCenterPoint commands
  511.        """
  512.         territory_centers = {
  513.             get_territory_id(territory_group): get_territory_center(territory_group)
  514.             for territory_group in get_territory_groups(self.svg, is_recursive=True)
  515.         }
  516.  
  517.         return [
  518.             {
  519.                 'command': 'setTerritoryCenterPoint',
  520.                 'id': territory_id,
  521.                 'x': center_point.x,
  522.                 'y': center_point.y
  523.             } for territory_id, center_point in territory_centers.items()
  524.         ]
  525.  
  526.     def _get_add_territory_connections_commands(self) -> List[Command]:
  527.         """
  528.        Parses svg and creates addTerritoryConnection commands
  529.  
  530.        A command is created for each diagram connector that connects two groups containing a
  531.        territory.
  532.        :return:
  533.        List of addTerritoryConnection commands
  534.        """
  535.         return [
  536.             {
  537.                 'command': 'addTerritoryConnection',
  538.                 'id1': self.get_connection_endpoint_id(connection, Inkscape.CONNECTION_START),
  539.                 'id2': self.get_connection_endpoint_id(connection, Inkscape.CONNECTION_END),
  540.                 'wrap': connection.getparent().label
  541.             }
  542.             for connection in get_connections(self._get_metadata_layer(MapLayers.CONNECTIONS))
  543.         ]
  544.  
  545.     def _get_add_bonus_commands(self) -> List[Command]:
  546.         """
  547.        Parses svg and creates addBonus commands.
  548.  
  549.        A command is created for each sub-layer of the WZ:Bonuses layer. Each of these sub-layers is
  550.        assumed to have a name of the form `bonus_name: bonus_value`. If a path node exists with the
  551.        id f"{Warzone.BONUS_IDENTIFIER}bonus_name" the fill color of that path is used as the bonus
  552.        color, otherwise the bonus color is black.
  553.  
  554.        :return:
  555.        List of addBonus commands
  556.        """
  557.  
  558.         return [
  559.             {
  560.                 'command': 'addBonus',
  561.                 'name': get_bonus_name(bonus),
  562.                 'armies': get_bonus_value(bonus),
  563.                 'color': get_bonus_color(bonus)
  564.             }
  565.             for bonus in self._get_metadata_type_layers(MapLayers.BONUSES)
  566.         ]
  567.  
  568.     def _get_add_territory_to_bonus_commands(self) -> List[Command]:
  569.         """
  570.        Parses svg and creates addTerritoryToBonus commands.
  571.  
  572.        Each sub-layer of the WZ:Bonuses layer is assumed to contain clones of Territory nodes (i.e.
  573.        path nodes whose id starts with Warzone.TERRITORY_IDENTIFIER). A command is created for the
  574.        linked territory of each clone in each of these sub-layers adding that territory to the
  575.        bonus of the layer it is in.
  576.  
  577.        :return:
  578.        List of addTerritoryToBonus commands
  579.        """
  580.         bonus_layers = self._get_metadata_type_layers(MapLayers.BONUSES)
  581.  
  582.         commands = []
  583.         for bonus_layer in bonus_layers:
  584.             for element in bonus_layer.getchildren():
  585.                 if isinstance(element, inkex.Use):
  586.                     linked_element = element.href
  587.                     if is_territory_group(linked_element):
  588.                         commands.append({
  589.                             'command': 'addTerritoryToBonus',
  590.                             'id': get_territory_id(linked_element),
  591.                             'bonusName': get_bonus_name(bonus_layer)
  592.                         })
  593.         return commands
  594.  
  595.     def _get_add_distribution_mode_commands(self) -> List[Command]:
  596.         """
  597.        Parses svg and creates addDistributionMode commands.
  598.  
  599.        A command is created for each sub-layer of the WZ:DistributionModes layer. Each of these
  600.        sub-layers should be named with the name of the distribution mode.
  601.  
  602.        :return:
  603.        List of addDistributionMode commands
  604.        """
  605.         distribution_mode_layer = self._get_metadata_layer(MapLayers.DISTRIBUTION_MODES)
  606.         commands = []
  607.         for distribution_mode in distribution_mode_layer.getchildren():
  608.             command = {
  609.                 'command': 'addDistributionMode',
  610.                 'name': distribution_mode.label
  611.             }
  612.             if is_scenario_distribution(distribution_mode):
  613.                 command['scenarios'] = [
  614.                     scenario.label for scenario in distribution_mode.getchildren()
  615.                 ]
  616.             commands.append(command)
  617.         return commands
  618.  
  619.     def _get_add_territory_to_distribution_commands(self) -> List[Command]:
  620.         """
  621.        Parses svg and creates addTerritoryToDistribution commands.
  622.  
  623.        Each sub-layer of the WZ:DistributionModes layer is assumed to contain clones of Territory
  624.        nodes (i.e. path nodes whose id starts with Warzone.TERRITORY_IDENTIFIER). A command is
  625.        created for the linked territory of each clone in each of these sub-layers adding that
  626.        territory to the distribution mode of the layer it is in.
  627.  
  628.        :return:
  629.        List of addTerritoryToDistribution commands
  630.        """
  631.         distribution_mode_layer = self._get_metadata_layer(MapLayers.DISTRIBUTION_MODES)
  632.         commands = []
  633.         for distribution_mode in distribution_mode_layer.getchildren():
  634.             if is_scenario_distribution(distribution_mode):
  635.                 for scenario in distribution_mode.getchildren():
  636.                     for territory in scenario.getchildren():
  637.                         commands.append({
  638.                             'command': 'addTerritoryToDistribution',
  639.                             'id': get_territory_id(territory.href),
  640.                             'distributionName': distribution_mode.label,
  641.                             'scenario': scenario.label
  642.                         })
  643.             else:
  644.                 for territory in distribution_mode.getchildren():
  645.                     commands.append({
  646.                         'command': 'addTerritoryToDistribution',
  647.                         'id': get_territory_id(territory.href),
  648.                         'distributionName': distribution_mode.label,
  649.                     })
  650.         return commands
  651.  
  652.     ####################
  653.     # VALIDATION UTILS #
  654.     ####################
  655.  
  656.     def _clean_up_tab_inputs(self) -> None:
  657.  
  658.         self.options.tab = self.options.tab if self.options.tab in self.TAB_OPTIONS else 'about'
  659.         self.options.territory_tab = (
  660.             self.options.territory_tab if self.options.territory_tab in self.TERRITORY_TAB_OPTIONS
  661.             else 'create'
  662.         )
  663.         self.options.bonus_tab = (
  664.             self.options.bonus_tab if self.options.bonus_tab in self.BONUS_TAB_OPTIONS
  665.             else 'create-update'
  666.         )
  667.         self.options.bonus_properties_tab = (
  668.             self.options.bonus_properties_tab
  669.             if self.options.bonus_properties_tab in self.BONUS_CREATE_UPDATE_TAB_OPTIONS
  670.             else 'create'
  671.         )
  672.         self.options.distribution_tab = (
  673.             self.options.distribution_tab
  674.             if self.options.distribution_tab in self.DISTRIBUTION_TAB_OPTIONS
  675.             else 'crud'
  676.         )
  677.         self.options.distribution_crud_tab = (
  678.             self.options.distribution_crud_tab
  679.             if self.options.distribution_crud_tab in self.DISTRIBUTION_CRUD_TAB_OPTIONS
  680.             else 'create'
  681.         )
  682.  
  683.     def _clean_up_bonus_inputs(self, operation: Operation) -> None:
  684.         """
  685.        Gets true inputs for bonus name, bonus link, and bonus layer. Raises an informative
  686.        exception if the bonus input doesn't validate.
  687.        :return:
  688.        """
  689.         is_create_update = operation in [Operation.CREATE, Operation.UPDATE]
  690.         is_update_territories = operation in [
  691.             Operation.ADD_TERRITORIES, Operation.REPLACE_TERRITORIES
  692.         ]
  693.  
  694.         bonus_name = self.options.bonus_name
  695.         bonus_link = self._get_bonus_link_path_from_selection()
  696.  
  697.         if not bonus_name:
  698.             if Operation.CREATE == operation:
  699.                 raise AbortExtension("Must provide a bonus name when creating a new bonus.")
  700.             if bonus_link is None:
  701.                 raise AbortExtension(
  702.                     "Either a bonus name must be provided or a bonus link must be selected."
  703.                 )
  704.             else:
  705.                 bonus_name = bonus_link.get_id().split(Warzone.BONUS_LINK_IDENTIFIER)[-1]
  706.  
  707.         if bonus_link is not None and get_bonus_link_id(bonus_name) != bonus_link.get_id():
  708.             raise AbortExtension(
  709.                 f"Bonus name '{bonus_name}' is not consistent with the selected bonus link"
  710.                 f" '{bonus_link.get_id()}'."
  711.             )
  712.  
  713.         bonus_name_update = (
  714.             self.options.bonus_name_update if Operation.UPDATE == operation else bonus_name
  715.         )
  716.  
  717.         bonus_link = (
  718.             bonus_link if bonus_link is not None
  719.             else self._get_bonus_link_path_from_name(bonus_name)
  720.         )
  721.  
  722.         if is_update_territories:
  723.             self._validate_add_territory_inputs()
  724.  
  725.         target_bonus_layers = self._get_bonus_layers_with_name(bonus_name_update)
  726.  
  727.         if operation == Operation.CREATE:
  728.             bonus_layer = None
  729.         else:
  730.             existing_bonus_layers = (
  731.                 target_bonus_layers if bonus_name == bonus_name_update
  732.                 else self._get_bonus_layers_with_name(bonus_name)
  733.             )
  734.             if not existing_bonus_layers:
  735.                 operation = 'delete' if operation == Operation.DELETE else 'modify'
  736.                 raise AbortExtension(f"Cannot {operation} non-existent bonus '{bonus_name}'.")
  737.             elif len(existing_bonus_layers) > 1:
  738.                 raise AbortExtension(
  739.                     f"Too many bonus layers match the bonus name {bonus_name}:"
  740.                     f" {[layer.label for layer in existing_bonus_layers]}"
  741.                 )
  742.             bonus_layer = existing_bonus_layers[0]
  743.  
  744.         if is_create_update and target_bonus_layers:
  745.             raise AbortExtension(
  746.                 f"Cannot create bonus '{bonus_name_update}' as bonus layers for this name already"
  747.                 f" exist: {[layer.label for layer in target_bonus_layers]}."
  748.             )
  749.  
  750.         if is_create_update:
  751.             if not self.options.bonus_color:
  752.                 if bonus_link is not None:
  753.                     self.options.bonus_color = bonus_link.effective_style().get_color()
  754.                 elif bonus_layer and (layer_color := bonus_layer.find(Svg.TITLE, NSS)) is not None:
  755.                     self.options.bonus_color = layer_color.text
  756.                 else:
  757.                     self.options.bonus_color = Color.DEFAULT_BONUS_COLOR
  758.  
  759.             if self.options.bonus_value != '':
  760.                 try:
  761.                     int(self.options.bonus_value)
  762.                 except ValueError:
  763.                     raise AbortExtension(
  764.                         f"If a bonus value is provided it must be an integer."
  765.                         f" Provided '{self.options.bonus_value}'."
  766.                     )
  767.             elif operation == Operation.CREATE:
  768.                 raise AbortExtension(f"Must provide a bonus value when creating a new bonus.")
  769.  
  770.             try:
  771.                 inkex.Color(self.options.bonus_color)
  772.             except inkex.colors.ColorError:
  773.                 raise AbortExtension(
  774.                     f"If a bonus color is provided if must be an RGB string in the form '#00EE33'."
  775.                     f" Provided {self.options.bonus_color}"
  776.                 )
  777.  
  778.         self.options.bonus_name = bonus_name_update if bonus_name_update else bonus_name
  779.         self.options.bonus_link_path = bonus_link
  780.         self.options.bonus_layer = bonus_layer
  781.  
  782.     def _clean_up_distribution_inputs(self, operation: Operation) -> None:
  783.         is_create_update = operation in [Operation.CREATE, Operation.UPDATE]
  784.         is_update_territories = operation in [
  785.             Operation.ADD_TERRITORIES, Operation.REPLACE_TERRITORIES
  786.         ]
  787.  
  788.         distribution_name = self.options.distribution_name
  789.  
  790.         if not distribution_name:
  791.             raise AbortExtension("Must provide a distribution mode name.")
  792.  
  793.         distribution_name_update = (
  794.             self.options.distribution_name_update if Operation.UPDATE == operation
  795.             else distribution_name
  796.         )
  797.  
  798.         target_distribution_layers = (
  799.             self._get_distribution_layers_with_name(distribution_name_update)
  800.         )
  801.  
  802.         if operation == Operation.CREATE:
  803.             distribution_layer = None
  804.         else:
  805.             existing_distribution_layers = (
  806.                 target_distribution_layers if distribution_name == distribution_name_update
  807.                 else self._get_distribution_layers_with_name(distribution_name)
  808.             )
  809.             if not existing_distribution_layers:
  810.                 operation = 'delete' if operation == Operation.DELETE else 'modify'
  811.                 raise AbortExtension(
  812.                     f"Cannot {operation} non-existent bonus '{distribution_name}'."
  813.                 )
  814.             elif len(existing_distribution_layers) > 1:
  815.                 raise AbortExtension(
  816.                     f"Too many distribution mde layers match the distribution mode name"
  817.                     f" {distribution_name}:"
  818.                     f" {[layer.label for layer in existing_distribution_layers]}"
  819.                 )
  820.             distribution_layer = existing_distribution_layers[0]
  821.  
  822.         if is_create_update and target_distribution_layers:
  823.             raise AbortExtension(
  824.                 f"Cannot create distribution mode '{distribution_name_update}' as distribution mode"
  825.                 f" layers for this name already exist:"
  826.                 f" {[layer.label for layer in target_distribution_layers]}."
  827.             )
  828.  
  829.         if is_update_territories:
  830.             scenario_name = self.options.distribution_territory_scenario_name
  831.             if is_scenario_distribution(distribution_layer):
  832.                 if not scenario_name:
  833.                     raise AbortExtension(
  834.                         "When adding a territory to a scenario distribution, you must provide the"
  835.                         " scenario name."
  836.                     )
  837.                 distribution_layer = self.find(
  838.                     f"./{Svg.GROUP}[@{Inkscape.LABEL}='{scenario_name}']", distribution_layer
  839.                 )
  840.                 if distribution_layer is None:
  841.                     raise AbortExtension(
  842.                         f"Cannot add territories to scenario '{scenario_name}'. It is not a"
  843.                         f" scenario of distribution mode '{distribution_name}'"
  844.                     )
  845.             elif scenario_name:
  846.                 raise AbortExtension(
  847.                     f"'{distribution_name}' is not a scenario distribution. Please remove the"
  848.                     f" scenario name."
  849.                 )
  850.  
  851.             self._validate_add_territory_inputs()
  852.  
  853.         # noinspection PyUnresolvedReferences
  854.         scenario_names = {
  855.             name for name in self.options.distribution_scenario_names.split('\\n') if name
  856.         }
  857.  
  858.         if operation == Operation.CREATE and len(scenario_names) == 1:
  859.             raise AbortExtension(
  860.                 "If creating a scenario distribution, you must provide at least TWO scenarios."
  861.             )
  862.         if operation == Operation.DELETE:
  863.             scenario_layer_names = {layer.label for layer in distribution_layer.getchildren()}
  864.             if non_existent_layers := scenario_names - scenario_layer_names:
  865.                 raise AbortExtension(f"Can't delete non-existent scenarios {non_existent_layers}.")
  866.             if len(remaining_layers := scenario_layer_names - scenario_names) < 2:
  867.                 additional_message = (
  868.                     f"The only remaining scenario is '{remaining_layers.pop()}'."
  869.                     if remaining_layers else
  870.                     "There are no remaining layers. \n\nIf you want to delete the whole"
  871.                     " distribution, you shouldn't specify any scenarios."
  872.                 )
  873.                 raise AbortExtension(
  874.                     "There must be at least TWO scenarios left when deleting scenarios from a"
  875.                     f" scenario distribution. {additional_message}"
  876.                 )
  877.  
  878.         self.options.distribution_name = (
  879.             distribution_name_update if distribution_name_update else distribution_name
  880.         )
  881.         self.options.distribution_layer = distribution_layer
  882.         self.options.distribution_scenario_names = scenario_names
  883.  
  884.     def _validate_add_territory_inputs(self) -> None:
  885.         if selected_paths := self.svg.selection.filter(inkex.PathElement):
  886.             raise AbortExtension(
  887.                 f"Please convert all selected paths into territories before adding them to a"
  888.                 f" bonus: {[path.get_id() for path in selected_paths]}."
  889.             )
  890.  
  891.         territories = [group for group in self.svg.selection if is_territory_group(group)]
  892.         if not territories:
  893.             raise AbortExtension("No territories have been selected.")
  894.  
  895.         self.options.territories = territories
  896.  
  897.     #################
  898.     # PARSING UTILS #
  899.     #################
  900.  
  901.     def find(self, xpath: str, root: inkex.BaseElement = None):
  902.         """
  903.        Finds a single element corresponding to the xpath from the root element. If no root provided
  904.        the svg document root is used.
  905.        :param xpath:
  906.        :param root:
  907.        :return:
  908.        """
  909.         return find(xpath, root if root is not None else self.svg)
  910.  
  911.     def _get_metadata_layer(
  912.             self,
  913.             metadata_type: str,
  914.             create: bool = False,
  915.             parent: inkex.Layer = None
  916.     ) -> inkex.Layer:
  917.         """
  918.        Returns the specified metadata layer node. If create, will create node if it doesn't exist.
  919.        If parent layer not selected, use svg root layer.
  920.        :param metadata_type:
  921.        :param create:
  922.        :return:
  923.        """
  924.         parent = parent if parent is not None else self.svg
  925.         layer = self.find(f"./{Svg.GROUP}[@{Inkscape.LABEL}='{metadata_type}']", parent)
  926.         if layer is None and create:
  927.             layer = inkex.Layer.new(metadata_type)
  928.             parent.add(layer)
  929.         return layer
  930.  
  931.     def _get_max_territory_id(self, territories: Set[inkex.PathElement] = None) -> int:
  932.         """
  933.        Gets the maximum territory id as an int in the territories. If territories is None, searches
  934.        the whole svg.
  935.        :return:
  936.        maximum int id
  937.        """
  938.         territories = get_territories(self.svg) if territories is None else territories
  939.         max_id = max([0] + [get_territory_id(territory) for territory in territories])
  940.         return max_id
  941.  
  942.     def _get_metadata_type_layers(
  943.             self, metadata_type: str, is_recursive: bool = True
  944.     ) -> List[inkex.Layer]:
  945.         """
  946.        Returns all layers of the input type. If not recursive only retrieves top-level layers
  947.        :param metadata_type:
  948.        :param is_recursive:
  949.        :return:
  950.        metadata layers
  951.        """
  952.         slash = '//' if is_recursive else '/'
  953.         bonus_layers = self.svg.xpath(
  954.             f"./{Svg.GROUP}[@{Inkscape.LABEL}='{metadata_type}']"
  955.             f"{slash}{Svg.GROUP}[@{Inkscape.GROUP_MODE}='{Inkscape.LAYER}']",
  956.             namespaces=NSS
  957.         )
  958.         return bonus_layers
  959.  
  960.     def get_connection_endpoint_id(self, connection: inkex.PathElement, endpoint_type: str) -> int:
  961.         """
  962.        Get the numeric id of the start territory of the connection
  963.        :param connection:
  964.        :param endpoint_type
  965.        :return:
  966.        """
  967.         rectangle_id = connection.get(get_uri(endpoint_type)).split('#')[-1]
  968.         territory_group = self.svg.getElementById(rectangle_id).getparent().getparent()
  969.         return get_territory_id(territory_group)
  970.  
  971.     def _get_bonus_link_path_from_name(self, bonus_name: str) -> inkex.PathElement:
  972.         """
  973.        Gets a bonus link path from name. Returns None if there aren't any
  974.        :param bonus_name:
  975.        :return:
  976.        """
  977.         bonus_link = self.svg.getElementById(get_bonus_link_id(bonus_name), elm=Svg.PATH)
  978.         return bonus_link
  979.  
  980.     def _get_bonus_link_path_from_selection(self) -> inkex.PathElement:
  981.         """
  982.        Gets a bonus link path from selection. Returns None if there aren't any and raises an
  983.        exception if there is more than one.
  984.        :return:
  985.        """
  986.         selected_bonus_links = [
  987.             self.find(f"./{Svg.PATH}", group)
  988.             for group in self.svg.selection.filter(inkex.Group) if is_bonus_link_group(group)
  989.         ]
  990.         selected_bonus_links.extend([
  991.             path for path in self.svg.selection.filter(inkex.PathElement)
  992.             if Warzone.BONUS_LINK_IDENTIFIER in path.get_id()
  993.         ])
  994.         if len(selected_bonus_links) == 1:
  995.             bonus_link = selected_bonus_links[0]
  996.         elif not selected_bonus_links:
  997.             bonus_link = None
  998.         else:
  999.             raise AbortExtension("Multiple bonus links have been selected.")
  1000.         return bonus_link
  1001.  
  1002.     def _get_bonus_layers_with_name(self, bonus_name: str) -> List[inkex.Layer]:
  1003.         bonus_link_id = get_bonus_link_id(bonus_name)
  1004.         return [
  1005.             layer for layer in self._get_metadata_type_layers(MapLayers.BONUSES)
  1006.             if bonus_link_id == get_bonus_link_id(get_bonus_name(layer))
  1007.         ]
  1008.  
  1009.     def _get_distribution_layers_with_name(self, distribution_name_update):
  1010.         return (
  1011.             self._get_metadata_layer(MapLayers.DISTRIBUTION_MODES)
  1012.             .xpath(f"./{Svg.GROUP}[@{Inkscape.LABEL}='{distribution_name_update}']")
  1013.         )
  1014.  
  1015.     ####################
  1016.     # METADATA SETTERS #
  1017.     ####################
  1018.  
  1019.     def _setup_map_layers(self):
  1020.         self._get_metadata_layer(MapLayers.DISTRIBUTION_MODES, create=True)
  1021.         self._get_metadata_layer(MapLayers.BONUSES, create=True)
  1022.         self._get_metadata_layer(MapLayers.TERRITORIES, create=True)
  1023.  
  1024.         connections_layer = self._get_metadata_layer(MapLayers.CONNECTIONS, create=True)
  1025.         self._get_metadata_layer(MapLayers.WRAP_VERTICAL, create=True, parent=connections_layer)
  1026.         self._get_metadata_layer(MapLayers.WRAP_HORIZONTAL, create=True, parent=connections_layer)
  1027.         self._get_metadata_layer(MapLayers.WRAP_NORMAL, create=True, parent=connections_layer)
  1028.  
  1029.         self._get_metadata_layer(MapLayers.BONUS_LINKS, create=True)
  1030.  
  1031.     def _set_bonus_link(
  1032.             self, bonus_link_path: inkex.PathElement,
  1033.             bonus_name: str,
  1034.             bonus_value: str,
  1035.             bonus_color: str
  1036.     ) -> inkex.Group:
  1037.         """
  1038.        Creates a bonus link if it doesn't exist and adds it to the bonus link layer. Updates any
  1039.        properties of bonus link it if already exists.
  1040.  
  1041.        :return:
  1042.        bonus link
  1043.        """
  1044.         bonus_link_id = get_bonus_link_id(bonus_name)
  1045.  
  1046.         # Create bonus link path if it does not exist
  1047.         if bonus_link_path is None:
  1048.             # todo figure out a good way to position a new bonus link
  1049.             location = (
  1050.                 self.svg.selection.bounding_box().center if self.svg.selection.bounding_box()
  1051.                 else self.svg.get_page_bbox().center
  1052.             )
  1053.             bonus_link_path = inkex.Rectangle.new(
  1054.                 left=location.x - Warzone.BONUS_LINK_SIDE / 2,
  1055.                 top=location.y - Warzone.BONUS_LINK_SIDE / 2,
  1056.                 width=Warzone.BONUS_LINK_SIDE,
  1057.                 height=Warzone.BONUS_LINK_SIDE,
  1058.                 ry=Warzone.RECT_ROUNDING,
  1059.                 rx=Warzone.RECT_ROUNDING,
  1060.             ).to_path_element()
  1061.  
  1062.         bonus_link_path.set_id(bonus_link_id)
  1063.  
  1064.         # Set bonus link fill and stroke
  1065.         bonus_link_style = bonus_link_path.effective_style()
  1066.         bonus_link_style.set_color(Color.BONUS_LINK_STROKE, name=Svg.STROKE)
  1067.         bonus_link_style.set_color(bonus_color)
  1068.  
  1069.         # Get bonus link group
  1070.         parent = bonus_link_path.getparent()
  1071.         if is_bonus_link_group(parent):
  1072.             bonus_link = parent
  1073.         else:
  1074.             # Create bonus link group if it doesn't exist
  1075.             location = bonus_link_path.bounding_box().center
  1076.             bonus_link = inkex.Group.new(
  1077.                 bonus_link_id,
  1078.                 bonus_link_path,
  1079.                 inkex.TextElement.new(
  1080.                     create_tspan(bonus_value, font_color=Color.WHITE),
  1081.                     x=location.x,
  1082.                     y=location.y + Warzone.ARMY_FONT_SIZE * 3 / 8,
  1083.                 ),
  1084.             )
  1085.  
  1086.         bonus_link.label = bonus_link_id
  1087.  
  1088.         # Set bonus link font color
  1089.         tspan = find(f"./{Svg.TEXT}/{Svg.TSPAN}", bonus_link)
  1090.         tspan.effective_style().set_color(
  1091.             Color.WHITE if bonus_link_style.get_color().to_rgb().to_hsl().lightness < 128
  1092.             else Color.BLACK
  1093.         )
  1094.         # Set bonus link value
  1095.         tspan.text = bonus_value
  1096.  
  1097.         # Add bonus link to bonus link layer
  1098.         bonus_link_layer = self._get_metadata_layer(MapLayers.BONUS_LINKS)
  1099.         if bonus_link.getparent() != bonus_link_layer:
  1100.             bonus_link_layer.add(bonus_link)
  1101.         return bonus_link
  1102.  
  1103.     def _get_or_create_bonus_layer(self, bonus_link: inkex.PathElement) -> inkex.Layer:
  1104.         """
  1105.        Finds the bonus layer corresponding to the old bonus name. Updates the bonus name and value
  1106.        if needed. Creates a new bonus layer if no bonus exists for that name. If a bonus link is
  1107.        provided, will update that bonus.
  1108.        :param bonus_link
  1109.        :return:
  1110.        bonus layer
  1111.        """
  1112.         old_bonus_name = self.options.bonus_name
  1113.         if bonus_link is not None:
  1114.             bonus_layers = self._get_metadata_type_layers(MapLayers.BONUSES)
  1115.  
  1116.             def find_bonus_layers_with_name(bonus_name: str) -> List[inkex.Layer]:
  1117.                 return [
  1118.                     layer for layer in bonus_layers
  1119.                     if bonus_name == get_bonus_link_id(
  1120.                         get_bonus_name(layer)
  1121.                     ).split(Warzone.BONUS_LINK_IDENTIFIER)[-1]
  1122.                 ]
  1123.  
  1124.             # raise exception if layer with new bonus name already exists
  1125.             if find_bonus_layers_with_name(self.options.bonus_name):
  1126.                 raise AbortExtension(
  1127.                     f"Cannot rename bonus with bonus link to {self.options.bonus_name}. A bonus"
  1128.                     f" with that name already exists."
  1129.                 )
  1130.  
  1131.             # set old bonus name to matching bonus layer name if exists
  1132.             bonus_link_id = bonus_link.get_id().split(Warzone.BONUS_LINK_IDENTIFIER)[-1]
  1133.             matching_bonus_layers = find_bonus_layers_with_name(bonus_link_id)
  1134.             if len(matching_bonus_layers) == 1:
  1135.                 old_bonus_name = get_bonus_name(matching_bonus_layers[0])
  1136.             elif len(matching_bonus_layers) > 1:
  1137.                 raise AbortExtension(
  1138.                     f"Multiple bonus layers exist matching bonus link {bonus_link_id}: "
  1139.                     f"{[layer.label for layer in matching_bonus_layers]}"
  1140.                 )
  1141.  
  1142.         # get bonuses layer
  1143.         bonuses_layer = self._get_metadata_layer(MapLayers.BONUSES)
  1144.  
  1145.         # get bonus layer for old bonus name and create if not exists
  1146.         bonus_layer = self.find(
  1147.             f"./{Svg.GROUP}[contains(@{Inkscape.LABEL}, '{old_bonus_name}: ')]", bonuses_layer
  1148.         )
  1149.         if bonus_layer is None:
  1150.             try:
  1151.                 bonus_value = int(self.options.bonus_value)
  1152.             except ValueError:
  1153.                 raise AbortExtension(
  1154.                     f"If creating a new bonus, a bonus value must be provided as an integer."
  1155.                     f" Provided '{self.options.bonus_value}'"
  1156.                 )
  1157.             if not self.options.bonus_name:
  1158.                 raise AbortExtension("If no bonus link is selected, a bonus name must be provided.")
  1159.             bonus_layer = inkex.Layer.new(f'{self.options.bonus_name}: {bonus_value}')
  1160.             bonuses_layer.add(bonus_layer)
  1161.         else:
  1162.             try:
  1163.                 bonus_value = int(
  1164.                     self.options.bonus_value if self.options.bonus_value != ''
  1165.                     else get_bonus_name(bonus_layer)[1]
  1166.                 )
  1167.                 self.options.bonus_value = str(bonus_value)
  1168.             except ValueError:
  1169.                 raise AbortExtension(
  1170.                     f"If a bonus value is provided it must be an integer."
  1171.                     f" Provided {self.options.bonus_value}"
  1172.                 )
  1173.  
  1174.             # update bonus name if name or value has changed
  1175.             new_bonus_name = self.options.bonus_name if self.options.bonus_name else old_bonus_name
  1176.             bonus_layer.label = f'{new_bonus_name}: {bonus_value}'
  1177.  
  1178.         return bonus_layer
  1179.  
  1180.     def _set_territory_stroke(self) -> None:
  1181.         processed_territory_ids = {None}
  1182.         for bonus_layer in self._get_metadata_type_layers(MapLayers.BONUSES):
  1183.             bonus_color = bonus_layer.find(Svg.TITLE, NSS).text
  1184.  
  1185.             for clone in bonus_layer.getchildren():
  1186.                 if clone.get(XLink.HREF) in processed_territory_ids:
  1187.                     continue
  1188.  
  1189.                 linked_element = clone.href
  1190.                 if is_territory_group(linked_element):
  1191.                     territory = self.find(f"./{Svg.PATH}", linked_element)
  1192.                     territory.effective_style().set_color(bonus_color, name=Svg.STROKE)
  1193.  
  1194.                 processed_territory_ids.add(clone.get(XLink.HREF))
  1195.  
  1196.  
  1197. def find(xpath: str, root: inkex.BaseElement):
  1198.     """
  1199.    Finds a single element corresponding to the xpath from the root element. If no root provided
  1200.    the svg document root is used.
  1201.    :param xpath:
  1202.    :param root:
  1203.    :return:
  1204.    """
  1205.     if 'contains(' in xpath:
  1206.         if target := root.xpath(xpath, NSS):
  1207.             target = target[0]
  1208.         else:
  1209.             target = None
  1210.     else:
  1211.         target = root.find(xpath, NSS)
  1212.     return target
  1213.  
  1214.  
  1215. def find_clone(element: inkex.BaseElement, root: inkex.Layer) -> inkex.Use:
  1216.     """
  1217.    Find a clone of the element which is a direct child of the root node.
  1218.    :param element:
  1219.    :param root:
  1220.    :return:
  1221.    """
  1222.     return find(f"./{Svg.CLONE}[@{XLink.HREF}='#{element.get_id()}']", root)
  1223.  
  1224.  
  1225. def is_territory_group(group: inkex.ShapeElement) -> bool:
  1226.     """
  1227.    Checks if element is a territory group. It is a territory group if it is a non-layer Group
  1228.    and has two children, one of which is a territory, the other of which is a center point
  1229.    group.
  1230.    :param group:
  1231.    :return:
  1232.    """
  1233.     valid = isinstance(group, inkex.Group)
  1234.     valid = valid and not isinstance(group, inkex.Layer)
  1235.     valid = valid and len(group.getchildren()) in [2, 3]
  1236.     valid = valid and len(get_territories(group, is_recursive=False)) == 1
  1237.     valid = valid and len(group.xpath(f"./{Svg.GROUP}[{Svg.RECTANGLE} and {Svg.TEXT}]")) == 1
  1238.     valid = valid and (len(group.getchildren()) == 2) or (len(group.xpath(f"./{Svg.TITLE}")) == 1)
  1239.     return valid
  1240.  
  1241.  
  1242. def is_territory(element: inkex.BaseElement) -> bool:
  1243.     """
  1244.    Checks if the given element is a territory
  1245.    :param element:
  1246.    :return:
  1247.    """
  1248.     return Warzone.TERRITORY_IDENTIFIER in element.get_id()
  1249.  
  1250.  
  1251. def get_territories(
  1252.         root: inkex.BaseElement, is_recursive: bool = True
  1253. ) -> List[inkex.PathElement]:
  1254.     """
  1255.    Gets all territory elements that are children of the root node. If not is_recursive, gets
  1256.    only direct children.
  1257.    :param root:
  1258.    :param is_recursive:
  1259.    :return:
  1260.    """
  1261.     slash = '//' if is_recursive else '/'
  1262.     return root.xpath(
  1263.         f".{slash}{Svg.PATH}[contains(@{Svg.ID}, '{Warzone.TERRITORY_IDENTIFIER}')]",
  1264.         namespaces=NSS
  1265.     )
  1266.  
  1267.  
  1268. def get_territory_groups(
  1269.         root: inkex.BaseElement, is_recursive: bool = True
  1270. ) -> List[inkex.Group]:
  1271.     return [
  1272.         territory.getparent() for territory in get_territories(root, is_recursive)
  1273.         if is_territory_group(territory.getparent())
  1274.     ]
  1275.  
  1276.  
  1277. def get_territory_id(territory: Union[str,  inkex.PathElement, inkex.Use, inkex.Group]) -> int:
  1278.     """
  1279.    Returns the id of the territory. If the argument is a string it must be of the form
  1280.    'Territory_X'. If the argument is a territory, it gets the int part of the element's id. If
  1281.    it is a clone, it gets the int part of the id of the linked element.
  1282.    :param territory:
  1283.    :return:
  1284.    territory id as required by the Warzone API
  1285.    """
  1286.     if isinstance(territory, str):
  1287.         territory_id = territory.split(Warzone.TERRITORY_IDENTIFIER)[-1]
  1288.     elif isinstance(territory, inkex.PathElement):
  1289.         territory_id = get_territory_id(territory.get(Svg.ID))
  1290.     elif isinstance(territory, inkex.Group) and is_territory_group(territory):
  1291.         territory_id = get_territory_id(get_territories(territory, is_recursive=False)[0])
  1292.     elif isinstance(territory, inkex.Use):
  1293.         territory_id = get_territory_id(territory.get(get_uri(XLink.HREF)))
  1294.     else:
  1295.         raise ValueError(f'Element {territory} is not a valid territory element. It must be a'
  1296.                          f' territory path, a territory group or a territory clone.')
  1297.     return int(territory_id)
  1298.  
  1299.  
  1300. def get_territory_name(territory: inkex.Group) -> str:
  1301.     """
  1302.    Get the name of the territory from its child title element. If no title, returns
  1303.    Warzone.UNNAMED_TERRITORY_NAME
  1304.    :param territory:
  1305.    :return:
  1306.    territory name
  1307.    """
  1308.     title = territory.find(Svg.TITLE, NSS)
  1309.     if title is not None:
  1310.         territory_name = title.text
  1311.     else:
  1312.         territory_name = None
  1313.     return territory_name
  1314.  
  1315.  
  1316. def get_territory_center(territory: inkex.Group) -> inkex.Vector2d:
  1317.     """
  1318.    Get the name of the territory from its child title element. If no title, returns
  1319.    Warzone.UNNAMED_TERRITORY_NAME
  1320.    :param territory:
  1321.    :return:
  1322.    territory name
  1323.    """
  1324.     center_rectangle: inkex.Rectangle = territory.find(f"./{Svg.GROUP}/{Svg.RECTANGLE}", NSS)
  1325.     return inkex.Vector2d(
  1326.         center_rectangle.left + center_rectangle.rx / 2,
  1327.         center_rectangle.top + center_rectangle.ry / 2
  1328.     )
  1329.  
  1330.  
  1331. def get_connections(
  1332.         root: inkex.BaseElement, is_recursive: bool = True
  1333. ) -> List[inkex.PathElement]:
  1334.     """
  1335.    Gets all connections that are children of the root node. If not is_recursive, gets
  1336.    only direct children.
  1337.    :param root:
  1338.    :param is_recursive:
  1339.    :return:
  1340.    """
  1341.     slash = '//' if is_recursive else '/'
  1342.     return root.xpath(
  1343.         f".{slash}{Svg.PATH}[@{Inkscape.CONNECTION_START} and @{Inkscape.CONNECTION_END}]",
  1344.         namespaces=NSS
  1345.     )
  1346.  
  1347.  
  1348. def get_bonus_name(bonus_layer: inkex.Layer) -> str:
  1349.     """
  1350.    Parses a bonus layer's label to get the bonus name
  1351.    :param bonus_layer:
  1352.    :return:
  1353.    """
  1354.     return ': '.join(bonus_layer.label.split(': ')[:-1])
  1355.  
  1356.  
  1357. def get_bonus_value(bonus_layer: inkex.Layer) -> int:
  1358.     """
  1359.    Parses a bonus layer's label to get the bonus value.
  1360.    :param bonus_layer:
  1361.    :return:
  1362.    """
  1363.     return int(bonus_layer.label.split(': ')[-1])
  1364.  
  1365.  
  1366. def get_bonus_color(bonus_layer: inkex.Layer) -> str:
  1367.     """
  1368.    Parses a bonus layer's title to get the bonus color.
  1369.    :param bonus_layer:
  1370.    :return:
  1371.    """
  1372.     return find(Svg.TITLE, bonus_layer).text
  1373.  
  1374.  
  1375. def get_bonus_link_id(bonus_name: str) -> str:
  1376.     """
  1377.    Converts a bonus name to the corresponding ID for its bonus link
  1378.    :param bonus_name:
  1379.    :return:
  1380.    bonus link id
  1381.    """
  1382.     return Warzone.BONUS_LINK_IDENTIFIER + re.sub(r'[^a-zA-Z0-9]+', '', bonus_name)
  1383.  
  1384.  
  1385. def is_bonus_link_group(group: inkex.ShapeElement) -> bool:
  1386.     """
  1387.    Checks if element is a bonus link group. It is a bonus link group if it is a non-layer Group
  1388.    and has two children, one of which is a bonus link, the other of which is a text element.
  1389.    :param group:
  1390.    :return:
  1391.    """
  1392.     valid = isinstance(group, inkex.Group)
  1393.     valid = valid and not isinstance(group, inkex.Layer)
  1394.     valid = valid and len(group.getchildren()) == 2
  1395.     valid = valid and find(
  1396.         f"./{Svg.PATH}[contains(@{Svg.ID}, '{Warzone.BONUS_LINK_IDENTIFIER}')]", group
  1397.     ) is not None
  1398.     valid = valid and find(f"./{Svg.TEXT}", group) is not None
  1399.     return valid
  1400.  
  1401.  
  1402. def is_scenario_distribution(distribution_layer: inkex.Layer) -> bool:
  1403.     """
  1404.    Checks if the distribution layer is a scenario distribution layer. Assumes the input is a
  1405.    distribution layer.
  1406.    :param distribution_layer:
  1407.    :return:
  1408.    """
  1409.     return bool(
  1410.         [child for child in distribution_layer.getchildren() if isinstance(child, inkex.Layer)]
  1411.     )
  1412.  
  1413.  
  1414. def create_territory(
  1415.         territory_path: inkex.PathElement, max_id: int, territory_layer: inkex.Layer = None
  1416. ) -> inkex.Group:
  1417.     """
  1418.    Converts territory path into a Warzone Territory.
  1419.  
  1420.    Sets the id of territory to the next Warzone Territory ID after the current maximum and
  1421.    creates a territory group containing a center-point and display army numbers. If
  1422.    territory_layer argument is passed, move territory group to the Territories layer.
  1423.  
  1424.    :param max_id:
  1425.    :param territory_path:
  1426.    :param territory_layer:
  1427.    :return maximum territory id as int
  1428.    """
  1429.     if Warzone.TERRITORY_IDENTIFIER not in territory_path.get_id():
  1430.         max_id += 1
  1431.         territory_path.set_id(f"{Warzone.TERRITORY_IDENTIFIER}{max_id}")
  1432.     parent: inkex.Group = territory_path.getparent()
  1433.     if not is_territory_group(parent):
  1434.         territory_group = inkex.Group.new(
  1435.             territory_path.get_id(),
  1436.             territory_path,
  1437.             create_center_point_group(territory_path.bounding_box().center),
  1438.         )
  1439.     else:
  1440.         territory_group = parent
  1441.         parent = territory_group.getparent()
  1442.     territory_style = territory_path.effective_style()
  1443.     territory_style[Svg.STROKE_WIDTH] = 1
  1444.     if territory_style.get_color() != Color.TERRITORY_FILL:
  1445.         territory_style.set_color(Color.TERRITORY_FILL)
  1446.     destination = territory_layer if territory_layer is not None else parent
  1447.     if territory_group not in destination:
  1448.         destination.add(territory_group)
  1449.     return territory_group
  1450.  
  1451.  
  1452. def remove_bonus_link(bonus_link: Union[inkex.Group, inkex.PathElement]) -> None:
  1453.     """
  1454.    Remove bonus link from the map
  1455.    :param bonus_link:
  1456.    :return:
  1457.    """
  1458.     if bonus_link is not None:
  1459.         if is_bonus_link_group(bonus_link.getparent()):
  1460.             element_to_remove = bonus_link.getparent()
  1461.         else:
  1462.             element_to_remove = bonus_link
  1463.         element_to_remove.getparent().remove(element_to_remove)
  1464.  
  1465.  
  1466. def create_center_point_group(center: inkex.Vector2d) -> inkex.Group:
  1467.     """
  1468.    Creates a group containing a rounded rectangle and sample army numbers centered at the
  1469.    input center-point
  1470.    :param center
  1471.    :return:
  1472.    center point group
  1473.    """
  1474.     # todo use https://blog.mapbox.com/a-new-algorithm-for-finding-a-visual-center-of-a-polygon-7c77e6492fbc
  1475.     #  to set a default center point
  1476.     return inkex.Group.new(
  1477.         'center-point',
  1478.         inkex.Rectangle.new(
  1479.             left=center.x - Warzone.RECT_WIDTH / 2,
  1480.             top=center.y - Warzone.RECT_HEIGHT / 2,
  1481.             width=Warzone.RECT_WIDTH,
  1482.             height=Warzone.RECT_HEIGHT,
  1483.             ry=Warzone.RECT_ROUNDING,
  1484.             rx=Warzone.RECT_ROUNDING,
  1485.             style=inkex.Style(
  1486.                 fill='none',
  1487.                 stroke=Color.TERRITORY_FILL,
  1488.                 stroke_width=1.0,
  1489.                 stroke_linecap='round',
  1490.                 stroke_linejoin='round',
  1491.             ),
  1492.         ),
  1493.         inkex.TextElement.new(
  1494.             create_tspan('88', font_color=Color.BLACK),
  1495.             x=center.x,
  1496.             y=center.y + Warzone.ARMY_FONT_SIZE * 3 / 8,
  1497.         ),
  1498.     )
  1499.  
  1500.  
  1501. def create_tspan(bonus_value, font_color: str):
  1502.     return inkex.Tspan.new(
  1503.         bonus_value,
  1504.         style=inkex.Style(
  1505.             fill=font_color,
  1506.             font_weight='bold',
  1507.             font_size=f'{Warzone.ARMY_FONT_SIZE}px',
  1508.             text_align='center',
  1509.             text_anchor='middle',
  1510.         )
  1511.     )
  1512.  
  1513.  
  1514. WZMapBuilder().run()
  1515.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement