Vearie

ODF File Opener

Feb 12th, 2022 (edited)
997
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 39.64 KB | None | 0 0
  1. """This tool is designed to emulate BZCC file indexing for ODF files and open search queries as tabs in a text editor."""
  2. VERSION = 1.04
  3.  
  4. # VERSION 1.04 Changes
  5. # - Now processes "@workshop" directory macro
  6.  
  7. import os
  8. import subprocess
  9.  
  10. # Default steam directory to be assumed if script could not read from registry.
  11. STEAM_DIRECTORY = "C:\\Program Files (x86)\\Steam"
  12.  
  13. # Path to text editor you want to open ODF files with, see function 'open_files'
  14. # TEXT_EDITOR = os.path.join(os.environ["WINDIR"], "system32", "notepad.exe") # Set TEXT_EDITOR_AS_TABS to False to use notepad
  15. #TEXT_EDITOR = "C:\\Program Files\\Notepad++\\notepad++.exe"
  16. TEXT_EDITOR = r"C:\Users\J\AppData\Roaming\Programs\SciTE\SciTE.exe" # @CHANGE
  17.  
  18. # If True, open ODF files as tabs. Otherwise open an instance for each file.
  19. TEXT_EDITOR_AS_TABS = True
  20. TEXT_EDITOR_MAX_TABS = 32
  21. TEXT_EDITOR_MAX_INSTANCES = 4 # Used if TEXT_EDITOR_AS_TABS == False
  22.  
  23. # Where to store cached ODF locations (need write permission)
  24. # Fast storage such as an SSD location would be preferred.
  25. # To rebuild cache, simply delete the files in this directory.
  26. CACHE_DIRECTORY = os.path.join(os.environ["TMP"], "Battlezone II Combat Commander ODF Cache")
  27. CACHE_MYDOCS_DIRECTORIES = False # Set this to True if you have a huge addon folder you rarely change to increase performance.
  28. CACHE_ROOT_DIRECTORIES = True
  29. CACHE_WORKSHOP_DIRECTORIES = True
  30.  
  31. # If True, uses regex matching for queries that contain any regex characters.
  32. # Otherwise simple wildcard * matches take its place.
  33. FULL_REGEX_MATCHING = False
  34.  
  35. # Show partial matches if no ODF matching any query is found.
  36. # This has the performance cost of iterating over all odf names
  37. # if any queries are not matched to check for partial matches.
  38. IMPLEMENT_SUGGESTIONS = True
  39.  
  40. # If True, use the currently running mod instead of the user-selected mod.
  41. # In 'launch.ini', the active mod id value updates as soon as a player clicks
  42. # to join a game and downloads the mod immediately as they enter the lobby.
  43. # If the player hasn't joined a game running a different mod, it will just be the same as selected mod.
  44. USE_ACTIVE_MOD_INSTEAD_OF_SELECTED_MOD = False
  45.  
  46. # This is called when the queries are finalized and the list of ODF files are passed
  47. def open_files(odf_full_file_paths=[]):
  48.     if not os.path.exists(TEXT_EDITOR):
  49.         print("%r not found. Set a valid program to open ODF files with.\n%d query matches:" % (TEXT_EDITOR, len(odf_full_file_paths)))
  50.         for path in odf_full_file_paths:
  51.             print(">", path)
  52.         return
  53.    
  54.     if TEXT_EDITOR_AS_TABS:
  55.         subprocess.call([TEXT_EDITOR] + odf_full_file_paths)
  56.     else:
  57.         for which in odf_full_file_paths:
  58.             subprocess.Popen([TEXT_EDITOR, which])
  59.  
  60. # ############################     ###########################
  61. # ###########################       ##########################
  62. # ###########################       ##########################
  63. # ###################                       ##################
  64. # ###############   ##########  #  ##########   ##############
  65. # #############  ############   #   ############  ############
  66. # ###########  ##############   #   ##############  ##########
  67. # ##########  ##############   ###   ##############  #########
  68. # ######### ######    #####    ###    ####    ######  ########
  69. # ########  #########      ##########       #########  #######
  70. # #######  #############  ###      ###   ############# #######
  71. # #######  ############  ###        ###   ############  ######
  72. # #######  #########     ####      #####     ######### #######
  73. # ########  ######    #####################    ######  #######
  74. # ###############                               ##############
  75. # ######       ###   ##########   ##########   ###       #####
  76. # ######       ################   ################       #####
  77. # #######     ################## ##################     ######
  78. # ###############   ############ ###########   ###############
  79. # ###################     #############     ##################
  80. # ____________________________________________________________
  81.  
  82. import re
  83. import sys
  84. import zlib
  85. import time
  86. import struct
  87. import pickle
  88. import configparser
  89. from datetime import datetime
  90. from collections import namedtuple
  91. from tempfile import NamedTemporaryFile
  92.  
  93. # IMPORT BZ2CFG START
  94. # from bz2cfg import CFG
  95. class ParseError(Exception):
  96.     def __init__(self, file="UNDEFINED", line=0, col=0, msg="Parsing Error"):
  97.         self.file = file
  98.         self.line = line
  99.         self.col = col
  100.         self.msg = msg
  101.    
  102.     def __str__(self):
  103.         return "%s:%d:%d - %s" % (self.file, self.line, self.col, self.msg)
  104.  
  105. class UnterminatedString(ParseError): pass
  106.  
  107. class ConfigContainer:
  108.     def walk(self):
  109.         for child in self.children:
  110.             yield child
  111.             yield from child.walk()
  112.    
  113.     def get_attribute(self, attribute_name, occurrence=1):
  114.         return self._find(attribute_name, self.attribute, occurrence)
  115.    
  116.     def get_attributes(self, name=None):
  117.         return self._findall(name, self.attribute, False)
  118.    
  119.     def get_container(self, container_name, occurrence=1):
  120.         return self._find(container_name, self.children, occurrence)
  121.    
  122.     def get_containers(self, name=None):
  123.         return self._findall(name, self.children)
  124.    
  125.     def _findall(self, name, _list, reverse=False):
  126.         if name:
  127.             name = name.casefold()
  128.        
  129.         for item in (reversed(_list) if reverse else _list):
  130.             if name is None or item.name.casefold() == name:
  131.                 yield item
  132.    
  133.     def _find(self, name, _list, occurrence=1):
  134.         reverse = occurrence < 0
  135.         occurrence = abs(occurrence)
  136.        
  137.         for index, container in enumerate(self._findall(name, _list, reverse), start=1):
  138.             if index == occurrence:
  139.                 return container
  140.        
  141.         raise KeyError("%r not found in %r" % (name, self.name))
  142.    
  143.     def __iter__(self):
  144.         for child in self.children:
  145.             yield child
  146.    
  147.     def __getitem__(self, container_name):
  148.         """Returns child container by name. Raises KeyError if not found."""
  149.         return self.get_container(container_name)
  150.  
  151. class CFG(ConfigContainer):
  152.     def __init__(self, filepath=None):
  153.         self.children = []
  154.         self.attribute = []
  155.         self.filepath = filepath
  156.        
  157.         self.name = filepath or "<Untitled - CFG>"
  158.        
  159.         if self.filepath:
  160.             with open(self.filepath, "r") as f:
  161.                 self.read(f)
  162.    
  163.     def __str__(self):
  164.         return "<%s %r>" % (__class__.__name__, self.name)
  165.    
  166.     # Yields either a word or a control character from a file, 1 at a time
  167.     def parse_words(self, f):
  168.         # Note: these characters do not add to the column counter
  169.         NON_CHARACTER = "\x00\r"
  170.  
  171.         # Characters which count as a valid name
  172.         NAME = "QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm0123456789_.+-"
  173.         WHITESPACE = " \t\n"
  174.        
  175.         line = col = 1
  176.         state = READ_ANY = 0
  177.         in_comment = 1
  178.         in_quote = None
  179.         word = ""
  180.        
  181.         for c in f.read():
  182.             if c in NON_CHARACTER:
  183.                 continue
  184.            
  185.             if c == "\n":
  186.                 line += 1
  187.                 col = 1
  188.             else:
  189.                 col += 1
  190.            
  191.             if in_quote:
  192.                 # Character matches quote, completing the string.
  193.                 # Note that BZ2 does not handle escapes (e.g. \").
  194.                 if c == in_quote:
  195.                     yield word, line, col
  196.                     word = ""
  197.                     in_quote = None
  198.                
  199.                 # New line without terminating quote.
  200.                 elif c == "\n":
  201.                     raise UnterminatedString(self.filepath, line, col, "Unterminated String")
  202.                
  203.                 else:
  204.                     word += c
  205.            
  206.             elif state == READ_ANY:
  207.                 if c in NAME:
  208.                     word += c
  209.                
  210.                 else:
  211.                     # End of continuous word
  212.                     if c in WHITESPACE:
  213.                         if word:
  214.                             yield word, line, col
  215.                             word = ""
  216.                    
  217.                     # Start of comment
  218.                     elif c == "/":
  219.                         if word:
  220.                             yield word, line, col
  221.                        
  222.                         state = in_comment
  223.                         word = c
  224.                    
  225.                     # Start of string quote
  226.                     elif c in "\"'":
  227.                         if word:
  228.                             yield word, line, col
  229.                             word = ""
  230.                        
  231.                         in_quote = c
  232.                    
  233.                     # Special control character
  234.                     elif c in "{}(),;":
  235.                         if word:
  236.                             yield word, line, col
  237.                             word = ""
  238.                        
  239.                         yield c, line, col
  240.                    
  241.                     # Unhandled
  242.                     else:
  243.                         raise ParseError(self.filepath, line, col, "Unexpected Character %r" % c)
  244.            
  245.             elif state == in_comment:
  246.                 # New line character indicates of comment
  247.                 if c == "\n":
  248.                     if word:
  249.                         pass # ignore comments
  250.                         # yield word
  251.                    
  252.                     word = ""
  253.                     state = READ_ANY
  254.                
  255.                 else:
  256.                     word += c
  257.    
  258.     def read(self, f):
  259.         EXPECT_NAME = 0
  260.         EXPECT_PARAM_OPEN = 1
  261.         EXPECT_PARAM = 2
  262.         EXPECT_CONTAINER_TYPE = 3
  263.         state = EXPECT_NAME
  264.        
  265.         # Temporary buffers
  266.         name = ""
  267.         parameters = []
  268.        
  269.         # Empty root container
  270.         container_at_level = [self]
  271.        
  272.         for word, line, col in self.parse_words(f):
  273.             if state == EXPECT_NAME:
  274.                 if word == "}":
  275.                     if len(container_at_level) <= 1:
  276.                         # Extra closing brace: Could raise an exception here, but I think it's safe to ignore.
  277.                         continue
  278.                    
  279.                     del(container_at_level[-1])
  280.                     continue
  281.                
  282.                 name = word
  283.                 parameters = []
  284.                 state = EXPECT_PARAM_OPEN
  285.                 continue
  286.            
  287.             elif state == EXPECT_PARAM_OPEN:
  288.                 if word == "(":
  289.                     state = EXPECT_PARAM
  290.                     continue
  291.            
  292.             elif state == EXPECT_PARAM:
  293.                 if word == ",":
  294.                     continue
  295.                
  296.                 elif word == ")":
  297.                     state = EXPECT_CONTAINER_TYPE
  298.                     continue
  299.                
  300.                 else:
  301.                     parameters += [word]
  302.                     continue
  303.            
  304.             elif state == EXPECT_CONTAINER_TYPE:
  305.                 # Start of a new container
  306.                 if word == "{":
  307.                     container = Container(name, *parameters)
  308.                     container_at_level[-1].children += [container]
  309.                     container_at_level.append(container)
  310.                    
  311.                     state = EXPECT_NAME
  312.                     continue
  313.                
  314.                 # End of attribute
  315.                 elif word == ";":
  316.                     container_at_level[-1].attribute += [Attribute(name, *parameters)]
  317.                     state = EXPECT_NAME
  318.                     continue
  319.            
  320.             raise ParseError(self.filepath, line, col)
  321.  
  322. class Container(ConfigContainer):
  323.     def __init__(self, name, *parameters):
  324.         self.name = name
  325.         self.parameter = parameters
  326.         self.children = []
  327.         self.attribute = []
  328.    
  329.     def __str__(self):
  330.         return "<%s %s(%s)>" % (__class__.__name__, self.name, ", ".join("\"%s\"" % p for p in self.parameter))
  331.  
  332. class Attribute:
  333.     def __init__(self, name, *parameters):
  334.         self.name = name
  335.         self.parameter = parameters
  336.    
  337.     def __str__(self):
  338.         return "<%s %s(%s)>" % (__class__.__name__, self.name, ", ".join("\"%s\"" % p for p in self.parameter))
  339. # IMPORT BZ2CFG END
  340.  
  341. # IMPORT STEAMVDF START
  342. class steamvdf:
  343.     class VDF_ParseError(Exception):
  344.         def __init__(self, msg, line, col):
  345.             self.msg = msg
  346.             self.line = line
  347.             self.col = col
  348.        
  349.         def __str__(self):
  350.             return self.msg + " (line %d column %d)" % (self.line + 1, self.col + 1) # +1 for 0-based to 1-based
  351.  
  352.     def read(path):
  353.         """Returns dictionary of {key: value or sub}, case sensitive."""
  354.         with open(path, "r") as f:
  355.             escape = False
  356.             in_quote = False
  357.             words = []
  358.            
  359.             root = {}
  360.             section = root
  361.             section_history = []
  362.            
  363.             for line, row_content in enumerate(f):
  364.                 for col, c in enumerate(row_content):
  365.                     if in_quote:
  366.                         if c in "\r\n":
  367.                             raise VDF_ParseError("unterminated string %r" % word, line, col)
  368.                         elif escape:
  369.                             escape = False
  370.                             # c is an escaped character (not handled for now)
  371.                             words[-1] += "\\" + c
  372.                         elif c == in_quote:
  373.                             in_quote = False
  374.                             if len(words) == 2:
  375.                                 key, value = tuple(words)
  376.                                 section[key] = value
  377.                                 words = []
  378.                             elif len(words) > 2:
  379.                                 raise VDF_ParseError("expected 2 items for key and value but got %d items" % len(words), line, col)
  380.                         elif c == "\\":
  381.                             escape = True
  382.                         else:
  383.                             words[-1] += c
  384.                     elif c in "'\"":
  385.                         in_quote = c
  386.                         words += [""]
  387.                     elif c == "{":
  388.                         if len(words) != 1:
  389.                             raise VDF_ParseError("expected exactly 1 item before opening brace but got %d items" % len(words), line, col)
  390.                        
  391.                         new_section = {}
  392.                         section_history.append(section)
  393.                         section[words[0]] = new_section
  394.                         section = new_section
  395.                         words = []
  396.                     elif c == "}":
  397.                         if len(section_history) <= 0:
  398.                             raise VDF_ParseError("closing brace not expected", line, col)
  399.                         section = section_history.pop()
  400.                     elif not c.isspace():
  401.                         raise VDF_ParseError("unexpected symbol %r" % c, line, col)
  402.            
  403.             if len(section_history) > 0:
  404.                 raise VDF_ParseError("%d braces left unclosed" % len(section_at)-1, line, col)
  405.            
  406.             return root
  407. # IMPOT STEAMVDF END
  408.  
  409. try:
  410.     winreg = None # Needed to have name in scope
  411.     try:
  412.         import winreg
  413.     except ImportError:
  414.         try:
  415.             import _winreg as winreg
  416.         except ImportError as exception:
  417.             print("Could not import winreg or _winreg.")
  418.             raise exception
  419.    
  420.     registry_key = winreg.OpenKeyEx(winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE), "SOFTWARE\\WOW6432Node\\Valve\\Steam")
  421.     key_value, key_type = winreg.QueryValueEx(registry_key, "InstallPath")
  422.     if key_type == winreg.REG_EXPAND_SZ:
  423.         key_value = os.path.expandvars(key_value)
  424.     elif key_type != winreg.REG_SZ:
  425.         raise TypeError()
  426.    
  427.     STEAM_DIRECTORY = key_value
  428.    
  429.     # Find which steam library BZCC is installed in
  430.     vdf = steamvdf.read(os.path.join(key_value, "steamapps/libraryfolders.vdf"))["libraryfolders"]
  431.     try:
  432.         for index in range(1000):
  433.             vdf_lib = vdf[str(index)]
  434.             for game_id, size in vdf_lib["apps"].items():
  435.                 if game_id == "624970":
  436.                     STEAM_DIRECTORY = vdf_lib["path"]
  437.                     raise KeyError() # Breaks out of both loops
  438.     except KeyError:
  439.         pass
  440. except Exception as e:
  441.     print("Failed to automatically determine steam folder.")
  442.     print("Edit this script to change the STEAM_DIRECTORY variable to your steam directory that has BZCC installed.")
  443.     print("Current STEAM_DIRECTORY set: %r" % STEAM_DIRECTORY)
  444.     print("Exception: %r" % e)
  445.  
  446. # '\' must be replaced with '/' to work with re.sub to avoid decoding character escapes
  447. BZ2R_STEAM_ID = 624970
  448. BZ2R_ROOT_DIRECTORY = ("%s/steamapps/common/BZ2R" % STEAM_DIRECTORY).replace("\\", "/")
  449. BZ2R_MYDOCS_DIRECTORY = ("%s/Documents/My Games/Battlezone Combat Commander" % os.environ["USERPROFILE"]).replace("\\", "/")
  450. BZ2R_WORKSHOP_DIRECTORY = ("%s/steamapps/workshop/content/%d" % (STEAM_DIRECTORY, BZ2R_STEAM_ID)).replace("\\", "/")
  451. BZ2R_ADDON_DIRECTORY = "%s/addon" % BZ2R_MYDOCS_DIRECTORY
  452. BZ2R_LAUNCHINI_FILE = "%s/launch.ini" % BZ2R_MYDOCS_DIRECTORY
  453. BZ2R_BZONECFG_FILE = "%s/bzone.cfg" % BZ2R_ROOT_DIRECTORY
  454. BZ2R_APPMANIFEST_FILE = "%s/steamapps/appmanifest_%d.acf" % (STEAM_DIRECTORY, BZ2R_STEAM_ID)
  455. BZ2R_APPWORKSHOP_FILE = "%s/steamapps/workshop/appworkshop_%d.acf" % (STEAM_DIRECTORY, BZ2R_STEAM_ID)
  456.  
  457. RE_ROOTDIR = re.compile(r"^@rootdir[/\\]+")
  458. RE_MYDOCSDIR = re.compile(r"^@mydocsdir[/\\]+")
  459. RE_WORKSHOPDIR = re.compile(r"^@workshop[/\\]+")
  460. RE_INTEGER = re.compile(r"\d+")
  461.  
  462. # If True, the steam .acf file timestamps will be checked against cache file creation dates.
  463. # In the instance an item is newer than the cache file, the cache will be rebuilt automatically.
  464. CACHE_DATE_CHECK = True
  465.  
  466. # Used to format time displays when indicating to user a cache is outdated (e.g. cache file is older than last workshop mod update)
  467. TIME_FORMAT = "%B %a %d %Y at %I:%M%p" # See https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
  468.  
  469. # Level cache is compressed with zlib (0 = none, 1-9 slowest to fastest)
  470. CACHE_COMPRESSION_LEVEL = 1 # Just 1 significantly decreases file size. Anything higher seems to be a tiny but unnecessary performance hit.
  471.  
  472. # Overwrite existing cache when applicable
  473. FORCE_GENERATE_CACHE = False
  474.  
  475. # If True, all .odf files will be deleted in the cache directory on start.
  476. # This is to clean up any loose temp files created by pak processing.
  477. # These temps can get left behind if the user kills the process before it cleans up.
  478. AUTO_CLEAN_PAK = True
  479.  
  480. # Print time it took to complete search (set to True to debug performance)
  481. SHOW_TIME = False
  482.  
  483. # Since this script only considers ODF files, this is disabled.
  484. # The functionality to process addons is included for potential future expansion.
  485. LOAD_ADDONS = False
  486. # ADDON_MOD_EXT_WHITELIST = (".pic", ".tga", ".png", ".jpg", ".bmp", ".dds", ".wav", ".ogg", ".cfg", ".bmf", ".bm2", ".inf", ".des", ".otf", ".brf", ".txt")
  487.  
  488. # If True, each ODF file in on-disk directories will be read to see if it is a valid game object.
  489. # This is to prevent items such as old '.odf' map descriptions from showing up.
  490. # Has a performance cost, and will not pick up non-gameobject odfs such as vehicle lists, weapon config, etc...
  491. # Caches would need to be rebuilt if changed. This setting does not apply to pak files.
  492. VALIDATE_ODF_FILE = False
  493.  
  494. GlobalInfo = namedtuple("GlobalInfo", "active_workshop_id selected_workshop_id active_addons language_id")
  495. ModInfo = namedtuple("ModInfo", "name catagory dependencies")
  496. DirectoryInfo = namedtuple("DirectoryInfo", "path cache recursive kind workshop_id cache_id")
  497.  
  498. KIND_ROOT = 0
  499. KIND_PAK = 1
  500. KIND_MYDOCS = 2
  501. KIND_WORKSHOP_CONFIG = 3 # Any DirectoryInfo object that is a workshop item should have a valid workshop_id value
  502. KIND_WORKSHOP_ASSET = 4
  503. KIND_WORKSHOP_ADDON = 5
  504.  
  505. # STEAM_INSTALL_REGISTRY_KEY = "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam", "Language" # https://partner.steamgames.com/doc/store/localization ?
  506. DEFAULT_LANGAUGE_CODE = "en" # "fr" "de"
  507.  
  508. def GetDirectoryInfo(**kwargs):
  509.     re_directory_delimiter = re.compile(r"[/\\]+")
  510.     path = kwargs["path"]
  511.    
  512.     # In BZCC, @rootdir/@mydocsdir only work if at the very start of path and all characters are lowercase.
  513.     path = RE_ROOTDIR.sub(BZ2R_ROOT_DIRECTORY + "/", path)
  514.     path = RE_MYDOCSDIR.sub(BZ2R_MYDOCS_DIRECTORY + "/", path)
  515.     path = RE_WORKSHOPDIR.sub(BZ2R_WORKSHOP_DIRECTORY + "/", path)
  516.    
  517.     if not os.path.isabs(path):
  518.         # BZCC behaviour seems to be that relative paths are treated as being @rootdir.
  519.         # This might be because BZCC is just using its CWD, but in this script we will assume @rootdir.
  520.         path = os.path.join(BZ2R_ROOT_DIRECTORY, path)
  521.    
  522.     path = os.path.normcase(path)
  523.     path = re_directory_delimiter.sub("/", path)
  524.    
  525.     recursive = 0 if kwargs["kind"] == KIND_PAK else int(kwargs["recursive"])
  526.     cache_id = zlib.adler32(path.encode("ascii", "ignore"))
  527.    
  528.     kwargs["cache_id"] = cache_id
  529.     kwargs["cache"] = os.path.join(CACHE_DIRECTORY, "%d%d.cache" % (recursive, cache_id))
  530.     kwargs["path"] = path
  531.    
  532.     if not os.path.exists(path):
  533.         raise FileNotFoundError("%r not found" % path)
  534.    
  535.     return DirectoryInfo(**kwargs)
  536.  
  537. def find_first_file(directory, ext):
  538.     if not os.path.exists(directory):
  539.         raise FileNotFoundError("folder not found %r" % (directory))
  540.    
  541.     ext = ext.casefold()
  542.     for root, folders, files in os.walk(directory):
  543.         for file in files:
  544.             if file[-len(ext)::].casefold() == ext:
  545.                 return os.path.join(root, file)
  546.         break
  547.    
  548.     raise FileNotFoundError("file ending in %r not found in %r" % (ext, directory))
  549.  
  550. def read_launch_ini(launch_ini_file_path=BZ2R_LAUNCHINI_FILE):
  551.     config = configparser.ConfigParser()
  552.     config.read(launch_ini_file_path)
  553.     return GlobalInfo(
  554.         int(config["config"]["globalConfigWorkshopId"]), # Current mod being played now (e.g. user joined modded game and auto-switched) (0 is stock)
  555.         int(config["config"]["selectedConfigWorkshopId"]), # Last mod selected from the mod menu by user aka default mod (0 is stock)
  556.         [int(i) for i in RE_INTEGER.findall(config["config"]["activeAddons"])], # List of active addon mods
  557.         int(config["config"]["language"]) # Language ID (0 or >= 4=auto, 1=en, 2=fr, 3=de)
  558.     )
  559.  
  560. def read_mod_ini(mod_ini_file_path):
  561.     config = configparser.ConfigParser()
  562.     config.read(mod_ini_file_path)
  563.     return ModInfo(
  564.         config["WORKSHOP"]["modName"].strip("\"") if "modName" in config["WORKSHOP"] else None, # Name of mod
  565.         config["WORKSHOP"]["modType"].strip("\""), # Kind of mod can be: Addon, Config, Asset
  566.         [int(i) for i in RE_INTEGER.findall(config["WORKSHOP"]["assetDependencies"] if "assetDependencies" in config["WORKSHOP"] else "")] # List of other mods this mod inherits
  567.     )
  568.  
  569. def read_game_timestamp(steam_appmanifest_file_path=BZ2R_APPMANIFEST_FILE):
  570.     re_timestamp_entry = re.compile(r"(?i)\"LastUpdated\"[\r\n\s]*\"(\d+)\"")
  571.    
  572.     with open(steam_appmanifest_file_path, "r") as f:
  573.         match = re_timestamp_entry.search(f.read())
  574.         if match:
  575.             return int(match.group(1))
  576.    
  577.     raise Exception("could not find timestamp in %r" % steam_appmanifest_file_path)
  578.  
  579. def read_workshop_timestamps(steam_appworkshop_file_path=BZ2R_APPWORKSHOP_FILE):
  580.     timestamps = dict()
  581.     re_timestamp_entry = re.compile(r"(?i)\"(\d+)\"[\r\n\s]*{[\r\n\s]*\"size\"[\r\n\s]*\"\d+\"[\r\n\s]*\"timeupdated\"[\r\n\s]*\"(\d+)\"")
  582.    
  583.     with open(steam_appworkshop_file_path, "r") as f:
  584.         for workshop_item, last_updated in re_timestamp_entry.findall(f.read()):
  585.             timestamps[int(workshop_item)] = int(last_updated)
  586.    
  587.     return timestamps
  588.  
  589. def odf_file_iter(directory, recursive=False):
  590.     def odf_is_game_object(filepath):
  591.         re_classlabel = re.compile(r"(?i)\s*classLabel\s*=\s*[^\s]")
  592.        
  593.         try:
  594.             with open(filepath, "r") as f:
  595.                 for line in f:
  596.                     if re_classlabel.match(line):
  597.                         return True
  598.         except UnicodeError:
  599.             pass
  600.        
  601.         return False
  602.    
  603.     for root, folders, files in os.walk(directory):
  604.         for file in files:
  605.             name, ext = os.path.splitext(file.casefold())
  606.             if ext != ".odf":
  607.                 continue
  608.            
  609.             full_path = os.path.join(root, file)
  610.             if not VALIDATE_ODF_FILE or odf_is_game_object(full_path):
  611.                 yield name, full_path
  612.        
  613.         if not recursive:
  614.             break
  615.  
  616. PakFileInfo = namedtuple("PakFileInfo", "bz2dir file_name data_offset comp_size real_size")
  617. def pak_archive_iter(bz2dir, extensions=[".odf"]):
  618.     with open(bz2dir.path, "rb") as f:
  619.         if struct.unpack("4sI", f.read(8)) != (b"DOCP", 2):
  620.             raise Exception("invalid pak header in %r" % pak_file_path)
  621.        
  622.         f.seek(16, 0)
  623.         file_count, file_offset = struct.unpack("II", f.read(8))
  624.         f.seek(file_offset)
  625.         for index in range(file_count):
  626.             f.seek(4, 1)
  627.             # Warning: Name not sanitized.
  628.             file_name = f.read(f.read(1)[0]).decode("ascii", "ignore")
  629.             name, ext = os.path.splitext(file_name.casefold())
  630.             if ext in extensions:
  631.                 yield name, PakFileInfo(bz2dir, file_name, *struct.unpack("III", f.read(12)))
  632.             else:
  633.                 f.seek(12, 1)
  634.  
  635. class SearchTool:
  636.     def __init__(self, global_info, default_bzone_cfg_path=BZ2R_BZONECFG_FILE, force_input=None):
  637.         self.global_info = global_info
  638.         self.mod_info = None
  639.         self.bzone_cfg_path = default_bzone_cfg_path
  640.         self.cache = dict()
  641.        
  642.         if global_info.selected_workshop_id:
  643.             mod_workshop_directory = os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(global_info.selected_workshop_id))
  644.             cfg_file = default_bzone_cfg_path
  645.            
  646.             try:
  647.                 cfg_file = find_first_file(mod_workshop_directory, ".cfg")
  648.                 ini_file = find_first_file(mod_workshop_directory, ".ini")
  649.                 self.mod_info = read_mod_ini(ini_file)
  650.                
  651.                 if self.mod_info.catagory.casefold() != "config":
  652.                     # After testing, it appears BZCC just ignores non-config mods and runs under the context of stock.
  653.                     self.mod_info = None
  654.                     self.global_info = global_info._replace(selected_workshop_id=0)
  655.                     print("Error - Workshop item %d is an %r mod, but a 'config' mod is required. Reverting to stock..." % (global_info.selected_workshop_id, self.mod_info.catagory))
  656.                     # raise Exception("invalid mod type %r in %r, must be config mod" % (self.mod_info.catagory, cfg_file))
  657.            
  658.             except FileNotFoundError:
  659.                 self.mod_info = None
  660.                 self.global_info = global_info._replace(selected_workshop_id=0)
  661.                 print("Error - Could not find config mod files for workshop item %d. Reverting to stock..." % global_info.selected_workshop_id)
  662.        
  663.         bzone = list(self.read_bzone_cfg())
  664.        
  665.         self.last_game_update = self.last_workshop_update = None
  666.         if CACHE_DATE_CHECK:
  667.             try:
  668.                 self.last_game_update = read_game_timestamp(BZ2R_APPMANIFEST_FILE)
  669.             except:
  670.                 print("Error - failed to get timestamp from %r" % BZ2R_APPMANIFEST_FILE)
  671.            
  672.             try:
  673.                 self.last_workshop_update = read_workshop_timestamps(BZ2R_APPWORKSHOP_FILE)
  674.             except:
  675.                 print("Error - failed to get timestamps from %r" % BZ2R_APPWORKSHOP_FILE)
  676.        
  677.         if self.mod_info:
  678.             print("<%s> w/ %d Active Addon Mods and %d Asset Dependencies" % (self.mod_info.name, len(self.global_info.active_addons), len(self.mod_info.dependencies)))
  679.             print("Workshop ID %d Using Config %r" % (self.global_info.selected_workshop_id, self.bzone_cfg_path))
  680.         else:
  681.             if os.path.normpath(self.bzone_cfg_path) == os.path.normpath(BZ2R_BZONECFG_FILE):
  682.                 print("Stock w/ %d Active Addon Mods" % len(self.global_info.active_addons))
  683.             else:
  684.                 print("%r w/ %d Active Addon Mods" % (os.path.basename(self.bzone_cfg_path), len(self.global_info.active_addons)))
  685.                 print("Using Config %r" % self.bzone_cfg_path)
  686.        
  687.         if FULL_REGEX_MATCHING:
  688.             print("Do not specify the extension '.odf' in your query. Separate multiple queries with spaces. If your query contains regular expression characters it will be used as a regex match.")
  689.         else:
  690.             print("Do not specify the extension '.odf' in your query. Separate multiple queries with spaces. You may use wildcards * for partial matches. Example: (*vscav evtank_* apwrck)")
  691.        
  692.         while True:
  693.             if self.prompt(bzone, force_input):
  694.                 break
  695.    
  696.     def read_bzone_cfg(self):
  697.         global_info, mod_info = self.global_info, self.mod_info
  698.         bzone_cfg_path = self.bzone_cfg_path
  699.         if global_info.selected_workshop_id:
  700.             bzone_cfg_path = self.bzone_cfg_path = find_first_file(os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(global_info.selected_workshop_id)), ".cfg")
  701.        
  702.         if not os.path.exists(bzone_cfg_path):
  703.             raise FileNotFoundError("%r not found" % bzone_cfg_path)
  704.        
  705.         configure_file_system = CFG(bzone_cfg_path)["ConfigureFileSystem"]
  706.        
  707.         if "appendtomydocs" in configure_file_system.attribute:
  708.             # TODO: What is even the point to this feature?
  709.             raise NotImplementedError("AppendToMyDocs in %r not supported" % bzone_cfg_path)
  710.        
  711.         use_stream_name = configure_file_system.get_attribute("SetActiveStream", -1).parameter[0].casefold()
  712.        
  713.         for stream in configure_file_system.get_containers("ConfigureStream"):
  714.             if stream.parameter[0].casefold() == use_stream_name:
  715.                 configure_stream = stream
  716.                 break
  717.         else:
  718.             raise LookupError("stream config %r not found in %r" % (use_stream_name, bzone_cfg_path))
  719.        
  720.         for attribute in configure_stream.get_attributes():
  721.             name = attribute.name.casefold()
  722.             kind = path = None
  723.            
  724.             if len(attribute.parameter) >= 1:
  725.                 path = attribute.parameter[0]
  726.                 if RE_ROOTDIR.match(path):
  727.                     kind = KIND_ROOT
  728.                 if RE_MYDOCSDIR.match(path):
  729.                     kind = KIND_MYDOCS
  730.            
  731.             if path:
  732.                 if name == "addstream":
  733.                     raise NotImplementedError("%r in %r not supported" % (attribute.name, bzone_cfg_path))
  734.                
  735.                 if name == "adddirrecurse":
  736.                     yield GetDirectoryInfo(path=path, recursive=True, kind=kind, workshop_id=0)
  737.                     continue
  738.                 elif name == "adddir":
  739.                     yield GetDirectoryInfo(path=path, recursive=False, kind=kind, workshop_id=0)
  740.                     continue
  741.                
  742.                 elif name == "addlangdir" and path:
  743.                     language_id = global_info.language_id if global_info.language_id in (1, 2, 3) else 0
  744.                     path += "/bz2r_%s" % {0: DEFAULT_LANGAUGE_CODE, 1: "en", 2: "fr", 3: "de"}[language_id]
  745.                     yield GetDirectoryInfo(path=path, recursive=True, kind=kind, workshop_id=0)
  746.                     continue
  747.                
  748.                 elif name == "addpack":
  749.                     yield GetDirectoryInfo(path=path, recursive=None, kind=KIND_PAK, workshop_id=0)
  750.                     continue
  751.                
  752.                 # Note that these AddLocals seem to assume the "selected mod".
  753.                 # They do not fire at all (checked in procmon) if no mod is selected.
  754.                 elif name == "addlocalworkshopdirrecurse":
  755.                     if not global_info.selected_workshop_id:
  756.                         print("Error - %r directive outside context of mod" % attribute)
  757.                         continue
  758.                    
  759.                     path = os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(global_info.selected_workshop_id), path)
  760.                     yield GetDirectoryInfo(path=path, recursive=True, kind=KIND_WORKSHOP_CONFIG, workshop_id=global_info.selected_workshop_id)
  761.                     continue
  762.                 elif name == "addlocalworkshopdir":
  763.                     if not global_info.selected_workshop_id:
  764.                         print("Error - %r directive outside context of mod" % attribute)
  765.                         continue
  766.                    
  767.                     path = os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(global_info.selected_workshop_id), path)
  768.                     yield GetDirectoryInfo(path=path, recursive=False, kind=KIND_WORKSHOP_CONFIG, workshop_id=global_info.selected_workshop_id)
  769.                     continue
  770.            
  771.             else:
  772.                 if name == "enableaddonmods":
  773.                     if not LOAD_ADDONS:
  774.                         continue
  775.                    
  776.                     for addon_mod_id in global_info.active_addons:
  777.                         # From an addon .INF:
  778.                         # "an addon workshop item must contain addonAssets/ folder."
  779.                         # "That folder will act as the root of a AddDirRecurse() call made by the engine"
  780.                         addon_path = os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(addon_mod_id), "addonAssets")
  781.                         yield GetDirectoryInfo(path=addon_path, recursive=True, kind=KIND_WORKSHOP_ADDON, workshop_id=addon_mod_id)
  782.                    
  783.                     continue
  784.                
  785.                 elif name == "addworkshopconfigs":
  786.                     # From the VSR .CFG:
  787.                     # "This is required so that the workshop is populated with the base directories
  788.                     # for each workshop item that contains a global config mod"
  789.                     # I don't think this actually causes bzcc to traverse any directories?
  790.                     continue
  791.                
  792.                 elif name == "addglobalconfigmod":
  793.                     if not global_info.selected_workshop_id:
  794.                         continue
  795.                    
  796.                     for dependency_id in mod_info.dependencies:
  797.                         dep_mod_workshop_directory = os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(dependency_id))
  798.                         dep_ini_file_path = find_first_file(dep_mod_workshop_directory, ".ini")
  799.                         dep_mod_info = read_mod_ini(dep_ini_file_path)
  800.                         if dep_mod_info.catagory.casefold() != "asset":
  801.                             raise Exception("%d invalid mod type %r in %r, dependencies must be asset mods" % (dependency_id, mod_info.catagory, dep_ini_file_path))
  802.                        
  803.                         # Assuming asset mods are just treated as AddDirRecurse()
  804.                         asset_path = os.path.join(BZ2R_WORKSHOP_DIRECTORY, str(dependency_id))
  805.                         yield GetDirectoryInfo(path=asset_path, recursive=True, kind=KIND_WORKSHOP_ASSET, workshop_id=dependency_id)
  806.                    
  807.                     continue
  808.            
  809.             raise Exception("unhandled config directive %r in %r" % (attribute.name, bzone_cfg_path))
  810.    
  811.     def prompt(self, bzone, force_input=None):
  812.         re_odf_name = re.compile(r"[^\s]+")
  813.        
  814.         user_input = force_input if force_input != None else input("\nODF: ")
  815.         print(">", user_input)
  816.        
  817.         queries = set()
  818.         regex_queries = set()
  819.        
  820.         for name in re_odf_name.findall(user_input):
  821.             if FULL_REGEX_MATCHING:
  822.                 if re.escape(name) != name:
  823.                     final_regex = "(?i)^" + name + "$" # Must match entire string
  824.                     # final_regex = "(?i)" + name # Must only match beginning
  825.                     try:
  826.                         regex_queries.add(re.compile(final_regex))
  827.                     except re.error as exception:
  828.                         print("invalid regex %r:" % final_regex, exception)
  829.                 else:
  830.                     queries.add(name.casefold())
  831.             elif "*" in name:
  832.                 # Regex matches will always result in the performance cost of checking every possible odf name.
  833.                 re_name = re.compile(".*".join((re.escape(segment.casefold()) for segment in name.split("*"))))
  834.                 regex_queries.add(re_name)
  835.             else:
  836.                 # If any names are not found, and suggestions are enabled, there will be a performance cost of checking every existing name.
  837.                 # Otherwise, if cached, the name is found very quickly using hash lookup (dictionary).
  838.                 queries.add(name.casefold())
  839.        
  840.         if not queries and not regex_queries:
  841.             return False
  842.        
  843.         start = time.time()
  844.         results = self.search(bzone, queries, regex_queries)
  845.         if SHOW_TIME:
  846.             print("Competion Time: %f" % (time.time() - start))
  847.        
  848.         if results:
  849.             maximum = TEXT_EDITOR_MAX_TABS if TEXT_EDITOR_AS_TABS else TEXT_EDITOR_MAX_INSTANCES
  850.            
  851.             if len(results) > maximum:
  852.                 print("Only showing %d/%d results." % (maximum, len(results)))
  853.                 results = {key: results[key] for index, key in enumerate(results, start=1) if index <= maximum}
  854.            
  855.             temp_files = []
  856.             for name, path in results.items():
  857.                 # TODO: Check if pak file was changed during the writing of each file, raising an exception?
  858.                 if type(path) == PakFileInfo:
  859.                     pak_info = path
  860.                     temp_file_path = os.path.join(CACHE_DIRECTORY, "%d-%s" % (pak_info.bz2dir.cache_id, pak_info.file_name))
  861.                     is_compressed = (pak_info.comp_size != pak_info.real_size)
  862.                     with open(pak_info.bz2dir.path, "rb") as pak:
  863.                         pak.seek(pak_info.data_offset)
  864.                         with open(temp_file_path, "wb") as tmp:
  865.                             if is_compressed:
  866.                                 tmp.write(zlib.decompress(pak.read(pak_info.comp_size)))
  867.                             else:
  868.                                 tmp.write(pak.read(pak_info.real_size))
  869.                    
  870.                     temp_files += [temp_file_path]
  871.                     results[name] = temp_file_path
  872.            
  873.             open_files(list(results.values()))
  874.            
  875.             # Cleanup
  876.             for temp_file_path in temp_files:
  877.                 if os.path.exists(temp_file_path):
  878.                     os.unlink(temp_file_path)
  879.        
  880.         return (force_input != None)
  881.        
  882.     def search(self, bzone, queries, regex_queries=[]):
  883.         """Returns results when all queries are matched or at end of search."""
  884.         results = dict()
  885.         suggestions = {query: list() for query in queries} if IMPLEMENT_SUGGESTIONS else None
  886.         temp_files = [] # Temporary file paths marked for deletion (from pak files)
  887.        
  888.         if not os.path.exists(CACHE_DIRECTORY):
  889.             os.makedirs(CACHE_DIRECTORY)
  890.        
  891.         for bz2dir in bzone:
  892.             generate_cache = use_cache = can_cache = False
  893.            
  894.             # Default iterator - iterate over files on disk in directory
  895.             odf_iterator = odf_file_iter(bz2dir.path, recursive=bz2dir.recursive)
  896.            
  897.             if bz2dir.kind == KIND_PAK:
  898.                 odf_iterator = pak_archive_iter(bz2dir)
  899.            
  900.             elif bz2dir.kind == KIND_ROOT and CACHE_ROOT_DIRECTORIES:
  901.                 can_cache = True
  902.                 if self.last_game_update != None and os.path.exists(bz2dir.cache):
  903.                     cache_file_time = os.path.getmtime(bz2dir.cache)
  904.                     if self.last_game_update > cache_file_time:
  905.                         game_date = datetime.fromtimestamp(self.last_game_update).strftime(TIME_FORMAT)
  906.                         cache_date = datetime.fromtimestamp(cache_file_time).strftime(TIME_FORMAT)
  907.                         print("Game was patched on %s, but cache last updated %s." % (game_date, cache_date))
  908.                         generate_cache = True
  909.            
  910.             elif bz2dir.kind == KIND_MYDOCS and CACHE_MYDOCS_DIRECTORIES:
  911.                 can_cache = True
  912.            
  913.             elif bz2dir.workshop_id and CACHE_WORKSHOP_DIRECTORIES:
  914.                 can_cache = True
  915.                 if self.last_workshop_update != None and os.path.exists(bz2dir.cache):
  916.                     if bz2dir.workshop_id in self.last_workshop_update:
  917.                         cache_file_time = os.path.getmtime(bz2dir.cache)
  918.                         if self.last_workshop_update[bz2dir.workshop_id] > cache_file_time:
  919.                             game_date = datetime.fromtimestamp(self.last_workshop_update[bz2dir.workshop_id]).strftime(TIME_FORMAT)
  920.                             cache_date = datetime.fromtimestamp(cache_file_time).strftime(TIME_FORMAT)
  921.                             print("Workshop item %d was updated %s, but cache last updated %s." % (bz2dir.workshop_id, game_date, cache_date))
  922.                             generate_cache = True
  923.            
  924.             if can_cache and not generate_cache:
  925.                 if os.path.exists(bz2dir.cache) and not FORCE_GENERATE_CACHE:
  926.                     if not bz2dir.cache in self.cache:
  927.                         with open(bz2dir.cache, "rb") as f:
  928.                             # Warning: The pickle module is not secure. Only unpickle data you trust!
  929.                             # cache[bz2dir.cache] = pickle.load(f)
  930.                             self.cache[bz2dir.cache] = pickle.loads(zlib.decompress(f.read()))
  931.                             use_cache = True
  932.                 else:
  933.                     generate_cache = True
  934.            
  935.             if use_cache:
  936.                 for query in tuple(queries):
  937.                     if query in self.cache[bz2dir.cache]:
  938.                         odf_file = self.cache[bz2dir.cache][query]
  939.                         if not os.path.exists(odf_file):
  940.                             print("Error - Cache for query %r points to non-existent file: %r" % (query, odf_file))
  941.                             generate_cache = True # Corrupt, file got moved or renamed, etc...
  942.                         else:
  943.                             if not query in results:
  944.                                 results[query] = odf_file
  945.                            
  946.                             queries.remove(query)
  947.                    
  948.                     if not queries and not regex_queries and not generate_cache:
  949.                         return results
  950.                
  951.                 if not suggestions and not regex_queries:
  952.                     continue # Next bz2dir, not found in this one, and not checking for suggestions or regex matches.
  953.                
  954.                 # Iterate over cache values instead of traversing directory.
  955.                 odf_iterator = iter(self.cache[bz2dir.cache].items())
  956.            
  957.             if generate_cache:
  958.                 print("Generating cache for %r..." % bz2dir.path)
  959.                 self.cache[bz2dir.cache] = dict()
  960.            
  961.             for odf_name, odf_file in odf_iterator:
  962.                 if generate_cache:
  963.                     # If multiple files with the same name exist, only 1 will regin supreme
  964.                     self.cache[bz2dir.cache][odf_name] = odf_file
  965.                
  966.                 if not use_cache and odf_name in tuple(queries):
  967.                     if not odf_name in results:
  968.                         results[odf_name] = odf_file
  969.                         queries.remove(odf_name)
  970.                    
  971.                     # All queries matched, and a cache is not being generated.
  972.                     if not queries and not regex_queries and not generate_cache:
  973.                         return results
  974.                    
  975.                     # Do not check regex_queries by skipping the rest of the
  976.                     # loop body for this iteration because we already matched this one.
  977.                     continue
  978.                
  979.                 # Check for partial matches
  980.                 elif suggestions:
  981.                     for query in queries:
  982.                         if query in odf_name:
  983.                             suggestions[query] += [odf_name]
  984.                
  985.                 # Regex (wildcard) matches
  986.                 for re_query in tuple(regex_queries):
  987.                     # if re_query.search(odf_name): # Match anywhere in name
  988.                     if re_query.match(odf_name): # Match beginning of name
  989.                         results[odf_name] = odf_file
  990.            
  991.             if generate_cache:
  992.                 with open(bz2dir.cache, "wb") as f:
  993.                     # pickle.dump(cache[bz2dir.cache], f)
  994.                     f.write(zlib.compress(pickle.dumps(self.cache[bz2dir.cache]), CACHE_COMPRESSION_LEVEL))
  995.        
  996.         if queries:
  997.             if suggestions:
  998.                 # Any matched query with suggestions for it may not have all possible suggestions in its list.
  999.                 # Only trust suggestions for unmatched queries.
  1000.                 for query in queries:
  1001.                     if suggestions[query]:
  1002.                         print("Suggestions for %r:" % query,  ", ".join(suggestions[query]))
  1003.                     else:
  1004.                         print("Query %r not found in any ODF names." % query)
  1005.             else:
  1006.                 print("Query %r not found." % query)
  1007.        
  1008.         elif regex_queries and len(results) == 0:
  1009.             print("No matches for your query.")
  1010.        
  1011.         return results
  1012.  
  1013. if __name__ == "__main__":
  1014.     if AUTO_CLEAN_PAK:
  1015.         for root, folders, files in os.walk(CACHE_DIRECTORY):
  1016.             for file in files:
  1017.                 name, ext = os.path.splitext(file)
  1018.                 if ext.casefold() == ".odf":
  1019.                     print("Deleting temp file %r..." % file)
  1020.                     os.unlink(os.path.join(root, file))
  1021.             break
  1022.             print("")
  1023.    
  1024.     global_info = read_launch_ini(BZ2R_LAUNCHINI_FILE)
  1025.     mod_info = None
  1026.     bzone_cfg = BZ2R_BZONECFG_FILE
  1027.    
  1028.     # In BZCC 'AddLocal' directives assume selected mod as the global mod, but you can override this by changing the variable below.
  1029.     if USE_ACTIVE_MOD_INSTEAD_OF_SELECTED_MOD:
  1030.         global_info.selected_workshop_id = global_info._replace(selected_workshop_id=global_info.active_workshop_id)
  1031.    
  1032.     # Command line argument '/config "path/to/bzone.cfg"' will override the mod and stock cfg.
  1033.     # This will cause the mod set by launch.ini to be ignored, which seems to be the case in BZCC.
  1034.     if len(sys.argv) > 2:
  1035.         re_config = re.compile(r"(?i)^\s*[+-/]config\s*$")
  1036.         re_mod = re.compile(r"(?i)^\s*[+-/]workshopid\s*$")
  1037.        
  1038.         is_config = re_config.match(sys.argv[1])
  1039.         is_mod = re_mod.match(sys.argv[1]) if not is_config else None
  1040.        
  1041.         if len(sys.argv) > 3 and not is_config and not is_mod:
  1042.             raise Exception("only /config 'path\\to\\bzone.cfg' or '/workshopid 1234567890' accepted as command line arguments")
  1043.        
  1044.         if is_config:
  1045.             # Using no mod results in using either the stock bzone.cfg or the "/config" bzone cfg
  1046.             global_info = global_info._replace(active_workshop_id=0, selected_workshop_id=0)
  1047.             bzone_cfg = sys.argv[2]
  1048.            
  1049.             if not os.path.isabs(bzone_cfg):
  1050.                 bzone_cfg = os.path.join(BZ2R_ROOT_DIRECTORY, bzone_cfg)
  1051.        
  1052.         elif is_mod:
  1053.             mod_id = int(sys.argv[2])
  1054.             global_info = global_info._replace(active_workshop_id=mod_id, selected_workshop_id=mod_id)
  1055.    
  1056.     SearchTool(global_info, bzone_cfg)
  1057.     # SearchTool(global_info, bzone_cfg, force_input="ivtank")
  1058.     # SearchTool(GlobalInfo(active_workshop_id=0, selected_workshop_id=0, active_addons=[], language_id=0)) # Force stock with no addons
  1059.  
Advertisement
Add Comment
Please, Sign In to add comment