diff --git a/bbp_ng/OP_OBJECT_legacy_align.py b/bbp_ng/OP_OBJECT_legacy_align.py index 852f4ea..368a285 100644 --- a/bbp_ng/OP_OBJECT_legacy_align.py +++ b/bbp_ng/OP_OBJECT_legacy_align.py @@ -140,7 +140,7 @@ class BBP_OT_legacy_align(bpy.types.Operator): def execute(self, context): # get processed objects (current_obj, target_objs) = _prepare_objects() - # INFO: YYC MARK: + # YYC MARK: # This statement is VERY IMPORTANT. # If this statement is not presented, Blender will return identity matrix # when getting world matrix from Object since the second execution of this function. diff --git a/bbp_ng/tools/bme_relatives.py b/bbp_ng/tools/bme_relatives.py new file mode 100644 index 0000000..865045d --- /dev/null +++ b/bbp_ng/tools/bme_relatives.py @@ -0,0 +1,230 @@ +import typing +import collections +import simple_po + +#region Translation Constant + +## TODO: +# This translation context string prefix is cpoied from UTIL_translation.py. +# If the context string of translation changed, please synchronize it. + +CTX_TRANSLATION: str = 'BBP/BME' + +#endregion + +#region BME Tokens + +## TODO: +# These token are copied from UTIL_bme.py. +# If anything changed, such as BME standard, these tokens should be synchronized between these 2 modules. + +TOKEN_IDENTIFIER: str = 'identifier' + +TOKEN_SHOWCASE: str = 'showcase' +TOKEN_SHOWCASE_TITLE: str = 'title' +TOKEN_SHOWCASE_ICON: str = 'icon' +TOKEN_SHOWCASE_TYPE: str = 'type' +TOKEN_SHOWCASE_CFGS: str = 'cfgs' +TOKEN_SHOWCASE_CFGS_FIELD: str = 'field' +TOKEN_SHOWCASE_CFGS_TYPE: str = 'type' +TOKEN_SHOWCASE_CFGS_TITLE: str = 'title' +TOKEN_SHOWCASE_CFGS_DESC: str = 'desc' +TOKEN_SHOWCASE_CFGS_DEFAULT: str = 'default' + +TOKEN_SKIP: str = 'skip' + +TOKEN_PARAMS: str = 'params' +TOKEN_PARAMS_FIELD: str = 'field' +TOKEN_PARAMS_DATA: str = 'data' + +TOKEN_VARS: str = 'vars' +TOKEN_VARS_FIELD: str = 'field' +TOKEN_VARS_DATA: str = 'data' + +TOKEN_VERTICES: str = 'vertices' +TOKEN_VERTICES_SKIP: str = 'skip' +TOKEN_VERTICES_DATA: str = 'data' + +TOKEN_FACES: str = 'faces' +TOKEN_FACES_SKIP: str = 'skip' +TOKEN_FACES_TEXTURE: str = 'texture' +TOKEN_FACES_INDICES: str = 'indices' +TOKEN_FACES_UVS: str = 'uvs' +TOKEN_FACES_NORMALS: str = 'normals' + +TOKEN_INSTANCES: str = 'instances' +TOKEN_INSTANCES_IDENTIFIER: str = 'identifier' +TOKEN_INSTANCES_SKIP: str = 'skip' +TOKEN_INSTANCES_PARAMS: str = 'params' +TOKEN_INSTANCES_TRANSFORM: str = 'transform' + +#endregion + +class Reporter(): + """ + General reporter commonly used by BME validator. + """ + + def __init__(self): + pass + + def __report(self, type: str, msg: str, context: str | None) -> None: + strl: str = f'[{type}]' + if context is not None: + strl += f'[{context}]' + strl += ' ' + msg + print(strl) + + def error(self, msg: str, context: str | None = None) -> None: + """ + @brief Report an error. + @param[in] msg The message to show. + @param[in] context The context of this message, e.g. the file path. None if no context. + """ + self.__report('Error', msg, context) + + def warning(self, msg: str, context: str | None = None) -> None: + """ + @brief Report a warning. + @param[in] msg The message to show. + @param[in] context The context of this message, e.g. the file path. None if no context. + """ + self.__report('Warning', msg, context) + + def info(self, msg: str, context: str | None = None) -> None: + """ + @brief Report a info. + @param[in] msg The message to show. + @param[in] context The context of this message, e.g. the file path. None if no context. + """ + self.__report('Info', msg, context) + +class Hierarchy(): + """ + The hierarchy builder for BME validator to build context string representing the location where error happen. + And it can be utilized by BME extractor to generate the context of translation. + """ + + __mStack: collections.deque[str] + + def __init__(self): + self.__mStack = collections.deque() + + def push(self, item: str) -> None: + """ + @brief Add an item into this hierarchy. + @param[in] item New added item. + """ + self.__mStack.append(item) + + def push_index(self, index: int) -> None: + """ + @brief Add an integral index into this hierarchy. + @details + The difference between this and normal push function is that added item is integral index. + This function will automatically convert it to string with a special format first, then push it into hierarchy. + @param[in] item New added index. + """ + self.__mStack.append(f'[{index}]') + + def pop(self) -> None: + """ + @brief Remove the top item from hierarchy + """ + self.__mStack.pop() + + def build_hierarchy_string(self) -> str: + """ + Build the string which can represent this hierarchy. + @return The built string representing this hierarchy. + """ + return '/'.join(self.__mStack) + +class BMEValidator(): + """ + The validator for BME prototype declarartions. + This validator will validate given prototype declaration JSON structure, + to check then whether have all essential fields BME standard required and whether have any unknown fields. + """ + + __mPrototypeSet: set[str] + __mHierarchy: Hierarchy + __mReporter: Reporter + + def __init__(self, reporter: Reporter): + self.__mPrototypeSet = set() + self.__mHierarchy = Hierarchy() + self.__mReporter = reporter + + def validate(self, assoc_file: str, prototypes: typing.Any) -> None: + self.__mHierarchy.push(assoc_file) + + self.__mHierarchy.pop() + +class BMEExtractor(): + """ + A GetText extractor for BME prototype declarations. + This extractor can extract all UI infomations which will be shown on Blender first. + Then write them into caller given PO file. So that translator can translate them. + + Blender default I18N plugin can not recognise these dynamic loaded content, + so that's the reason why this class invented. + + Please note all data should be validate first, then pass to this class. + Otherwise it is undefined behavior. + """ + + __mAssocFile: str + __mHierarchy: Hierarchy + __mPoWriter: simple_po.PoWriter + + def __init__(self, po_writer: simple_po.PoWriter): + self.__mAssocFile = '' + self.__mHierarchy = Hierarchy() + self.__mPoWriter = po_writer + + def extract(self, assoc_file: str, prototypes: list[dict[str, typing.Any]]) -> None: + self.__mAssocFile = assoc_file + for prototype in prototypes: + self.__extract_prototype(prototype) + + def __add_translation(self, strl: str) -> None: + self.__mPoWriter.add_entry( + strl, + CTX_TRANSLATION + '/' + self.__mHierarchy.build_hierarchy_string(), + self.__mAssocFile + ) + + def __extract_prototype(self, prototype: dict[str, typing.Any]) -> None: + # get identifier first + identifier: str = prototype[TOKEN_IDENTIFIER] + self.__mHierarchy.push(identifier) + + # get showcase node and only write PO file if it is not template prototype + showcase: dict[str, typing.Any] | None = prototype[TOKEN_SHOWCASE] + if showcase is not None: + self.__extract_showcase(showcase) + + self.__mHierarchy.pop() + + def __extract_showcase(self, showcase: dict[str, typing.Any]) -> None: + # export self name first + self.__add_translation(showcase[TOKEN_SHOWCASE_TITLE]) + + # iterate cfgs + cfgs: list[dict[str, typing.Any]] = showcase[TOKEN_SHOWCASE_CFGS] + for cfg_index, cfg in enumerate(cfgs): + self.__extract_showcase_cfg(cfg_index, cfg) + + def __extract_showcase_cfg(self, index: int, cfg: dict[str, typing.Any]) -> None: + self.__mHierarchy.push_index(index) + + # extract field title and description + title: str = cfg[TOKEN_SHOWCASE_CFGS_TITLE] + desc: str = cfg[TOKEN_SHOWCASE_CFGS_DESC] + + # and export them respectively + self.__add_translation(title) + self.__add_translation(desc) + + self.__mHierarchy.pop() diff --git a/bbp_ng/tools/build_jsons.py b/bbp_ng/tools/build_jsons.py index 5a7b469..9a7178e 100644 --- a/bbp_ng/tools/build_jsons.py +++ b/bbp_ng/tools/build_jsons.py @@ -1,17 +1,7 @@ -import os, json +import os, json, typing +import bme_relatives, simple_po import common -def compress_json(src_file: str, dst_file: str) -> None: - with open(src_file, 'r', encoding = 'utf-8') as fr: - with open(dst_file, 'w', encoding = 'utf-8') as fw: - json.dump( - json.load(fr), # load from src file - fw, - indent = None, # no indent. the most narrow style. - separators = (',', ':'), # also for narrow style. - sort_keys = False, # do not sort key - ) - def create_compressed_jsons() -> None: # get folder path root_folder: str = common.get_plugin_folder() @@ -38,6 +28,83 @@ def create_compressed_jsons() -> None: print('Done.') -if __name__ == '__main__': - create_compressed_jsons() +class JsonCompressor(): + __mReporter: bme_relatives.Reporter + __mPoWriter: simple_po.PoWriter + __mValidator: bme_relatives.BMEValidator + __mExtractor: bme_relatives.BMEExtractor + + def __init__(self): + self.__mReporter = bme_relatives.Reporter() + self.__mPoWriter = simple_po.PoWriter( + os.path.join(common.get_plugin_folder(), 'i18n', 'bme.pot'), + 'BME Prototypes' + ) + self.__mValidator = bme_relatives.BMEValidator(self.__mReporter) + self.__mExtractor = bme_relatives.BMEExtractor(self.__mPoWriter) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def close(self) -> None: + self.__mPoWriter.close() + + def run(self) -> None: + self.__compress_jsons() + + def __compress_jsons(self) -> None: + # get folder path + root_folder: str = common.get_plugin_folder() + + # prepare handler + def folder_handler(src_folder: str, dst_folder: str) -> None: + # just create folder + self.__mReporter.info(f'Creating Folder: {src_folder} -> {dst_folder}') + os.makedirs(dst_folder, exist_ok = True) + def file_handler(src_file: str, dst_file: str) -> None: + # skip non-json + if not src_file.endswith('.json'): return + # call compress func + self.__mReporter.info(f'Processing Json: {src_file} -> {dst_file}') + self.__compress_json(src_file, dst_file) + + # call common processor + common.common_file_migrator( + os.path.join(root_folder, 'raw_jsons'), + os.path.join(root_folder, 'jsons'), + folder_handler, + file_handler + ) + + self.__mReporter.info('Done.') + + def __compress_json(self, src_file: str, dst_file: str) -> None: + # load data first + loaded_prototypes: typing.Any + with open(src_file, 'r', encoding = 'utf-8') as fr: + loaded_prototypes = json.load(fr) + + # validate loaded data + self.__mValidator.validate(os.path.basename(src_file), loaded_prototypes) + + # extract translation + self.__mExtractor.extract(os.path.basename(src_file), loaded_prototypes) + + # save result + with open(dst_file, 'w', encoding = 'utf-8') as fw: + json.dump( + loaded_prototypes, # loaded data + fw, + indent = None, # no indent. the most narrow style. + separators = (',', ':'), # also for narrow style. + sort_keys = False, # do not sort key + ) + + +if __name__ == '__main__': + with JsonCompressor() as json_compressor: + json_compressor.run() diff --git a/bbp_ng/tools/simple_po.py b/bbp_ng/tools/simple_po.py new file mode 100644 index 0000000..35a1c76 --- /dev/null +++ b/bbp_ng/tools/simple_po.py @@ -0,0 +1,99 @@ +import typing +import io +import datetime + +class PoWriter(): + """ + The simple PO file writer. + This class is just served for writing POT files. + It may be convenient when exporting PO file for thoese whose format can not be parsed by formal tools. + """ + + __cEscapeCharsDict: typing.ClassVar[dict[str, str]] = { + '\\': '\\\\', + '"': '\\"', + '\n': '\\n', + '\t': '\\t', + } + __cEscapeCharsTable: typing.ClassVar[dict] = str.maketrans(__cEscapeCharsDict) + __mPoFile: io.TextIOWrapper + + def __init__(self, po_file_path: str, project_name: str): + # open file + self.__mPoFile = open(po_file_path, 'w', encoding = 'utf-8') + # add default header + self.__add_header(project_name) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def close(self) -> None: + self.__mPoFile.close() + + def __write_line(self, val: str) -> None: + self.__mPoFile.write(val) + self.__mPoFile.write('\n') + + def __escape_str(self, val: str) -> str: + """ + This function escapes a given string to make it safe to use as a C++ string literal. + @param[in] val Original string + @return Escaped string + """ + return val.translate(PoWriter.__cEscapeCharsTable) + + def __add_header(self, project_name: str) -> None: + """ + Add default header for PO file. + @param[in] project_name The project name written in file. + """ + now_datetime = datetime.datetime.now() + self.__write_line('# FIRST AUTHOR , YEAR.') + self.__write_line('msgid ""') + self.__write_line('msgstr ""') + self.__write_line(f'"Project-Id-Version: {self.__escape_str(project_name)}\\n"') + self.__write_line(f'"POT-Creation-Date: {now_datetime.strftime("%Y-%m-%d %H:%M%Z")}\\n"') + self.__write_line('"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"') + self.__write_line('"Last-Translator: FULL NAME \\n"') + self.__write_line('"Language-Team: LANGUAGE \\n"') + self.__write_line('"Language: __POT__\\n"') + self.__write_line('"MIME-Version: 1.0\\n"') + self.__write_line('"Content-Type: text/plain; charset=UTF-8\\n"') + self.__write_line('"Content-Transfer-Encoding: 8bit\\n"') + self.__write_line('"X-Generator: simple_po.PoWriter\\n"') + + def add_entry(self, msg: str, msg_context: str | None = None, extracted_comment: str | None = None, reference: str | None = None) -> None: + """ + @brief Write an entry into PO file with given arguments. + @details + Please note this function will NOT check whether there already is a duplicated entry which has been written. + You must check this on your own. + @param[in] msg The message string need to be translated. + @param[in] msg_context The context of this message. + @param[in] extracted_comment The extracted comment of this message. None if no reference. Line breaker is not allowed. + @param[in] reference The code refernece of this message. None if no reference. Line breaker is not allowed. + """ + # empty string will not be translated + if msg == '': return + + # write blank line first + self.__write_line('') + if extracted_comment: + self.__write_line(f'#. {extracted_comment}') + if reference: + self.__write_line(f'#: {reference}') + if msg_context: + self.__write_line(f'msgctxt "{self.__escape_str(msg_context)}"') + self.__write_line(f'msgid "{self.__escape_str(msg)}"') + self.__write_line('msgstr ""') + + def build_code_reference(self, code_file_path: str, code_line_number: int) -> str: + """ + A convenient function to build code reference string used when adding entry. + @param[in] code_file_path The path to associated code file. + @param[in] code_line_number The line number of associated code within given file. + """ + return f'{code_file_path}:{code_line_number}'